From 4bbc9ea92c9fd367776e23685742e1c115f1f01f Mon Sep 17 00:00:00 2001 From: ashilkn Date: Thu, 16 Jul 2026 17:31:10 +0530 Subject: [PATCH 01/91] [mob][photos] Add social actions to memories --- .../photos/changes/memories-social-actions.md | 1 + mobile/apps/photos/lib/db/social_db.dart | 22 + .../models/social/social_data_provider.dart | 7 + .../lib/services/collections_service.dart | 37 ++ .../ui/home/memories/full_screen_memory.dart | 23 + .../like_collection_selector_sheet.dart | 10 +- .../social/widgets/file_social_overlay.dart | 445 ++++++++++++++++++ .../lib/ui/viewer/file/file_bottom_bar.dart | 17 +- 8 files changed, 553 insertions(+), 9 deletions(-) create mode 100644 mobile/apps/photos/changes/memories-social-actions.md create mode 100644 mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart diff --git a/mobile/apps/photos/changes/memories-social-actions.md b/mobile/apps/photos/changes/memories-social-actions.md new file mode 100644 index 00000000000..992a0406db1 --- /dev/null +++ b/mobile/apps/photos/changes/memories-social-actions.md @@ -0,0 +1 @@ +- Added likes, comments, and latest comment previews to memories. diff --git a/mobile/apps/photos/lib/db/social_db.dart b/mobile/apps/photos/lib/db/social_db.dart index 28ff6085e82..acfb9d0aea1 100644 --- a/mobile/apps/photos/lib/db/social_db.dart +++ b/mobile/apps/photos/lib/db/social_db.dart @@ -153,6 +153,28 @@ class SocialDB { return rows.map(_rowToComment).toList(); } + Future getLatestCommentForFile( + int fileID, { + required List collectionIDs, + }) async { + if (collectionIDs.isEmpty) { + return null; + } + + final placeholders = List.filled(collectionIDs.length, '?').join(','); + final db = await database; + final rows = await db.query( + _commentsTable, + where: + 'file_id = ? AND is_deleted = 0 ' + 'AND collection_id IN ($placeholders)', + whereArgs: [fileID, ...collectionIDs], + orderBy: 'created_at DESC', + limit: 1, + ); + return rows.isEmpty ? null : _rowToComment(rows.first); + } + Future getCommentCountForFile(int fileID) async { final db = await database; final result = await db.rawQuery( diff --git a/mobile/apps/photos/lib/models/social/social_data_provider.dart b/mobile/apps/photos/lib/models/social/social_data_provider.dart index fdd45ae4681..b2cc58d7bfc 100644 --- a/mobile/apps/photos/lib/models/social/social_data_provider.dart +++ b/mobile/apps/photos/lib/models/social/social_data_provider.dart @@ -24,6 +24,13 @@ class SocialDataProvider { return _db.getCommentsForFile(fileID); } + Future getLatestCommentForFile( + int fileID, { + required List collectionIDs, + }) { + return _db.getLatestCommentForFile(fileID, collectionIDs: collectionIDs); + } + Future getCommentCountForFile(int fileID) { return _db.getCommentCountForFile(fileID); } diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index 9da6a0f4117..41e9581e7cc 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -295,6 +295,43 @@ class CollectionsService { return false; } + /// Returns the shared collections containing [uploadedFileID]. + /// + /// A collection is considered shared if it has sharees, has a public link, + /// or is owned by someone else (incoming share). Results are sorted by ID so + /// callers have a deterministic primary collection. + Future> getSharedCollectionsForFile( + int uploadedFileID, { + bool includeHidden = false, + }) async { + final Set collectionIDs = await _filesDB.getAllCollectionIDsOfFile( + uploadedFileID, + ); + final int? currentUserID = _config.getUserID(); + if (collectionIDs.isEmpty || currentUserID == null) { + return []; + } + + final hiddenCollectionIDs = includeHidden + ? const {} + : getHiddenCollectionIds(); + final sharedCollections = + collectionIDs + .map((collectionID) => _collectionIDToCollections[collectionID]) + .whereType() + .where( + (collection) => + !collection.isDeleted && + !hiddenCollectionIDs.contains(collection.id) && + (collection.hasSharees || + collection.hasLink || + !collection.isOwner(currentUserID)), + ) + .toList() + ..sort((first, second) => first.id.compareTo(second.id)); + return sharedCollections; + } + /// Returns the count of shared collections containing this file. /// Uses early exit optimization - stops counting at 2. /// diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 1ce9b15a8af..455887b8e89 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -35,6 +35,7 @@ import "package:photos/ui/components/base_bottom_sheet.dart"; import "package:photos/ui/home/memories/custom_listener.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/home/memories/memory_video_prefetcher.dart"; +import "package:photos/ui/social/widgets/file_social_overlay.dart"; import "package:photos/ui/viewer/file/file_widget.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/ui/viewer/file_details/favorite_widget.dart"; @@ -874,6 +875,28 @@ class _FullScreenMemoryState extends State { const BottomIcons(), ], ), + ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + return FileSocialOverlay( + file: inheritedData.memories[safeIndex].file, + currentUserID: Configuration.instance.getUserID(), + onInteractionStart: () { + _toggleAnimation(pause: true); + Bus.instance.fire(PauseVideoEvent()); + }, + onInteractionEnd: () { + Bus.instance.fire(ResumeVideoEvent()); + _toggleAnimation(pause: false); + }, + ); + }, + ), ], ), ), diff --git a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart index 9b8352c76da..acdad1bf3a4 100644 --- a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart @@ -38,6 +38,7 @@ Future showLikeCollectionSelectorSheet( required int fileID, required int currentUserID, EnteFile? file, + Set? allowedCollectionIDs, }) { return showModalBottomSheet( context: context, @@ -47,6 +48,7 @@ Future showLikeCollectionSelectorSheet( fileID: fileID, currentUserID: currentUserID, file: file, + allowedCollectionIDs: allowedCollectionIDs, ), ); } @@ -55,11 +57,13 @@ class LikeCollectionSelectorSheet extends StatefulWidget { final int fileID; final int currentUserID; final EnteFile? file; + final Set? allowedCollectionIDs; const LikeCollectionSelectorSheet({ required this.fileID, required this.currentUserID, this.file, + this.allowedCollectionIDs, super.key, }); @@ -100,7 +104,11 @@ class _LikeCollectionSelectorSheetState .whereType() .where( (c) => - c.hasSharees || c.hasLink || !c.isOwner(widget.currentUserID), + (c.hasSharees || + c.hasLink || + !c.isOwner(widget.currentUserID)) && + (widget.allowedCollectionIDs == null || + widget.allowedCollectionIDs!.contains(c.id)), ) .toList(); diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart new file mode 100644 index 00000000000..be7bf552a61 --- /dev/null +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -0,0 +1,445 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:ente_icons/ente_icons.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/social_data_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/api/collection/user.dart"; +import "package:photos/models/collection/collection.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/models/social/comment.dart"; +import "package:photos/models/social/comment_author_utils.dart"; +import "package:photos/models/social/reaction.dart"; +import "package:photos/models/social/social_data_provider.dart"; +import "package:photos/services/collections_service.dart"; +import "package:photos/ui/notification/toast.dart"; +import "package:photos/ui/sharing/user_avator_widget.dart"; +import "package:photos/ui/social/comments_screen.dart"; +import "package:photos/ui/social/like_collection_selector_sheet.dart"; +import "package:photos/ui/social/likes_bottom_sheet.dart"; + +final _logger = Logger("FileSocialOverlay"); + +const _likedColor = Color(0xFF08C225); + +class FileSocialOverlay extends StatefulWidget { + final EnteFile file; + final int? currentUserID; + final VoidCallback onInteractionStart; + final VoidCallback onInteractionEnd; + final bool includeHiddenCollections; + + const FileSocialOverlay({ + required this.file, + required this.currentUserID, + required this.onInteractionStart, + required this.onInteractionEnd, + this.includeHiddenCollections = false, + super.key, + }); + + @override + State createState() => _FileSocialOverlayState(); +} + +class _FileSocialOverlayState extends State { + late final StreamSubscription + _socialDataUpdatedSubscription; + + List _sharedCollections = const []; + bool _hasLiked = false; + int _commentCount = 0; + Comment? _latestComment; + User? _latestCommentAuthor; + int _loadGeneration = 0; + + @override + void initState() { + super.initState(); + _socialDataUpdatedSubscription = Bus.instance + .on() + .listen((_) => unawaited(_refreshSocialState())); + unawaited(_refreshSocialState()); + } + + @override + void didUpdateWidget(covariant FileSocialOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID || + oldWidget.currentUserID != widget.currentUserID || + oldWidget.includeHiddenCollections != widget.includeHiddenCollections) { + _loadGeneration++; + _clearSocialState(); + unawaited(_refreshSocialState()); + } + } + + @override + void dispose() { + _loadGeneration++; + _socialDataUpdatedSubscription.cancel(); + super.dispose(); + } + + void _clearSocialState() { + _sharedCollections = const []; + _hasLiked = false; + _commentCount = 0; + _latestComment = null; + _latestCommentAuthor = null; + } + + bool _isCurrentLoad(int fileID, int generation) { + return mounted && + generation == _loadGeneration && + widget.file.uploadedFileID == fileID; + } + + Future _refreshSocialState() async { + final fileID = widget.file.uploadedFileID; + final currentUserID = widget.currentUserID; + final generation = ++_loadGeneration; + + if (fileID == null || currentUserID == null) { + _clearSocialState(); + return; + } + + try { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile( + fileID, + includeHidden: widget.includeHiddenCollections, + ); + if (!_isCurrentLoad(fileID, generation)) return; + + if (sharedCollections.isEmpty) { + setState(_clearSocialState); + return; + } + + final provider = SocialDataProvider.instance; + final collectionIDs = sharedCollections + .map((collection) => collection.id) + .toList(); + final results = await Future.wait([ + provider.getReactionsForFile(fileID), + provider.getCommentCountForFile(fileID), + provider.getLatestCommentForFile(fileID, collectionIDs: collectionIDs), + ]); + if (!_isCurrentLoad(fileID, generation)) return; + + final reactions = results[0] as List; + final commentCount = results[1] as int; + final latestComment = results[2] as Comment?; + User? latestCommentAuthor; + if (latestComment != null) { + final anonDisplayNames = latestComment.isAnonymous + ? await provider.getAnonDisplayNamesForCollection( + latestComment.collectionID, + ) + : const {}; + if (!_isCurrentLoad(fileID, generation)) return; + + latestCommentAuthor = CommentAuthorResolver().resolve( + comment: latestComment, + anonDisplayNames: anonDisplayNames, + registeredUserResolver: (userID) => CollectionsService.instance + .getFileOwner(userID, latestComment.collectionID), + ); + } + + if (!_isCurrentLoad(fileID, generation)) return; + setState(() { + _sharedCollections = sharedCollections; + _hasLiked = reactions.any( + (reaction) => reaction.userID == currentUserID && !reaction.isDeleted, + ); + _commentCount = commentCount; + _latestComment = latestComment; + _latestCommentAuthor = latestCommentAuthor; + }); + } catch (error, stackTrace) { + _logger.warning( + "Failed to refresh social overlay for file $fileID", + error, + stackTrace, + ); + } + } + + Future _toggleReaction() async { + final file = widget.file; + final fileID = file.uploadedFileID; + final currentUserID = widget.currentUserID; + if (fileID == null || currentUserID == null || _sharedCollections.isEmpty) { + return; + } + + if (_hasLiked) { + await _unlikeFromAllCollections(fileID, currentUserID); + return; + } + + if (_sharedCollections.length == 1) { + final previousState = _hasLiked; + setState(() => _hasLiked = true); + try { + await SocialDataProvider.instance.toggleReaction( + userID: currentUserID, + collectionID: _sharedCollections.single.id, + fileID: fileID, + ); + } catch (error, stackTrace) { + _logger.severe("Failed to like photo", error, stackTrace); + if (mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + showShortToast( + context, + AppLocalizations.of(context).failedToUpdateLike, + ); + } + } + return; + } + + await _runSheetAndRefresh( + () => showLikeCollectionSelectorSheet( + context, + fileID: fileID, + currentUserID: currentUserID, + file: file, + allowedCollectionIDs: _sharedCollections + .map((collection) => collection.id) + .toSet(), + ), + ); + } + + Future _unlikeFromAllCollections(int fileID, int currentUserID) async { + final previousState = _hasLiked; + setState(() => _hasLiked = false); + + try { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile(fileID, includeHidden: true); + var failedCount = 0; + for (final collection in sharedCollections) { + try { + final reactions = await SocialDataProvider.instance + .getReactionsForFileInCollection(fileID, collection.id); + final userReaction = reactions.firstWhereOrNull( + (reaction) => + reaction.userID == currentUserID && !reaction.isDeleted, + ); + if (userReaction != null) { + await SocialDataProvider.instance.toggleReaction( + userID: currentUserID, + collectionID: collection.id, + fileID: fileID, + ); + } + } catch (error, stackTrace) { + failedCount++; + _logger.warning( + "Failed to unlike from ${collection.displayName}", + error, + stackTrace, + ); + } + } + + if (failedCount > 0 && mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + showShortToast( + context, + AppLocalizations.of(context).failedToUpdateLike, + ); + } + } catch (error, stackTrace) { + _logger.severe( + "Failed to unlike from all collections", + error, + stackTrace, + ); + if (mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + showShortToast( + context, + AppLocalizations.of(context).failedToUpdateLike, + ); + } + } + } + + Future _showLikes() async { + final fileID = widget.file.uploadedFileID; + if (fileID == null || _sharedCollections.isEmpty) return; + final initialCollectionID = _sharedCollections.first.id; + await _runSheetAndRefresh( + () => showLikesBottomSheet( + context, + fileID: fileID, + initialCollectionID: initialCollectionID, + ), + ); + } + + Future _openComments({Comment? comment}) async { + final fileID = widget.file.uploadedFileID; + if (fileID == null || _sharedCollections.isEmpty) return; + await _runSheetAndRefresh( + () => showFileCommentsBottomSheet( + context, + collectionID: comment?.collectionID ?? _sharedCollections.first.id, + fileID: fileID, + highlightCommentID: comment?.id, + ), + ); + } + + Future _runSheetAndRefresh(Future Function() showSheet) async { + widget.onInteractionStart(); + try { + await showSheet(); + await _refreshSocialState(); + } finally { + widget.onInteractionEnd(); + } + } + + @override + Widget build(BuildContext context) { + if (_sharedCollections.isEmpty) { + return const SizedBox.shrink(); + } + + return Positioned( + right: 16, + bottom: 80, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (_latestComment != null && _latestCommentAuthor != null) + Padding( + padding: const EdgeInsets.only(right: 8, bottom: 4), + child: _buildLatestCommentPill( + _latestComment!, + _latestCommentAuthor!, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: AppLocalizations.of(context).like, + child: GestureDetector( + onLongPress: () => unawaited(_showLikes()), + child: IconButton( + style: IconButton.styleFrom( + overlayColor: WidgetStateColor.transparent, + ), + onPressed: () => unawaited(_toggleReaction()), + icon: Icon( + _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + ), + ), + ), + ), + Tooltip( + message: AppLocalizations.of(context).comments, + child: IconButton( + onPressed: () => unawaited(_openComments()), + icon: _buildCommentIcon(), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildLatestCommentPill(Comment comment, User author) { + final currentUserID = widget.currentUserID; + if (currentUserID == null) return const SizedBox.shrink(); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => unawaited(_openComments(comment: comment)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.55, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UserAvatarWidget( + author, + type: AvatarType.small, + currentUserID: currentUserID, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + comment.data, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildCommentIcon() { + return Stack( + clipBehavior: Clip.none, + children: [ + const Icon(EnteIcons.commentBubbleStroke, color: Colors.white), + if (_commentCount > 0) + Positioned( + right: -4, + top: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: Colors.black, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: Text( + _commentCount > 99 ? "99+" : _commentCount.toString(), + style: const TextStyle( + color: Colors.black, + fontSize: 8, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart index eefa1288181..524fa4de1e9 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart @@ -397,9 +397,7 @@ class FileBottomBarState extends State { Future _toggleReaction() async { final file = widget.file; - if (file.uploadedFileID == null || - file.collectionID == null || - widget.userID == null) { + if (file.uploadedFileID == null || widget.userID == null) { return; } @@ -409,11 +407,14 @@ class FileBottomBarState extends State { return; } - // Check how many shared collections contain this file - final sharedCount = await CollectionsService.instance - .getSharedCollectionCountForFile(file.uploadedFileID!); + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile(file.uploadedFileID!, includeHidden: true); - if (sharedCount <= 1) { + if (sharedCollections.isEmpty) { + return; + } + + if (sharedCollections.length == 1) { // Single shared collection: like directly final previousState = _hasLiked; _hasLiked = true; @@ -422,7 +423,7 @@ class FileBottomBarState extends State { try { await SocialDataProvider.instance.toggleReaction( userID: widget.userID!, - collectionID: file.collectionID!, + collectionID: sharedCollections.single.id, fileID: file.uploadedFileID, ); } catch (e) { From 4cc77d013fd3b80eb15972b117f76508d6cd4907 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Thu, 16 Jul 2026 18:16:32 +0530 Subject: [PATCH 02/91] [mob][photos] Harden memories social interactions Use centralized shared-collection resolution so the selector excludes deleted collections. --- .../lib/services/collections_service.dart | 44 ---------- .../like_collection_selector_sheet.dart | 27 +++--- .../social/widgets/file_social_overlay.dart | 85 +++++++++++-------- 3 files changed, 60 insertions(+), 96 deletions(-) diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index 41e9581e7cc..f65902652b0 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -332,50 +332,6 @@ class CollectionsService { return sharedCollections; } - /// Returns the count of shared collections containing this file. - /// Uses early exit optimization - stops counting at 2. - /// - /// Returns: - /// - 0: File not in any shared collection - /// - 1: File in exactly one shared collection - /// - 2: File in multiple shared collections (early exit) - Future getSharedCollectionCountForFile(int uploadedFileID) async { - final Set collectionIDs = await _filesDB.getAllCollectionIDsOfFile( - uploadedFileID, - ); - - if (collectionIDs.isEmpty) { - return 0; - } - - final int? currentUserID = _config.getUserID(); - if (currentUserID == null) { - return 0; - } - - int sharedCount = 0; - for (final int collectionID in collectionIDs) { - final Collection? collection = _collectionIDToCollections[collectionID]; - - if (collection == null || collection.isDeleted) { - continue; - } - - // Same logic as isFileInSharedCollection - if (collection.hasSharees || - collection.hasLink || - !collection.isOwner(currentUserID)) { - sharedCount++; - // Early exit: we only need to distinguish between 0, 1, and >1 - if (sharedCount > 1) { - return 2; - } - } - } - - return sharedCount; - } - Future> getArchivedCollection() async { final allCollections = getCollectionsForUI(); return allCollections diff --git a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart index acdad1bf3a4..d234f402f22 100644 --- a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart @@ -93,24 +93,17 @@ class _LikeCollectionSelectorSheetState // Load file if not provided (for thumbnail) _file ??= await FilesDB.instance.getAnyUploadedFile(widget.fileID); - // Get all collections containing this file - final collectionIDs = await FilesDB.instance.getAllCollectionIDsOfFile( - widget.fileID, - ); - - // Filter to shared collections only - final sharedCollections = collectionIDs - .map((id) => CollectionsService.instance.getCollectionByID(id)) - .whereType() - .where( - (c) => - (c.hasSharees || - c.hasLink || - !c.isOwner(widget.currentUserID)) && - (widget.allowedCollectionIDs == null || + final sharedCollections = + (await CollectionsService.instance.getSharedCollectionsForFile( + widget.fileID, + includeHidden: true, + )) + .where( + (c) => + (widget.allowedCollectionIDs == null || widget.allowedCollectionIDs!.contains(c.id)), - ) - .toList(); + ) + .toList(); // If no shared collections, close the sheet if (sharedCollections.isEmpty) { diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index be7bf552a61..e748bf4292b 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -55,6 +55,7 @@ class _FileSocialOverlayState extends State { Comment? _latestComment; User? _latestCommentAuthor; int _loadGeneration = 0; + int? _reactionUpdateFileID; @override void initState() { @@ -72,6 +73,7 @@ class _FileSocialOverlayState extends State { oldWidget.currentUserID != widget.currentUserID || oldWidget.includeHiddenCollections != widget.includeHiddenCollections) { _loadGeneration++; + _reactionUpdateFileID = null; _clearSocialState(); unawaited(_refreshSocialState()); } @@ -175,48 +177,58 @@ class _FileSocialOverlayState extends State { final file = widget.file; final fileID = file.uploadedFileID; final currentUserID = widget.currentUserID; - if (fileID == null || currentUserID == null || _sharedCollections.isEmpty) { + if (fileID == null || + currentUserID == null || + _sharedCollections.isEmpty || + _reactionUpdateFileID == fileID) { return; } - if (_hasLiked) { - await _unlikeFromAllCollections(fileID, currentUserID); - return; - } + _reactionUpdateFileID = fileID; + try { + if (_hasLiked) { + await _unlikeFromAllCollections(fileID, currentUserID); + return; + } - if (_sharedCollections.length == 1) { - final previousState = _hasLiked; - setState(() => _hasLiked = true); - try { - await SocialDataProvider.instance.toggleReaction( - userID: currentUserID, - collectionID: _sharedCollections.single.id, - fileID: fileID, - ); - } catch (error, stackTrace) { - _logger.severe("Failed to like photo", error, stackTrace); - if (mounted && widget.file.uploadedFileID == fileID) { - setState(() => _hasLiked = previousState); - showShortToast( - context, - AppLocalizations.of(context).failedToUpdateLike, + if (_sharedCollections.length == 1) { + final previousState = _hasLiked; + setState(() => _hasLiked = true); + try { + await SocialDataProvider.instance.toggleReaction( + userID: currentUserID, + collectionID: _sharedCollections.single.id, + fileID: fileID, ); + } catch (error, stackTrace) { + _logger.severe("Failed to like photo", error, stackTrace); + if (mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + showShortToast( + context, + AppLocalizations.of(context).failedToUpdateLike, + ); + } } + return; } - return; - } - await _runSheetAndRefresh( - () => showLikeCollectionSelectorSheet( - context, - fileID: fileID, - currentUserID: currentUserID, - file: file, - allowedCollectionIDs: _sharedCollections - .map((collection) => collection.id) - .toSet(), - ), - ); + await _runSheetAndRefresh( + () => showLikeCollectionSelectorSheet( + context, + fileID: fileID, + currentUserID: currentUserID, + file: file, + allowedCollectionIDs: _sharedCollections + .map((collection) => collection.id) + .toSet(), + ), + ); + } finally { + if (_reactionUpdateFileID == fileID) { + _reactionUpdateFileID = null; + } + } } Future _unlikeFromAllCollections(int fileID, int currentUserID) async { @@ -294,7 +306,10 @@ class _FileSocialOverlayState extends State { await _runSheetAndRefresh( () => showFileCommentsBottomSheet( context, - collectionID: comment?.collectionID ?? _sharedCollections.first.id, + collectionID: + comment?.collectionID ?? + _latestComment?.collectionID ?? + _sharedCollections.first.id, fileID: fileID, highlightCommentID: comment?.id, ), From 1af1f797358ee14f47b81c5424c8731b7edc81dd Mon Sep 17 00:00:00 2001 From: ashilkn Date: Thu, 16 Jul 2026 22:19:40 +0530 Subject: [PATCH 03/91] [mob][photos] Redesign memories viewer --- .../changes/memories-viewer-redesign.md | 1 + .../collections/collection_action_sheet.dart | 4 +- .../ui/home/memories/full_screen_memory.dart | 972 +++++++++--------- .../memories/memory_progress_indicator.dart | 160 ++- .../social/widgets/file_social_overlay.dart | 109 +- .../empty_states/shared_empty_state.dart | 10 +- .../file_selection_actions_widget.dart | 44 +- .../people_selection_action_widget.dart | 12 +- .../lib/ui/viewer/file/file_app_bar.dart | 20 +- .../memory_progress_indicator_test.dart | 66 ++ 10 files changed, 792 insertions(+), 606 deletions(-) create mode 100644 mobile/apps/photos/changes/memories-viewer-redesign.md create mode 100644 mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart diff --git a/mobile/apps/photos/changes/memories-viewer-redesign.md b/mobile/apps/photos/changes/memories-viewer-redesign.md new file mode 100644 index 00000000000..5a5367a9c8c --- /dev/null +++ b/mobile/apps/photos/changes/memories-viewer-redesign.md @@ -0,0 +1 @@ +- Redesigned the memories viewer with edge-to-edge media, clearer controls, and add-to-album support. diff --git a/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart b/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart index 00335754dbf..1629054559a 100644 --- a/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart +++ b/mobile/apps/photos/lib/ui/collections/collection_action_sheet.dart @@ -76,7 +76,7 @@ String _actionName( return text; } -void showCollectionActionSheet( +Future showCollectionActionSheet( BuildContext context, { SelectedFiles? selectedFiles, List? sharedFiles, @@ -99,7 +99,7 @@ void showCollectionActionSheet( ? selectedPeople.length : selectedFiles?.files.length ?? 0; - showBottomSheetComponent( + return showBottomSheetComponent( context: context, builder: (_) => BottomSheetComponent( title: _actionName(context, actionType, filesCount), diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 455887b8e89..1c07c805707 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -1,11 +1,11 @@ import "dart:async"; -import "dart:io"; import "dart:math"; import "dart:ui"; import "package:connectivity_plus/connectivity_plus.dart"; +import "package:ente_components/theme/text_styles.dart" as component; +import "package:ente_icons/ente_icons.dart"; import "package:ente_pure_utils/ente_pure_utils.dart"; -import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_svg/flutter_svg.dart"; @@ -22,22 +22,21 @@ 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/memories/memory.dart"; +import "package:photos/models/selected_files.dart"; import "package:photos/module/download/file.dart"; import "package:photos/module/download/thumbnail.dart"; import "package:photos/service_locator.dart"; import "package:photos/services/memory_share_service.dart"; import "package:photos/services/smart_memories_service.dart"; -import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; -import "package:photos/theme/text_style.dart"; import "package:photos/ui/actions/file/file_actions.dart"; +import "package:photos/ui/collections/collection_action_sheet.dart"; import "package:photos/ui/components/base_bottom_sheet.dart"; import "package:photos/ui/home/memories/custom_listener.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/home/memories/memory_video_prefetcher.dart"; import "package:photos/ui/social/widgets/file_social_overlay.dart"; import "package:photos/ui/viewer/file/file_widget.dart"; -import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/ui/viewer/file_details/favorite_widget.dart"; import "package:photos/ui/viewer/gallery/jump_to_date_gallery.dart"; import "package:photos/utils/dialog_util.dart"; @@ -337,12 +336,8 @@ class FullScreenMemory extends StatefulWidget { } class _FullScreenMemoryState extends State { - final _showTitle = ValueNotifier(true); AnimationController? _progressAnimationController; AnimationController? _zoomAnimationController; - final ValueNotifier durationNotifier = ValueNotifier( - const Duration(seconds: 5), - ); // Differentiates the photo crossfade tempo: snappy for manual taps, // slower/cinematic for auto-advance. Set at the call site before the // index bump so AnimatedSwitcher reads the right duration on rebuild. @@ -377,9 +372,6 @@ class _FullScreenMemoryState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _firstPhotoOpacity = 1); }); - Future.delayed(const Duration(seconds: 3), () { - if (mounted) _showTitle.value = false; - }); hasPointerOnScreenNotifier.addListener(_hasPointerListener); _detailSheetEventSubscription = Bus.instance.on().listen( @@ -405,8 +397,6 @@ class _FullScreenMemoryState extends State { @override void dispose() { - _showTitle.dispose(); - durationNotifier.dispose(); hasPointerOnScreenNotifier.removeListener(_hasPointerListener); _detailSheetEventSubscription.cancel(); _progressAnimationController = null; @@ -497,12 +487,14 @@ class _FullScreenMemoryState extends State { if (_progressAnimationController?.isAnimating == true) { _progressAnimationController!.stop(); } - durationNotifier.value = Duration(seconds: duration); + final memoryDuration = Duration(seconds: duration); _progressAnimationController ?..stop() ..reset() - ..duration = durationNotifier.value - ..forward(); + ..duration = memoryDuration; + if (!_isAnimationPaused) { + _progressAnimationController?.forward(); + } _zoomAnimationController ?..stop() ..reset(); @@ -578,329 +570,172 @@ class _FullScreenMemoryState extends State { _resetAnimation(); } + Future _runWithViewerPaused(Future Function() action) async { + if (!mounted) return null; + _pauseViewer(); + try { + return await action(); + } finally { + _resumeViewer(); + } + } + + void _pauseViewer() { + if (!mounted) return; + _toggleAnimation(pause: true); + Bus.instance.fire(PauseVideoEvent()); + } + + void _resumeViewer() { + if (!mounted) return; + Bus.instance.fire(ResumeVideoEvent()); + _toggleAnimation(pause: false); + } + @override Widget build(BuildContext context) { final inheritedData = FullScreenMemoryData.of(context); if (inheritedData == null || inheritedData.memories.isEmpty) { return const SizedBox.shrink(); } - final showStepProgressIndicator = - inheritedData.memories.length < kMemoryProgressTickCutoff; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: SafeArea( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: strokeFainterDark, width: 1), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Scaffold( - backgroundColor: Colors.black, - extendBodyBehindAppBar: true, - appBar: AppBar( - toolbarHeight: 64, - primary: false, - automaticallyImplyLeading: false, - title: ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: const Padding( - padding: EdgeInsets.fromLTRB(4, 8, 8, 8), - child: Icon(Icons.close, color: Colors.white, size: 20), - ), - ), - builder: (context, value, child) { - final safeIndex = _clampedMemoryIndex( - value, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 32), - ValueListenableBuilder( - valueListenable: durationNotifier, - builder: (context, duration, _) { - return MemoryProgressIndicator( - totalSteps: inheritedData.memories.length, - currentIndex: safeIndex, - selectedColor: Colors.white, - unselectedColor: Colors.white.withValues( - alpha: 0.4, - ), - duration: duration, - animationController: - _setProgressAnimationController, - onAnimationControllerDisposed: - _clearProgressAnimationController, - onComplete: () { - _autoAdvanceTransition = true; - _goToNext(inheritedData); - }, - ); - }, - ), - const SizedBox(height: 6), - Row( - children: [ - child!, - GestureDetector( - onTap: () async { - final fullScreenState = context - .findAncestorStateOfType< - _FullScreenMemoryState - >(); - fullScreenState?._toggleAnimation(pause: true); - Bus.instance.fire(PauseVideoEvent()); - await routeToPage( - context, - JumpToDateGallery( - fileToJumpTo: - inheritedData.memories[safeIndex].file, - ), - ); - Bus.instance.fire(ResumeVideoEvent()); - fullScreenState?._toggleAnimation(pause: false); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - // color: fillFaintDark, - color: blurStrokeFaintDark, - border: Border.all( - color: strokeFaintDark, - width: 1, - ), - ), - child: Row( - children: [ - const SizedBox(width: 2), - Padding( - padding: const EdgeInsets.all(2.0), - child: Text( - SmartMemoriesService.getDateFormatted( - creationTime: inheritedData - .memories[safeIndex] - .file - .creationTime!, - context: context, - ), - style: getEnteTextTheme(context) - .miniMuted - .copyWith(color: textBaseDark), - ), - ), - const SizedBox(width: 4), - const Icon( - Icons.keyboard_arrow_right_outlined, - size: 14, - color: blurStrokeBaseDark, - ), - ], - ), - ), - ), - const Spacer(), - ], - ), - ], - ); + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + for ( + var i = 1; + i <= _FullScreenMemoryDataUpdaterState._thumbnailLookaheadCap; + i++ + ) { + final j = safeIndex + i; + if (j >= inheritedData.memories.length) break; + inheritedData.preloadThumbnail( + inheritedData.memories[j].file, + ); + } + for ( + var i = 1; + i <= _FullScreenMemoryDataUpdaterState._fileLookaheadCap; + i++ + ) { + final j = safeIndex + i; + if (j >= inheritedData.memories.length) break; + preloadFile(inheritedData.memories[j].file); + } + inheritedData.preloadVideos(safeIndex + 1); + final currentMemory = inheritedData.memories[safeIndex]; + final isVideo = currentMemory.file.fileType == FileType.video; + final currentFile = currentMemory.file; + + return MemoriesPointerGestureListener( + onTap: (PointerEvent event) { + _autoAdvanceTransition = false; + HapticFeedback.selectionClick(); + final screenWidth = MediaQuery.sizeOf(context).width; + final goToPreviousTapAreaWidth = screenWidth * 0.20; + if (event.localPosition.dx < goToPreviousTapAreaWidth) { + _goToPrevious(inheritedData); + } else { + _goToNext(inheritedData); + } }, - ), - flexibleSpace: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color.fromARGB(75, 0, 0, 0), - Color.fromARGB(37, 0, 0, 0), - Colors.transparent, - Colors.transparent, - ], - stops: [0, 0.45, 0.8, 1], - ), - ), - ), - backgroundColor: Colors.transparent, - elevation: 0, - ), - body: Stack( - alignment: Alignment.bottomCenter, - children: [ - const _MemoryBlur(), - ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - builder: (context, index, _) { - final safeIndex = _clampedMemoryIndex( - index, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - for ( - var i = 1; - i <= - _FullScreenMemoryDataUpdaterState - ._thumbnailLookaheadCap; - i++ - ) { - final j = safeIndex + i; - if (j >= inheritedData.memories.length) break; - inheritedData.preloadThumbnail( - inheritedData.memories[j].file, + hasPointerNotifier: hasPointerOnScreenNotifier, + child: AnimatedOpacity( + opacity: _firstPhotoOpacity, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOut, + child: AnimatedSwitcher( + duration: _autoAdvanceTransition + ? _autoCrossfadeDuration + : _manualCrossfadeDuration, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + fit: StackFit.expand, + children: [...previousChildren, ?currentChild], ); - } - for ( - var i = 1; - i <= - _FullScreenMemoryDataUpdaterState._fileLookaheadCap; - i++ - ) { - final j = safeIndex + i; - if (j >= inheritedData.memories.length) break; - preloadFile(inheritedData.memories[j].file); - } - inheritedData.preloadVideos(safeIndex + 1); - final currentMemory = inheritedData.memories[safeIndex]; - final isVideo = - currentMemory.file.fileType == FileType.video; - final currentFile = currentMemory.file; - - return MemoriesPointerGestureListener( - onTap: (PointerEvent event) { - _autoAdvanceTransition = false; - HapticFeedback.selectionClick(); - final screenWidth = MediaQuery.sizeOf(context).width; - final goToPreviousTapAreaWidth = screenWidth * 0.20; - if (event.localPosition.dx < - goToPreviousTapAreaWidth) { - _goToPrevious(inheritedData); - } else { - _goToNext(inheritedData); - } - }, - hasPointerNotifier: hasPointerOnScreenNotifier, - child: AnimatedOpacity( - opacity: _firstPhotoOpacity, - duration: const Duration(milliseconds: 400), - curve: Curves.easeOut, - child: AnimatedSwitcher( - duration: _autoAdvanceTransition - ? _autoCrossfadeDuration - : _manualCrossfadeDuration, - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - layoutBuilder: (currentChild, previousChildren) { - return Stack( - fit: StackFit.expand, - children: [...previousChildren, ?currentChild], - ); - }, - child: MemoriesZoomWidget( - key: ValueKey( - currentFile.uploadedFileID ?? - currentFile.localID, - ), - scaleController: _setZoomAnimationController, - onScaleControllerDisposed: - _clearZoomAnimationController, - zoomIn: safeIndex % 2 == 0, - isVideo: isVideo, - child: FileWidget( - currentFile, - autoPlay: false, - tagPrefix: "memories", - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - isFromMemories: true, - playbackCallback: (shouldEnable, _) { - _toggleAnimation(pause: !shouldEnable); - }, - onFinalFileLoad: - ({required int memoryDuration}) { - onFinalFileLoad(memoryDuration); - }, - ), - ), + }, + child: MemoriesZoomWidget( + key: ValueKey( + currentFile.uploadedFileID ?? currentFile.localID, + ), + scaleController: _setZoomAnimationController, + onScaleControllerDisposed: + _clearZoomAnimationController, + zoomIn: safeIndex % 2 == 0, + isVideo: isVideo, + child: FileWidget( + currentFile, + autoPlay: false, + tagPrefix: "memories", + backgroundDecoration: const BoxDecoration( + color: Colors.black, ), + isFromMemories: true, + playbackCallback: (shouldEnable, _) { + _toggleAnimation(pause: !shouldEnable); + }, + onFinalFileLoad: ({required int memoryDuration}) { + onFinalFileLoad(memoryDuration); + }, ), - ); - }, - ), - BottomGradient(showTitle: _showTitle), - Column( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: _showTitle, - builder: (context, value, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: value - ? Padding( - padding: const EdgeInsets.fromLTRB( - 32, - 4, - 32, - 12, - ), - child: Hero( - tag: widget.title, - child: Text( - widget.title, - style: const TextStyle( - color: Colors.white, - fontSize: 30, - fontFamily: "Montserrat", - ), - textAlign: TextAlign.left, - ), - ), - ) - : showStepProgressIndicator - ? const SizedBox.shrink() - : const MemoryCounter(), - ); - }, ), - const BottomIcons(), - ], + ), ), - ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - builder: (context, index, _) { - final safeIndex = _clampedMemoryIndex( - index, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - return FileSocialOverlay( - file: inheritedData.memories[safeIndex].file, - currentUserID: Configuration.instance.getUserID(), - onInteractionStart: () { - _toggleAnimation(pause: true); - Bus.instance.fire(PauseVideoEvent()); - }, - onInteractionEnd: () { - Bus.instance.fire(ResumeVideoEvent()); - _toggleAnimation(pause: false); - }, - ); - }, + ); + }, + ), + const _MemoryViewerScrims(), + ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + return FileSocialOverlay( + file: inheritedData.memories[safeIndex].file, + currentUserID: Configuration.instance.getUserID(), + onInteractionStart: _pauseViewer, + onInteractionEnd: _resumeViewer, + ); + }, + ), + const BottomIcons(), + _MemoryTopChrome( + title: widget.title, + onClose: () => Navigator.pop(context), + onDateTap: (file) { + unawaited( + _runWithViewerPaused( + () => routeToPage( + context, + JumpToDateGallery(fileToJumpTo: file), + ), ), - ], - ), + ); + }, + animationController: _setProgressAnimationController, + onAnimationControllerDisposed: _clearProgressAnimationController, + onComplete: () { + _autoAdvanceTransition = true; + _goToNext(inheritedData); + }, ), - ), + ], ), ), ); @@ -922,145 +757,307 @@ class BottomIcons extends StatelessWidget { context.findAncestorWidgetOfExactType()?.title ?? AppLocalizations.of(context).memories; - return ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - builder: (context, value, _) { - final safeIndex = _clampedMemoryIndex( - value, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - final currentFile = inheritedData.memories[safeIndex].file; - final List rowChildren = [ - IconButton( - icon: Icon( - Platform.isAndroid ? Icons.info_outline : CupertinoIcons.info, - color: Colors.white, //same for both themes - ), - onPressed: () async { - fullScreenState?._toggleAnimation(pause: true); - await showDetailsSheet(context, currentFile); - fullScreenState?._toggleAnimation(pause: false); - }, - ), - ]; - - final isOwner = - currentFile.ownerID == null || - (Configuration.instance.getUserID() ?? 0) == currentFile.ownerID; - if (isOwner) { - rowChildren.addAll([ - IconButton( - icon: Icon( - Platform.isAndroid - ? Icons.delete_outline - : CupertinoIcons.delete, - color: Colors.white, //same for both themes + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, value, _) { + final safeIndex = _clampedMemoryIndex( + value, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + final currentFile = inheritedData.memories[safeIndex].file; + if (fullScreenState == null) return const SizedBox.shrink(); + + final l10n = AppLocalizations.of(context); + final isOwner = currentFile.isOwner; + final collection = currentFile.collectionID == null + ? null + : collectionsService.getCollectionByID(currentFile.collectionID!); + final isHidden = + currentFile.isUploaded && (collection?.isHidden() ?? false); + final rowChildren = [ + _MemoryActionButton( + tooltip: l10n.info, + icon: const HugeIcon( + icon: HugeIcons.strokeRoundedInformationCircle, + color: Colors.white, + size: 24, ), onPressed: () async { - final currentIndex = _clampedMemoryIndex( - inheritedData.indexNotifier.value, - inheritedData.memories.length, + await fullScreenState._runWithViewerPaused( + () => showDetailsSheet(context, currentFile), ); - if (currentIndex == null) return; - fullScreenState?._toggleAnimation(pause: true); - await showSingleFileDeleteSheet( - context, - inheritedData.memories[currentIndex].file, - onFileRemoved: (file) => { - inheritedData.removeCurrentMemory.call(), - if (inheritedData.memories.isEmpty) - {Navigator.of(context).pop()}, - }, + }, + ), + _MemoryActionButton( + tooltip: l10n.share, + icon: const HugeIcon( + icon: HugeIcons.strokeRoundedShare08, + color: Colors.white, + size: 24, + ), + onPressed: () async { + await fullScreenState._runWithViewerPaused( + () => _shareMemory(context, inheritedData, memoryTitle), ); - fullScreenState?._toggleAnimation(pause: false); }, ), - ]); - if (!isLocalGalleryMode) { - rowChildren.add( - SizedBox(height: 32, child: FavoriteWidget(currentFile)), - ); - } - } - rowChildren.add( - IconButton( - icon: Icon( - Icons.adaptive.share, - color: Colors.white, //same for both themes + if (currentFile.isUploaded && !isHidden) + _MemoryActionButton( + tooltip: l10n.addToAlbum, + icon: const Icon( + EnteIcons.addToAlbum, + color: Colors.white, + size: 24, + ), + onPressed: () async { + await fullScreenState._runWithViewerPaused(() async { + final selectedFiles = SelectedFiles(); + selectedFiles.files.add(currentFile); + await showCollectionActionSheet( + context, + selectedFiles: selectedFiles, + actionType: CollectionActionType.addFiles, + ); + }); + }, + ), + if (isOwner) + _MemoryActionButton( + tooltip: l10n.delete, + icon: const HugeIcon( + icon: HugeIcons.strokeRoundedDelete02, + color: Colors.white, + size: 24, + ), + onPressed: () async { + await fullScreenState._runWithViewerPaused(() async { + final actionIndex = _clampedMemoryIndex( + inheritedData.indexNotifier.value, + inheritedData.memories.length, + ); + if (actionIndex == null) return; + final actionFile = inheritedData.memories[actionIndex].file; + if (!actionFile.isOwner) return; + var shouldCloseViewer = false; + await showSingleFileDeleteSheet( + context, + actionFile, + onFileRemoved: (file) { + fullScreenState.hasFinalFileLoaded = false; + fullScreenState._resetAnimation(); + inheritedData.removeCurrentMemory(); + shouldCloseViewer = inheritedData.memories.isEmpty; + }, + ); + if (shouldCloseViewer && context.mounted) { + Navigator.of(context).pop(); + } + }); + }, + ), + ]; + final safePadding = MediaQuery.paddingOf(context); + return Padding( + padding: EdgeInsets.fromLTRB( + safePadding.left + 24, + 20, + safePadding.right + 24, + safePadding.bottom + 12, ), - onPressed: () async { - fullScreenState?._toggleAnimation(pause: true); - await _shareMemory(context, inheritedData, memoryTitle); - fullScreenState?._toggleAnimation(pause: false); - }, - ), - ); - return Container( - alignment: Alignment.bottomCenter, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: rowChildren, - ), - ); - }, + child: Row( + children: rowChildren + .map((child) => Expanded(child: Center(child: child))) + .toList(growable: false), + ), + ); + }, + ), ); } } -class MemoryCounter extends StatelessWidget { - const MemoryCounter({super.key}); +class _MemoryActionButton extends StatelessWidget { + final String tooltip; + final Widget icon; + final Future Function() onPressed; + + const _MemoryActionButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + }); @override Widget build(BuildContext context) { - final inheritedData = FullScreenMemoryData.of(context); - if (inheritedData == null || inheritedData.memories.isEmpty) { - return const SizedBox.shrink(); - } - return ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - builder: (context, value, _) { - final safeIndex = _clampedMemoryIndex( - value, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - return Text( - "${safeIndex + 1}/${inheritedData.memories.length}", - style: darkTextTheme.bodyMuted, - ); - }, + return SizedBox.square( + dimension: 48, + child: IconButton( + tooltip: tooltip, + padding: const EdgeInsets.all(12), + style: IconButton.styleFrom( + minimumSize: const Size.square(48), + maximumSize: const Size.square(48), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + overlayColor: Colors.white.withValues(alpha: 0.08), + ), + onPressed: () => unawaited(onPressed()), + icon: icon, + ), ); } } -class BottomGradient extends StatelessWidget { - final ValueNotifier showTitle; - const BottomGradient({super.key, required this.showTitle}); +class _MemoryTopChrome extends StatelessWidget { + final String title; + final VoidCallback onClose; + final ValueChanged onDateTap; + final void Function(AnimationController) animationController; + final void Function(AnimationController) onAnimationControllerDisposed; + final VoidCallback onComplete; + + const _MemoryTopChrome({ + required this.title, + required this.onClose, + required this.onDateTap, + required this.animationController, + required this.onAnimationControllerDisposed, + required this.onComplete, + }); @override Widget build(BuildContext context) { - return IgnorePointer( - child: ValueListenableBuilder( - valueListenable: showTitle, - builder: (context, value, _) { - return AnimatedContainer( - duration: const Duration(milliseconds: 875), - curve: Curves.easeOutQuart, - height: value ? 240 : 120, - width: double.infinity, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Color.fromARGB(97, 0, 0, 0), - Color.fromARGB(42, 0, 0, 0), - Colors.transparent, - ], - stops: [0, 0.5, 1.0], - ), + final inheritedData = FullScreenMemoryData.of(context); + if (inheritedData == null || inheritedData.memories.isEmpty) { + return const SizedBox.shrink(); + } + final safePadding = MediaQuery.paddingOf(context); + return Align( + alignment: Alignment.topCenter, + child: ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + final currentFile = inheritedData.memories[safeIndex].file; + final showFavorite = currentFile.isOwner && !isLocalGalleryMode; + return Padding( + padding: EdgeInsets.only(top: safePadding.top), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + safePadding.left + 16, + 0, + safePadding.right + 16, + 0, + ), + child: MemoryProgressIndicator( + totalSteps: inheritedData.memories.length, + currentIndex: safeIndex, + selectedColor: Colors.white, + unselectedColor: Colors.white.withValues(alpha: 0.4), + animationController: animationController, + onAnimationControllerDisposed: + onAnimationControllerDisposed, + onComplete: onComplete, + ), + ), + const SizedBox(height: 12), + Padding( + padding: EdgeInsets.fromLTRB( + safePadding.left + 16, + 0, + safePadding.right + 16, + 0, + ), + child: SizedBox( + height: 52, + child: Row( + children: [ + SizedBox.square( + dimension: 48, + child: IconButton( + tooltip: AppLocalizations.of(context).close, + padding: const EdgeInsets.all(15), + style: IconButton.styleFrom( + minimumSize: const Size.square(48), + maximumSize: const Size.square(48), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + overlayColor: Colors.white.withValues( + alpha: 0.08, + ), + ), + onPressed: onClose, + icon: const Icon( + Icons.close, + color: Colors.white, + size: 18, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onDateTap(currentFile), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Hero( + tag: title, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: component.TextStyles.display3 + .copyWith(color: Colors.white), + ), + ), + Text( + SmartMemoriesService.getDateFormatted( + creationTime: currentFile.creationTime!, + context: context, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: component.TextStyles.mini.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), + if (showFavorite) ...[ + const SizedBox(width: 8), + SizedBox.square( + dimension: 48, + child: Center( + child: FavoriteWidget( + currentFile, + key: ValueKey( + currentFile.uploadedFileID ?? + currentFile.localID, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ], ), ); }, @@ -1069,44 +1066,65 @@ class BottomGradient extends StatelessWidget { } } -class _MemoryBlur extends StatelessWidget { - const _MemoryBlur(); +class _MemoryViewerScrims extends StatelessWidget { + const _MemoryViewerScrims(); @override Widget build(BuildContext context) { - final inheritedData = FullScreenMemoryData.of(context); - if (inheritedData == null || inheritedData.memories.isEmpty) { - return const SizedBox.shrink(); - } - return ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - builder: (context, value, _) { - final safeIndex = _clampedMemoryIndex( - value, - inheritedData.memories.length, - ); - if (safeIndex == null) return const SizedBox.shrink(); - final currentFile = inheritedData.memories[safeIndex].file; - if (currentFile.fileType == FileType.video) { - return const SizedBox.shrink(); - } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 750), - switchInCurve: Curves.easeOutExpo, - switchOutCurve: Curves.easeInExpo, - child: ImageFiltered( - key: ValueKey(safeIndex), - imageFilter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), - child: ThumbnailWidget( - currentFile, - placeholderColor: Colors.black, - shouldShowSyncStatus: false, - shouldShowFavoriteIcon: false, - shouldShowVideoOverlayIcon: false, + final topHeight = MediaQuery.paddingOf(context).top + 104; + return IgnorePointer( + child: Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: double.infinity, + height: topHeight, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xB8000000), + Color(0x70000000), + Colors.transparent, + ], + stops: [0, 0.6, 1], + ), + ), + ), + ), + ), ), ), - ); - }, + const Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: double.infinity, + height: 200, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Color(0x8A000000), + Colors.black, + ], + stops: [0, 0.55, 1], + ), + ), + ), + ), + ), + ], + ), ); } } diff --git a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart index 070b22da76f..2a6ece5a57c 100644 --- a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart +++ b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart @@ -1,6 +1,47 @@ import "package:flutter/material.dart"; -const int kMemoryProgressTickCutoff = 60; +const double kMemoryProgressSegmentWidth = 8.0; +const double kMemoryProgressGap = 10.0; +const double kMemoryProgressHeight = 4.0; +const double kMemoryProgressMinimumCurrentWidth = 24.0; + +int memoryProgressCapacityForWidth( + double availableWidth, { + double segmentWidth = kMemoryProgressSegmentWidth, + double gap = kMemoryProgressGap, + double minimumCurrentWidth = kMemoryProgressMinimumCurrentWidth, +}) { + if (!availableWidth.isFinite || + availableWidth <= 0 || + !segmentWidth.isFinite || + segmentWidth <= 0 || + !gap.isFinite || + gap < 0 || + !minimumCurrentWidth.isFinite || + minimumCurrentWidth <= 0) { + return 1; + } + + final capacity = + 1 + + ((availableWidth - minimumCurrentWidth) / (segmentWidth + gap)).floor(); + return capacity < 1 ? 1 : capacity; +} + +bool memoryProgressUsesContinuousTrack({ + required int totalSteps, + required double availableWidth, + double segmentWidth = kMemoryProgressSegmentWidth, + double gap = kMemoryProgressGap, + double minimumCurrentWidth = kMemoryProgressMinimumCurrentWidth, +}) => + totalSteps > + memoryProgressCapacityForWidth( + availableWidth, + segmentWidth: segmentWidth, + gap: gap, + minimumCurrentWidth: minimumCurrentWidth, + ); class MemoryProgressIndicator extends StatefulWidget { final int totalSteps; @@ -21,8 +62,8 @@ class MemoryProgressIndicator extends StatefulWidget { this.duration = const Duration(seconds: 5), this.selectedColor = Colors.white, this.unselectedColor = Colors.white54, - this.height = 2.0, - this.gap = 4.0, + this.height = kMemoryProgressHeight, + this.gap = kMemoryProgressGap, this.animationController, this.onAnimationControllerDisposed, this.onComplete, @@ -72,63 +113,68 @@ class _MemoryProgressIndicatorState extends State @override Widget build(BuildContext context) { - // For very large memories, a per-file tick bar would render as pixel-thin - // slivers. Show a single continuous bar instead, combining completed-file - // progress with the current file's in-flight animation. - if (widget.totalSteps >= kMemoryProgressTickCutoff) { - return AnimatedBuilder( - animation: _animation, - builder: (context, _) { - final progress = - (widget.currentIndex + _animation.value) / widget.totalSteps; - return LinearProgressIndicator( - value: progress.clamp(0.0, 1.0), - backgroundColor: widget.unselectedColor, - valueColor: AlwaysStoppedAnimation(widget.selectedColor), - minHeight: widget.height, - borderRadius: BorderRadius.circular(12), + return LayoutBuilder( + builder: (context, constraints) { + if (memoryProgressUsesContinuousTrack( + totalSteps: widget.totalSteps, + availableWidth: constraints.maxWidth, + gap: widget.gap, + )) { + return AnimatedBuilder( + animation: _animation, + builder: (context, _) { + final progress = + (widget.currentIndex + _animation.value) / widget.totalSteps; + return LinearProgressIndicator( + value: progress.clamp(0.0, 1.0), + backgroundColor: widget.unselectedColor, + valueColor: AlwaysStoppedAnimation(widget.selectedColor), + minHeight: widget.height, + borderRadius: BorderRadius.circular(12), + ); + }, ); - }, - ); - } - return Row( - children: List.generate(widget.totalSteps, (index) { - return Expanded( - child: Padding( - padding: EdgeInsets.only(right: widget.gap), - child: index < widget.currentIndex - ? Container( - height: widget.height, - decoration: BoxDecoration( - color: widget.selectedColor, - borderRadius: BorderRadius.circular(12), - ), - ) - : index == widget.currentIndex - ? AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return LinearProgressIndicator( - value: _animation.value, - backgroundColor: widget.unselectedColor, - valueColor: AlwaysStoppedAnimation( - widget.selectedColor, - ), - minHeight: widget.height, - borderRadius: BorderRadius.circular(12), - ); - }, - ) - : Container( - height: widget.height, - decoration: BoxDecoration( - color: widget.unselectedColor, + } + + final segments = []; + for (var index = 0; index < widget.totalSteps; index++) { + if (index > 0) { + segments.add(SizedBox(width: widget.gap)); + } + + final segment = index == widget.currentIndex + ? AnimatedBuilder( + animation: _animation, + builder: (context, _) { + return LinearProgressIndicator( + value: _animation.value, + backgroundColor: widget.unselectedColor, + valueColor: AlwaysStoppedAnimation( + widget.selectedColor, + ), + minHeight: widget.height, borderRadius: BorderRadius.circular(12), - ), + ); + }, + ) + : Container( + height: widget.height, + decoration: BoxDecoration( + color: index < widget.currentIndex + ? widget.selectedColor + : widget.unselectedColor, + borderRadius: BorderRadius.circular(12), ), - ), - ); - }), + ); + + segments.add( + index == widget.currentIndex + ? Expanded(child: segment) + : SizedBox(width: kMemoryProgressSegmentWidth, child: segment), + ); + } + return Row(children: segments); + }, ); } } diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index e748bf4292b..5bbc5c4117d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -1,6 +1,7 @@ import "dart:async"; import "package:collection/collection.dart"; +import "package:ente_components/theme/text_styles.dart" as component; import "package:ente_icons/ente_icons.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; @@ -24,6 +25,9 @@ import "package:photos/ui/social/likes_bottom_sheet.dart"; final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); +const _socialControlsSize = 40.0; +const _bottomActionBarHeight = 80.0; +const _socialToActionBarGap = 14.0; class FileSocialOverlay extends StatefulWidget { final EnteFile file; @@ -332,15 +336,17 @@ class _FileSocialOverlayState extends State { return const SizedBox.shrink(); } + final safePadding = MediaQuery.paddingOf(context); return Positioned( - right: 16, - bottom: 80, + right: safePadding.right + 24, + bottom: + safePadding.bottom + _bottomActionBarHeight + _socialToActionBarGap, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (_latestComment != null && _latestCommentAuthor != null) Padding( - padding: const EdgeInsets.only(right: 8, bottom: 4), + padding: const EdgeInsets.only(right: 12, bottom: 4), child: _buildLatestCommentPill( _latestComment!, _latestCommentAuthor!, @@ -353,23 +359,40 @@ class _FileSocialOverlayState extends State { message: AppLocalizations.of(context).like, child: GestureDetector( onLongPress: () => unawaited(_showLikes()), - child: IconButton( - style: IconButton.styleFrom( - overlayColor: WidgetStateColor.transparent, - ), - onPressed: () => unawaited(_toggleReaction()), - icon: Icon( - _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, - color: _hasLiked ? _likedColor : Colors.white, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: const EdgeInsets.all(8), + style: IconButton.styleFrom( + minimumSize: const Size.square(_socialControlsSize), + maximumSize: const Size.square(_socialControlsSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + overlayColor: WidgetStateColor.transparent, + ), + onPressed: () => unawaited(_toggleReaction()), + icon: Icon( + _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + ), ), ), ), ), + const SizedBox(height: 16), Tooltip( message: AppLocalizations.of(context).comments, - child: IconButton( - onPressed: () => unawaited(_openComments()), - icon: _buildCommentIcon(), + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: const EdgeInsets.all(8), + style: IconButton.styleFrom( + minimumSize: const Size.square(_socialControlsSize), + maximumSize: const Size.square(_socialControlsSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => unawaited(_openComments()), + icon: _buildCommentIcon(), + ), ), ), ], @@ -390,34 +413,44 @@ class _FileSocialOverlayState extends State { constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.55, ), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.white.withValues(alpha: 0.18)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - UserAvatarWidget( - author, - type: AvatarType.small, - currentUserID: currentUserID, + child: Stack( + clipBehavior: Clip.none, + children: [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(6), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - const SizedBox(width: 6), - Flexible( - child: Text( - comment.data, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 12), + child: Text( + comment.data, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: component.TextStyles.mini.copyWith( + color: Colors.black, ), ), - ], + ), ), - ), + Positioned( + left: -10, + top: -10, + child: UserAvatarWidget( + author, + type: AvatarType.small, + currentUserID: currentUserID, + ), + ), + ], ), ), ); diff --git a/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart b/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart index 627c7db1482..c8822b98108 100644 --- a/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart +++ b/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:ente_components/ente_components.dart"; import "package:flutter/material.dart"; import "package:photos/generated/l10n.dart"; @@ -64,9 +66,11 @@ class SharedEmptyState extends StatelessWidget { label: strings.shareAnAlbum, shouldSurfaceExecutionStates: false, onTap: () async { - showCollectionActionSheet( - context, - actionType: CollectionActionType.shareCollection, + unawaited( + showCollectionActionSheet( + context, + actionType: CollectionActionType.shareCollection, + ), ); }, ), diff --git a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart index 99055cd4215..07f5feb5bb8 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -609,30 +609,38 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.moveFiles, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.moveFiles, + ), ); } Future _moveFilesToHiddenAlbum() async { - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.moveToHiddenCollection, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.moveToHiddenCollection, + ), ); } Future _addToAlbum() async { - showCollectionActionSheet(context, selectedFiles: widget.selectedFiles); + unawaited( + showCollectionActionSheet(context, selectedFiles: widget.selectedFiles), + ); } Future _addToHiddenAlbum() async { - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.addToHiddenAlbum, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.addToHiddenAlbum, + ), ); } @@ -883,10 +891,12 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.unHide, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.unHide, + ), ); } diff --git a/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart index 0ee875095e9..7106755cc1d 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:ente_pure_utils/ente_pure_utils.dart"; import "package:flutter/material.dart"; import "package:hugeicons/hugeicons.dart"; @@ -352,10 +354,12 @@ class _PeopleSelectionActionWidgetState final selectedPersonIds = _getSelectedPersonIds(personMap); if (selectedPersonIds.isEmpty) return; if (!mounted) return; - showCollectionActionSheet( - context, - selectedPeople: selectedPersonIds, - actionType: CollectionActionType.autoAddPeople, + unawaited( + showCollectionActionSheet( + context, + selectedPeople: selectedPersonIds, + actionType: CollectionActionType.autoAddPeople, + ), ); widget.selectedPeople.clearAll(); } 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 facde36dea8..a324e1189f6 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 @@ -565,10 +565,12 @@ class FileAppBarState extends State { } else if (value == 10) { final selectedFiles = SelectedFiles(); selectedFiles.files.add(widget.file); - showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.addFiles, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: selectedFiles, + actionType: CollectionActionType.addFiles, + ), ); } } @@ -615,10 +617,12 @@ class FileAppBarState extends State { Future _handleUnHideRequest(BuildContext context) async { final selectedFiles = SelectedFiles(); selectedFiles.files.add(widget.file); - showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.unHide, + unawaited( + showCollectionActionSheet( + context, + selectedFiles: selectedFiles, + actionType: CollectionActionType.unHide, + ), ); } diff --git a/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart new file mode 100644 index 00000000000..5a2f185cd5d --- /dev/null +++ b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart @@ -0,0 +1,66 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:photos/ui/home/memories/memory_progress_indicator.dart"; + +void main() { + group("memory progress capacity", () { + test("derives phone and tablet capacity from the available width", () { + expect(memoryProgressCapacityForWidth(375), 20); + expect(memoryProgressCapacityForWidth(343), 18); + expect(memoryProgressCapacityForWidth(736), 40); + expect(memoryProgressCapacityForWidth(329), 17); + expect(memoryProgressCapacityForWidth(330), 18); + }); + + test("switches to a continuous track only after capacity", () { + expect( + memoryProgressUsesContinuousTrack(totalSteps: 18, availableWidth: 343), + isFalse, + ); + expect( + memoryProgressUsesContinuousTrack(totalSteps: 19, availableWidth: 343), + isTrue, + ); + expect( + memoryProgressUsesContinuousTrack(totalSteps: 41, availableWidth: 736), + isTrue, + ); + }); + }); + + testWidgets("keeps the current phone segment flexible at capacity", ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: 343, + child: MemoryProgressIndicator(totalSteps: 18, currentIndex: 7), + ), + ), + ), + ); + + expect(find.byType(Row), findsOneWidget); + expect(tester.getSize(find.byType(LinearProgressIndicator)).width, 37); + }); + + testWidgets("uses the full track when the phone capacity overflows", ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: 343, + child: MemoryProgressIndicator(totalSteps: 19, currentIndex: 7), + ), + ), + ), + ); + + expect(find.byType(Row), findsNothing); + expect(tester.getSize(find.byType(LinearProgressIndicator)).width, 343); + }); +} From 03c2fd5c6cb33c46619c56372a4809701cd69e53 Mon Sep 17 00:00:00 2001 From: Priyanshu Dangare Date: Fri, 17 Jul 2026 12:06:47 +0530 Subject: [PATCH 04/91] [mobile][photos] fix missing media management hint sheet call in delete selection path --- .../lib/ui/viewer/actions/file_selection_actions_widget.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart index 8230264e1ca..6af764aec0b 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -1097,6 +1097,9 @@ class _FileSelectionActionsWidgetState onTap: () async { try { await deleteFilesOnDeviceOnly(context, filesToDelete); + if (mounted) { + await showMediaManagementHintSheet(context); + } } catch (e) { if (mounted) { await showGenericErrorDialog(context: context, error: e); From e4ad6f89027ff39cfbb5e8875f2017d912cd8e0c Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 17 Jul 2026 13:32:09 +0530 Subject: [PATCH 05/91] [mob][photos] Refine memories viewer visuals --- .../photos/changes/memory-viewer-backdrop.md | 1 + .../lib/services/memories_cache_service.dart | 290 ++++++++++++++---- .../ui/home/memories/full_screen_memory.dart | 81 ++++- .../memories/memory_progress_indicator.dart | 52 ++-- .../memory_progress_indicator_test.dart | 121 +++++--- 5 files changed, 396 insertions(+), 149 deletions(-) create mode 100644 mobile/apps/photos/changes/memory-viewer-backdrop.md diff --git a/mobile/apps/photos/changes/memory-viewer-backdrop.md b/mobile/apps/photos/changes/memory-viewer-backdrop.md new file mode 100644 index 00000000000..0a4b2db48ca --- /dev/null +++ b/mobile/apps/photos/changes/memory-viewer-backdrop.md @@ -0,0 +1 @@ +- Improved the memories viewer with a full-screen blurred backdrop and evenly sized progress indicators. diff --git a/mobile/apps/photos/lib/services/memories_cache_service.dart b/mobile/apps/photos/lib/services/memories_cache_service.dart index 20d06855630..75dfb7dc315 100644 --- a/mobile/apps/photos/lib/services/memories_cache_service.dart +++ b/mobile/apps/photos/lib/services/memories_cache_service.dart @@ -6,6 +6,7 @@ import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart" show kDebugMode; import "package:logging/logging.dart"; import "package:path_provider/path_provider.dart"; +import "package:photos/core/configuration.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/db/files_db.dart"; import "package:photos/db/memories_db.dart"; @@ -50,6 +51,12 @@ class MemoriesCacheService { static const _shouldUpdateCacheKey = "memories.shouldUpdateCache"; static const _tripMemoryCarryForwardLimit = kTripSurfaceSlots; + // TEMP(memory-viewer-verification): remove before merge. + static const _kTempLibraryMemories = bool.fromEnvironment( + "debugSocialMemoriesMix", + defaultValue: false, + ); + /// Delay is for cache update to be done not during app init, during which a /// lot of other things are happening. static const _kCacheUpdateDelay = Duration(seconds: 20); @@ -62,6 +69,11 @@ class MemoriesCacheService { : MemoriesDB.instance; List? _cachedMemories; + List? _debugLibraryMemories; + List? _debugLibraryMemoriesSource; + int? _debugLibraryMemoriesUserID; + String? _debugLibraryMemoriesInputSignature; + bool _debugLibraryMemoriesAttempted = false; List? get currentMemoriesSync => _cachedMemories; bool _shouldUpdate = false; bool _pendingInitialOfflineCacheUpgrade = false; @@ -72,6 +84,7 @@ class MemoriesCacheService { final _memoriesUpdateLock = Lock(); final _memoriesGetLock = Lock(); + final _debugLibraryMemoriesLock = Lock(); MemoriesCacheService(this._prefs) { _logger.info("MemoriesCacheService constructor"); @@ -616,82 +629,223 @@ class MemoriesCacheService { _logger.info('Showing memories is disabled in settings, showing none'); return []; } - return _memoriesGetLock.synchronized(() async { - if (_cachedMemories != null && _cachedMemories!.isNotEmpty) { - final currentMemories = _cachedMemories! - .where((memory) => memory.shouldShowNow()) - .toList(); - if (currentMemories.isNotEmpty) { - _logger.info("Found memories in memory cache"); - return currentMemories; - } - _logger.info( - "In-memory memories not valid for current window, refreshing cache", - ); - } else if (onlyUseCache) { - _logger.info("Only using cache, no memories found"); - return []; - } - try { - if (!enableSmartMemories) { - await _calculateRegularFillers(); - return _cachedMemories!; - } - final cacheFileExists = await _cacheFileExists(); - _cachedMemories = await _getMemoriesFromCache(); - if (_cachedMemories == null || _cachedMemories!.isEmpty) { - final shouldRefreshEmptyCache = - _cachedMemories == null || - _shouldUpdate || - _timeToUpdateCache() || - lastMemoriesCacheUpdateTime == 0; - if (onlyUseCache) { - _logger.info("Only using cache, no memories found"); - return []; + final memories = await _memoriesGetLock.synchronized>( + () async { + if (_cachedMemories != null && _cachedMemories!.isNotEmpty) { + final currentMemories = _cachedMemories! + .where((memory) => memory.shouldShowNow()) + .toList(); + if (currentMemories.isNotEmpty) { + _logger.info("Found memories in memory cache"); + return currentMemories; } - if (!shouldRefreshEmptyCache) { - _logger.info("Found fresh empty memories cache"); - return []; + _logger.info( + "In-memory memories not valid for current window, refreshing cache", + ); + } else if (onlyUseCache) { + _logger.info("Only using cache, no memories found"); + return []; + } + try { + if (!enableSmartMemories) { + await _calculateRegularFillers(); + return _cachedMemories!; } - if (!cacheFileExists) { - _logger.info( - "No disk cache (fresh install): serving simple memories, " - "smart memories will upgrade in background", - ); - _cachedMemories = await smartMemoriesService.calcSimpleMemories(); - if (_shouldDeferInitialOfflineCacheUpgrade()) { - _pendingInitialOfflineCacheUpgrade = true; + final cacheFileExists = await _cacheFileExists(); + _cachedMemories = await _getMemoriesFromCache(); + if (_cachedMemories == null || _cachedMemories!.isEmpty) { + final shouldRefreshEmptyCache = + _cachedMemories == null || + _shouldUpdate || + _timeToUpdateCache() || + lastMemoriesCacheUpdateTime == 0; + if (onlyUseCache) { + _logger.info("Only using cache, no memories found"); + return []; + } + if (!shouldRefreshEmptyCache) { + _logger.info("Found fresh empty memories cache"); + return []; + } + if (!cacheFileExists) { _logger.info( - "Deferring initial offline memories cache upgrade until first import metadata is ready", - ); - } else { - unawaited( - updateCache( - forced: true, - ).whenComplete(() => Bus.instance.fire(MemoriesChangedEvent())), + "No disk cache (fresh install): serving simple memories, " + "smart memories will upgrade in background", ); + _cachedMemories = await smartMemoriesService.calcSimpleMemories(); + if (_shouldDeferInitialOfflineCacheUpgrade()) { + _pendingInitialOfflineCacheUpgrade = true; + _logger.info( + "Deferring initial offline memories cache upgrade until first import metadata is ready", + ); + } else { + unawaited( + updateCache(forced: true).whenComplete( + () => Bus.instance.fire(MemoriesChangedEvent()), + ), + ); + } + return _cachedMemories!; } - return _cachedMemories!; + _logger.warning( + "No memories found in cache, force updating cache. Possible severe caching issue", + ); + await updateCache(forced: true); + } else { + _logger.info("Found memories in cache"); } - _logger.warning( - "No memories found in cache, force updating cache. Possible severe caching issue", - ); - await updateCache(forced: true); - } else { - _logger.info("Found memories in cache"); - } - if (_cachedMemories == null || _cachedMemories!.isEmpty) { - _logger.severe( - "No memories found in (computed) cache, getting fillers", - ); - await _calculateRegularFillers(); + if (_cachedMemories == null || _cachedMemories!.isEmpty) { + _logger.severe( + "No memories found in (computed) cache, getting fillers", + ); + await _calculateRegularFillers(); + } + return _cachedMemories!; + } catch (e, s) { + _logger.severe("Error in getMemories", e, s); + return []; } - return _cachedMemories!; - } catch (e, s) { - _logger.severe("Error in getMemories", e, s); - return []; + }, + ); + return _debugBuildLibraryMemories(memories); + } + + // TEMP(memory-viewer-verification): remove before merge. This exposes a + // broad sample of the account library as several memory cards so that the + // viewer can be exercised with varied media and sharing states. + Future> _debugBuildLibraryMemories( + List memories, + ) async { + if (!_kTempLibraryMemories || isLocalGalleryMode || memories.isEmpty) { + return memories; + } + return _debugLibraryMemoriesLock.synchronized( + () => _debugBuildLibraryMemoriesLocked(memories), + ); + } + + Future> _debugBuildLibraryMemoriesLocked( + List memories, + ) async { + final source = _cachedMemories ?? memories; + final userID = Configuration.instance.getUserID(); + final inputSignature = memories + .map((memory) => "${memory.id}:${memory.memories.length}") + .join("|"); + final sameContext = + identical(_debugLibraryMemoriesSource, source) && + _debugLibraryMemoriesUserID == userID && + _debugLibraryMemoriesInputSignature == inputSignature; + if (!sameContext) { + _debugLibraryMemories = null; + _debugLibraryMemoriesAttempted = false; + _debugLibraryMemoriesSource = source; + _debugLibraryMemoriesUserID = userID; + _debugLibraryMemoriesInputSignature = inputSignature; + } + if (_debugLibraryMemoriesAttempted) { + return _debugLibraryMemories ?? memories; + } + _debugLibraryMemoriesAttempted = true; + + final searchFiles = await SearchService.instance.getAllFilesForSearch(); + final localOnlyFiles = await FilesDB.instance.getUnUploadedLocalFiles(); + final uniqueFiles = {}; + String debugFileID(EnteFile file) { + final uploadedFileID = file.uploadedFileID; + if (uploadedFileID != null && uploadedFileID != -1) { + return "uploaded:$uploadedFileID"; } - }); + final localID = file.localID; + if (localID != null && localID.isNotEmpty) { + return "local:$localID"; + } + final generatedID = file.generatedID; + return generatedID != null + ? "generated:$generatedID" + : "object:${identityHashCode(file)}"; + } + + void addFiles(Iterable files) { + for (final file in files) { + if (file.creationTime == null) continue; + uniqueFiles.putIfAbsent(debugFileID(file), () => file); + } + } + + addFiles(searchFiles); + addFiles(localOnlyFiles); + if (uniqueFiles.isEmpty) { + _logger.warning("TEMP library memories unavailable: no usable files"); + return memories; + } + + final sortedFiles = uniqueFiles.values.toList() + ..sort((first, second) { + final creationOrder = second.creationTime!.compareTo( + first.creationTime!, + ); + if (creationOrder != 0) return creationOrder; + return (second.generatedID ?? -1).compareTo(first.generatedID ?? -1); + }); + const maxFiles = 600; + const targetFilesPerMemory = 20; + const maxMemoryGroups = 12; + final selectedFiles = sortedFiles.take(maxFiles).toList(); + var groupCount = + (selectedFiles.length + targetFilesPerMemory - 1) ~/ + targetFilesPerMemory; + if (groupCount > maxMemoryGroups) groupCount = maxMemoryGroups; + + final now = DateTime.now().microsecondsSinceEpoch; + final firstDateToShow = now - const Duration(days: 1).inMicroseconds; + final lastDateToShow = now + const Duration(days: 1).inMicroseconds; + final languageCode = (await getLocale())?.languageCode ?? "en"; + final seenTimes = await _memoriesDB.getSeenTimes(); + final filesPerGroup = selectedFiles.length ~/ groupCount; + final groupsWithExtraFile = selectedFiles.length % groupCount; + final debugMemories = []; + var offset = 0; + for (var groupIndex = 0; groupIndex < groupCount; groupIndex++) { + final groupSize = + filesPerGroup + (groupIndex < groupsWithExtraFile ? 1 : 0); + final groupFiles = selectedFiles.sublist(offset, offset + groupSize); + offset += groupSize; + final newestCreationTime = groupFiles.first.creationTime!; + final oldestCreationTime = groupFiles.last.creationTime!; + final newestDate = SmartMemoriesService.getDateFormatted( + creationTime: newestCreationTime, + languageCode: languageCode, + ); + final oldestDate = SmartMemoriesService.getDateFormatted( + creationTime: oldestCreationTime, + languageCode: languageCode, + ); + final title = oldestDate == newestDate + ? newestDate + : "$oldestDate – $newestDate"; + debugMemories.add( + SmartMemory( + Memory.fromFiles(groupFiles, seenTimes), + MemoryType.filler, + title, + firstDateToShow, + lastDateToShow, + id: "debug-library-$userID-$groupIndex", + firstCreationTime: oldestCreationTime, + lastCreationTime: newestCreationTime, + ), + ); + } + + _debugLibraryMemories = debugMemories; + _logger.info( + "TEMP library memories enabled: ${selectedFiles.length} of " + "${uniqueFiles.length} files across ${debugMemories.length} memories " + "(${searchFiles.length} search files, " + "${localOnlyFiles.length} local-only files)", + ); + return debugMemories; } Future _calculateRegularFillers() async { diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 1c07c805707..c32584753e7 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -37,6 +37,7 @@ import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/home/memories/memory_video_prefetcher.dart"; import "package:photos/ui/social/widgets/file_social_overlay.dart"; import "package:photos/ui/viewer/file/file_widget.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/ui/viewer/file_details/favorite_widget.dart"; import "package:photos/ui/viewer/gallery/jump_to_date_gallery.dart"; import "package:photos/utils/dialog_util.dart"; @@ -605,6 +606,7 @@ class _FullScreenMemoryState extends State { body: Stack( fit: StackFit.expand, children: [ + const _MemoryBlur(), ValueListenableBuilder( valueListenable: inheritedData.indexNotifier, builder: (context, index, _) { @@ -681,7 +683,7 @@ class _FullScreenMemoryState extends State { autoPlay: false, tagPrefix: "memories", backgroundDecoration: const BoxDecoration( - color: Colors.black, + color: Colors.transparent, ), isFromMemories: true, playbackCallback: (shouldEnable, _) { @@ -1081,22 +1083,17 @@ class _MemoryViewerScrims extends StatelessWidget { child: SizedBox( width: double.infinity, height: topHeight, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), - child: const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xB8000000), - Color(0x70000000), - Colors.transparent, - ], - stops: [0, 0.6, 1], - ), - ), + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xB8000000), + Color(0x70000000), + Colors.transparent, + ], + stops: [0, 0.6, 1], ), ), ), @@ -1129,6 +1126,56 @@ class _MemoryViewerScrims extends StatelessWidget { } } +class _MemoryBlur extends StatelessWidget { + const _MemoryBlur(); + + @override + Widget build(BuildContext context) { + final inheritedData = FullScreenMemoryData.of(context); + if (inheritedData == null || inheritedData.memories.isEmpty) { + return const SizedBox.shrink(); + } + return ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + final currentFile = inheritedData.memories[safeIndex].file; + if (currentFile.fileType == FileType.video) { + return const SizedBox.shrink(); + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 750), + switchInCurve: Curves.easeOutExpo, + switchOutCurve: Curves.easeInExpo, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + fit: StackFit.expand, + children: [...previousChildren, ?currentChild], + ); + }, + child: ImageFiltered( + key: ValueKey( + "memory-blur-${currentFile.uploadedFileID ?? currentFile.localID ?? safeIndex}", + ), + imageFilter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), + child: ThumbnailWidget( + currentFile, + placeholderColor: Colors.black, + shouldShowSyncStatus: false, + shouldShowFavoriteIcon: false, + shouldShowVideoOverlayIcon: false, + ), + ), + ); + }, + ); + } +} + class MemoriesZoomWidget extends StatefulWidget { final Widget child; final bool isVideo; diff --git a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart index 2a6ece5a57c..b85d4bf793b 100644 --- a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart +++ b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart @@ -1,47 +1,40 @@ import "package:flutter/material.dart"; -const double kMemoryProgressSegmentWidth = 8.0; const double kMemoryProgressGap = 10.0; const double kMemoryProgressHeight = 4.0; -const double kMemoryProgressMinimumCurrentWidth = 24.0; +const double kMemoryProgressMinSegmentWidth = 8.0; -int memoryProgressCapacityForWidth( - double availableWidth, { - double segmentWidth = kMemoryProgressSegmentWidth, +double memoryProgressSegmentWidthForLayout({ + required int totalSteps, + required double availableWidth, double gap = kMemoryProgressGap, - double minimumCurrentWidth = kMemoryProgressMinimumCurrentWidth, }) { - if (!availableWidth.isFinite || + if (totalSteps <= 0 || + !availableWidth.isFinite || availableWidth <= 0 || - !segmentWidth.isFinite || - segmentWidth <= 0 || !gap.isFinite || - gap < 0 || - !minimumCurrentWidth.isFinite || - minimumCurrentWidth <= 0) { - return 1; + gap < 0) { + return 0; } - final capacity = - 1 + - ((availableWidth - minimumCurrentWidth) / (segmentWidth + gap)).floor(); - return capacity < 1 ? 1 : capacity; + final availableSegmentWidth = availableWidth - ((totalSteps - 1) * gap); + if (availableSegmentWidth <= 0) return 0; + + return availableSegmentWidth / totalSteps; } bool memoryProgressUsesContinuousTrack({ required int totalSteps, required double availableWidth, - double segmentWidth = kMemoryProgressSegmentWidth, double gap = kMemoryProgressGap, - double minimumCurrentWidth = kMemoryProgressMinimumCurrentWidth, }) => - totalSteps > - memoryProgressCapacityForWidth( - availableWidth, - segmentWidth: segmentWidth, - gap: gap, - minimumCurrentWidth: minimumCurrentWidth, - ); + totalSteps > 1 && + memoryProgressSegmentWidthForLayout( + totalSteps: totalSteps, + availableWidth: availableWidth, + gap: gap, + ) < + kMemoryProgressMinSegmentWidth; class MemoryProgressIndicator extends StatefulWidget { final int totalSteps; @@ -168,9 +161,10 @@ class _MemoryProgressIndicatorState extends State ); segments.add( - index == widget.currentIndex - ? Expanded(child: segment) - : SizedBox(width: kMemoryProgressSegmentWidth, child: segment), + Expanded( + key: ValueKey("memory-progress-segment-$index"), + child: segment, + ), ); } return Row(children: segments); diff --git a/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart index 5a2f185cd5d..ac604be9059 100644 --- a/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart +++ b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart @@ -3,50 +3,101 @@ import "package:flutter_test/flutter_test.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; void main() { - group("memory progress capacity", () { - test("derives phone and tablet capacity from the available width", () { - expect(memoryProgressCapacityForWidth(375), 20); - expect(memoryProgressCapacityForWidth(343), 18); - expect(memoryProgressCapacityForWidth(736), 40); - expect(memoryProgressCapacityForWidth(329), 17); - expect(memoryProgressCapacityForWidth(330), 18); - }); - - test("switches to a continuous track only after capacity", () { - expect( - memoryProgressUsesContinuousTrack(totalSteps: 18, availableWidth: 343), - isFalse, - ); + group("memory progress layout", () { + test("derives equal segment widths from the available width", () { expect( - memoryProgressUsesContinuousTrack(totalSteps: 19, availableWidth: 343), - isTrue, + memoryProgressSegmentWidthForLayout(totalSteps: 6, availableWidth: 343), + closeTo(48.833333, 0.000001), ); expect( - memoryProgressUsesContinuousTrack(totalSteps: 41, availableWidth: 736), - isTrue, + memoryProgressSegmentWidthForLayout( + totalSteps: 18, + availableWidth: 343, + ), + closeTo(9.611111, 0.000001), ); }); - }); - testWidgets("keeps the current phone segment flexible at capacity", ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: Center( - child: SizedBox( - width: 343, - child: MemoryProgressIndicator(totalSteps: 18, currentIndex: 7), + test( + "switches when equal chunks would be narrower than the legibility limit", + () { + expect( + memoryProgressUsesContinuousTrack( + totalSteps: 19, + availableWidth: 343, ), - ), - ), + isFalse, + ); + expect( + memoryProgressUsesContinuousTrack( + totalSteps: 20, + availableWidth: 343, + ), + isTrue, + ); + expect( + memoryProgressUsesContinuousTrack( + totalSteps: 41, + availableWidth: 736, + ), + isFalse, + ); + expect( + memoryProgressUsesContinuousTrack( + totalSteps: 42, + availableWidth: 736, + ), + isTrue, + ); + }, ); - - expect(find.byType(Row), findsOneWidget); - expect(tester.getSize(find.byType(LinearProgressIndicator)).width, 37); }); - testWidgets("uses the full track when the phone capacity overflows", ( + for (final availableWidth in [343.0, 736.0]) { + for (final totalSteps in [6, 18]) { + testWidgets("fills ${availableWidth}px with $totalSteps equal chunks", ( + tester, + ) async { + final currentIndex = totalSteps ~/ 2; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: availableWidth, + child: MemoryProgressIndicator( + totalSteps: totalSteps, + currentIndex: currentIndex, + ), + ), + ), + ), + ); + + expect(find.byType(Row), findsOneWidget); + final widths = [ + for (var index = 0; index < totalSteps; index++) + tester + .getSize(find.byKey(ValueKey("memory-progress-segment-$index"))) + .width, + ]; + final segmentWidth = widths.first; + final currentWidth = widths[currentIndex]; + + expect(currentWidth / segmentWidth, closeTo(1.0, 0.000001)); + for (final width in widths) { + expect(width, closeTo(segmentWidth, 0.000001)); + } + expect( + widths.reduce((left, right) => left + right) + + ((totalSteps - 1) * kMemoryProgressGap), + closeTo(availableWidth, 0.000001), + ); + }); + } + } + + testWidgets("uses the full track when segmented chunks become too narrow", ( tester, ) async { await tester.pumpWidget( @@ -54,7 +105,7 @@ void main() { home: Center( child: SizedBox( width: 343, - child: MemoryProgressIndicator(totalSteps: 19, currentIndex: 7), + child: MemoryProgressIndicator(totalSteps: 20, currentIndex: 7), ), ), ), From 7044a0d2c252437715a425513a089c060a6a313c Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 17 Jul 2026 14:46:28 +0530 Subject: [PATCH 06/91] [mob][photos] Adapt memories viewer scrim --- .../changes/memory-viewer-adaptive-scrim.md | 1 + .../ui/home/memories/full_screen_memory.dart | 65 +++++++++++++------ .../social/widgets/file_social_overlay.dart | 4 ++ 3 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md diff --git a/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md b/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md new file mode 100644 index 00000000000..15dedcc7237 --- /dev/null +++ b/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md @@ -0,0 +1 @@ +- Improved memory controls with a lighter backdrop that adapts to social actions. diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index c32584753e7..790f713be13 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -6,6 +6,7 @@ import "package:connectivity_plus/connectivity_plus.dart"; import "package:ente_components/theme/text_styles.dart" as component; import "package:ente_icons/ente_icons.dart"; import "package:ente_pure_utils/ente_pure_utils.dart"; +import "package:flutter/foundation.dart" show ValueListenable; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_svg/flutter_svg.dart"; @@ -70,6 +71,9 @@ bool _isValidMemoryIndex(int index, int length) { return index >= 0 && index < length; } +const _memoryBottomActionBarHeight = 80.0; +const _memorySocialScrimHeight = 296.0; + class FullScreenMemoryDataUpdater extends StatefulWidget { final List memories; final int initialIndex; @@ -358,6 +362,7 @@ class _FullScreenMemoryState extends State { // invalidates the prior delayed forward. Object? _kenBurnsStartToken; bool _isAnimationPaused = false; + final _socialControlsVisible = ValueNotifier(false); /// Used to check if any pointer is on the screen. final hasPointerOnScreenNotifier = ValueNotifier(false); @@ -400,6 +405,7 @@ class _FullScreenMemoryState extends State { void dispose() { hasPointerOnScreenNotifier.removeListener(_hasPointerListener); _detailSheetEventSubscription.cancel(); + _socialControlsVisible.dispose(); _progressAnimationController = null; _zoomAnimationController = null; _kenBurnsStartToken = null; @@ -531,6 +537,7 @@ class _FullScreenMemoryState extends State { inheritedData.indexNotifier.value += 1; _onPageChange(inheritedData, currentIndex + 1); } else if (widget.onNextMemory != null) { + _setSocialControlsVisible(false); widget.onNextMemory!(); } else { isAtFirstOrLastFile = true; @@ -550,6 +557,7 @@ class _FullScreenMemoryState extends State { inheritedData.indexNotifier.value -= 1; _onPageChange(inheritedData, currentIndex - 1); } else if (widget.onPreviousMemory != null) { + _setSocialControlsVisible(false); widget.onPreviousMemory!(); } else { isAtFirstOrLastFile = true; @@ -560,6 +568,7 @@ class _FullScreenMemoryState extends State { void _onPageChange(FullScreenMemoryData inheritedData, int index) { if (!_isValidMemoryIndex(index, inheritedData.memories.length)) return; + _setSocialControlsVisible(false); isAtFirstOrLastFile = false; unawaited( memoriesCacheService.markMemoryAsSeen( @@ -571,6 +580,12 @@ class _FullScreenMemoryState extends State { _resetAnimation(); } + void _setSocialControlsVisible(bool visible) { + if (_socialControlsVisible.value != visible) { + _socialControlsVisible.value = visible; + } + } + Future _runWithViewerPaused(Future Function() action) async { if (!mounted) return null; _pauseViewer(); @@ -699,7 +714,7 @@ class _FullScreenMemoryState extends State { ); }, ), - const _MemoryViewerScrims(), + _MemoryViewerScrims(socialControlsVisible: _socialControlsVisible), ValueListenableBuilder( valueListenable: inheritedData.indexNotifier, builder: (context, index, _) { @@ -713,6 +728,7 @@ class _FullScreenMemoryState extends State { currentUserID: Configuration.instance.getUserID(), onInteractionStart: _pauseViewer, onInteractionEnd: _resumeViewer, + onVisibilityChanged: _setSocialControlsVisible, ); }, ), @@ -850,6 +866,7 @@ class BottomIcons extends StatelessWidget { context, actionFile, onFileRemoved: (file) { + fullScreenState._setSocialControlsVisible(false); fullScreenState.hasFinalFileLoaded = false; fullScreenState._resetAnimation(); inheritedData.removeCurrentMemory(); @@ -1069,7 +1086,9 @@ class _MemoryTopChrome extends StatelessWidget { } class _MemoryViewerScrims extends StatelessWidget { - const _MemoryViewerScrims(); + final ValueListenable socialControlsVisible; + + const _MemoryViewerScrims({required this.socialControlsVisible}); @override Widget build(BuildContext context) { @@ -1099,25 +1118,33 @@ class _MemoryViewerScrims extends StatelessWidget { ), ), ), - const Align( + Align( alignment: Alignment.bottomCenter, - child: SizedBox( - width: double.infinity, - height: 200, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Color(0x8A000000), - Colors.black, - ], - stops: [0, 0.55, 1], + child: ValueListenableBuilder( + valueListenable: socialControlsVisible, + builder: (context, isVisible, _) { + final bottomInset = MediaQuery.paddingOf(context).bottom; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: double.infinity, + height: isVisible + ? _memorySocialScrimHeight + : bottomInset + _memoryBottomActionBarHeight, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color.fromARGB(97, 0, 0, 0), + Color.fromARGB(42, 0, 0, 0), + Colors.transparent, + ], + stops: [0, 0.5, 1], + ), ), - ), - ), + ); + }, ), ), ], diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 5bbc5c4117d..26ec33ed5ac 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -34,6 +34,7 @@ class FileSocialOverlay extends StatefulWidget { final int? currentUserID; final VoidCallback onInteractionStart; final VoidCallback onInteractionEnd; + final ValueChanged onVisibilityChanged; final bool includeHiddenCollections; const FileSocialOverlay({ @@ -41,6 +42,7 @@ class FileSocialOverlay extends StatefulWidget { required this.currentUserID, required this.onInteractionStart, required this.onInteractionEnd, + required this.onVisibilityChanged, this.includeHiddenCollections = false, super.key, }); @@ -124,6 +126,7 @@ class _FileSocialOverlayState extends State { if (sharedCollections.isEmpty) { setState(_clearSocialState); + widget.onVisibilityChanged(false); return; } @@ -168,6 +171,7 @@ class _FileSocialOverlayState extends State { _latestComment = latestComment; _latestCommentAuthor = latestCommentAuthor; }); + widget.onVisibilityChanged(true); } catch (error, stackTrace) { _logger.warning( "Failed to refresh social overlay for file $fileID", From d61ce0361ade3f7de5ba2a800dbf53b8141e9a83 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 17 Jul 2026 17:18:57 +0530 Subject: [PATCH 07/91] feat(photos): refine memories viewer interactions --- .../changes/memory-viewer-interactions.md | 1 + .../lib/ui/home/memories/custom_listener.dart | 51 +++++++++-- .../ui/home/memories/full_screen_memory.dart | 88 +++++++++++++++---- .../social/widgets/file_social_overlay.dart | 69 +++++++++------ .../viewer/file_details/favorite_widget.dart | 69 +++++++++------ .../home/memories/custom_listener_test.dart | 68 ++++++++++++++ 6 files changed, 272 insertions(+), 74 deletions(-) create mode 100644 mobile/apps/photos/changes/memory-viewer-interactions.md create mode 100644 mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart diff --git a/mobile/apps/photos/changes/memory-viewer-interactions.md b/mobile/apps/photos/changes/memory-viewer-interactions.md new file mode 100644 index 00000000000..01eb511c464 --- /dev/null +++ b/mobile/apps/photos/changes/memory-viewer-interactions.md @@ -0,0 +1 @@ +- Refined memory viewer controls, comments, and swipe-up file details. diff --git a/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart b/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart index 72bbcf5a0ef..2a055f54fbb 100644 --- a/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart +++ b/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart @@ -28,6 +28,8 @@ class ActivePointers with ChangeNotifier { class MemoriesPointerGestureListener extends StatefulWidget { final Widget child; final Function(PointerEvent)? onTap; + final VoidCallback? onSwipeUp; + final bool Function()? canSwipeUp; // final VoidCallback? onLongPress; // final VoidCallback? onLongPressUp; @@ -37,6 +39,9 @@ class MemoriesPointerGestureListener extends StatefulWidget { /// Maximum movement (in logical pixels) before we consider it a drag. final double touchSlop; + /// Minimum upward displacement before a gesture opens file details. + final double swipeUpThreshold; + /// Notifier that indicates whether there are active pointers. final ValueNotifier? hasPointerNotifier; static const double kTouchSlop = 18.0; // Default touch slop value @@ -45,11 +50,14 @@ class MemoriesPointerGestureListener extends StatefulWidget { super.key, required this.child, this.onTap, + this.onSwipeUp, + this.canSwipeUp, // this.onLongPress, // this.onLongPressUp, this.hasPointerNotifier, this.longPressDuration = const Duration(milliseconds: 500), this.touchSlop = kTouchSlop, // from flutter/gestures/constants.dart + this.swipeUpThreshold = 48, }); @override @@ -62,6 +70,7 @@ class MemoriesPointerGestureListenerState Timer? _longPressTimer; bool _longPressFired = false; Offset? _downPosition; + int? _trackedPointer; bool hasPointerMoved = false; final _activePointers = ActivePointers(); @@ -79,6 +88,12 @@ class MemoriesPointerGestureListenerState void _handlePointerDown(PointerDownEvent event) { _addPointer(event.pointer); + if (_trackedPointer != null) { + _longPressTimer?.cancel(); + _longPressTimer = null; + return; + } + _trackedPointer = event.pointer; _downPosition = event.localPosition; _longPressFired = false; _longPressTimer?.cancel(); @@ -89,34 +104,58 @@ class MemoriesPointerGestureListenerState } void _handlePointerMove(PointerMoveEvent event) { - if (_longPressTimer != null && _downPosition != null) { + if (event.pointer == _trackedPointer && _downPosition != null) { final distance = (event.localPosition - _downPosition!).distance; if (distance > widget.touchSlop) { // user started dragging – cancel long‐press hasPointerMoved = true; - _longPressTimer!.cancel(); + _longPressTimer?.cancel(); _longPressTimer = null; } } } void _handlePointerUp(PointerUpEvent event) { + if (event.pointer != _trackedPointer) { + _removePointer(event.pointer); + return; + } + _longPressTimer?.cancel(); _longPressTimer = null; + final wasPartOfMultitouch = + _activePointers.activePointerWasPartOfMultitouch; + final displacement = _downPosition == null + ? Offset.zero + : event.localPosition - _downPosition!; + final isSwipeUp = + widget.onSwipeUp != null && + (widget.canSwipeUp?.call() ?? true) && + !wasPartOfMultitouch && + displacement.dy <= -widget.swipeUpThreshold && + displacement.dy.abs() > displacement.dx.abs(); + + // Release the pointer before callbacks so opening a sheet can take over + // the viewer's pause state without the release immediately resuming it. + _removePointer(event.pointer); if (_longPressFired) { // widget.onLongPressUp?.call(); + } else if (isSwipeUp) { + widget.onSwipeUp?.call(); } else { - if (!_activePointers.activePointerWasPartOfMultitouch && - !hasPointerMoved) { + if (!wasPartOfMultitouch && !hasPointerMoved) { widget.onTap?.call(event); } } - _removePointer(event.pointer); _reset(); } void _handlePointerCancel(PointerCancelEvent event) { + if (event.pointer != _trackedPointer) { + _removePointer(event.pointer); + return; + } _longPressTimer?.cancel(); _longPressTimer = null; _longPressFired = false; @@ -154,5 +193,7 @@ class MemoriesPointerGestureListenerState void _reset() { hasPointerMoved = false; + _downPosition = null; + _trackedPointer = null; } } diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 790f713be13..2efae00887b 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -4,7 +4,6 @@ import "dart:ui"; import "package:connectivity_plus/connectivity_plus.dart"; import "package:ente_components/theme/text_styles.dart" as component; -import "package:ente_icons/ente_icons.dart"; import "package:ente_pure_utils/ente_pure_utils.dart"; import "package:flutter/foundation.dart" show ValueListenable; import "package:flutter/material.dart"; @@ -362,6 +361,7 @@ class _FullScreenMemoryState extends State { // invalidates the prior delayed forward. Object? _kenBurnsStartToken; bool _isAnimationPaused = false; + bool _isMediaZoomed = false; final _socialControlsVisible = ValueNotifier(false); /// Used to check if any pointer is on the screen. @@ -527,6 +527,7 @@ class _FullScreenMemoryState extends State { void _goToNext(FullScreenMemoryData inheritedData) { if (inheritedData.memories.isEmpty) return; + _isMediaZoomed = false; hasFinalFileLoaded = false; final currentIndex = _clampedMemoryIndex( inheritedData.indexNotifier.value, @@ -547,6 +548,7 @@ class _FullScreenMemoryState extends State { void _goToPrevious(FullScreenMemoryData inheritedData) { if (inheritedData.memories.isEmpty) return; + _isMediaZoomed = false; hasFinalFileLoaded = false; final currentIndex = _clampedMemoryIndex( inheritedData.indexNotifier.value, @@ -568,6 +570,7 @@ class _FullScreenMemoryState extends State { void _onPageChange(FullScreenMemoryData inheritedData, int index) { if (!_isValidMemoryIndex(index, inheritedData.memories.length)) return; + _isMediaZoomed = false; _setSocialControlsVisible(false); isAtFirstOrLastFile = false; unawaited( @@ -667,6 +670,15 @@ class _FullScreenMemoryState extends State { _goToNext(inheritedData); } }, + onSwipeUp: () { + if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; + unawaited( + _runWithViewerPaused( + () => showDetailsSheet(context, currentFile), + ), + ); + }, + canSwipeUp: () => !_isMediaZoomed, hasPointerNotifier: hasPointerOnScreenNotifier, child: AnimatedOpacity( opacity: _firstPhotoOpacity, @@ -701,6 +713,9 @@ class _FullScreenMemoryState extends State { color: Colors.transparent, ), isFromMemories: true, + shouldDisableScroll: (isZoomed) { + _isMediaZoomed = isZoomed; + }, playbackCallback: (shouldEnable, _) { _toggleAnimation(pause: !shouldEnable); }, @@ -827,8 +842,8 @@ class BottomIcons extends StatelessWidget { if (currentFile.isUploaded && !isHidden) _MemoryActionButton( tooltip: l10n.addToAlbum, - icon: const Icon( - EnteIcons.addToAlbum, + icon: const HugeIcon( + icon: HugeIcons.strokeRoundedAddSquare, color: Colors.white, size: 24, ), @@ -1006,7 +1021,7 @@ class _MemoryTopChrome extends StatelessWidget { dimension: 48, child: IconButton( tooltip: AppLocalizations.of(context).close, - padding: const EdgeInsets.all(15), + padding: const EdgeInsets.all(8), style: IconButton.styleFrom( minimumSize: const Size.square(48), maximumSize: const Size.square(48), @@ -1016,10 +1031,10 @@ class _MemoryTopChrome extends StatelessWidget { ), ), onPressed: onClose, - icon: const Icon( - Icons.close, + icon: const HugeIcon( + icon: HugeIcons.strokeRoundedCancel01, color: Colors.white, - size: 18, + size: 32, ), ), ), @@ -1042,16 +1057,31 @@ class _MemoryTopChrome extends StatelessWidget { .copyWith(color: Colors.white), ), ), - Text( - SmartMemoriesService.getDateFormatted( - creationTime: currentFile.creationTime!, - context: context, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: component.TextStyles.mini.copyWith( - color: Colors.white, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + SmartMemoriesService.getDateFormatted( + creationTime: + currentFile.creationTime!, + context: context, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: component.TextStyles.mini + .copyWith(color: Colors.white), + ), + ), + const SizedBox(width: 8), + const SizedBox( + width: 4, + height: 8, + child: CustomPaint( + painter: _MemoryDateChevronPainter(), + ), + ), + ], ), ], ), @@ -1064,6 +1094,8 @@ class _MemoryTopChrome extends StatelessWidget { child: Center( child: FavoriteWidget( currentFile, + iconSize: 24, + tapTargetSize: 48, key: ValueKey( currentFile.uploadedFileID ?? currentFile.localID, @@ -1085,6 +1117,28 @@ class _MemoryTopChrome extends StatelessWidget { } } +class _MemoryDateChevronPainter extends CustomPainter { + const _MemoryDateChevronPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.25 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(0.5, 0.5) + ..lineTo(size.width - 0.5, size.height / 2) + ..lineTo(0.5, size.height - 0.5); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + class _MemoryViewerScrims extends StatelessWidget { final ValueListenable socialControlsVisible; diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 26ec33ed5ac..902a467284a 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "dart:math" as math; import "package:collection/collection.dart"; import "package:ente_components/theme/text_styles.dart" as component; @@ -26,6 +27,7 @@ final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); const _socialControlsSize = 40.0; +const _socialIconSize = 32.0; const _bottomActionBarHeight = 80.0; const _socialToActionBarGap = 14.0; @@ -366,7 +368,7 @@ class _FileSocialOverlayState extends State { child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(4), style: IconButton.styleFrom( minimumSize: const Size.square(_socialControlsSize), maximumSize: const Size.square(_socialControlsSize), @@ -377,6 +379,7 @@ class _FileSocialOverlayState extends State { icon: Icon( _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, color: _hasLiked ? _likedColor : Colors.white, + size: _socialIconSize, ), ), ), @@ -388,7 +391,7 @@ class _FileSocialOverlayState extends State { child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(4), style: IconButton.styleFrom( minimumSize: const Size.square(_socialControlsSize), maximumSize: const Size.square(_socialControlsSize), @@ -415,7 +418,7 @@ class _FileSocialOverlayState extends State { onTap: () => unawaited(_openComments(comment: comment)), child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.55, + maxWidth: math.min(MediaQuery.sizeOf(context).width * 0.5, 200), ), child: Stack( clipBehavior: Clip.none, @@ -446,12 +449,21 @@ class _FileSocialOverlayState extends State { ), ), Positioned( - left: -10, - top: -10, - child: UserAvatarWidget( - author, - type: AvatarType.small, - currentUserID: currentUserID, + left: -3, + top: -6, + child: Container( + width: 22, + height: 22, + padding: const EdgeInsets.all(1), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: UserAvatarWidget( + author, + type: AvatarType.small, + currentUserID: currentUserID, + ), ), ), ], @@ -464,30 +476,33 @@ class _FileSocialOverlayState extends State { return Stack( clipBehavior: Clip.none, children: [ - const Icon(EnteIcons.commentBubbleStroke, color: Colors.white), + const Icon( + EnteIcons.commentBubbleStroke, + color: Colors.white, + size: _socialIconSize, + ), if (_commentCount > 0) Positioned( - right: -4, - top: -4, + right: -5, + top: -5, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: const BoxDecoration( color: Colors.white, - borderRadius: const BorderRadius.all(Radius.circular(16)), - border: Border.all( - color: Colors.black, - width: 2, - strokeAlign: BorderSide.strokeAlignOutside, - ), + borderRadius: BorderRadius.all(Radius.circular(19)), ), - child: Text( - _commentCount > 99 ? "99+" : _commentCount.toString(), - style: const TextStyle( - color: Colors.black, - fontSize: 8, - fontWeight: FontWeight.w500, + child: Center( + child: Text( + _commentCount > 99 ? "99+" : _commentCount.toString(), + style: const TextStyle( + color: Colors.black, + fontSize: 10, + height: 1.2, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), ), diff --git a/mobile/apps/photos/lib/ui/viewer/file_details/favorite_widget.dart b/mobile/apps/photos/lib/ui/viewer/file_details/favorite_widget.dart index 124f1a8c366..947e1ab0727 100644 --- a/mobile/apps/photos/lib/ui/viewer/file_details/favorite_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/file_details/favorite_widget.dart @@ -11,8 +11,15 @@ import "package:rive/rive.dart" as rive; class FavoriteWidget extends StatefulWidget { final EnteFile file; + final double? iconSize; + final double? tapTargetSize; - const FavoriteWidget(this.file, {super.key}); + const FavoriteWidget( + this.file, { + this.iconSize, + this.tapTargetSize, + super.key, + }); @override State createState() => _FavoriteWidgetState(); @@ -165,35 +172,47 @@ class _FavoriteWidgetState extends State { Widget build(BuildContext context) { // Show blank while initial state is being fetched if (_isFavorite == null || _isLoading) { - return const SizedBox(width: 22, height: 22); + final placeholderSize = widget.tapTargetSize ?? 22; + return SizedBox.square(dimension: placeholderSize); } + final riveWidget = rive.RiveWidgetBuilder( + fileLoader: _riveFileLoader, + stateMachineSelector: const rive.StateMachineNamed("State Machine 1"), + onLoaded: _handleRiveLoaded, + builder: (BuildContext context, rive.RiveState state) { + if (state is rive.RiveLoaded) { + return rive.RiveWidget( + controller: state.controller, + fit: rive.Fit.contain, + ); + } + if (state is rive.RiveFailed) { + _logger.warning("Failed to load Rive file: ${state.error}"); + } + return const SizedBox.shrink(); + }, + ); + return GestureDetector( onTap: _onTap, behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), - width: 34, - height: 30, - child: rive.RiveWidgetBuilder( - fileLoader: _riveFileLoader, - stateMachineSelector: const rive.StateMachineNamed("State Machine 1"), - onLoaded: _handleRiveLoaded, - builder: (BuildContext context, rive.RiveState state) { - if (state is rive.RiveLoaded) { - return rive.RiveWidget( - controller: state.controller, - fit: rive.Fit.contain, - ); - } - if (state is rive.RiveFailed) { - _logger.warning("Failed to load Rive file: ${state.error}"); - } - // Loading state - return const SizedBox.shrink(); - }, - ), - ), + child: widget.iconSize != null || widget.tapTargetSize != null + ? SizedBox.square( + dimension: widget.tapTargetSize ?? widget.iconSize!, + child: Center( + child: SizedBox.square( + dimension: widget.iconSize ?? widget.tapTargetSize!, + child: riveWidget, + ), + ), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + width: 34, + height: 30, + child: riveWidget, + ), ); } } diff --git a/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart b/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart new file mode 100644 index 00000000000..15654bb11f8 --- /dev/null +++ b/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart @@ -0,0 +1,68 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:photos/ui/home/memories/custom_listener.dart"; + +void main() { + const targetKey = ValueKey("gesture-target"); + + Future pumpListener( + WidgetTester tester, { + required VoidCallback onSwipeUp, + bool Function()? canSwipeUp, + }) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 200, + child: MemoriesPointerGestureListener( + onSwipeUp: onSwipeUp, + canSwipeUp: canSwipeUp, + child: const ColoredBox(key: targetKey, color: Color(0xFF000000)), + ), + ), + ), + ), + ); + } + + testWidgets("opens details after an upward-dominant swipe", (tester) async { + var swipeCount = 0; + await pumpListener(tester, onSwipeUp: () => swipeCount++); + + await tester.drag(find.byKey(targetKey), const Offset(10, -60)); + + expect(swipeCount, 1); + }); + + testWidgets("does not open details while media is zoomed", (tester) async { + var swipeCount = 0; + await pumpListener( + tester, + onSwipeUp: () => swipeCount++, + canSwipeUp: () => false, + ); + + await tester.drag(find.byKey(targetKey), const Offset(0, -60)); + + expect(swipeCount, 0); + }); + + testWidgets("disqualifies the full gesture after multitouch", (tester) async { + var swipeCount = 0; + await pumpListener(tester, onSwipeUp: () => swipeCount++); + final center = tester.getCenter(find.byKey(targetKey)); + final firstPointer = await tester.startGesture(center, pointer: 1); + final secondPointer = await tester.startGesture( + center + const Offset(10, 0), + pointer: 2, + ); + + await secondPointer.up(); + await firstPointer.moveBy(const Offset(0, -60)); + await firstPointer.up(); + + expect(swipeCount, 0); + }); +} From 068bbab9f1fc913f8a0a6892cfcca1f740d3e8a6 Mon Sep 17 00:00:00 2001 From: Priyanshu Dangare Date: Fri, 17 Jul 2026 17:58:42 +0530 Subject: [PATCH 08/91] [mobile][photos] Fix media management hint flow --- .../ui/viewer/actions/file_selection_actions_widget.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart index 6af764aec0b..a4ae0ad1c78 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -1097,9 +1097,6 @@ class _FileSelectionActionsWidgetState onTap: () async { try { await deleteFilesOnDeviceOnly(context, filesToDelete); - if (mounted) { - await showMediaManagementHintSheet(context); - } } catch (e) { if (mounted) { await showGenericErrorDialog(context: context, error: e); @@ -1123,6 +1120,9 @@ class _FileSelectionActionsWidgetState if (actionResult?.action == ButtonAction.first) { widget.selectedFiles.clearAll(); + if (mounted) { + await showMediaManagementHintSheet(context); + } } } From 679c6ecbfdf155efa75a869192a6c8aed11644ac Mon Sep 17 00:00:00 2001 From: ashilkn Date: Fri, 17 Jul 2026 18:20:46 +0530 Subject: [PATCH 09/91] fix(photos): address memories viewer review findings --- .../changes/memory-viewer-adaptive-scrim.md | 1 - .../photos/changes/memory-viewer-backdrop.md | 1 - .../changes/memory-viewer-interactions.md | 1 - .../lib/services/memories_cache_service.dart | 290 ++++-------------- .../lib/ui/home/memories/custom_listener.dart | 13 +- .../ui/home/memories/full_screen_memory.dart | 49 ++- .../memories/memory_progress_indicator.dart | 8 +- .../memories/memory_viewer_constants.dart | 2 + .../social/widgets/file_social_overlay.dart | 50 ++- .../home/memories/custom_listener_test.dart | 27 ++ 10 files changed, 151 insertions(+), 291 deletions(-) delete mode 100644 mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md delete mode 100644 mobile/apps/photos/changes/memory-viewer-backdrop.md delete mode 100644 mobile/apps/photos/changes/memory-viewer-interactions.md create mode 100644 mobile/apps/photos/lib/ui/home/memories/memory_viewer_constants.dart diff --git a/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md b/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md deleted file mode 100644 index 15dedcc7237..00000000000 --- a/mobile/apps/photos/changes/memory-viewer-adaptive-scrim.md +++ /dev/null @@ -1 +0,0 @@ -- Improved memory controls with a lighter backdrop that adapts to social actions. diff --git a/mobile/apps/photos/changes/memory-viewer-backdrop.md b/mobile/apps/photos/changes/memory-viewer-backdrop.md deleted file mode 100644 index 0a4b2db48ca..00000000000 --- a/mobile/apps/photos/changes/memory-viewer-backdrop.md +++ /dev/null @@ -1 +0,0 @@ -- Improved the memories viewer with a full-screen blurred backdrop and evenly sized progress indicators. diff --git a/mobile/apps/photos/changes/memory-viewer-interactions.md b/mobile/apps/photos/changes/memory-viewer-interactions.md deleted file mode 100644 index 01eb511c464..00000000000 --- a/mobile/apps/photos/changes/memory-viewer-interactions.md +++ /dev/null @@ -1 +0,0 @@ -- Refined memory viewer controls, comments, and swipe-up file details. diff --git a/mobile/apps/photos/lib/services/memories_cache_service.dart b/mobile/apps/photos/lib/services/memories_cache_service.dart index 75dfb7dc315..20d06855630 100644 --- a/mobile/apps/photos/lib/services/memories_cache_service.dart +++ b/mobile/apps/photos/lib/services/memories_cache_service.dart @@ -6,7 +6,6 @@ import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart" show kDebugMode; import "package:logging/logging.dart"; import "package:path_provider/path_provider.dart"; -import "package:photos/core/configuration.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/db/files_db.dart"; import "package:photos/db/memories_db.dart"; @@ -51,12 +50,6 @@ class MemoriesCacheService { static const _shouldUpdateCacheKey = "memories.shouldUpdateCache"; static const _tripMemoryCarryForwardLimit = kTripSurfaceSlots; - // TEMP(memory-viewer-verification): remove before merge. - static const _kTempLibraryMemories = bool.fromEnvironment( - "debugSocialMemoriesMix", - defaultValue: false, - ); - /// Delay is for cache update to be done not during app init, during which a /// lot of other things are happening. static const _kCacheUpdateDelay = Duration(seconds: 20); @@ -69,11 +62,6 @@ class MemoriesCacheService { : MemoriesDB.instance; List? _cachedMemories; - List? _debugLibraryMemories; - List? _debugLibraryMemoriesSource; - int? _debugLibraryMemoriesUserID; - String? _debugLibraryMemoriesInputSignature; - bool _debugLibraryMemoriesAttempted = false; List? get currentMemoriesSync => _cachedMemories; bool _shouldUpdate = false; bool _pendingInitialOfflineCacheUpgrade = false; @@ -84,7 +72,6 @@ class MemoriesCacheService { final _memoriesUpdateLock = Lock(); final _memoriesGetLock = Lock(); - final _debugLibraryMemoriesLock = Lock(); MemoriesCacheService(this._prefs) { _logger.info("MemoriesCacheService constructor"); @@ -629,223 +616,82 @@ class MemoriesCacheService { _logger.info('Showing memories is disabled in settings, showing none'); return []; } - final memories = await _memoriesGetLock.synchronized>( - () async { - if (_cachedMemories != null && _cachedMemories!.isNotEmpty) { - final currentMemories = _cachedMemories! - .where((memory) => memory.shouldShowNow()) - .toList(); - if (currentMemories.isNotEmpty) { - _logger.info("Found memories in memory cache"); - return currentMemories; - } - _logger.info( - "In-memory memories not valid for current window, refreshing cache", - ); - } else if (onlyUseCache) { - _logger.info("Only using cache, no memories found"); - return []; + return _memoriesGetLock.synchronized(() async { + if (_cachedMemories != null && _cachedMemories!.isNotEmpty) { + final currentMemories = _cachedMemories! + .where((memory) => memory.shouldShowNow()) + .toList(); + if (currentMemories.isNotEmpty) { + _logger.info("Found memories in memory cache"); + return currentMemories; } - try { - if (!enableSmartMemories) { - await _calculateRegularFillers(); - return _cachedMemories!; + _logger.info( + "In-memory memories not valid for current window, refreshing cache", + ); + } else if (onlyUseCache) { + _logger.info("Only using cache, no memories found"); + return []; + } + try { + if (!enableSmartMemories) { + await _calculateRegularFillers(); + return _cachedMemories!; + } + final cacheFileExists = await _cacheFileExists(); + _cachedMemories = await _getMemoriesFromCache(); + if (_cachedMemories == null || _cachedMemories!.isEmpty) { + final shouldRefreshEmptyCache = + _cachedMemories == null || + _shouldUpdate || + _timeToUpdateCache() || + lastMemoriesCacheUpdateTime == 0; + if (onlyUseCache) { + _logger.info("Only using cache, no memories found"); + return []; } - final cacheFileExists = await _cacheFileExists(); - _cachedMemories = await _getMemoriesFromCache(); - if (_cachedMemories == null || _cachedMemories!.isEmpty) { - final shouldRefreshEmptyCache = - _cachedMemories == null || - _shouldUpdate || - _timeToUpdateCache() || - lastMemoriesCacheUpdateTime == 0; - if (onlyUseCache) { - _logger.info("Only using cache, no memories found"); - return []; - } - if (!shouldRefreshEmptyCache) { - _logger.info("Found fresh empty memories cache"); - return []; - } - if (!cacheFileExists) { + if (!shouldRefreshEmptyCache) { + _logger.info("Found fresh empty memories cache"); + return []; + } + if (!cacheFileExists) { + _logger.info( + "No disk cache (fresh install): serving simple memories, " + "smart memories will upgrade in background", + ); + _cachedMemories = await smartMemoriesService.calcSimpleMemories(); + if (_shouldDeferInitialOfflineCacheUpgrade()) { + _pendingInitialOfflineCacheUpgrade = true; _logger.info( - "No disk cache (fresh install): serving simple memories, " - "smart memories will upgrade in background", + "Deferring initial offline memories cache upgrade until first import metadata is ready", + ); + } else { + unawaited( + updateCache( + forced: true, + ).whenComplete(() => Bus.instance.fire(MemoriesChangedEvent())), ); - _cachedMemories = await smartMemoriesService.calcSimpleMemories(); - if (_shouldDeferInitialOfflineCacheUpgrade()) { - _pendingInitialOfflineCacheUpgrade = true; - _logger.info( - "Deferring initial offline memories cache upgrade until first import metadata is ready", - ); - } else { - unawaited( - updateCache(forced: true).whenComplete( - () => Bus.instance.fire(MemoriesChangedEvent()), - ), - ); - } - return _cachedMemories!; } - _logger.warning( - "No memories found in cache, force updating cache. Possible severe caching issue", - ); - await updateCache(forced: true); - } else { - _logger.info("Found memories in cache"); - } - if (_cachedMemories == null || _cachedMemories!.isEmpty) { - _logger.severe( - "No memories found in (computed) cache, getting fillers", - ); - await _calculateRegularFillers(); + return _cachedMemories!; } - return _cachedMemories!; - } catch (e, s) { - _logger.severe("Error in getMemories", e, s); - return []; + _logger.warning( + "No memories found in cache, force updating cache. Possible severe caching issue", + ); + await updateCache(forced: true); + } else { + _logger.info("Found memories in cache"); } - }, - ); - return _debugBuildLibraryMemories(memories); - } - - // TEMP(memory-viewer-verification): remove before merge. This exposes a - // broad sample of the account library as several memory cards so that the - // viewer can be exercised with varied media and sharing states. - Future> _debugBuildLibraryMemories( - List memories, - ) async { - if (!_kTempLibraryMemories || isLocalGalleryMode || memories.isEmpty) { - return memories; - } - return _debugLibraryMemoriesLock.synchronized( - () => _debugBuildLibraryMemoriesLocked(memories), - ); - } - - Future> _debugBuildLibraryMemoriesLocked( - List memories, - ) async { - final source = _cachedMemories ?? memories; - final userID = Configuration.instance.getUserID(); - final inputSignature = memories - .map((memory) => "${memory.id}:${memory.memories.length}") - .join("|"); - final sameContext = - identical(_debugLibraryMemoriesSource, source) && - _debugLibraryMemoriesUserID == userID && - _debugLibraryMemoriesInputSignature == inputSignature; - if (!sameContext) { - _debugLibraryMemories = null; - _debugLibraryMemoriesAttempted = false; - _debugLibraryMemoriesSource = source; - _debugLibraryMemoriesUserID = userID; - _debugLibraryMemoriesInputSignature = inputSignature; - } - if (_debugLibraryMemoriesAttempted) { - return _debugLibraryMemories ?? memories; - } - _debugLibraryMemoriesAttempted = true; - - final searchFiles = await SearchService.instance.getAllFilesForSearch(); - final localOnlyFiles = await FilesDB.instance.getUnUploadedLocalFiles(); - final uniqueFiles = {}; - String debugFileID(EnteFile file) { - final uploadedFileID = file.uploadedFileID; - if (uploadedFileID != null && uploadedFileID != -1) { - return "uploaded:$uploadedFileID"; - } - final localID = file.localID; - if (localID != null && localID.isNotEmpty) { - return "local:$localID"; - } - final generatedID = file.generatedID; - return generatedID != null - ? "generated:$generatedID" - : "object:${identityHashCode(file)}"; - } - - void addFiles(Iterable files) { - for (final file in files) { - if (file.creationTime == null) continue; - uniqueFiles.putIfAbsent(debugFileID(file), () => file); + if (_cachedMemories == null || _cachedMemories!.isEmpty) { + _logger.severe( + "No memories found in (computed) cache, getting fillers", + ); + await _calculateRegularFillers(); + } + return _cachedMemories!; + } catch (e, s) { + _logger.severe("Error in getMemories", e, s); + return []; } - } - - addFiles(searchFiles); - addFiles(localOnlyFiles); - if (uniqueFiles.isEmpty) { - _logger.warning("TEMP library memories unavailable: no usable files"); - return memories; - } - - final sortedFiles = uniqueFiles.values.toList() - ..sort((first, second) { - final creationOrder = second.creationTime!.compareTo( - first.creationTime!, - ); - if (creationOrder != 0) return creationOrder; - return (second.generatedID ?? -1).compareTo(first.generatedID ?? -1); - }); - const maxFiles = 600; - const targetFilesPerMemory = 20; - const maxMemoryGroups = 12; - final selectedFiles = sortedFiles.take(maxFiles).toList(); - var groupCount = - (selectedFiles.length + targetFilesPerMemory - 1) ~/ - targetFilesPerMemory; - if (groupCount > maxMemoryGroups) groupCount = maxMemoryGroups; - - final now = DateTime.now().microsecondsSinceEpoch; - final firstDateToShow = now - const Duration(days: 1).inMicroseconds; - final lastDateToShow = now + const Duration(days: 1).inMicroseconds; - final languageCode = (await getLocale())?.languageCode ?? "en"; - final seenTimes = await _memoriesDB.getSeenTimes(); - final filesPerGroup = selectedFiles.length ~/ groupCount; - final groupsWithExtraFile = selectedFiles.length % groupCount; - final debugMemories = []; - var offset = 0; - for (var groupIndex = 0; groupIndex < groupCount; groupIndex++) { - final groupSize = - filesPerGroup + (groupIndex < groupsWithExtraFile ? 1 : 0); - final groupFiles = selectedFiles.sublist(offset, offset + groupSize); - offset += groupSize; - final newestCreationTime = groupFiles.first.creationTime!; - final oldestCreationTime = groupFiles.last.creationTime!; - final newestDate = SmartMemoriesService.getDateFormatted( - creationTime: newestCreationTime, - languageCode: languageCode, - ); - final oldestDate = SmartMemoriesService.getDateFormatted( - creationTime: oldestCreationTime, - languageCode: languageCode, - ); - final title = oldestDate == newestDate - ? newestDate - : "$oldestDate – $newestDate"; - debugMemories.add( - SmartMemory( - Memory.fromFiles(groupFiles, seenTimes), - MemoryType.filler, - title, - firstDateToShow, - lastDateToShow, - id: "debug-library-$userID-$groupIndex", - firstCreationTime: oldestCreationTime, - lastCreationTime: newestCreationTime, - ), - ); - } - - _debugLibraryMemories = debugMemories; - _logger.info( - "TEMP library memories enabled: ${selectedFiles.length} of " - "${uniqueFiles.length} files across ${debugMemories.length} memories " - "(${searchFiles.length} search files, " - "${localOnlyFiles.length} local-only files)", - ); - return debugMemories; + }); } Future _calculateRegularFillers() async { diff --git a/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart b/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart index 2a055f54fbb..86ddf1b4d3c 100644 --- a/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart +++ b/mobile/apps/photos/lib/ui/home/memories/custom_listener.dart @@ -125,24 +125,23 @@ class MemoriesPointerGestureListenerState _longPressTimer = null; final wasPartOfMultitouch = _activePointers.activePointerWasPartOfMultitouch; - final displacement = _downPosition == null - ? Offset.zero - : event.localPosition - _downPosition!; + final displacement = event.localPosition - _downPosition!; final isSwipeUp = widget.onSwipeUp != null && (widget.canSwipeUp?.call() ?? true) && !wasPartOfMultitouch && displacement.dy <= -widget.swipeUpThreshold && - displacement.dy.abs() > displacement.dx.abs(); + displacement.dy.abs() > displacement.dx.abs() && + displacement.dx.abs() <= widget.touchSlop; // Release the pointer before callbacks so opening a sheet can take over // the viewer's pause state without the release immediately resuming it. _removePointer(event.pointer); - if (_longPressFired) { - // widget.onLongPressUp?.call(); - } else if (isSwipeUp) { + if (isSwipeUp) { widget.onSwipeUp?.call(); + } else if (_longPressFired) { + // widget.onLongPressUp?.call(); } else { if (!wasPartOfMultitouch && !hasPointerMoved) { widget.onTap?.call(event); diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 2efae00887b..458135377c8 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -35,6 +35,7 @@ import "package:photos/ui/components/base_bottom_sheet.dart"; import "package:photos/ui/home/memories/custom_listener.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/home/memories/memory_video_prefetcher.dart"; +import "package:photos/ui/home/memories/memory_viewer_constants.dart"; import "package:photos/ui/social/widgets/file_social_overlay.dart"; import "package:photos/ui/viewer/file/file_widget.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; @@ -70,9 +71,6 @@ bool _isValidMemoryIndex(int index, int length) { return index >= 0 && index < length; } -const _memoryBottomActionBarHeight = 80.0; -const _memorySocialScrimHeight = 296.0; - class FullScreenMemoryDataUpdater extends StatefulWidget { final List memories; final int initialIndex; @@ -406,9 +404,6 @@ class _FullScreenMemoryState extends State { hasPointerOnScreenNotifier.removeListener(_hasPointerListener); _detailSheetEventSubscription.cancel(); _socialControlsVisible.dispose(); - _progressAnimationController = null; - _zoomAnimationController = null; - _kenBurnsStartToken = null; super.dispose(); } @@ -491,9 +486,6 @@ class _FullScreenMemoryState extends State { if (!mounted) return; hasFinalFileLoaded = true; isAtFirstOrLastFile = false; - if (_progressAnimationController?.isAnimating == true) { - _progressAnimationController!.stop(); - } final memoryDuration = Duration(seconds: duration); _progressAnimationController ?..stop() @@ -521,7 +513,9 @@ class _FullScreenMemoryState extends State { }); } else { _kenBurnsStartToken = null; - _zoomAnimationController?.forward(); + if (!_isAnimationPaused) { + _zoomAnimationController?.forward(); + } } } @@ -535,9 +529,9 @@ class _FullScreenMemoryState extends State { )!; inheritedData.indexNotifier.value = currentIndex; if (currentIndex < inheritedData.memories.length - 1) { - inheritedData.indexNotifier.value += 1; _onPageChange(inheritedData, currentIndex + 1); } else if (widget.onNextMemory != null) { + _resetAnimation(); _setSocialControlsVisible(false); widget.onNextMemory!(); } else { @@ -556,9 +550,9 @@ class _FullScreenMemoryState extends State { )!; inheritedData.indexNotifier.value = currentIndex; if (currentIndex > 0) { - inheritedData.indexNotifier.value -= 1; _onPageChange(inheritedData, currentIndex - 1); } else if (widget.onPreviousMemory != null) { + _resetAnimation(); _setSocialControlsVisible(false); widget.onPreviousMemory!(); } else { @@ -720,6 +714,15 @@ class _FullScreenMemoryState extends State { _toggleAnimation(pause: !shouldEnable); }, onFinalFileLoad: ({required int memoryDuration}) { + final activeIndex = _clampedMemoryIndex( + inheritedData.indexNotifier.value, + inheritedData.memories.length, + ); + if (activeIndex == null || + inheritedData.memories[activeIndex].file != + currentFile) { + return; + } onFinalFileLoad(memoryDuration); }, ), @@ -1089,18 +1092,12 @@ class _MemoryTopChrome extends StatelessWidget { ), if (showFavorite) ...[ const SizedBox(width: 8), - SizedBox.square( - dimension: 48, - child: Center( - child: FavoriteWidget( - currentFile, - iconSize: 24, - tapTargetSize: 48, - key: ValueKey( - currentFile.uploadedFileID ?? - currentFile.localID, - ), - ), + FavoriteWidget( + currentFile, + iconSize: 24, + tapTargetSize: 48, + key: ValueKey( + currentFile.uploadedFileID ?? currentFile.localID, ), ), ], @@ -1183,8 +1180,8 @@ class _MemoryViewerScrims extends StatelessWidget { curve: Curves.easeOut, width: double.infinity, height: isVisible - ? _memorySocialScrimHeight - : bottomInset + _memoryBottomActionBarHeight, + ? kMemorySocialScrimHeight + : bottomInset + kMemoryBottomActionBarHeight, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, diff --git a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart index b85d4bf793b..1d646383aaf 100644 --- a/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart +++ b/mobile/apps/photos/lib/ui/home/memories/memory_progress_indicator.dart @@ -9,13 +9,7 @@ double memoryProgressSegmentWidthForLayout({ required double availableWidth, double gap = kMemoryProgressGap, }) { - if (totalSteps <= 0 || - !availableWidth.isFinite || - availableWidth <= 0 || - !gap.isFinite || - gap < 0) { - return 0; - } + if (totalSteps <= 0) return 0; final availableSegmentWidth = availableWidth - ((totalSteps - 1) * gap); if (availableSegmentWidth <= 0) return 0; diff --git a/mobile/apps/photos/lib/ui/home/memories/memory_viewer_constants.dart b/mobile/apps/photos/lib/ui/home/memories/memory_viewer_constants.dart new file mode 100644 index 00000000000..fb6be8ff015 --- /dev/null +++ b/mobile/apps/photos/lib/ui/home/memories/memory_viewer_constants.dart @@ -0,0 +1,2 @@ +const kMemoryBottomActionBarHeight = 80.0; +const kMemorySocialScrimHeight = 296.0; diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 902a467284a..c9aa8b0c0b5 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -17,6 +17,7 @@ import "package:photos/models/social/comment_author_utils.dart"; import "package:photos/models/social/reaction.dart"; import "package:photos/models/social/social_data_provider.dart"; import "package:photos/services/collections_service.dart"; +import "package:photos/ui/home/memories/memory_viewer_constants.dart"; import "package:photos/ui/notification/toast.dart"; import "package:photos/ui/sharing/user_avator_widget.dart"; import "package:photos/ui/social/comments_screen.dart"; @@ -28,7 +29,6 @@ final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); const _socialControlsSize = 40.0; const _socialIconSize = 32.0; -const _bottomActionBarHeight = 80.0; const _socialToActionBarGap = 14.0; class FileSocialOverlay extends StatefulWidget { @@ -37,7 +37,6 @@ class FileSocialOverlay extends StatefulWidget { final VoidCallback onInteractionStart; final VoidCallback onInteractionEnd; final ValueChanged onVisibilityChanged; - final bool includeHiddenCollections; const FileSocialOverlay({ required this.file, @@ -45,7 +44,6 @@ class FileSocialOverlay extends StatefulWidget { required this.onInteractionStart, required this.onInteractionEnd, required this.onVisibilityChanged, - this.includeHiddenCollections = false, super.key, }); @@ -63,7 +61,7 @@ class _FileSocialOverlayState extends State { Comment? _latestComment; User? _latestCommentAuthor; int _loadGeneration = 0; - int? _reactionUpdateFileID; + final _reactionUpdateFileIDs = {}; @override void initState() { @@ -78,10 +76,7 @@ class _FileSocialOverlayState extends State { void didUpdateWidget(covariant FileSocialOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID || - oldWidget.currentUserID != widget.currentUserID || - oldWidget.includeHiddenCollections != widget.includeHiddenCollections) { - _loadGeneration++; - _reactionUpdateFileID = null; + oldWidget.currentUserID != widget.currentUserID) { _clearSocialState(); unawaited(_refreshSocialState()); } @@ -89,7 +84,6 @@ class _FileSocialOverlayState extends State { @override void dispose() { - _loadGeneration++; _socialDataUpdatedSubscription.cancel(); super.dispose(); } @@ -115,15 +109,22 @@ class _FileSocialOverlayState extends State { if (fileID == null || currentUserID == null) { _clearSocialState(); + // Defer because a user-null transition can arrive from didUpdateWidget. + scheduleMicrotask(() { + if (!mounted || + generation != _loadGeneration || + widget.file.uploadedFileID != fileID || + widget.currentUserID != currentUserID) { + return; + } + widget.onVisibilityChanged(false); + }); return; } try { final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile( - fileID, - includeHidden: widget.includeHiddenCollections, - ); + .getSharedCollectionsForFile(fileID); if (!_isCurrentLoad(fileID, generation)) return; if (sharedCollections.isEmpty) { @@ -167,7 +168,7 @@ class _FileSocialOverlayState extends State { setState(() { _sharedCollections = sharedCollections; _hasLiked = reactions.any( - (reaction) => reaction.userID == currentUserID && !reaction.isDeleted, + (reaction) => reaction.userID == currentUserID, ); _commentCount = commentCount; _latestComment = latestComment; @@ -175,6 +176,7 @@ class _FileSocialOverlayState extends State { }); widget.onVisibilityChanged(true); } catch (error, stackTrace) { + // Preserve last-good state; page changes clear state and visibility. _logger.warning( "Failed to refresh social overlay for file $fileID", error, @@ -190,11 +192,11 @@ class _FileSocialOverlayState extends State { if (fileID == null || currentUserID == null || _sharedCollections.isEmpty || - _reactionUpdateFileID == fileID) { + _reactionUpdateFileIDs.contains(fileID)) { return; } - _reactionUpdateFileID = fileID; + _reactionUpdateFileIDs.add(fileID); try { if (_hasLiked) { await _unlikeFromAllCollections(fileID, currentUserID); @@ -235,9 +237,7 @@ class _FileSocialOverlayState extends State { ), ); } finally { - if (_reactionUpdateFileID == fileID) { - _reactionUpdateFileID = null; - } + _reactionUpdateFileIDs.remove(fileID); } } @@ -254,8 +254,7 @@ class _FileSocialOverlayState extends State { final reactions = await SocialDataProvider.instance .getReactionsForFileInCollection(fileID, collection.id); final userReaction = reactions.firstWhereOrNull( - (reaction) => - reaction.userID == currentUserID && !reaction.isDeleted, + (reaction) => reaction.userID == currentUserID, ); if (userReaction != null) { await SocialDataProvider.instance.toggleReaction( @@ -346,7 +345,9 @@ class _FileSocialOverlayState extends State { return Positioned( right: safePadding.right + 24, bottom: - safePadding.bottom + _bottomActionBarHeight + _socialToActionBarGap, + safePadding.bottom + + kMemoryBottomActionBarHeight + + _socialToActionBarGap, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -410,9 +411,6 @@ class _FileSocialOverlayState extends State { } Widget _buildLatestCommentPill(Comment comment, User author) { - final currentUserID = widget.currentUserID; - if (currentUserID == null) return const SizedBox.shrink(); - return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => unawaited(_openComments(comment: comment)), @@ -462,7 +460,7 @@ class _FileSocialOverlayState extends State { child: UserAvatarWidget( author, type: AvatarType.small, - currentUserID: currentUserID, + currentUserID: widget.currentUserID!, ), ), ), diff --git a/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart b/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart index 15654bb11f8..c9f9823ff1f 100644 --- a/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart +++ b/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart @@ -36,6 +36,33 @@ void main() { expect(swipeCount, 1); }); + testWidgets("opens details when an upward swipe follows a hold", ( + tester, + ) async { + var swipeCount = 0; + await pumpListener(tester, onSwipeUp: () => swipeCount++); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(targetKey)), + ); + + await tester.pump(const Duration(milliseconds: 600)); + await gesture.moveBy(const Offset(0, -60)); + await gesture.up(); + + expect(swipeCount, 1); + }); + + testWidgets("does not open details after horizontal page displacement", ( + tester, + ) async { + var swipeCount = 0; + await pumpListener(tester, onSwipeUp: () => swipeCount++); + + await tester.drag(find.byKey(targetKey), const Offset(20, -60)); + + expect(swipeCount, 0); + }); + testWidgets("does not open details while media is zoomed", (tester) async { var swipeCount = 0; await pumpListener( From eecf8af977723655333438986e21c73ff7505e04 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 11:29:36 +0530 Subject: [PATCH 10/91] refactor(photos): clarify latest comment query scope --- mobile/apps/photos/lib/db/social_db.dart | 13 +++++++++---- .../lib/models/social/social_data_provider.dart | 9 +++++++-- .../lib/ui/social/widgets/file_social_overlay.dart | 5 ++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/mobile/apps/photos/lib/db/social_db.dart b/mobile/apps/photos/lib/db/social_db.dart index acfb9d0aea1..4ef36cf6b0c 100644 --- a/mobile/apps/photos/lib/db/social_db.dart +++ b/mobile/apps/photos/lib/db/social_db.dart @@ -153,22 +153,27 @@ class SocialDB { return rows.map(_rowToComment).toList(); } + /// Restricts the lookup to [candidateCollectionIDs] so the composite + /// file/collection index can narrow the rows considered for the latest item. Future getLatestCommentForFile( int fileID, { - required List collectionIDs, + required List candidateCollectionIDs, }) async { - if (collectionIDs.isEmpty) { + if (candidateCollectionIDs.isEmpty) { return null; } - final placeholders = List.filled(collectionIDs.length, '?').join(','); + final placeholders = List.filled( + candidateCollectionIDs.length, + '?', + ).join(','); final db = await database; final rows = await db.query( _commentsTable, where: 'file_id = ? AND is_deleted = 0 ' 'AND collection_id IN ($placeholders)', - whereArgs: [fileID, ...collectionIDs], + whereArgs: [fileID, ...candidateCollectionIDs], orderBy: 'created_at DESC', limit: 1, ); diff --git a/mobile/apps/photos/lib/models/social/social_data_provider.dart b/mobile/apps/photos/lib/models/social/social_data_provider.dart index b2cc58d7bfc..b551dc04f0f 100644 --- a/mobile/apps/photos/lib/models/social/social_data_provider.dart +++ b/mobile/apps/photos/lib/models/social/social_data_provider.dart @@ -24,11 +24,16 @@ class SocialDataProvider { return _db.getCommentsForFile(fileID); } + /// [candidateCollectionIDs] narrows the indexed lookup to collections the + /// caller already knows are eligible. Future getLatestCommentForFile( int fileID, { - required List collectionIDs, + required List candidateCollectionIDs, }) { - return _db.getLatestCommentForFile(fileID, collectionIDs: collectionIDs); + return _db.getLatestCommentForFile( + fileID, + candidateCollectionIDs: candidateCollectionIDs, + ); } Future getCommentCountForFile(int fileID) { diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index c9aa8b0c0b5..f3b6321d0f0 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -140,7 +140,10 @@ class _FileSocialOverlayState extends State { final results = await Future.wait([ provider.getReactionsForFile(fileID), provider.getCommentCountForFile(fileID), - provider.getLatestCommentForFile(fileID, collectionIDs: collectionIDs), + provider.getLatestCommentForFile( + fileID, + candidateCollectionIDs: collectionIDs, + ), ]); if (!_isCurrentLoad(fileID, generation)) return; From 11ce8cee18cc1fe2467c5b7700f014d6ec020151 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 12:29:20 +0530 Subject: [PATCH 11/91] refactor(photos): defer memory social collection resolution --- .../lib/services/collections_service.dart | 70 +++++++++++---- .../like_collection_selector_sheet.dart | 30 ++----- .../social/widgets/file_social_overlay.dart | 88 ++++++++++++------- .../lib/ui/viewer/file/file_bottom_bar.dart | 1 + 4 files changed, 114 insertions(+), 75 deletions(-) diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index f65902652b0..51d1d7f3755 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -312,26 +312,62 @@ class CollectionsService { return []; } - final hiddenCollectionIDs = includeHidden - ? const {} - : getHiddenCollectionIds(); - final sharedCollections = - collectionIDs - .map((collectionID) => _collectionIDToCollections[collectionID]) - .whereType() - .where( - (collection) => - !collection.isDeleted && - !hiddenCollectionIDs.contains(collection.id) && - (collection.hasSharees || - collection.hasLink || - !collection.isOwner(currentUserID)), - ) - .toList() - ..sort((first, second) => first.id.compareTo(second.id)); + final sharedCollections = []; + for (final collectionID in collectionIDs) { + final collection = _collectionIDToCollections[collectionID]; + if (_isEligibleSharedCollection( + collection, + currentUserID, + includeHidden: includeHidden, + )) { + sharedCollections.add(collection!); + } + } + sharedCollections.sort((first, second) => first.id.compareTo(second.id)); return sharedCollections; } + /// Returns IDs of visible shared collections containing [uploadedFileID]. + /// + /// Use this to scope social content that may be rendered in the UI without + /// constructing a list of collection objects on the view path. + Future> getVisibleSharedCollectionIDsForFile( + int uploadedFileID, + ) async { + final collectionIDs = await _filesDB.getAllCollectionIDsOfFile( + uploadedFileID, + ); + final currentUserID = _config.getUserID(); + if (collectionIDs.isEmpty || currentUserID == null) { + return []; + } + + final sharedCollectionIDs = []; + for (final collectionID in collectionIDs) { + if (_isEligibleSharedCollection( + _collectionIDToCollections[collectionID], + currentUserID, + includeHidden: false, + )) { + sharedCollectionIDs.add(collectionID); + } + } + return sharedCollectionIDs; + } + + bool _isEligibleSharedCollection( + Collection? collection, + int currentUserID, { + required bool includeHidden, + }) { + return collection != null && + !collection.isDeleted && + (includeHidden || !collection.isHidden()) && + (collection.hasSharees || + collection.hasLink || + !collection.isOwner(currentUserID)); + } + Future> getArchivedCollection() async { final allCollections = getCollectionsForUI(); return allCollections diff --git a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart index d234f402f22..af352082be5 100644 --- a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart @@ -8,7 +8,6 @@ import "package:photos/models/collection/collection.dart"; import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/social/social_data_provider.dart"; -import "package:photos/services/collections_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/components/buttons/icon_button_widget.dart"; @@ -32,13 +31,14 @@ class _CollectionLikeState { /// Parameters: /// - [fileID]: The uploaded file ID to like /// - [currentUserID]: Current user's ID for checking existing likes +/// - [collections]: Shared collections eligible for this action /// - [file]: The EnteFile for displaying thumbnail (optional, will fetch if null) Future showLikeCollectionSelectorSheet( BuildContext context, { required int fileID, required int currentUserID, + required List collections, EnteFile? file, - Set? allowedCollectionIDs, }) { return showModalBottomSheet( context: context, @@ -47,8 +47,8 @@ Future showLikeCollectionSelectorSheet( builder: (_) => LikeCollectionSelectorSheet( fileID: fileID, currentUserID: currentUserID, + collections: collections, file: file, - allowedCollectionIDs: allowedCollectionIDs, ), ); } @@ -56,14 +56,14 @@ Future showLikeCollectionSelectorSheet( class LikeCollectionSelectorSheet extends StatefulWidget { final int fileID; final int currentUserID; + final List collections; final EnteFile? file; - final Set? allowedCollectionIDs; const LikeCollectionSelectorSheet({ required this.fileID, required this.currentUserID, + required this.collections, this.file, - this.allowedCollectionIDs, super.key, }); @@ -93,27 +93,9 @@ class _LikeCollectionSelectorSheetState // Load file if not provided (for thumbnail) _file ??= await FilesDB.instance.getAnyUploadedFile(widget.fileID); - final sharedCollections = - (await CollectionsService.instance.getSharedCollectionsForFile( - widget.fileID, - includeHidden: true, - )) - .where( - (c) => - (widget.allowedCollectionIDs == null || - widget.allowedCollectionIDs!.contains(c.id)), - ) - .toList(); - - // If no shared collections, close the sheet - if (sharedCollections.isEmpty) { - if (mounted) Navigator.of(context).pop(); - return; - } - // Fetch like states in parallel final collectionStates = await Future.wait( - sharedCollections.map((collection) async { + widget.collections.map((collection) async { final reactions = await SocialDataProvider.instance .getReactionsForFileInCollection(widget.fileID, collection.id); final isLiked = reactions.any( diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index f3b6321d0f0..41cb2dd46d9 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -10,7 +10,6 @@ import "package:photos/core/event_bus.dart"; import "package:photos/events/social_data_updated_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; -import "package:photos/models/collection/collection.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/social/comment.dart"; import "package:photos/models/social/comment_author_utils.dart"; @@ -55,7 +54,7 @@ class _FileSocialOverlayState extends State { late final StreamSubscription _socialDataUpdatedSubscription; - List _sharedCollections = const []; + List _visibleSharedCollectionIDs = const []; bool _hasLiked = false; int _commentCount = 0; Comment? _latestComment; @@ -89,7 +88,7 @@ class _FileSocialOverlayState extends State { } void _clearSocialState() { - _sharedCollections = const []; + _visibleSharedCollectionIDs = const []; _hasLiked = false; _commentCount = 0; _latestComment = null; @@ -123,20 +122,17 @@ class _FileSocialOverlayState extends State { } try { - final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile(fileID); + final collectionIDs = await CollectionsService.instance + .getVisibleSharedCollectionIDsForFile(fileID); if (!_isCurrentLoad(fileID, generation)) return; - if (sharedCollections.isEmpty) { + if (collectionIDs.isEmpty) { setState(_clearSocialState); widget.onVisibilityChanged(false); return; } final provider = SocialDataProvider.instance; - final collectionIDs = sharedCollections - .map((collection) => collection.id) - .toList(); final results = await Future.wait([ provider.getReactionsForFile(fileID), provider.getCommentCountForFile(fileID), @@ -169,7 +165,7 @@ class _FileSocialOverlayState extends State { if (!_isCurrentLoad(fileID, generation)) return; setState(() { - _sharedCollections = sharedCollections; + _visibleSharedCollectionIDs = collectionIDs; _hasLiked = reactions.any( (reaction) => reaction.userID == currentUserID, ); @@ -194,7 +190,7 @@ class _FileSocialOverlayState extends State { final currentUserID = widget.currentUserID; if (fileID == null || currentUserID == null || - _sharedCollections.isEmpty || + _visibleSharedCollectionIDs.isEmpty || _reactionUpdateFileIDs.contains(fileID)) { return; } @@ -206,13 +202,22 @@ class _FileSocialOverlayState extends State { return; } - if (_sharedCollections.length == 1) { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile(fileID); + if (!mounted || + widget.file.uploadedFileID != fileID || + widget.currentUserID != currentUserID || + sharedCollections.isEmpty) { + return; + } + + if (sharedCollections.length == 1) { final previousState = _hasLiked; setState(() => _hasLiked = true); try { await SocialDataProvider.instance.toggleReaction( userID: currentUserID, - collectionID: _sharedCollections.single.id, + collectionID: sharedCollections.single.id, fileID: fileID, ); } catch (error, stackTrace) { @@ -233,10 +238,8 @@ class _FileSocialOverlayState extends State { context, fileID: fileID, currentUserID: currentUserID, + collections: sharedCollections, file: file, - allowedCollectionIDs: _sharedCollections - .map((collection) => collection.id) - .toSet(), ), ); } finally { @@ -301,31 +304,48 @@ class _FileSocialOverlayState extends State { Future _showLikes() async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _sharedCollections.isEmpty) return; - final initialCollectionID = _sharedCollections.first.id; - await _runSheetAndRefresh( - () => showLikesBottomSheet( + if (fileID == null || _visibleSharedCollectionIDs.isEmpty) return; + await _runSheetAndRefresh(() async { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile(fileID); + if (!mounted || + widget.file.uploadedFileID != fileID || + sharedCollections.isEmpty) { + return; + } + await showLikesBottomSheet( context, fileID: fileID, - initialCollectionID: initialCollectionID, - ), - ); + initialCollectionID: sharedCollections.first.id, + ); + }); } Future _openComments({Comment? comment}) async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _sharedCollections.isEmpty) return; - await _runSheetAndRefresh( - () => showFileCommentsBottomSheet( + if (fileID == null || _visibleSharedCollectionIDs.isEmpty) return; + await _runSheetAndRefresh(() async { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile(fileID); + if (!mounted || + widget.file.uploadedFileID != fileID || + sharedCollections.isEmpty) { + return; + } + + final preferredCollectionID = + comment?.collectionID ?? _latestComment?.collectionID; + final preferredCollection = sharedCollections.firstWhereOrNull( + (collection) => collection.id == preferredCollectionID, + ); + + await showFileCommentsBottomSheet( context, - collectionID: - comment?.collectionID ?? - _latestComment?.collectionID ?? - _sharedCollections.first.id, + collectionID: preferredCollection?.id ?? sharedCollections.first.id, fileID: fileID, - highlightCommentID: comment?.id, - ), - ); + highlightCommentID: preferredCollection == null ? null : comment?.id, + ); + }); } Future _runSheetAndRefresh(Future Function() showSheet) async { @@ -340,7 +360,7 @@ class _FileSocialOverlayState extends State { @override Widget build(BuildContext context) { - if (_sharedCollections.isEmpty) { + if (_visibleSharedCollectionIDs.isEmpty) { return const SizedBox.shrink(); } diff --git a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart index 524fa4de1e9..728284292d6 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart @@ -441,6 +441,7 @@ class FileBottomBarState extends State { context, fileID: file.uploadedFileID!, currentUserID: widget.userID!, + collections: sharedCollections, file: file, ); // Refresh state after sheet closes to sync heart icon From 474febb47771ab51ee593aa0f994a8782303d90d Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 12:37:14 +0530 Subject: [PATCH 12/91] refactor(photos): clarify non-hidden collection lookup --- mobile/apps/photos/lib/services/collections_service.dart | 4 ++-- .../photos/lib/ui/social/widgets/file_social_overlay.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index 51d1d7f3755..c72bb263431 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -327,11 +327,11 @@ class CollectionsService { return sharedCollections; } - /// Returns IDs of visible shared collections containing [uploadedFileID]. + /// Returns IDs of non-hidden shared collections containing [uploadedFileID]. /// /// Use this to scope social content that may be rendered in the UI without /// constructing a list of collection objects on the view path. - Future> getVisibleSharedCollectionIDsForFile( + Future> getNonHiddenSharedCollectionIDsForFile( int uploadedFileID, ) async { final collectionIDs = await _filesDB.getAllCollectionIDsOfFile( diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 41cb2dd46d9..d1b9f776865 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -123,7 +123,7 @@ class _FileSocialOverlayState extends State { try { final collectionIDs = await CollectionsService.instance - .getVisibleSharedCollectionIDsForFile(fileID); + .getNonHiddenSharedCollectionIDsForFile(fileID); if (!_isCurrentLoad(fileID, generation)) return; if (collectionIDs.isEmpty) { From 49357f0d6c07ef64892c3b75666e910b2f2a54a6 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 12:40:31 +0530 Subject: [PATCH 13/91] refactor(photos): preserve shared collection order --- mobile/apps/photos/lib/services/collections_service.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index c72bb263431..924afca103b 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -298,8 +298,7 @@ class CollectionsService { /// Returns the shared collections containing [uploadedFileID]. /// /// A collection is considered shared if it has sharees, has a public link, - /// or is owned by someone else (incoming share). Results are sorted by ID so - /// callers have a deterministic primary collection. + /// or is owned by someone else (incoming share). Future> getSharedCollectionsForFile( int uploadedFileID, { bool includeHidden = false, @@ -323,7 +322,6 @@ class CollectionsService { sharedCollections.add(collection!); } } - sharedCollections.sort((first, second) => first.id.compareTo(second.id)); return sharedCollections; } From 23642b4d7bdaf64c09997a6984329bcc4f155837 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 14:42:40 +0530 Subject: [PATCH 14/91] refactor(photos): rename memory top overlay --- .../photos/lib/ui/home/memories/full_screen_memory.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 458135377c8..b3b737d3271 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -751,7 +751,7 @@ class _FullScreenMemoryState extends State { }, ), const BottomIcons(), - _MemoryTopChrome( + _MemoryTopOverlay( title: widget.title, onClose: () => Navigator.pop(context), onDateTap: (file) { @@ -949,7 +949,7 @@ class _MemoryActionButton extends StatelessWidget { } } -class _MemoryTopChrome extends StatelessWidget { +class _MemoryTopOverlay extends StatelessWidget { final String title; final VoidCallback onClose; final ValueChanged onDateTap; @@ -957,7 +957,7 @@ class _MemoryTopChrome extends StatelessWidget { final void Function(AnimationController) onAnimationControllerDisposed; final VoidCallback onComplete; - const _MemoryTopChrome({ + const _MemoryTopOverlay({ required this.title, required this.onClose, required this.onDateTap, From 395ae275371ba81dfa3a79be4bbd82de6bbcaf7e Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 14:51:24 +0530 Subject: [PATCH 15/91] fix(photos): start memory progress with video playback --- .../photos/changes/memory-video-progress.md | 1 + .../ui/home/memories/full_screen_memory.dart | 40 ++++++++++++++----- 2 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 mobile/apps/photos/changes/memory-video-progress.md diff --git a/mobile/apps/photos/changes/memory-video-progress.md b/mobile/apps/photos/changes/memory-video-progress.md new file mode 100644 index 00000000000..5d130a9d8e7 --- /dev/null +++ b/mobile/apps/photos/changes/memory-video-progress.md @@ -0,0 +1 @@ +- Fixed memory progress remaining paused when a video starts playing. diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index b3b737d3271..221f2a24c59 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -358,7 +358,9 @@ class _FullScreenMemoryState extends State { // Tokenises a pending zoom-start so a newer onFinalFileLoad cleanly // invalidates the prior delayed forward. Object? _kenBurnsStartToken; - bool _isAnimationPaused = false; + bool _isViewerPaused = false; + bool _isPlaybackPaused = false; + bool get _isAnimationPaused => _isViewerPaused || _isPlaybackPaused; bool _isMediaZoomed = false; final _socialControlsVisible = ValueNotifier(false); @@ -433,16 +435,24 @@ class _FullScreenMemoryState extends State { void _toggleAnimation({required bool pause}) { if (!mounted) return; - _isAnimationPaused = pause; - if (pause) { + _isViewerPaused = pause; + _syncAnimationState(); + } + + void _togglePlaybackAnimation({required bool pause}) { + if (!mounted) return; + _isPlaybackPaused = pause; + _syncAnimationState(); + } + + void _syncAnimationState() { + if (_isAnimationPaused) { _progressAnimationController?.stop(); _zoomAnimationController?.stop(); - } else { - if (hasFinalFileLoaded || isAtFirstOrLastFile) { - _progressAnimationController?.forward(); - if (_kenBurnsStartToken == null) { - _zoomAnimationController?.forward(); - } + } else if (hasFinalFileLoaded || isAtFirstOrLastFile) { + _progressAnimationController?.forward(); + if (_kenBurnsStartToken == null) { + _zoomAnimationController?.forward(); } } } @@ -484,6 +494,7 @@ class _FullScreenMemoryState extends State { void onFinalFileLoad(int duration) { if (!mounted) return; + _isPlaybackPaused = false; hasFinalFileLoaded = true; isAtFirstOrLastFile = false; final memoryDuration = Duration(seconds: duration); @@ -711,7 +722,16 @@ class _FullScreenMemoryState extends State { _isMediaZoomed = isZoomed; }, playbackCallback: (shouldEnable, _) { - _toggleAnimation(pause: !shouldEnable); + final activeIndex = _clampedMemoryIndex( + inheritedData.indexNotifier.value, + inheritedData.memories.length, + ); + if (activeIndex == null || + inheritedData.memories[activeIndex].file != + currentFile) { + return; + } + _togglePlaybackAnimation(pause: !shouldEnable); }, onFinalFileLoad: ({required int memoryDuration}) { final activeIndex = _clampedMemoryIndex( From dbd1387b505d71cbe7fcd2e4b935ddb31621ab23 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 15:31:35 +0530 Subject: [PATCH 16/91] refactor(photos): clarify social refresh guards --- .../social/widgets/file_social_overlay.dart | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index d1b9f776865..b27eb88b927 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -59,7 +59,7 @@ class _FileSocialOverlayState extends State { int _commentCount = 0; Comment? _latestComment; User? _latestCommentAuthor; - int _loadGeneration = 0; + int _latestRefreshID = 0; final _reactionUpdateFileIDs = {}; @override @@ -95,23 +95,26 @@ class _FileSocialOverlayState extends State { _latestCommentAuthor = null; } - bool _isCurrentLoad(int fileID, int generation) { + bool _canApplySocialRefresh({ + required int requestedFileID, + required int refreshID, + }) { return mounted && - generation == _loadGeneration && - widget.file.uploadedFileID == fileID; + refreshID == _latestRefreshID && + widget.file.uploadedFileID == requestedFileID; } Future _refreshSocialState() async { final fileID = widget.file.uploadedFileID; final currentUserID = widget.currentUserID; - final generation = ++_loadGeneration; + final refreshID = ++_latestRefreshID; if (fileID == null || currentUserID == null) { _clearSocialState(); // Defer because a user-null transition can arrive from didUpdateWidget. scheduleMicrotask(() { if (!mounted || - generation != _loadGeneration || + refreshID != _latestRefreshID || widget.file.uploadedFileID != fileID || widget.currentUserID != currentUserID) { return; @@ -124,7 +127,12 @@ class _FileSocialOverlayState extends State { try { final collectionIDs = await CollectionsService.instance .getNonHiddenSharedCollectionIDsForFile(fileID); - if (!_isCurrentLoad(fileID, generation)) return; + if (!_canApplySocialRefresh( + requestedFileID: fileID, + refreshID: refreshID, + )) { + return; + } if (collectionIDs.isEmpty) { setState(_clearSocialState); @@ -141,7 +149,12 @@ class _FileSocialOverlayState extends State { candidateCollectionIDs: collectionIDs, ), ]); - if (!_isCurrentLoad(fileID, generation)) return; + if (!_canApplySocialRefresh( + requestedFileID: fileID, + refreshID: refreshID, + )) { + return; + } final reactions = results[0] as List; final commentCount = results[1] as int; @@ -153,7 +166,12 @@ class _FileSocialOverlayState extends State { latestComment.collectionID, ) : const {}; - if (!_isCurrentLoad(fileID, generation)) return; + if (!_canApplySocialRefresh( + requestedFileID: fileID, + refreshID: refreshID, + )) { + return; + } latestCommentAuthor = CommentAuthorResolver().resolve( comment: latestComment, @@ -163,7 +181,12 @@ class _FileSocialOverlayState extends State { ); } - if (!_isCurrentLoad(fileID, generation)) return; + if (!_canApplySocialRefresh( + requestedFileID: fileID, + refreshID: refreshID, + )) { + return; + } setState(() { _visibleSharedCollectionIDs = collectionIDs; _hasLiked = reactions.any( From 36e2481e88937821e850c825bdeb7f32c6f45ffd Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 16:33:00 +0530 Subject: [PATCH 17/91] Reuse social overlay in gallery viewer --- .../reuse-social-overlay-in-gallery.md | 1 + mobile/apps/photos/lib/db/social_db.dart | 14 + .../models/social/social_data_provider.dart | 4 + .../lib/services/collections_service.dart | 15 +- .../ui/home/memories/full_screen_memory.dart | 24 +- .../photos/lib/ui/social/comments_screen.dart | 38 +-- .../lib/ui/social/likes_bottom_sheet.dart | 36 +-- .../social/widgets/file_social_overlay.dart | 205 ++++++++----- .../lib/ui/viewer/file/detail_page.dart | 44 +++ .../lib/ui/viewer/file/file_bottom_bar.dart | 284 ------------------ mobile/apps/photos/pubspec.yaml | 2 + .../apps/photos/test/db/social_db_test.dart | 133 ++++++++ .../ui/social/file_social_context_test.dart | 32 ++ 13 files changed, 419 insertions(+), 413 deletions(-) create mode 100644 mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md create mode 100644 mobile/apps/photos/test/db/social_db_test.dart create mode 100644 mobile/apps/photos/test/ui/social/file_social_context_test.dart diff --git a/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md b/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md new file mode 100644 index 00000000000..43cc7cf6323 --- /dev/null +++ b/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md @@ -0,0 +1 @@ +- Added consistent likes and comments controls to the gallery photo viewer. diff --git a/mobile/apps/photos/lib/db/social_db.dart b/mobile/apps/photos/lib/db/social_db.dart index 4ef36cf6b0c..bb6a019fc29 100644 --- a/mobile/apps/photos/lib/db/social_db.dart +++ b/mobile/apps/photos/lib/db/social_db.dart @@ -296,6 +296,20 @@ class SocialDB { return rows.map(_rowToReaction).toList(); } + Future hasUserReactedToFile(int fileID, int userID) async { + final db = await database; + final rows = await db.rawQuery( + ''' + SELECT 1 FROM $_reactionsTable + WHERE file_id = ? AND user_id = ? + AND comment_id IS NULL AND is_deleted = 0 + LIMIT 1 + ''', + [fileID, userID], + ); + return rows.isNotEmpty; + } + Future> getReactionsForFileInCollection( int fileID, int collectionID, diff --git a/mobile/apps/photos/lib/models/social/social_data_provider.dart b/mobile/apps/photos/lib/models/social/social_data_provider.dart index b551dc04f0f..2b297f6f4fa 100644 --- a/mobile/apps/photos/lib/models/social/social_data_provider.dart +++ b/mobile/apps/photos/lib/models/social/social_data_provider.dart @@ -48,6 +48,10 @@ class SocialDataProvider { return _db.getReactionsForFile(fileID); } + Future hasUserReactedToFile(int fileID, int userID) { + return _db.hasUserReactedToFile(fileID, userID); + } + Future> getReactionsForFileInCollection( int fileID, int collectionID, diff --git a/mobile/apps/photos/lib/services/collections_service.dart b/mobile/apps/photos/lib/services/collections_service.dart index 924afca103b..4ad53d6be7a 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -325,13 +325,14 @@ class CollectionsService { return sharedCollections; } - /// Returns IDs of non-hidden shared collections containing [uploadedFileID]. + /// Returns IDs of shared collections containing [uploadedFileID]. /// - /// Use this to scope social content that may be rendered in the UI without - /// constructing a list of collection objects on the view path. - Future> getNonHiddenSharedCollectionIDsForFile( - int uploadedFileID, - ) async { + /// Use this to scope social content on the view path without constructing a + /// list of collection objects. + Future> getSharedCollectionIDsForFile( + int uploadedFileID, { + bool includeHidden = false, + }) async { final collectionIDs = await _filesDB.getAllCollectionIDsOfFile( uploadedFileID, ); @@ -345,7 +346,7 @@ class CollectionsService { if (_isEligibleSharedCollection( _collectionIDToCollections[collectionID], currentUserID, - includeHidden: false, + includeHidden: includeHidden, )) { sharedCollectionIDs.add(collectionID); } diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 221f2a24c59..60ae24690ab 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -44,6 +44,9 @@ import "package:photos/ui/viewer/gallery/jump_to_date_gallery.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/share_util.dart"; +const _socialRightInset = 24.0; +const _socialToActionBarGap = 14.0; + //There are two states of variables that FullScreenMemory depends on: //1. The list of memories //2. The current index of the page view @@ -761,12 +764,21 @@ class _FullScreenMemoryState extends State { inheritedData.memories.length, ); if (safeIndex == null) return const SizedBox.shrink(); - return FileSocialOverlay( - file: inheritedData.memories[safeIndex].file, - currentUserID: Configuration.instance.getUserID(), - onInteractionStart: _pauseViewer, - onInteractionEnd: _resumeViewer, - onVisibilityChanged: _setSocialControlsVisible, + final padding = MediaQuery.paddingOf(context); + return Positioned( + right: padding.right + _socialRightInset, + bottom: + padding.bottom + + kMemoryBottomActionBarHeight + + _socialToActionBarGap, + child: FileSocialOverlay( + file: inheritedData.memories[safeIndex].file, + currentUserID: Configuration.instance.getUserID(), + openingCollectionID: null, + onInteractionStart: _pauseViewer, + onInteractionEnd: _resumeViewer, + onVisibilityChanged: _setSocialControlsVisible, + ), ); }, ), diff --git a/mobile/apps/photos/lib/ui/social/comments_screen.dart b/mobile/apps/photos/lib/ui/social/comments_screen.dart index 8390c472a42..884476e1563 100644 --- a/mobile/apps/photos/lib/ui/social/comments_screen.dart +++ b/mobile/apps/photos/lib/ui/social/comments_screen.dart @@ -4,7 +4,6 @@ import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:photos/core/configuration.dart"; import "package:photos/core/event_bus.dart"; -import "package:photos/db/files_db.dart"; import "package:photos/events/comment_deleted_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; @@ -42,6 +41,7 @@ Future showFileCommentsBottomSheet( required int collectionID, required int fileID, String? highlightCommentID, + List? sharedCollections, }) { return showModalBottomSheet( context: context, @@ -52,6 +52,7 @@ Future showFileCommentsBottomSheet( collectionID: collectionID, fileID: fileID, highlightCommentID: highlightCommentID, + sharedCollections: sharedCollections, ), ); } @@ -61,12 +62,14 @@ class _DraggableCommentsSheet extends StatefulWidget { final int collectionID; final int fileID; final String? highlightCommentID; + final List? sharedCollections; const _DraggableCommentsSheet({ required this.launchContext, required this.collectionID, required this.fileID, this.highlightCommentID, + this.sharedCollections, }); @override @@ -99,6 +102,7 @@ class _DraggableCommentsSheetState extends State<_DraggableCommentsSheet> { collectionID: widget.collectionID, fileID: widget.fileID, highlightCommentID: widget.highlightCommentID, + sharedCollections: widget.sharedCollections, dragController: scrollController, sheetController: sheetController, ), @@ -110,6 +114,7 @@ class FileCommentsBottomSheet extends StatefulWidget { final BuildContext launchContext; final int collectionID; final int fileID; + final List? sharedCollections; /// Optional comment ID to scroll to and highlight. final String? highlightCommentID; @@ -124,6 +129,7 @@ class FileCommentsBottomSheet extends StatefulWidget { required this.launchContext, required this.collectionID, required this.fileID, + this.sharedCollections, required this.dragController, required this.sheetController, this.highlightCommentID, @@ -206,28 +212,14 @@ class _FileCommentsBottomSheetState extends State { } Future _loadSharedCollections() async { - final collectionIDs = await FilesDB.instance.getAllCollectionIDsOfFile( - widget.fileID, - ); - - // Filter to shared collections first (sync operation) - var sharedCollectionsList = collectionIDs - .map((id) => CollectionsService.instance.getCollectionByID(id)) - .whereType() - .where((c) => c.hasSharees || c.hasLink || !c.isOwner(_currentUserID)) - .toList(); - - // Filter out hidden collections unless viewing from a hidden collection - final hiddenCollectionIds = CollectionsService.instance - .getHiddenCollectionIds(); - final isInitialCollectionHidden = hiddenCollectionIds.contains( - widget.collectionID, - ); - if (!isInitialCollectionHidden) { - sharedCollectionsList = sharedCollectionsList - .where((c) => !hiddenCollectionIds.contains(c.id)) - .toList(); - } + final sharedCollectionsList = + widget.sharedCollections ?? + await CollectionsService.instance.getSharedCollectionsForFile( + widget.fileID, + includeHidden: CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.collectionID), + ); // Fetch data in parallel final sharedCollections = await Future.wait( diff --git a/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart b/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart index 325347eb95c..5ec894df4e9 100644 --- a/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart @@ -3,7 +3,6 @@ import "dart:async"; import "package:ente_icons/ente_icons.dart"; import "package:flutter/material.dart"; import "package:photos/core/configuration.dart"; -import "package:photos/db/files_db.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; import "package:photos/models/collection/collection.dart"; @@ -26,6 +25,7 @@ Future showLikesBottomSheet( BuildContext context, { required int fileID, required int initialCollectionID, + List? sharedCollections, }) { return showModalBottomSheet( context: context, @@ -35,6 +35,7 @@ Future showLikesBottomSheet( launchContext: context, fileID: fileID, initialCollectionID: initialCollectionID, + sharedCollections: sharedCollections, ), ); } @@ -43,11 +44,13 @@ class LikesBottomSheet extends StatefulWidget { final BuildContext launchContext; final int fileID; final int initialCollectionID; + final List? sharedCollections; const LikesBottomSheet({ required this.launchContext, required this.fileID, required this.initialCollectionID, + this.sharedCollections, super.key, }); @@ -78,29 +81,14 @@ class _LikesBottomSheetState extends State { Future _loadSharedCollections() async { try { - // Get all collections containing this file - final collectionIDs = await FilesDB.instance.getAllCollectionIDsOfFile( - widget.fileID, - ); - - // Filter to shared collections only - var sharedCollectionsList = collectionIDs - .map((id) => CollectionsService.instance.getCollectionByID(id)) - .whereType() - .where((c) => c.hasSharees || c.hasLink || !c.isOwner(_currentUserID)) - .toList(); - - // Filter out hidden collections unless viewing from a hidden collection - final hiddenCollectionIds = CollectionsService.instance - .getHiddenCollectionIds(); - final isInitialCollectionHidden = hiddenCollectionIds.contains( - widget.initialCollectionID, - ); - if (!isInitialCollectionHidden) { - sharedCollectionsList = sharedCollectionsList - .where((c) => !hiddenCollectionIds.contains(c.id)) - .toList(); - } + final sharedCollectionsList = + widget.sharedCollections ?? + await CollectionsService.instance.getSharedCollectionsForFile( + widget.fileID, + includeHidden: CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.initialCollectionID), + ); // Fetch like counts and thumbnails in parallel final sharedCollections = await Future.wait( diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index b27eb88b927..e751ccfd6e8 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -10,13 +10,12 @@ import "package:photos/core/event_bus.dart"; import "package:photos/events/social_data_updated_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; +import "package:photos/models/collection/collection.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/social/comment.dart"; import "package:photos/models/social/comment_author_utils.dart"; -import "package:photos/models/social/reaction.dart"; import "package:photos/models/social/social_data_provider.dart"; import "package:photos/services/collections_service.dart"; -import "package:photos/ui/home/memories/memory_viewer_constants.dart"; import "package:photos/ui/notification/toast.dart"; import "package:photos/ui/sharing/user_avator_widget.dart"; import "package:photos/ui/social/comments_screen.dart"; @@ -28,24 +27,50 @@ final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); const _socialControlsSize = 40.0; const _socialIconSize = 32.0; -const _socialToActionBarGap = 14.0; +typedef FileSocialContextIdentity = ({ + int? uploadedFileID, + int? openingCollectionID, + int? currentUserID, +}); + +bool fileSocialContextIncludesHiddenCollections( + int? openingCollectionID, + Set hiddenCollectionIDs, +) { + return openingCollectionID != null && + hiddenCollectionIDs.contains(openingCollectionID); +} + +/// Social content for a file. The host owns placement and visibility effects. class FileSocialOverlay extends StatefulWidget { final EnteFile file; final int? currentUserID; - final VoidCallback onInteractionStart; - final VoidCallback onInteractionEnd; - final ValueChanged onVisibilityChanged; + + /// The collection the viewer was opened from, or null when there is no + /// opening collection context. Memories must not pass a deduped file row's + /// collection ID here. + final int? openingCollectionID; + final VoidCallback? onInteractionStart; + final VoidCallback? onInteractionEnd; + final ValueChanged? onVisibilityChanged; const FileSocialOverlay({ required this.file, required this.currentUserID, - required this.onInteractionStart, - required this.onInteractionEnd, - required this.onVisibilityChanged, + required this.openingCollectionID, + this.onInteractionStart, + this.onInteractionEnd, + this.onVisibilityChanged, super.key, }); + FileSocialContextIdentity get contextIdentity => ( + uploadedFileID: file.uploadedFileID, + openingCollectionID: openingCollectionID, + currentUserID: currentUserID, + ); + @override State createState() => _FileSocialOverlayState(); } @@ -54,7 +79,7 @@ class _FileSocialOverlayState extends State { late final StreamSubscription _socialDataUpdatedSubscription; - List _visibleSharedCollectionIDs = const []; + List _eligibleSharedCollectionIDs = const []; bool _hasLiked = false; int _commentCount = 0; Comment? _latestComment; @@ -74,8 +99,7 @@ class _FileSocialOverlayState extends State { @override void didUpdateWidget(covariant FileSocialOverlay oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID || - oldWidget.currentUserID != widget.currentUserID) { + if (oldWidget.contextIdentity != widget.contextIdentity) { _clearSocialState(); unawaited(_refreshSocialState()); } @@ -88,7 +112,7 @@ class _FileSocialOverlayState extends State { } void _clearSocialState() { - _visibleSharedCollectionIDs = const []; + _eligibleSharedCollectionIDs = const []; _hasLiked = false; _commentCount = 0; _latestComment = null; @@ -96,17 +120,26 @@ class _FileSocialOverlayState extends State { } bool _canApplySocialRefresh({ - required int requestedFileID, + required FileSocialContextIdentity requestedContext, required int refreshID, }) { return mounted && refreshID == _latestRefreshID && - widget.file.uploadedFileID == requestedFileID; + widget.contextIdentity == requestedContext; + } + + bool get _includeHiddenCollections { + return fileSocialContextIncludesHiddenCollections( + widget.openingCollectionID, + CollectionsService.instance.getHiddenCollectionIds(), + ); } Future _refreshSocialState() async { + // Recheck the captured context after every await before touching state. final fileID = widget.file.uploadedFileID; final currentUserID = widget.currentUserID; + final requestedContext = widget.contextIdentity; final refreshID = ++_latestRefreshID; if (fileID == null || currentUserID == null) { @@ -115,20 +148,22 @@ class _FileSocialOverlayState extends State { scheduleMicrotask(() { if (!mounted || refreshID != _latestRefreshID || - widget.file.uploadedFileID != fileID || - widget.currentUserID != currentUserID) { + widget.contextIdentity != requestedContext) { return; } - widget.onVisibilityChanged(false); + widget.onVisibilityChanged?.call(false); }); return; } try { final collectionIDs = await CollectionsService.instance - .getNonHiddenSharedCollectionIDsForFile(fileID); + .getSharedCollectionIDsForFile( + fileID, + includeHidden: _includeHiddenCollections, + ); if (!_canApplySocialRefresh( - requestedFileID: fileID, + requestedContext: requestedContext, refreshID: refreshID, )) { return; @@ -136,13 +171,13 @@ class _FileSocialOverlayState extends State { if (collectionIDs.isEmpty) { setState(_clearSocialState); - widget.onVisibilityChanged(false); + widget.onVisibilityChanged?.call(false); return; } final provider = SocialDataProvider.instance; final results = await Future.wait([ - provider.getReactionsForFile(fileID), + provider.hasUserReactedToFile(fileID, currentUserID), provider.getCommentCountForFile(fileID), provider.getLatestCommentForFile( fileID, @@ -150,13 +185,13 @@ class _FileSocialOverlayState extends State { ), ]); if (!_canApplySocialRefresh( - requestedFileID: fileID, + requestedContext: requestedContext, refreshID: refreshID, )) { return; } - final reactions = results[0] as List; + final hasLiked = results[0] as bool; final commentCount = results[1] as int; final latestComment = results[2] as Comment?; User? latestCommentAuthor; @@ -167,7 +202,7 @@ class _FileSocialOverlayState extends State { ) : const {}; if (!_canApplySocialRefresh( - requestedFileID: fileID, + requestedContext: requestedContext, refreshID: refreshID, )) { return; @@ -182,21 +217,19 @@ class _FileSocialOverlayState extends State { } if (!_canApplySocialRefresh( - requestedFileID: fileID, + requestedContext: requestedContext, refreshID: refreshID, )) { return; } setState(() { - _visibleSharedCollectionIDs = collectionIDs; - _hasLiked = reactions.any( - (reaction) => reaction.userID == currentUserID, - ); + _eligibleSharedCollectionIDs = collectionIDs; + _hasLiked = hasLiked; _commentCount = commentCount; _latestComment = latestComment; _latestCommentAuthor = latestCommentAuthor; }); - widget.onVisibilityChanged(true); + widget.onVisibilityChanged?.call(true); } catch (error, stackTrace) { // Preserve last-good state; page changes clear state and visibility. _logger.warning( @@ -211,9 +244,10 @@ class _FileSocialOverlayState extends State { final file = widget.file; final fileID = file.uploadedFileID; final currentUserID = widget.currentUserID; + final actionContext = widget.contextIdentity; if (fileID == null || currentUserID == null || - _visibleSharedCollectionIDs.isEmpty || + _eligibleSharedCollectionIDs.isEmpty || _reactionUpdateFileIDs.contains(fileID)) { return; } @@ -221,15 +255,17 @@ class _FileSocialOverlayState extends State { _reactionUpdateFileIDs.add(fileID); try { if (_hasLiked) { - await _unlikeFromAllCollections(fileID, currentUserID); + await _unlikeFromAllCollections(fileID, currentUserID, actionContext); return; } final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile(fileID); + .getSharedCollectionsForFile( + fileID, + includeHidden: _includeHiddenCollections, + ); if (!mounted || - widget.file.uploadedFileID != fileID || - widget.currentUserID != currentUserID || + widget.contextIdentity != actionContext || sharedCollections.isEmpty) { return; } @@ -245,7 +281,7 @@ class _FileSocialOverlayState extends State { ); } catch (error, stackTrace) { _logger.severe("Failed to like photo", error, stackTrace); - if (mounted && widget.file.uploadedFileID == fileID) { + if (mounted && widget.contextIdentity == actionContext) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -270,7 +306,11 @@ class _FileSocialOverlayState extends State { } } - Future _unlikeFromAllCollections(int fileID, int currentUserID) async { + Future _unlikeFromAllCollections( + int fileID, + int currentUserID, + FileSocialContextIdentity actionContext, + ) async { final previousState = _hasLiked; setState(() => _hasLiked = false); @@ -302,7 +342,9 @@ class _FileSocialOverlayState extends State { } } - if (failedCount > 0 && mounted && widget.file.uploadedFileID == fileID) { + if (failedCount > 0 && + mounted && + widget.contextIdentity == actionContext) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -315,7 +357,7 @@ class _FileSocialOverlayState extends State { error, stackTrace, ); - if (mounted && widget.file.uploadedFileID == fileID) { + if (mounted && widget.contextIdentity == actionContext) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -327,74 +369,96 @@ class _FileSocialOverlayState extends State { Future _showLikes() async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _visibleSharedCollectionIDs.isEmpty) return; + final actionContext = widget.contextIdentity; + if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile(fileID); + .getSharedCollectionsForFile( + fileID, + includeHidden: _includeHiddenCollections, + ); if (!mounted || - widget.file.uploadedFileID != fileID || + widget.contextIdentity != actionContext || sharedCollections.isEmpty) { return; } + final initialCollection = sharedCollections.firstWhereOrNull( + (collection) => collection.id == actionContext.openingCollectionID, + ); await showLikesBottomSheet( context, fileID: fileID, - initialCollectionID: sharedCollections.first.id, + initialCollectionID: + initialCollection?.id ?? sharedCollections.first.id, + sharedCollections: sharedCollections, ); }); } Future _openComments({Comment? comment}) async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _visibleSharedCollectionIDs.isEmpty) return; + final actionContext = widget.contextIdentity; + if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile(fileID); + .getSharedCollectionsForFile( + fileID, + includeHidden: _includeHiddenCollections, + ); if (!mounted || - widget.file.uploadedFileID != fileID || + widget.contextIdentity != actionContext || sharedCollections.isEmpty) { return; } - final preferredCollectionID = - comment?.collectionID ?? _latestComment?.collectionID; - final preferredCollection = sharedCollections.firstWhereOrNull( - (collection) => collection.id == preferredCollectionID, - ); + Collection? commentCollection; + Collection? openingCollection; + Collection? latestCommentCollection; + for (final collection in sharedCollections) { + if (collection.id == comment?.collectionID) { + commentCollection = collection; + } + if (collection.id == actionContext.openingCollectionID) { + openingCollection = collection; + } + if (collection.id == _latestComment?.collectionID) { + latestCommentCollection = collection; + } + } + final initialCollection = + commentCollection ?? + openingCollection ?? + latestCommentCollection ?? + sharedCollections.first; await showFileCommentsBottomSheet( context, - collectionID: preferredCollection?.id ?? sharedCollections.first.id, + collectionID: initialCollection.id, fileID: fileID, - highlightCommentID: preferredCollection == null ? null : comment?.id, + highlightCommentID: commentCollection == null ? null : comment?.id, + sharedCollections: sharedCollections, ); }); } Future _runSheetAndRefresh(Future Function() showSheet) async { - widget.onInteractionStart(); + widget.onInteractionStart?.call(); try { await showSheet(); await _refreshSocialState(); } finally { - widget.onInteractionEnd(); + widget.onInteractionEnd?.call(); } } @override Widget build(BuildContext context) { - if (_visibleSharedCollectionIDs.isEmpty) { + if (_eligibleSharedCollectionIDs.isEmpty) { return const SizedBox.shrink(); } - final safePadding = MediaQuery.paddingOf(context); - return Positioned( - right: safePadding.right + 24, - bottom: - safePadding.bottom + - kMemoryBottomActionBarHeight + - _socialToActionBarGap, - child: Row( + return LayoutBuilder( + builder: (context, constraints) => Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (_latestComment != null && _latestCommentAuthor != null) @@ -403,6 +467,7 @@ class _FileSocialOverlayState extends State { child: _buildLatestCommentPill( _latestComment!, _latestCommentAuthor!, + math.min(constraints.maxWidth * 0.5, 200), ), ), Column( @@ -456,14 +521,16 @@ class _FileSocialOverlayState extends State { ); } - Widget _buildLatestCommentPill(Comment comment, User author) { + Widget _buildLatestCommentPill( + Comment comment, + User author, + double maxWidth, + ) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => unawaited(_openComments(comment: comment)), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: math.min(MediaQuery.sizeOf(context).width * 0.5, 200), - ), + constraints: BoxConstraints(maxWidth: maxWidth), child: Stack( clipBehavior: Clip.none, children: [ diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index 0f77e15997d..995789b57c0 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -25,6 +25,7 @@ import "package:photos/services/collections_service.dart"; import "package:photos/states/detail_page_state.dart"; import "package:photos/ui/common/fast_scroll_physics.dart"; import 'package:photos/ui/notification/toast.dart'; +import "package:photos/ui/social/widgets/file_social_overlay.dart"; import "package:photos/ui/tools/editor/image_editor/image_editor_page.dart"; import "package:photos/ui/tools/editor/video_editor_page.dart"; import "package:photos/ui/viewer/file/file_app_bar.dart"; @@ -37,6 +38,9 @@ import "package:photos/ui/viewer/file/qr_code_highlight_overlay.dart"; import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/utils/dialog_util.dart'; +const _socialRightInset = 24.0; +const _socialBottomBarClearance = 74.0; + enum DetailPageMode { minimalistic, full } class DetailPageConfiguration { @@ -283,6 +287,12 @@ class _BodyState extends State<_Body> { }, valueListenable: _selectedIndexNotifier, ), + ValueListenableBuilder( + valueListenable: _selectedIndexNotifier, + builder: (BuildContext context, int selectedIndex, _) { + return _buildSocialOverlay(_files![selectedIndex]); + }, + ), ValueListenableBuilder( valueListenable: _selectedIndexNotifier, builder: (BuildContext context, int selectedIndex, _) { @@ -393,6 +403,40 @@ class _BodyState extends State<_Body> { .ignore(); } + Widget _buildSocialOverlay(EnteFile file) { + if (widget.config.mode == DetailPageMode.minimalistic || + isGuestView || + file is TrashFile) { + return const SizedBox.shrink(); + } + + final padding = MediaQuery.paddingOf(context); + final fullScreenNotifier = InheritedDetailPageState.of( + context, + ).enableFullScreenNotifier; + return Positioned( + right: padding.right + _socialRightInset, + bottom: padding.bottom + _socialBottomBarClearance, + child: ValueListenableBuilder( + valueListenable: fullScreenNotifier, + builder: (context, isFullScreen, child) => IgnorePointer( + ignoring: isFullScreen, + child: AnimatedOpacity( + opacity: isFullScreen ? 0 : 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: child, + ), + ), + child: FileSocialOverlay( + file: file, + currentUserID: Configuration.instance.getUserID(), + openingCollectionID: file.collectionID, + ), + ), + ); + } + Widget _buildPageView() { return PageView.builder( clipBehavior: Clip.none, diff --git a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart index 728284292d6..e179696946e 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart @@ -1,13 +1,10 @@ import "dart:async"; import "dart:io"; -import "package:collection/collection.dart"; import "package:ente_icons/ente_icons.dart"; import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; -import "package:logging/logging.dart"; import "package:photos/core/event_bus.dart"; -import "package:photos/db/files_db.dart"; import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/collection/collection.dart"; @@ -15,23 +12,16 @@ import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/file/trash_file.dart"; import "package:photos/models/selected_files.dart"; -import "package:photos/models/social/social_data_provider.dart"; import 'package:photos/module/metadata/panorama.dart'; import "package:photos/service_locator.dart"; import "package:photos/services/collections_service.dart"; import "package:photos/states/detail_page_state.dart"; import "package:photos/ui/actions/file/file_actions.dart"; import "package:photos/ui/collections/collection_action_sheet.dart"; -import "package:photos/ui/notification/toast.dart"; -import "package:photos/ui/social/comments_screen.dart"; -import "package:photos/ui/social/like_collection_selector_sheet.dart"; -import "package:photos/ui/social/likes_bottom_sheet.dart"; import "package:photos/ui/viewer/actions/suggest_delete_sheet.dart"; import "package:photos/utils/delete_file_util.dart"; import "package:photos/utils/share_util.dart"; -final _logger = Logger("FileBottomBar"); - class FileBottomBar extends StatefulWidget { final EnteFile file; final Function(EnteFile) onFileRemoved; @@ -57,13 +47,10 @@ class FileBottomBarState extends State { bool isGuestView = false; late final StreamSubscription _guestViewEventSubscription; int? lastFileGenID; - bool _hasLiked = false; - int _commentCount = 0; @override void initState() { super.initState(); - _updateSocialState(); _guestViewEventSubscription = Bus.instance.on().listen(( event, ) { @@ -73,44 +60,6 @@ class FileBottomBarState extends State { }); } - @override - void didUpdateWidget(FileBottomBar oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID) { - _updateSocialState(); - } - } - - Future _updateSocialState({bool sync = false}) async { - if (widget.file.uploadedFileID == null) { - _hasLiked = false; - _commentCount = 0; - return; - } - - final fileID = widget.file.uploadedFileID!; - final collectionID = widget.file.collectionID; - final provider = SocialDataProvider.instance; - - // Sync from server if requested and we have a collection ID - if (sync && collectionID != null) { - try { - await provider.syncFileReactions(collectionID, fileID); - } catch (_) { - // Ignore sync errors, continue with local data - } - } - - // Check if user has liked - final reactions = await provider.getReactionsForFile(fileID); - _hasLiked = reactions.any((r) => r.userID == widget.userID && !r.isDeleted); - - // Get comment count - _commentCount = await provider.getCommentCountForFile(fileID); - - safeRefresh(); - } - @override void dispose() { _guestViewEventSubscription.cancel(); @@ -142,17 +91,7 @@ class FileBottomBarState extends State { ); } - void safeRefresh() { - if (mounted) { - setState(() {}); - } - } - Widget _getBottomBar() { - Logger( - "FileBottomBar", - ).fine("building bottom bar ${widget.file.generatedID}"); - final isInSharedCollection = InheritedDetailPageState.maybeOf( context, @@ -257,12 +196,6 @@ class FileBottomBarState extends State { ), ); } - - // Add social icons (heart, comment) for files in shared collections. - if (isInSharedCollection) { - children.add(_buildHeartIcon()); - children.add(_buildCommentIcon()); - } } return ValueListenableBuilder( valueListenable: widget.enableFullScreenNotifier, @@ -373,151 +306,6 @@ class FileBottomBarState extends State { ); } - Widget _buildHeartIcon() { - return Tooltip( - message: AppLocalizations.of(context).like, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: GestureDetector( - onLongPress: _showLikesBottomSheet, - child: IconButton( - style: IconButton.styleFrom( - overlayColor: WidgetStateColor.transparent, - ), - onPressed: _toggleReaction, - icon: Icon( - _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, - color: _hasLiked ? const Color(0xFF08C225) : Colors.white, - ), - ), - ), - ), - ); - } - - Future _toggleReaction() async { - final file = widget.file; - if (file.uploadedFileID == null || widget.userID == null) { - return; - } - - // If already liked, unlike from ALL shared collections - if (_hasLiked) { - await _unlikeFromAllCollections(); - return; - } - - final sharedCollections = await CollectionsService.instance - .getSharedCollectionsForFile(file.uploadedFileID!, includeHidden: true); - - if (sharedCollections.isEmpty) { - return; - } - - if (sharedCollections.length == 1) { - // Single shared collection: like directly - final previousState = _hasLiked; - _hasLiked = true; - safeRefresh(); - - try { - await SocialDataProvider.instance.toggleReaction( - userID: widget.userID!, - collectionID: sharedCollections.single.id, - fileID: file.uploadedFileID, - ); - } catch (e) { - _logger.severe("Failed to like photo", e); - if (mounted) { - _hasLiked = previousState; - safeRefresh(); - showShortToast(context, "Failed to like photo"); - } - } - } else { - // Multiple shared collections: show selector bottom sheet - if (!mounted) return; - await showLikeCollectionSelectorSheet( - context, - fileID: file.uploadedFileID!, - currentUserID: widget.userID!, - collections: sharedCollections, - file: file, - ); - // Refresh state after sheet closes to sync heart icon - await _updateSocialState(); - } - } - - /// Removes the user's like from all shared collections containing this file - Future _unlikeFromAllCollections() async { - final file = widget.file; - if (file.uploadedFileID == null || widget.userID == null) return; - - final currentUserID = widget.userID!; - final fileID = file.uploadedFileID!; - - // Optimistic UI update - final previousState = _hasLiked; - _hasLiked = false; - safeRefresh(); - - try { - // Get all collections containing this file - final collectionIDs = await FilesDB.instance.getAllCollectionIDsOfFile( - fileID, - ); - - // Filter to shared collections - final sharedCollections = collectionIDs - .map((id) => CollectionsService.instance.getCollectionByID(id)) - .whereType() - .where((c) => c.hasSharees || c.hasLink || !c.isOwner(currentUserID)) - .toList(); - - // Track failures - int failedCount = 0; - - // Unlike from each collection where user has an active like - for (final collection in sharedCollections) { - try { - final reactions = await SocialDataProvider.instance - .getReactionsForFileInCollection(fileID, collection.id); - - final userReaction = reactions.firstWhereOrNull( - (r) => r.userID == currentUserID && !r.isDeleted, - ); - - if (userReaction != null) { - await SocialDataProvider.instance.toggleReaction( - userID: currentUserID, - collectionID: collection.id, - fileID: fileID, - ); - } - } catch (e) { - failedCount++; - debugPrint("Failed to unlike from ${collection.displayName}: $e"); - } - } - - // Show toast and rollback if any failed - if (failedCount > 0 && mounted) { - _hasLiked = previousState; - safeRefresh(); - showShortToast(context, "Failed to unlike photo"); - } - } catch (e) { - // Rollback on error (e.g., fetching collections failed) - debugPrint("Failed to unlike from all collections: $e"); - if (mounted) { - _hasLiked = previousState; - safeRefresh(); - showShortToast(context, "Failed to remove like"); - } - } - } - Future _onSuggestDelete(Collection collection) async { if (widget.file.uploadedFileID == null) { return; @@ -533,76 +321,4 @@ class FileBottomBarState extends State { }, ); } - - void _showLikesBottomSheet() { - final file = widget.file; - if (file.uploadedFileID == null || file.collectionID == null) return; - - showLikesBottomSheet( - context, - fileID: file.uploadedFileID!, - initialCollectionID: file.collectionID!, - ); - } - - Widget _buildCommentIcon() { - return Tooltip( - message: AppLocalizations.of(context).comments, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: IconButton( - onPressed: _openCommentsScreen, - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(EnteIcons.commentBubbleStroke, color: Colors.white), - if (_commentCount > 0) - Positioned( - right: -4, - top: -4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.all(Radius.circular(16)), - border: Border.all( - color: Colors.black, - width: 2, - strokeAlign: BorderSide.strokeAlignOutside, - ), - ), - child: Text( - _commentCount > 99 ? '99+' : _commentCount.toString(), - style: const TextStyle( - color: Colors.black, - fontSize: 8, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - ), - ); - } - - void _openCommentsScreen() { - final file = widget.file; - if (file.collectionID == null) return; - - showFileCommentsBottomSheet( - context, - collectionID: file.collectionID!, - fileID: file.uploadedFileID!, - ).then((_) { - // Refresh comment count when returning from comments bottom sheet - _updateSocialState(); - }); - } } diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 35cdb379591..153c14740f4 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -260,6 +260,8 @@ dev_dependencies: json_annotation: 4.9.0 json_serializable: 6.11.2 mockito: 5.5.1 + path_provider_platform_interface: 2.1.3 + sqflite_common_ffi: 2.3.7+1 test: 1.26.3 # ------------------------------ diff --git a/mobile/apps/photos/test/db/social_db_test.dart b/mobile/apps/photos/test/db/social_db_test.dart new file mode 100644 index 00000000000..097c37a7817 --- /dev/null +++ b/mobile/apps/photos/test/db/social_db_test.dart @@ -0,0 +1,133 @@ +import "dart:io"; + +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:photos/db/social_db.dart"; +import "package:photos/models/social/comment.dart"; +import "package:photos/models/social/reaction.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late PathProviderPlatform previousPathProvider; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + previousPathProvider = PathProviderPlatform.instance; + tempDir = await Directory.systemTemp.createTemp("social_db_test_"); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + }); + + setUp(() => SocialDB.instance.clearAllData()); + + tearDownAll(() async { + await (await SocialDB.instance.database).close(); + PathProviderPlatform.instance = previousPathProvider; + await tempDir.delete(recursive: true); + }); + + test("heart state is file-wide across collections", () async { + await SocialDB.instance.upsertReactions([ + _reaction(id: "visible", collectionID: 10, userID: 1), + _reaction(id: "hidden", collectionID: 20, userID: 2), + _reaction(id: "deleted", collectionID: 20, userID: 3, isDeleted: true), + _reaction( + id: "comment-reaction", + collectionID: 10, + userID: 4, + commentID: "comment", + ), + ]); + + expect(await SocialDB.instance.hasUserReactedToFile(100, 1), isTrue); + expect(await SocialDB.instance.hasUserReactedToFile(100, 2), isTrue); + expect(await SocialDB.instance.hasUserReactedToFile(100, 3), isFalse); + expect(await SocialDB.instance.hasUserReactedToFile(100, 4), isFalse); + }); + + test("latest comment stays inside the eligible collection scope", () async { + await SocialDB.instance.upsertComments([ + _comment(id: "visible", collectionID: 10, createdAt: 10), + _comment(id: "hidden", collectionID: 20, createdAt: 20), + ]); + + final visibleLatest = await SocialDB.instance.getLatestCommentForFile( + 100, + candidateCollectionIDs: [10], + ); + final hiddenLatest = await SocialDB.instance.getLatestCommentForFile( + 100, + candidateCollectionIDs: [20], + ); + + expect(visibleLatest?.id, "visible"); + expect(hiddenLatest?.id, "hidden"); + expect( + await SocialDB.instance.getLatestCommentForFile( + 100, + candidateCollectionIDs: const [], + ), + isNull, + ); + }); + + test("comment count is aggregated across collections", () async { + await SocialDB.instance.upsertComments([ + _comment(id: "visible", collectionID: 10, createdAt: 10), + _comment(id: "hidden", collectionID: 20, createdAt: 20), + _comment(id: "deleted", collectionID: 20, createdAt: 30, isDeleted: true), + ]); + + expect(await SocialDB.instance.getCommentCountForFile(100), 2); + }); +} + +Reaction _reaction({ + required String id, + required int collectionID, + required int userID, + bool isDeleted = false, + String? commentID, +}) { + return Reaction( + id: id, + collectionID: collectionID, + fileID: 100, + commentID: commentID, + data: "❤", + isDeleted: isDeleted, + userID: userID, + createdAt: 1, + updatedAt: 1, + ); +} + +Comment _comment({ + required String id, + required int collectionID, + required int createdAt, + bool isDeleted = false, +}) { + return Comment( + id: id, + collectionID: collectionID, + fileID: 100, + data: id, + isDeleted: isDeleted, + userID: 1, + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.documentsPath); + + final String documentsPath; + + @override + Future getApplicationDocumentsPath() async => documentsPath; +} diff --git a/mobile/apps/photos/test/ui/social/file_social_context_test.dart b/mobile/apps/photos/test/ui/social/file_social_context_test.dart new file mode 100644 index 00000000000..a512aef3cd9 --- /dev/null +++ b/mobile/apps/photos/test/ui/social/file_social_context_test.dart @@ -0,0 +1,32 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/ui/social/widgets/file_social_overlay.dart"; + +void main() { + test("non-hidden opening context excludes hidden collections", () { + expect(fileSocialContextIncludesHiddenCollections(10, {20}), isFalse); + }); + + test("hidden opening context includes all shared collections", () { + expect(fileSocialContextIncludesHiddenCollections(20, {20}), isTrue); + }); + + test( + "widget treats a changed collection ID as a different async context", + () { + final file = EnteFile()..uploadedFileID = 100; + final first = FileSocialOverlay( + file: file, + currentUserID: 1, + openingCollectionID: 10, + ); + final second = FileSocialOverlay( + file: file, + currentUserID: 1, + openingCollectionID: 20, + ); + + expect(first.contextIdentity, isNot(second.contextIdentity)); + }, + ); +} From 0cb783cefe68a107c3bd8e566875bb4da445ca35 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 16:55:22 +0530 Subject: [PATCH 18/91] Remove social overlay event refresh --- .../lib/ui/social/widgets/file_social_overlay.dart | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index e751ccfd6e8..88877ddbdc3 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -6,8 +6,6 @@ import "package:ente_components/theme/text_styles.dart" as component; import "package:ente_icons/ente_icons.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; -import "package:photos/core/event_bus.dart"; -import "package:photos/events/social_data_updated_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; import "package:photos/models/collection/collection.dart"; @@ -76,9 +74,6 @@ class FileSocialOverlay extends StatefulWidget { } class _FileSocialOverlayState extends State { - late final StreamSubscription - _socialDataUpdatedSubscription; - List _eligibleSharedCollectionIDs = const []; bool _hasLiked = false; int _commentCount = 0; @@ -90,9 +85,6 @@ class _FileSocialOverlayState extends State { @override void initState() { super.initState(); - _socialDataUpdatedSubscription = Bus.instance - .on() - .listen((_) => unawaited(_refreshSocialState())); unawaited(_refreshSocialState()); } @@ -105,12 +97,6 @@ class _FileSocialOverlayState extends State { } } - @override - void dispose() { - _socialDataUpdatedSubscription.cancel(); - super.dispose(); - } - void _clearSocialState() { _eligibleSharedCollectionIDs = const []; _hasLiked = false; From c12dfcde7391b22e7298057dd73b3b8e829d2e94 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 17:12:21 +0530 Subject: [PATCH 19/91] Simplify social overlay refresh guards --- .../social/widgets/file_social_overlay.dart | 60 ++++++------------- .../ui/social/file_social_context_test.dart | 20 ------- 2 files changed, 19 insertions(+), 61 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 88877ddbdc3..90f1604d96b 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -26,12 +26,6 @@ const _likedColor = Color(0xFF08C225); const _socialControlsSize = 40.0; const _socialIconSize = 32.0; -typedef FileSocialContextIdentity = ({ - int? uploadedFileID, - int? openingCollectionID, - int? currentUserID, -}); - bool fileSocialContextIncludesHiddenCollections( int? openingCollectionID, Set hiddenCollectionIDs, @@ -63,12 +57,6 @@ class FileSocialOverlay extends StatefulWidget { super.key, }); - FileSocialContextIdentity get contextIdentity => ( - uploadedFileID: file.uploadedFileID, - openingCollectionID: openingCollectionID, - currentUserID: currentUserID, - ); - @override State createState() => _FileSocialOverlayState(); } @@ -91,7 +79,7 @@ class _FileSocialOverlayState extends State { @override void didUpdateWidget(covariant FileSocialOverlay oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.contextIdentity != widget.contextIdentity) { + if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID) { _clearSocialState(); unawaited(_refreshSocialState()); } @@ -106,12 +94,12 @@ class _FileSocialOverlayState extends State { } bool _canApplySocialRefresh({ - required FileSocialContextIdentity requestedContext, + required int requestedFileID, required int refreshID, }) { return mounted && refreshID == _latestRefreshID && - widget.contextIdentity == requestedContext; + widget.file.uploadedFileID == requestedFileID; } bool get _includeHiddenCollections { @@ -122,10 +110,9 @@ class _FileSocialOverlayState extends State { } Future _refreshSocialState() async { - // Recheck the captured context after every await before touching state. + // Recheck the captured file after every await before touching state. final fileID = widget.file.uploadedFileID; final currentUserID = widget.currentUserID; - final requestedContext = widget.contextIdentity; final refreshID = ++_latestRefreshID; if (fileID == null || currentUserID == null) { @@ -134,7 +121,7 @@ class _FileSocialOverlayState extends State { scheduleMicrotask(() { if (!mounted || refreshID != _latestRefreshID || - widget.contextIdentity != requestedContext) { + widget.file.uploadedFileID != fileID) { return; } widget.onVisibilityChanged?.call(false); @@ -149,7 +136,7 @@ class _FileSocialOverlayState extends State { includeHidden: _includeHiddenCollections, ); if (!_canApplySocialRefresh( - requestedContext: requestedContext, + requestedFileID: fileID, refreshID: refreshID, )) { return; @@ -171,7 +158,7 @@ class _FileSocialOverlayState extends State { ), ]); if (!_canApplySocialRefresh( - requestedContext: requestedContext, + requestedFileID: fileID, refreshID: refreshID, )) { return; @@ -188,7 +175,7 @@ class _FileSocialOverlayState extends State { ) : const {}; if (!_canApplySocialRefresh( - requestedContext: requestedContext, + requestedFileID: fileID, refreshID: refreshID, )) { return; @@ -203,7 +190,7 @@ class _FileSocialOverlayState extends State { } if (!_canApplySocialRefresh( - requestedContext: requestedContext, + requestedFileID: fileID, refreshID: refreshID, )) { return; @@ -230,7 +217,6 @@ class _FileSocialOverlayState extends State { final file = widget.file; final fileID = file.uploadedFileID; final currentUserID = widget.currentUserID; - final actionContext = widget.contextIdentity; if (fileID == null || currentUserID == null || _eligibleSharedCollectionIDs.isEmpty || @@ -241,7 +227,7 @@ class _FileSocialOverlayState extends State { _reactionUpdateFileIDs.add(fileID); try { if (_hasLiked) { - await _unlikeFromAllCollections(fileID, currentUserID, actionContext); + await _unlikeFromAllCollections(fileID, currentUserID); return; } @@ -251,7 +237,7 @@ class _FileSocialOverlayState extends State { includeHidden: _includeHiddenCollections, ); if (!mounted || - widget.contextIdentity != actionContext || + widget.file.uploadedFileID != fileID || sharedCollections.isEmpty) { return; } @@ -267,7 +253,7 @@ class _FileSocialOverlayState extends State { ); } catch (error, stackTrace) { _logger.severe("Failed to like photo", error, stackTrace); - if (mounted && widget.contextIdentity == actionContext) { + if (mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -292,11 +278,7 @@ class _FileSocialOverlayState extends State { } } - Future _unlikeFromAllCollections( - int fileID, - int currentUserID, - FileSocialContextIdentity actionContext, - ) async { + Future _unlikeFromAllCollections(int fileID, int currentUserID) async { final previousState = _hasLiked; setState(() => _hasLiked = false); @@ -328,9 +310,7 @@ class _FileSocialOverlayState extends State { } } - if (failedCount > 0 && - mounted && - widget.contextIdentity == actionContext) { + if (failedCount > 0 && mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -343,7 +323,7 @@ class _FileSocialOverlayState extends State { error, stackTrace, ); - if (mounted && widget.contextIdentity == actionContext) { + if (mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); showShortToast( context, @@ -355,7 +335,6 @@ class _FileSocialOverlayState extends State { Future _showLikes() async { final fileID = widget.file.uploadedFileID; - final actionContext = widget.contextIdentity; if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance @@ -364,12 +343,12 @@ class _FileSocialOverlayState extends State { includeHidden: _includeHiddenCollections, ); if (!mounted || - widget.contextIdentity != actionContext || + widget.file.uploadedFileID != fileID || sharedCollections.isEmpty) { return; } final initialCollection = sharedCollections.firstWhereOrNull( - (collection) => collection.id == actionContext.openingCollectionID, + (collection) => collection.id == widget.openingCollectionID, ); await showLikesBottomSheet( context, @@ -383,7 +362,6 @@ class _FileSocialOverlayState extends State { Future _openComments({Comment? comment}) async { final fileID = widget.file.uploadedFileID; - final actionContext = widget.contextIdentity; if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance @@ -392,7 +370,7 @@ class _FileSocialOverlayState extends State { includeHidden: _includeHiddenCollections, ); if (!mounted || - widget.contextIdentity != actionContext || + widget.file.uploadedFileID != fileID || sharedCollections.isEmpty) { return; } @@ -404,7 +382,7 @@ class _FileSocialOverlayState extends State { if (collection.id == comment?.collectionID) { commentCollection = collection; } - if (collection.id == actionContext.openingCollectionID) { + if (collection.id == widget.openingCollectionID) { openingCollection = collection; } if (collection.id == _latestComment?.collectionID) { diff --git a/mobile/apps/photos/test/ui/social/file_social_context_test.dart b/mobile/apps/photos/test/ui/social/file_social_context_test.dart index a512aef3cd9..573bcbddd6b 100644 --- a/mobile/apps/photos/test/ui/social/file_social_context_test.dart +++ b/mobile/apps/photos/test/ui/social/file_social_context_test.dart @@ -1,5 +1,4 @@ import "package:flutter_test/flutter_test.dart"; -import "package:photos/models/file/file.dart"; import "package:photos/ui/social/widgets/file_social_overlay.dart"; void main() { @@ -10,23 +9,4 @@ void main() { test("hidden opening context includes all shared collections", () { expect(fileSocialContextIncludesHiddenCollections(20, {20}), isTrue); }); - - test( - "widget treats a changed collection ID as a different async context", - () { - final file = EnteFile()..uploadedFileID = 100; - final first = FileSocialOverlay( - file: file, - currentUserID: 1, - openingCollectionID: 10, - ); - final second = FileSocialOverlay( - file: file, - currentUserID: 1, - openingCollectionID: 20, - ); - - expect(first.contextIdentity, isNot(second.contextIdentity)); - }, - ); } From cb8fc3c9b130f0ffe9c82c454a719ee04fcf47ed Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 17:28:50 +0530 Subject: [PATCH 20/91] Simplify social viewer async flow --- .../ui/home/memories/full_screen_memory.dart | 18 ++--- .../social/widgets/file_social_overlay.dart | 80 ++++++------------- .../empty_states/shared_empty_state.dart | 14 +--- .../file_selection_actions_widget.dart | 45 +++++------ .../people_selection_action_widget.dart | 14 ++-- .../lib/ui/viewer/file/file_app_bar.dart | 20 ++--- .../ui/social/file_social_context_test.dart | 12 --- 7 files changed, 67 insertions(+), 136 deletions(-) delete mode 100644 mobile/apps/photos/test/ui/social/file_social_context_test.dart diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 60ae24690ab..26a45bc2b0d 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -680,10 +680,8 @@ class _FullScreenMemoryState extends State { }, onSwipeUp: () { if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; - unawaited( - _runWithViewerPaused( - () => showDetailsSheet(context, currentFile), - ), + _runWithViewerPaused( + () => showDetailsSheet(context, currentFile), ); }, canSwipeUp: () => !_isMediaZoomed, @@ -787,12 +785,10 @@ class _FullScreenMemoryState extends State { title: widget.title, onClose: () => Navigator.pop(context), onDateTap: (file) { - unawaited( - _runWithViewerPaused( - () => routeToPage( - context, - JumpToDateGallery(fileToJumpTo: file), - ), + _runWithViewerPaused( + () => routeToPage( + context, + JumpToDateGallery(fileToJumpTo: file), ), ); }, @@ -974,7 +970,7 @@ class _MemoryActionButton extends StatelessWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, overlayColor: Colors.white.withValues(alpha: 0.08), ), - onPressed: () => unawaited(onPressed()), + onPressed: onPressed, icon: icon, ), ); diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 90f1604d96b..7467db911b4 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -26,14 +26,6 @@ const _likedColor = Color(0xFF08C225); const _socialControlsSize = 40.0; const _socialIconSize = 32.0; -bool fileSocialContextIncludesHiddenCollections( - int? openingCollectionID, - Set hiddenCollectionIDs, -) { - return openingCollectionID != null && - hiddenCollectionIDs.contains(openingCollectionID); -} - /// Social content for a file. The host owns placement and visibility effects. class FileSocialOverlay extends StatefulWidget { final EnteFile file; @@ -62,7 +54,7 @@ class FileSocialOverlay extends StatefulWidget { } class _FileSocialOverlayState extends State { - List _eligibleSharedCollectionIDs = const []; + bool _hasEligibleSharedCollections = false; bool _hasLiked = false; int _commentCount = 0; Comment? _latestComment; @@ -86,46 +78,34 @@ class _FileSocialOverlayState extends State { } void _clearSocialState() { - _eligibleSharedCollectionIDs = const []; + _hasEligibleSharedCollections = false; _hasLiked = false; _commentCount = 0; _latestComment = null; _latestCommentAuthor = null; } - bool _canApplySocialRefresh({ - required int requestedFileID, - required int refreshID, - }) { - return mounted && - refreshID == _latestRefreshID && - widget.file.uploadedFileID == requestedFileID; + bool _isCurrentSocialRefresh(int refreshID) { + return mounted && refreshID == _latestRefreshID; } bool get _includeHiddenCollections { - return fileSocialContextIncludesHiddenCollections( - widget.openingCollectionID, - CollectionsService.instance.getHiddenCollectionIds(), - ); + final openingCollectionID = widget.openingCollectionID; + return openingCollectionID != null && + CollectionsService.instance.getHiddenCollectionIds().contains( + openingCollectionID, + ); } Future _refreshSocialState() async { - // Recheck the captured file after every await before touching state. + // Recheck that this refresh is still current after every await. final fileID = widget.file.uploadedFileID; final currentUserID = widget.currentUserID; final refreshID = ++_latestRefreshID; if (fileID == null || currentUserID == null) { _clearSocialState(); - // Defer because a user-null transition can arrive from didUpdateWidget. - scheduleMicrotask(() { - if (!mounted || - refreshID != _latestRefreshID || - widget.file.uploadedFileID != fileID) { - return; - } - widget.onVisibilityChanged?.call(false); - }); + widget.onVisibilityChanged?.call(false); return; } @@ -135,10 +115,7 @@ class _FileSocialOverlayState extends State { fileID, includeHidden: _includeHiddenCollections, ); - if (!_canApplySocialRefresh( - requestedFileID: fileID, - refreshID: refreshID, - )) { + if (!_isCurrentSocialRefresh(refreshID)) { return; } @@ -157,10 +134,7 @@ class _FileSocialOverlayState extends State { candidateCollectionIDs: collectionIDs, ), ]); - if (!_canApplySocialRefresh( - requestedFileID: fileID, - refreshID: refreshID, - )) { + if (!_isCurrentSocialRefresh(refreshID)) { return; } @@ -174,10 +148,7 @@ class _FileSocialOverlayState extends State { latestComment.collectionID, ) : const {}; - if (!_canApplySocialRefresh( - requestedFileID: fileID, - refreshID: refreshID, - )) { + if (!_isCurrentSocialRefresh(refreshID)) { return; } @@ -189,14 +160,11 @@ class _FileSocialOverlayState extends State { ); } - if (!_canApplySocialRefresh( - requestedFileID: fileID, - refreshID: refreshID, - )) { + if (!_isCurrentSocialRefresh(refreshID)) { return; } setState(() { - _eligibleSharedCollectionIDs = collectionIDs; + _hasEligibleSharedCollections = true; _hasLiked = hasLiked; _commentCount = commentCount; _latestComment = latestComment; @@ -219,7 +187,7 @@ class _FileSocialOverlayState extends State { final currentUserID = widget.currentUserID; if (fileID == null || currentUserID == null || - _eligibleSharedCollectionIDs.isEmpty || + !_hasEligibleSharedCollections || _reactionUpdateFileIDs.contains(fileID)) { return; } @@ -335,7 +303,7 @@ class _FileSocialOverlayState extends State { Future _showLikes() async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; + if (fileID == null || !_hasEligibleSharedCollections) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( @@ -362,7 +330,7 @@ class _FileSocialOverlayState extends State { Future _openComments({Comment? comment}) async { final fileID = widget.file.uploadedFileID; - if (fileID == null || _eligibleSharedCollectionIDs.isEmpty) return; + if (fileID == null || !_hasEligibleSharedCollections) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( @@ -417,7 +385,7 @@ class _FileSocialOverlayState extends State { @override Widget build(BuildContext context) { - if (_eligibleSharedCollectionIDs.isEmpty) { + if (!_hasEligibleSharedCollections) { return const SizedBox.shrink(); } @@ -440,7 +408,7 @@ class _FileSocialOverlayState extends State { Tooltip( message: AppLocalizations.of(context).like, child: GestureDetector( - onLongPress: () => unawaited(_showLikes()), + onLongPress: _showLikes, child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( @@ -451,7 +419,7 @@ class _FileSocialOverlayState extends State { tapTargetSize: MaterialTapTargetSize.shrinkWrap, overlayColor: WidgetStateColor.transparent, ), - onPressed: () => unawaited(_toggleReaction()), + onPressed: _toggleReaction, icon: Icon( _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, color: _hasLiked ? _likedColor : Colors.white, @@ -473,7 +441,7 @@ class _FileSocialOverlayState extends State { maximumSize: const Size.square(_socialControlsSize), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - onPressed: () => unawaited(_openComments()), + onPressed: _openComments, icon: _buildCommentIcon(), ), ), @@ -492,7 +460,7 @@ class _FileSocialOverlayState extends State { ) { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => unawaited(_openComments(comment: comment)), + onTap: () => _openComments(comment: comment), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: Stack( diff --git a/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart b/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart index c8822b98108..0942d84314f 100644 --- a/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart +++ b/mobile/apps/photos/lib/ui/tabs/albums/empty_states/shared_empty_state.dart @@ -1,5 +1,3 @@ -import "dart:async"; - import "package:ente_components/ente_components.dart"; import "package:flutter/material.dart"; import "package:photos/generated/l10n.dart"; @@ -65,14 +63,10 @@ class SharedEmptyState extends StatelessWidget { ButtonComponent( label: strings.shareAnAlbum, shouldSurfaceExecutionStates: false, - onTap: () async { - unawaited( - showCollectionActionSheet( - context, - actionType: CollectionActionType.shareCollection, - ), - ); - }, + onTap: () => showCollectionActionSheet( + context, + actionType: CollectionActionType.shareCollection, + ), ), ], ), diff --git a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart index 07f5feb5bb8..70d26e6230a 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -609,38 +609,33 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - unawaited( - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.moveFiles, - ), + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.moveFiles, ); } Future _moveFilesToHiddenAlbum() async { - unawaited( - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.moveToHiddenCollection, - ), + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.moveToHiddenCollection, ); } Future _addToAlbum() async { - unawaited( - showCollectionActionSheet(context, selectedFiles: widget.selectedFiles), + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, ); } Future _addToHiddenAlbum() async { - unawaited( - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.addToHiddenAlbum, - ), + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.addToHiddenAlbum, ); } @@ -891,12 +886,10 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - unawaited( - showCollectionActionSheet( - context, - selectedFiles: widget.selectedFiles, - actionType: CollectionActionType.unHide, - ), + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + actionType: CollectionActionType.unHide, ); } diff --git a/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart b/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart index 7106755cc1d..274d67877bd 100644 --- a/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/actions/people_selection_action_widget.dart @@ -1,5 +1,3 @@ -import "dart:async"; - import "package:ente_pure_utils/ente_pure_utils.dart"; import "package:flutter/material.dart"; import "package:hugeicons/hugeicons.dart"; @@ -354,14 +352,12 @@ class _PeopleSelectionActionWidgetState final selectedPersonIds = _getSelectedPersonIds(personMap); if (selectedPersonIds.isEmpty) return; if (!mounted) return; - unawaited( - showCollectionActionSheet( - context, - selectedPeople: selectedPersonIds, - actionType: CollectionActionType.autoAddPeople, - ), - ); widget.selectedPeople.clearAll(); + await showCollectionActionSheet( + context, + selectedPeople: selectedPersonIds, + actionType: CollectionActionType.autoAddPeople, + ); } Future _updatePinState(bool shouldPin) async { 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 a324e1189f6..62c533cd981 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 @@ -565,12 +565,10 @@ class FileAppBarState extends State { } else if (value == 10) { final selectedFiles = SelectedFiles(); selectedFiles.files.add(widget.file); - unawaited( - showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.addFiles, - ), + await showCollectionActionSheet( + context, + selectedFiles: selectedFiles, + actionType: CollectionActionType.addFiles, ); } } @@ -617,12 +615,10 @@ class FileAppBarState extends State { Future _handleUnHideRequest(BuildContext context) async { final selectedFiles = SelectedFiles(); selectedFiles.files.add(widget.file); - unawaited( - showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.unHide, - ), + await showCollectionActionSheet( + context, + selectedFiles: selectedFiles, + actionType: CollectionActionType.unHide, ); } diff --git a/mobile/apps/photos/test/ui/social/file_social_context_test.dart b/mobile/apps/photos/test/ui/social/file_social_context_test.dart deleted file mode 100644 index 573bcbddd6b..00000000000 --- a/mobile/apps/photos/test/ui/social/file_social_context_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "package:flutter_test/flutter_test.dart"; -import "package:photos/ui/social/widgets/file_social_overlay.dart"; - -void main() { - test("non-hidden opening context excludes hidden collections", () { - expect(fileSocialContextIncludesHiddenCollections(10, {20}), isFalse); - }); - - test("hidden opening context includes all shared collections", () { - expect(fileSocialContextIncludesHiddenCollections(20, {20}), isTrue); - }); -} From 74922e006be8b11902099730e34d4edd3f549217 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 17:59:58 +0530 Subject: [PATCH 21/91] Remove redundant social action guards --- .../photos/lib/ui/social/widgets/file_social_overlay.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 7467db911b4..9ac99f101f7 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -187,7 +187,6 @@ class _FileSocialOverlayState extends State { final currentUserID = widget.currentUserID; if (fileID == null || currentUserID == null || - !_hasEligibleSharedCollections || _reactionUpdateFileIDs.contains(fileID)) { return; } @@ -303,7 +302,7 @@ class _FileSocialOverlayState extends State { Future _showLikes() async { final fileID = widget.file.uploadedFileID; - if (fileID == null || !_hasEligibleSharedCollections) return; + if (fileID == null) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( @@ -330,7 +329,7 @@ class _FileSocialOverlayState extends State { Future _openComments({Comment? comment}) async { final fileID = widget.file.uploadedFileID; - if (fileID == null || !_hasEligibleSharedCollections) return; + if (fileID == null) return; await _runSheetAndRefresh(() async { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( From c9bc687382ae5e712106b9f5dce8832c9a161e29 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 18:06:50 +0530 Subject: [PATCH 22/91] Clarify reaction update tracking name --- .../photos/lib/ui/social/widgets/file_social_overlay.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 9ac99f101f7..d7145c99c74 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -60,7 +60,7 @@ class _FileSocialOverlayState extends State { Comment? _latestComment; User? _latestCommentAuthor; int _latestRefreshID = 0; - final _reactionUpdateFileIDs = {}; + final _fileIDsWithReactionUpdateInProgress = {}; @override void initState() { @@ -187,11 +187,11 @@ class _FileSocialOverlayState extends State { final currentUserID = widget.currentUserID; if (fileID == null || currentUserID == null || - _reactionUpdateFileIDs.contains(fileID)) { + _fileIDsWithReactionUpdateInProgress.contains(fileID)) { return; } - _reactionUpdateFileIDs.add(fileID); + _fileIDsWithReactionUpdateInProgress.add(fileID); try { if (_hasLiked) { await _unlikeFromAllCollections(fileID, currentUserID); @@ -241,7 +241,7 @@ class _FileSocialOverlayState extends State { ), ); } finally { - _reactionUpdateFileIDs.remove(fileID); + _fileIDsWithReactionUpdateInProgress.remove(fileID); } } From 92a0fe355ffdeb8aebfa7067163d9bed87283b03 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 18:13:38 +0530 Subject: [PATCH 23/91] Lower social action failure log severity --- .../photos/lib/ui/social/widgets/file_social_overlay.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index d7145c99c74..1c26adcbaa8 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -219,7 +219,7 @@ class _FileSocialOverlayState extends State { fileID: fileID, ); } catch (error, stackTrace) { - _logger.severe("Failed to like photo", error, stackTrace); + _logger.warning("Failed to like photo", error, stackTrace); if (mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); showShortToast( @@ -285,7 +285,7 @@ class _FileSocialOverlayState extends State { ); } } catch (error, stackTrace) { - _logger.severe( + _logger.warning( "Failed to unlike from all collections", error, stackTrace, From 0ca9981ae8254ebdebbf7438a65645911822d474 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Mon, 20 Jul 2026 18:37:55 +0530 Subject: [PATCH 24/91] Limit like failure toast visibility --- .../like_collection_selector_sheet.dart | 12 ++++++---- .../social/widgets/file_social_overlay.dart | 23 +++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart index af352082be5..b0f75804851 100644 --- a/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/like_collection_selector_sheet.dart @@ -1,4 +1,5 @@ import "package:ente_icons/ente_icons.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:photos/core/constants.dart"; @@ -8,6 +9,7 @@ import "package:photos/models/collection/collection.dart"; import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/social/social_data_provider.dart"; +import "package:photos/service_locator.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/components/buttons/icon_button_widget.dart"; @@ -137,10 +139,12 @@ class _LikeCollectionSelectorSheetState _logger.severe("Failed to toggle like", e); if (mounted) { setState(() => state.isLiked = previousState); - showShortToast( - context, - AppLocalizations.of(context).failedToUpdateLike, - ); + if (flagService.internalUser || kDebugMode) { + showShortToast( + context, + AppLocalizations.of(context).failedToUpdateLike, + ); + } } } } diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 1c26adcbaa8..e15e2a4b3e8 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -4,6 +4,7 @@ import "dart:math" as math; import "package:collection/collection.dart"; import "package:ente_components/theme/text_styles.dart" as component; import "package:ente_icons/ente_icons.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:photos/generated/l10n.dart"; @@ -13,6 +14,7 @@ import "package:photos/models/file/file.dart"; import "package:photos/models/social/comment.dart"; import "package:photos/models/social/comment_author_utils.dart"; import "package:photos/models/social/social_data_provider.dart"; +import "package:photos/service_locator.dart"; import "package:photos/services/collections_service.dart"; import "package:photos/ui/notification/toast.dart"; import "package:photos/ui/sharing/user_avator_widget.dart"; @@ -222,10 +224,7 @@ class _FileSocialOverlayState extends State { _logger.warning("Failed to like photo", error, stackTrace); if (mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); - showShortToast( - context, - AppLocalizations.of(context).failedToUpdateLike, - ); + _showFailedToUpdateLikeToast(); } } return; @@ -279,10 +278,7 @@ class _FileSocialOverlayState extends State { if (failedCount > 0 && mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); - showShortToast( - context, - AppLocalizations.of(context).failedToUpdateLike, - ); + _showFailedToUpdateLikeToast(); } } catch (error, stackTrace) { _logger.warning( @@ -292,14 +288,17 @@ class _FileSocialOverlayState extends State { ); if (mounted && widget.file.uploadedFileID == fileID) { setState(() => _hasLiked = previousState); - showShortToast( - context, - AppLocalizations.of(context).failedToUpdateLike, - ); + _showFailedToUpdateLikeToast(); } } } + void _showFailedToUpdateLikeToast() { + if (flagService.internalUser || kDebugMode) { + showShortToast(context, AppLocalizations.of(context).failedToUpdateLike); + } + } + Future _showLikes() async { final fileID = widget.file.uploadedFileID; if (fileID == null) return; From e7ff9fcc7add0f63cf7e27baff62d2759268e8a7 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 10:36:49 +0530 Subject: [PATCH 25/91] Prefer latest comment collection in comment sheet --- .../apps/photos/lib/ui/social/widgets/file_social_overlay.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index e15e2a4b3e8..a52101a24e2 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -357,8 +357,8 @@ class _FileSocialOverlayState extends State { } final initialCollection = commentCollection ?? - openingCollection ?? latestCommentCollection ?? + openingCollection ?? sharedCollections.first; await showFileCommentsBottomSheet( From 18e21c8f09f09e4f0d922ad275dbecaa6770a1a3 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 10:55:17 +0530 Subject: [PATCH 26/91] Increase social control touch targets --- .../social/widgets/file_social_overlay.dart | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index a52101a24e2..341a8e1c7ea 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -25,8 +25,9 @@ import "package:photos/ui/social/likes_bottom_sheet.dart"; final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); -const _socialControlsSize = 40.0; +const _socialControlsSize = 48.0; const _socialIconSize = 32.0; +const _socialIconPadding = EdgeInsets.fromLTRB(12, 12, 4, 4); /// Social content for a file. The host owns placement and visibility effects. class FileSocialOverlay extends StatefulWidget { @@ -393,7 +394,7 @@ class _FileSocialOverlayState extends State { children: [ if (_latestComment != null && _latestCommentAuthor != null) Padding( - padding: const EdgeInsets.only(right: 12, bottom: 4), + padding: const EdgeInsets.only(right: 4, bottom: 4), child: _buildLatestCommentPill( _latestComment!, _latestCommentAuthor!, @@ -410,11 +411,8 @@ class _FileSocialOverlayState extends State { child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: const EdgeInsets.all(4), + padding: _socialIconPadding, style: IconButton.styleFrom( - minimumSize: const Size.square(_socialControlsSize), - maximumSize: const Size.square(_socialControlsSize), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, overlayColor: WidgetStateColor.transparent, ), onPressed: _toggleReaction, @@ -427,18 +425,13 @@ class _FileSocialOverlayState extends State { ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), Tooltip( message: AppLocalizations.of(context).comments, child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: const EdgeInsets.all(4), - style: IconButton.styleFrom( - minimumSize: const Size.square(_socialControlsSize), - maximumSize: const Size.square(_socialControlsSize), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), + padding: _socialIconPadding, onPressed: _openComments, icon: _buildCommentIcon(), ), From 976fbeb92d6c28c34c518c2b6bd13ecfdf62f09b Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 11:12:33 +0530 Subject: [PATCH 27/91] Clarify hidden social collection context --- mobile/apps/photos/lib/ui/social/comments_screen.dart | 8 +++++--- .../apps/photos/lib/ui/social/likes_bottom_sheet.dart | 8 +++++--- .../lib/ui/social/widgets/file_social_overlay.dart | 10 +++++----- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/comments_screen.dart b/mobile/apps/photos/lib/ui/social/comments_screen.dart index 884476e1563..8a02f2dab38 100644 --- a/mobile/apps/photos/lib/ui/social/comments_screen.dart +++ b/mobile/apps/photos/lib/ui/social/comments_screen.dart @@ -211,14 +211,16 @@ class _FileCommentsBottomSheetState extends State { super.dispose(); } + bool _isOpenedFromHiddenCollection() => CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.collectionID); + Future _loadSharedCollections() async { final sharedCollectionsList = widget.sharedCollections ?? await CollectionsService.instance.getSharedCollectionsForFile( widget.fileID, - includeHidden: CollectionsService.instance - .getHiddenCollectionIds() - .contains(widget.collectionID), + includeHidden: _isOpenedFromHiddenCollection(), ); // Fetch data in parallel diff --git a/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart b/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart index 5ec894df4e9..a1a0bc5ebde 100644 --- a/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart +++ b/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart @@ -79,15 +79,17 @@ class _LikesBottomSheetState extends State { _loadSharedCollections(); } + bool _isOpenedFromHiddenCollection() => CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.initialCollectionID); + Future _loadSharedCollections() async { try { final sharedCollectionsList = widget.sharedCollections ?? await CollectionsService.instance.getSharedCollectionsForFile( widget.fileID, - includeHidden: CollectionsService.instance - .getHiddenCollectionIds() - .contains(widget.initialCollectionID), + includeHidden: _isOpenedFromHiddenCollection(), ); // Fetch like counts and thumbnails in parallel diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 341a8e1c7ea..09277d36e41 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -92,7 +92,7 @@ class _FileSocialOverlayState extends State { return mounted && refreshID == _latestRefreshID; } - bool get _includeHiddenCollections { + bool _isOpenedFromHiddenCollection() { final openingCollectionID = widget.openingCollectionID; return openingCollectionID != null && CollectionsService.instance.getHiddenCollectionIds().contains( @@ -116,7 +116,7 @@ class _FileSocialOverlayState extends State { final collectionIDs = await CollectionsService.instance .getSharedCollectionIDsForFile( fileID, - includeHidden: _includeHiddenCollections, + includeHidden: _isOpenedFromHiddenCollection(), ); if (!_isCurrentSocialRefresh(refreshID)) { return; @@ -204,7 +204,7 @@ class _FileSocialOverlayState extends State { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( fileID, - includeHidden: _includeHiddenCollections, + includeHidden: _isOpenedFromHiddenCollection(), ); if (!mounted || widget.file.uploadedFileID != fileID || @@ -307,7 +307,7 @@ class _FileSocialOverlayState extends State { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( fileID, - includeHidden: _includeHiddenCollections, + includeHidden: _isOpenedFromHiddenCollection(), ); if (!mounted || widget.file.uploadedFileID != fileID || @@ -334,7 +334,7 @@ class _FileSocialOverlayState extends State { final sharedCollections = await CollectionsService.instance .getSharedCollectionsForFile( fileID, - includeHidden: _includeHiddenCollections, + includeHidden: _isOpenedFromHiddenCollection(), ); if (!mounted || widget.file.uploadedFileID != fileID || From 0f9bef99418f0e3c488e70a816184c52397edbad Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 11:35:48 +0530 Subject: [PATCH 28/91] Extract social overlay widgets --- .../social/widgets/file_social_overlay.dart | 136 +++++++++++------- .../lib/ui/viewer/file/detail_page.dart | 88 +++++++----- 2 files changed, 135 insertions(+), 89 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 09277d36e41..f6fab9c1073 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -389,69 +389,90 @@ class _FileSocialOverlayState extends State { } return LayoutBuilder( - builder: (context, constraints) => Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (_latestComment != null && _latestCommentAuthor != null) - Padding( - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: _buildLatestCommentPill( - _latestComment!, - _latestCommentAuthor!, - math.min(constraints.maxWidth * 0.5, 200), + builder: (context, constraints) { + final latestComment = _latestComment; + final latestCommentAuthor = _latestCommentAuthor; + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (latestComment != null && latestCommentAuthor != null) + Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + maxWidth: math.min(constraints.maxWidth * 0.5, 200), + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), + ), ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Tooltip( - message: AppLocalizations.of(context).like, - child: GestureDetector( - onLongPress: _showLikes, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: AppLocalizations.of(context).like, + child: GestureDetector( + onLongPress: _showLikes, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _socialIconPadding, + style: IconButton.styleFrom( + overlayColor: WidgetStateColor.transparent, + ), + onPressed: _toggleReaction, + icon: Icon( + _hasLiked + ? EnteIcons.likeFilled + : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + size: _socialIconSize, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Tooltip( + message: AppLocalizations.of(context).comments, child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( padding: _socialIconPadding, - style: IconButton.styleFrom( - overlayColor: WidgetStateColor.transparent, - ), - onPressed: _toggleReaction, - icon: Icon( - _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, - color: _hasLiked ? _likedColor : Colors.white, - size: _socialIconSize, - ), + onPressed: _openComments, + icon: _CommentBadgeIcon(count: _commentCount), ), ), ), - ), - const SizedBox(height: 8), - Tooltip( - message: AppLocalizations.of(context).comments, - child: SizedBox.square( - dimension: _socialControlsSize, - child: IconButton( - padding: _socialIconPadding, - onPressed: _openComments, - icon: _buildCommentIcon(), - ), - ), - ), - ], - ), - ], - ), + ], + ), + ], + ); + }, ); } +} - Widget _buildLatestCommentPill( - Comment comment, - User author, - double maxWidth, - ) { +class _LatestCommentPill extends StatelessWidget { + final Comment comment; + final User author; + final double maxWidth; + final int currentUserID; + final VoidCallback onTap; + + const _LatestCommentPill({ + required this.comment, + required this.author, + required this.maxWidth, + required this.currentUserID, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _openComments(comment: comment), + onTap: onTap, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: Stack( @@ -496,7 +517,7 @@ class _FileSocialOverlayState extends State { child: UserAvatarWidget( author, type: AvatarType.small, - currentUserID: widget.currentUserID!, + currentUserID: currentUserID, ), ), ), @@ -505,8 +526,15 @@ class _FileSocialOverlayState extends State { ), ); } +} + +class _CommentBadgeIcon extends StatelessWidget { + final int count; - Widget _buildCommentIcon() { + const _CommentBadgeIcon({required this.count}); + + @override + Widget build(BuildContext context) { return Stack( clipBehavior: Clip.none, children: [ @@ -515,7 +543,7 @@ class _FileSocialOverlayState extends State { color: Colors.white, size: _socialIconSize, ), - if (_commentCount > 0) + if (count > 0) Positioned( right: -5, top: -5, @@ -528,7 +556,7 @@ class _FileSocialOverlayState extends State { ), child: Center( child: Text( - _commentCount > 99 ? "99+" : _commentCount.toString(), + count > 99 ? "99+" : count.toString(), style: const TextStyle( color: Colors.black, fontSize: 10, diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index 995789b57c0..75d3784176a 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -290,7 +290,11 @@ class _BodyState extends State<_Body> { ValueListenableBuilder( valueListenable: _selectedIndexNotifier, builder: (BuildContext context, int selectedIndex, _) { - return _buildSocialOverlay(_files![selectedIndex]); + return _GallerySocialOverlay( + file: _files![selectedIndex], + mode: widget.config.mode, + isGuestView: isGuestView, + ); }, ), ValueListenableBuilder( @@ -403,40 +407,6 @@ class _BodyState extends State<_Body> { .ignore(); } - Widget _buildSocialOverlay(EnteFile file) { - if (widget.config.mode == DetailPageMode.minimalistic || - isGuestView || - file is TrashFile) { - return const SizedBox.shrink(); - } - - final padding = MediaQuery.paddingOf(context); - final fullScreenNotifier = InheritedDetailPageState.of( - context, - ).enableFullScreenNotifier; - return Positioned( - right: padding.right + _socialRightInset, - bottom: padding.bottom + _socialBottomBarClearance, - child: ValueListenableBuilder( - valueListenable: fullScreenNotifier, - builder: (context, isFullScreen, child) => IgnorePointer( - ignoring: isFullScreen, - child: AnimatedOpacity( - opacity: isFullScreen ? 0 : 1, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - child: child, - ), - ), - child: FileSocialOverlay( - file: file, - currentUserID: Configuration.instance.getUserID(), - openingCollectionID: file.collectionID, - ), - ), - ); - } - Widget _buildPageView() { return PageView.builder( clipBehavior: Clip.none, @@ -670,3 +640,51 @@ class _BodyState extends State<_Body> { return files[index]; } } + +/// Must remain under a [Stack] because this widget builds a [Positioned]. +class _GallerySocialOverlay extends StatelessWidget { + final EnteFile file; + final DetailPageMode mode; + final bool isGuestView; + + const _GallerySocialOverlay({ + required this.file, + required this.mode, + required this.isGuestView, + }); + + @override + Widget build(BuildContext context) { + if (mode == DetailPageMode.minimalistic || + isGuestView || + file is TrashFile) { + return const SizedBox.shrink(); + } + + final padding = MediaQuery.paddingOf(context); + final fullScreenNotifier = InheritedDetailPageState.of( + context, + ).enableFullScreenNotifier; + return Positioned( + right: padding.right + _socialRightInset, + bottom: padding.bottom + _socialBottomBarClearance, + child: ValueListenableBuilder( + valueListenable: fullScreenNotifier, + builder: (context, isFullScreen, child) => IgnorePointer( + ignoring: isFullScreen, + child: AnimatedOpacity( + opacity: isFullScreen ? 0 : 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: child, + ), + ), + child: FileSocialOverlay( + file: file, + currentUserID: Configuration.instance.getUserID(), + openingCollectionID: file.collectionID, + ), + ), + ); + } +} From 47cb611c94538958cdf9686a1bf6c88697e9582e Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 11:36:42 +0530 Subject: [PATCH 29/91] Size latest comment by viewer width --- .../apps/photos/lib/ui/social/widgets/file_social_overlay.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index f6fab9c1073..914e3c38e0e 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -1,5 +1,4 @@ import "dart:async"; -import "dart:math" as math; import "package:collection/collection.dart"; import "package:ente_components/theme/text_styles.dart" as component; @@ -401,7 +400,7 @@ class _FileSocialOverlayState extends State { child: _LatestCommentPill( comment: latestComment, author: latestCommentAuthor, - maxWidth: math.min(constraints.maxWidth * 0.5, 200), + maxWidth: constraints.maxWidth * 0.6, currentUserID: widget.currentUserID!, onTap: () => _openComments(comment: latestComment), ), From 0b8b98ad157791ca55c1258ac3fe658e5a63fa63 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 11:58:05 +0530 Subject: [PATCH 30/91] Constrain latest comment overlay width --- .../social/widgets/file_social_overlay.dart | 103 +++++++++--------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 914e3c38e0e..a8f9f95308d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -387,67 +387,62 @@ class _FileSocialOverlayState extends State { return const SizedBox.shrink(); } - return LayoutBuilder( - builder: (context, constraints) { - final latestComment = _latestComment; - final latestCommentAuthor = _latestCommentAuthor; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, + final latestComment = _latestComment; + final latestCommentAuthor = _latestCommentAuthor; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (latestComment != null && latestCommentAuthor != null) + Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + maxWidth: MediaQuery.sizeOf(context).width * 0.6, + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, children: [ - if (latestComment != null && latestCommentAuthor != null) - Padding( - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: _LatestCommentPill( - comment: latestComment, - author: latestCommentAuthor, - maxWidth: constraints.maxWidth * 0.6, - currentUserID: widget.currentUserID!, - onTap: () => _openComments(comment: latestComment), - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Tooltip( - message: AppLocalizations.of(context).like, - child: GestureDetector( - onLongPress: _showLikes, - child: SizedBox.square( - dimension: _socialControlsSize, - child: IconButton( - padding: _socialIconPadding, - style: IconButton.styleFrom( - overlayColor: WidgetStateColor.transparent, - ), - onPressed: _toggleReaction, - icon: Icon( - _hasLiked - ? EnteIcons.likeFilled - : EnteIcons.likeStroke, - color: _hasLiked ? _likedColor : Colors.white, - size: _socialIconSize, - ), - ), + Tooltip( + message: AppLocalizations.of(context).like, + child: GestureDetector( + onLongPress: _showLikes, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _socialIconPadding, + style: IconButton.styleFrom( + overlayColor: WidgetStateColor.transparent, ), - ), - ), - const SizedBox(height: 8), - Tooltip( - message: AppLocalizations.of(context).comments, - child: SizedBox.square( - dimension: _socialControlsSize, - child: IconButton( - padding: _socialIconPadding, - onPressed: _openComments, - icon: _CommentBadgeIcon(count: _commentCount), + onPressed: _toggleReaction, + icon: Icon( + _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + size: _socialIconSize, ), ), ), - ], + ), + ), + const SizedBox(height: 8), + Tooltip( + message: AppLocalizations.of(context).comments, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _socialIconPadding, + onPressed: _openComments, + icon: _CommentBadgeIcon(count: _commentCount), + ), + ), ), ], - ); - }, + ), + ], ); } } From 4ef8521badc0ade1ed578b33690183523aa617b1 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 12:29:23 +0530 Subject: [PATCH 31/91] Avoid social overlay flicker on file changes --- .../ui/home/memories/full_screen_memory.dart | 1 - .../social/widgets/file_social_overlay.dart | 104 ++++++++++-------- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 26a45bc2b0d..64920f189a4 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -579,7 +579,6 @@ class _FullScreenMemoryState extends State { void _onPageChange(FullScreenMemoryData inheritedData, int index) { if (!_isValidMemoryIndex(index, inheritedData.memories.length)) return; _isMediaZoomed = false; - _setSocialControlsVisible(false); isAtFirstOrLastFile = false; unawaited( memoriesCacheService.markMemoryAsSeen( diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index a8f9f95308d..36004e4ad9d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -61,6 +61,7 @@ class _FileSocialOverlayState extends State { int _commentCount = 0; Comment? _latestComment; User? _latestCommentAuthor; + int? _loadedFileID; int _latestRefreshID = 0; final _fileIDsWithReactionUpdateInProgress = {}; @@ -74,12 +75,12 @@ class _FileSocialOverlayState extends State { void didUpdateWidget(covariant FileSocialOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID) { - _clearSocialState(); unawaited(_refreshSocialState()); } } void _clearSocialState() { + _loadedFileID = null; _hasEligibleSharedCollections = false; _hasLiked = false; _commentCount = 0; @@ -171,15 +172,19 @@ class _FileSocialOverlayState extends State { _commentCount = commentCount; _latestComment = latestComment; _latestCommentAuthor = latestCommentAuthor; + _loadedFileID = fileID; }); widget.onVisibilityChanged?.call(true); } catch (error, stackTrace) { - // Preserve last-good state; page changes clear state and visibility. _logger.warning( "Failed to refresh social overlay for file $fileID", error, stackTrace, ); + if (_isCurrentSocialRefresh(refreshID) && _loadedFileID != fileID) { + setState(_clearSocialState); + widget.onVisibilityChanged?.call(false); + } } } @@ -389,60 +394,63 @@ class _FileSocialOverlayState extends State { final latestComment = _latestComment; final latestCommentAuthor = _latestCommentAuthor; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (latestComment != null && latestCommentAuthor != null) - Padding( - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: _LatestCommentPill( - comment: latestComment, - author: latestCommentAuthor, - maxWidth: MediaQuery.sizeOf(context).width * 0.6, - currentUserID: widget.currentUserID!, - onTap: () => _openComments(comment: latestComment), + return IgnorePointer( + ignoring: _loadedFileID != widget.file.uploadedFileID, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (latestComment != null && latestCommentAuthor != null) + Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + maxWidth: MediaQuery.sizeOf(context).width * 0.6, + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), + ), ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Tooltip( - message: AppLocalizations.of(context).like, - child: GestureDetector( - onLongPress: _showLikes, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: AppLocalizations.of(context).like, + child: GestureDetector( + onLongPress: _showLikes, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _socialIconPadding, + style: IconButton.styleFrom( + overlayColor: WidgetStateColor.transparent, + ), + onPressed: _toggleReaction, + icon: Icon( + _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + size: _socialIconSize, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Tooltip( + message: AppLocalizations.of(context).comments, child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( padding: _socialIconPadding, - style: IconButton.styleFrom( - overlayColor: WidgetStateColor.transparent, - ), - onPressed: _toggleReaction, - icon: Icon( - _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, - color: _hasLiked ? _likedColor : Colors.white, - size: _socialIconSize, - ), + onPressed: _openComments, + icon: _CommentBadgeIcon(count: _commentCount), ), ), ), - ), - const SizedBox(height: 8), - Tooltip( - message: AppLocalizations.of(context).comments, - child: SizedBox.square( - dimension: _socialControlsSize, - child: IconButton( - padding: _socialIconPadding, - onPressed: _openComments, - icon: _CommentBadgeIcon(count: _commentCount), - ), - ), - ), - ], - ), - ], + ], + ), + ], + ), ); } } From 930a775b1a420d526a153a73c3b0f5cf6dc4dbe1 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Mon, 20 Jul 2026 14:08:21 +0530 Subject: [PATCH 32/91] Limit recovery sheet scanner resource use --- .../legacy/src/features/legacy-kit/scan.ts | 135 +++++++++++++++--- 1 file changed, 117 insertions(+), 18 deletions(-) diff --git a/web/apps/legacy/src/features/legacy-kit/scan.ts b/web/apps/legacy/src/features/legacy-kit/scan.ts index ae471e80d7c..7d01d975e26 100644 --- a/web/apps/legacy/src/features/legacy-kit/scan.ts +++ b/web/apps/legacy/src/features/legacy-kit/scan.ts @@ -4,7 +4,20 @@ type DecodeQR = (typeof import("qr/decode.js"))["default"]; const legacyKitShareMetadataPrefix = "ente-legacy-kit-share-v1:"; const legacyKitBundleMetadataPrefix = "ente-legacy-kit-shares-v1:"; +const maxTextFileBytes = 64 * 1024; +const maxPDFFileBytes = 32 * 1024 * 1024; +const maxImageFileBytes = 32 * 1024 * 1024; +const maxInputImagePixels = 64 * 1024 * 1024; +const maxInputImageSide = 16_000; +const maxCanvasPixels = 6 * 1024 * 1024; +const maxCanvasSide = 2_400; +const maxPDFPagesToScan = 5; +const maxPDFTextChars = 128 * 1024; const pdfFallbackTimeoutMS = 15_000; +const pdfPageTimeoutMS = 5_000; + +const tooLargeMessage = + "This recovery sheet is too large to scan safely. Try the original PDF or a smaller, clearer photo."; interface DecodableImage { data: Uint8ClampedArray | number[]; @@ -56,6 +69,12 @@ const withTimeout = async ( } }; +const assertFileSize = (file: File, maxBytes: number) => { + if (file.size > maxBytes) { + throw new Error(tooLargeMessage); + } +}; + const findJsonObject = (value: string) => { const start = value.indexOf('{"pv"'); if (start < 0) return undefined; @@ -174,9 +193,26 @@ const loadDrawableFromFile = async (file: File): Promise => { }; const imageDataFromDrawable = (drawable: DrawableImage) => { + if ( + drawable.width <= 0 || + drawable.height <= 0 || + drawable.width > maxInputImageSide || + drawable.height > maxInputImageSide || + drawable.width * drawable.height > maxInputImagePixels + ) { + throw new Error(tooLargeMessage); + } + + const scale = Math.min( + 1, + maxCanvasSide / Math.max(drawable.width, drawable.height), + Math.sqrt(maxCanvasPixels / (drawable.width * drawable.height)), + ); + const width = Math.max(1, Math.floor(drawable.width * scale)); + const height = Math.max(1, Math.floor(drawable.height * scale)); const canvas = document.createElement("canvas"); - canvas.width = drawable.width; - canvas.height = drawable.height; + canvas.width = width; + canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { throw new Error("Could not read that image."); @@ -270,40 +306,92 @@ const decodeQrImage = async (image: DecodableImage) => { const textFromPDF = async (pdf: PDFDocumentProxy) => { const chunks: string[] = []; - for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + let charCount = 0; + const pageCount = Math.min(pdf.numPages, maxPDFPagesToScan); + for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { const page = await pdf.getPage(pageNumber); - const textContent = await page.getTextContent(); - chunks.push( - textContent.items + try { + const textContent = await withTimeout( + page.getTextContent(), + pdfPageTimeoutMS, + "Could not read that PDF page.", + ); + const pageText = textContent.items .map((item) => ("str" in item ? item.str : "")) - .join(""), - ); + .join("") + .slice(0, maxPDFTextChars - charCount); + chunks.push(pageText); + charCount += pageText.length; + } finally { + page.cleanup(); + } + if (charCount >= maxPDFTextChars) { + break; + } } return chunks.join("\n"); }; -const renderPDFPage = async (page: PDFPageProxy) => { +const pdfRenderScale = (page: PDFPageProxy) => { const baseViewport = page.getViewport({ scale: 1 }); - const scale = Math.min( + if ( + !Number.isFinite(baseViewport.width) || + !Number.isFinite(baseViewport.height) || + baseViewport.width <= 0 || + baseViewport.height <= 0 + ) { + throw new Error(tooLargeMessage); + } + const pixelScale = Math.sqrt( + maxCanvasPixels / (baseViewport.width * baseViewport.height), + ); + return Math.min( 3, - Math.max(2, 1800 / Math.max(baseViewport.width, baseViewport.height)), + maxCanvasSide / Math.max(baseViewport.width, baseViewport.height), + pixelScale, ); - const viewport = page.getViewport({ scale }); +}; + +const renderPDFPage = async (page: PDFPageProxy) => { + const viewport = page.getViewport({ scale: pdfRenderScale(page) }); + const width = Math.max(1, Math.ceil(viewport.width)); + const height = Math.max(1, Math.ceil(viewport.height)); + if (width * height > maxCanvasPixels) { + throw new Error(tooLargeMessage); + } + const canvas = document.createElement("canvas"); - canvas.width = Math.ceil(viewport.width); - canvas.height = Math.ceil(viewport.height); + canvas.width = width; + canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { throw new Error("Could not render that PDF."); } - await page.render({ canvas, canvasContext: context, viewport }).promise; + const renderTask = page.render({ + canvas, + canvasContext: context, + viewport, + }); + try { + await withTimeout( + renderTask.promise, + pdfPageTimeoutMS, + "Could not render that PDF page.", + ); + } catch (error) { + renderTask.cancel(); + throw error; + } return context.getImageData(0, 0, canvas.width, canvas.height); }; const decodeQrFromPDFBytes = async (bytes: Uint8Array) => { const pdfjs = (await import("pdfjs-dist/legacy/webpack.mjs")) as unknown as typeof import("pdfjs-dist"); - const loadingTask = pdfjs.getDocument({ data: bytes.slice() }); + const loadingTask = pdfjs.getDocument({ + data: bytes.slice(), + disableFontFace: true, + }); let pdf: PDFDocumentProxy; try { pdf = await withTimeout( @@ -317,17 +405,25 @@ const decodeQrFromPDFBytes = async (bytes: Uint8Array) => { } try { - const textCode = findJsonObject(await textFromPDF(pdf)); + let textCode: string | undefined; + try { + textCode = findJsonObject(await textFromPDF(pdf)); + } catch { + // Text extraction is a fast path; scanned PDFs should still use QR rendering. + } if (textCode) { return textCode; } - for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + const pageCount = Math.min(pdf.numPages, maxPDFPagesToScan); + for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { const page = await pdf.getPage(pageNumber); try { return await decodeQrImage(await renderPDFPage(page)); } catch { // Try the next page before surfacing a failure. + } finally { + page.cleanup(); } } } finally { @@ -338,6 +434,7 @@ const decodeQrFromPDFBytes = async (bytes: Uint8Array) => { }; const decodeQrFromPDF = async (file: File) => { + assertFileSize(file, maxPDFFileBytes); const bytes = new Uint8Array(await file.arrayBuffer()); const metadataCode = codeFromPDFMetadata(bytes); if (metadataCode) return metadataCode; @@ -347,8 +444,10 @@ const decodeQrFromPDF = async (file: File) => { export const readLegacyKitCodeFromFile = async (file: File) => { if (isPDF(file)) return decodeQrFromPDF(file); if (isPlainText(file)) { + assertFileSize(file, maxTextFileBytes); const value = await file.text(); return findJsonObject(value) ?? value.trim(); } + assertFileSize(file, maxImageFileBytes); return decodeQrImage(await imageDataFromFile(file)); }; From eccad3bbfd589d2e5e5a2bd901c8ccc65af78236 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 11:21:59 +0530 Subject: [PATCH 33/91] Avoid redundant sensitive key derivations The login key returned during a password change comes from the same successful adaptive derivation whose salt and parameters are stored in the updated attributes. Reusing it is byte-equivalent to deriving the KEK and login key again. Use fixed key material in the SRP setup test because Argon policy is not under test there. --- rust/crates/accounts/src/flow.rs | 22 ++++++++-------------- rust/crates/core/src/auth/api.rs | 3 +-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/rust/crates/accounts/src/flow.rs b/rust/crates/accounts/src/flow.rs index 3171c74bab3..6443e1e20b3 100644 --- a/rust/crates/accounts/src/flow.rs +++ b/rust/crates/accounts/src/flow.rs @@ -11,7 +11,7 @@ use ente_core::{ auth::{ self, DecryptedSecrets, GeneratedSrpSetup, KeyAttributes as CoreKeyAttributes, KeyDerivationStrength, derive_kek, generate_keys_with_strength, generate_srp_setup, - get_recovery_key, + generate_srp_setup_with_login_key, get_recovery_key, }, crypto::{self, SecretVec, secretbox}, }; @@ -411,24 +411,18 @@ where &self, params: ChangePasswordParams, ) -> Result { - let (updated_key_attributes_core, _) = auth::generate_key_attributes_for_new_password( - ¶ms.master_key, - &to_core_key_attributes(¶ms.key_attributes), - ¶ms.password, - )?; + let (updated_key_attributes_core, login_key) = + auth::generate_key_attributes_for_new_password( + ¶ms.master_key, + &to_core_key_attributes(¶ms.key_attributes), + ¶ms.password, + )?; let updated_key_attributes = to_api_key_attributes(&updated_key_attributes_core); let updated_key_attr = UpdatedKeyAttr::from(&updated_key_attributes); - let kek = derive_kek( - ¶ms.password, - &updated_key_attributes.kek_salt, - updated_key_attributes.mem_limit as u32, - updated_key_attributes.ops_limit as u32, - )?; - let srp_user_id = Uuid::new_v4(); - let srp_setup = generate_srp_setup(&kek, &srp_user_id.to_string())?; + let srp_setup = generate_srp_setup_with_login_key(&login_key, &srp_user_id.to_string())?; let update = self .complete_srp_update( &srp_user_id, diff --git a/rust/crates/core/src/auth/api.rs b/rust/crates/core/src/auth/api.rs index ef1b1bebcea..ab02ff27bf8 100644 --- a/rust/crates/core/src/auth/api.rs +++ b/rust/crates/core/src/auth/api.rs @@ -464,8 +464,7 @@ mod tests { #[cfg(feature = "srp")] #[test] fn test_generate_srp_setup() { - let generated = generate_sensitive_kek("test_password").unwrap(); - let srp_setup = generate_srp_setup(&generated.key, "test-user-id").unwrap(); + let srp_setup = generate_srp_setup(&[1; 32], "test-user-id").unwrap(); assert_eq!(srp_setup.srp_salt.len(), 16); assert_eq!(srp_setup.login_sub_key.len(), 16); From 9b83d8ec9006da05c8da9e87049cf7501449d761 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 11:22:32 +0530 Subject: [PATCH 34/91] Use cargo test for Rust CI Nextest made the non-doc tests roughly two minutes faster, but preserving doctests required a second Cargo pass whose compilation made the complete lint about five minutes slower. Use one cargo test invocation to keep that coverage and remove the extra CI dependency. --- .github/workflows/rust-lint.yml | 6 +----- .github/workflows/warm-caches.yml | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rust-lint.yml b/.github/workflows/rust-lint.yml index f03ba30dace..325726c2b5d 100644 --- a/.github/workflows/rust-lint.yml +++ b/.github/workflows/rust-lint.yml @@ -47,11 +47,7 @@ jobs: - run: cargo clippy --all-targets --locked - - run: cargo install cargo-nextest --version 0.9.140 --locked - - - run: cargo nextest run --locked --workspace --features ente-photos/ml-assets - - - run: cargo test --locked --workspace --features ente-photos/ml-assets --doc + - run: cargo test --locked --workspace --features ente-photos/ml-assets - run: cargo install cargo-audit --version 0.22.2 --locked diff --git a/.github/workflows/warm-caches.yml b/.github/workflows/warm-caches.yml index 3f6f7b7a15c..23b5dd28990 100644 --- a/.github/workflows/warm-caches.yml +++ b/.github/workflows/warm-caches.yml @@ -51,9 +51,7 @@ jobs: - run: cargo clippy --all-targets --locked - - run: cargo install cargo-nextest --version 0.9.140 --locked - - - run: cargo nextest run --locked --workspace --features ente-photos/ml-assets + - run: cargo test --locked --workspace --features ente-photos/ml-assets - run: cargo install cargo-audit --version 0.22.2 --locked From 1ab9e9c284b72a4d0fb4e539d4559cd1a4fa213c Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 12:52:07 +0530 Subject: [PATCH 35/91] Animate social comment updates --- .../photos/changes/animate-social-overlay.md | 1 + .../social/widgets/file_social_overlay.dart | 120 +++++++++++++----- 2 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 mobile/apps/photos/changes/animate-social-overlay.md diff --git a/mobile/apps/photos/changes/animate-social-overlay.md b/mobile/apps/photos/changes/animate-social-overlay.md new file mode 100644 index 00000000000..ce36c37d30f --- /dev/null +++ b/mobile/apps/photos/changes/animate-social-overlay.md @@ -0,0 +1 @@ +- Added polished animations when comments and comment counts change in the photo viewer. diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 36004e4ad9d..a2d34f0bc1d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -28,6 +28,12 @@ const _socialControlsSize = 48.0; const _socialIconSize = 32.0; const _socialIconPadding = EdgeInsets.fromLTRB(12, 12, 4, 4); +Duration _motionDuration(BuildContext context, int milliseconds) { + return MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : Duration(milliseconds: milliseconds); +} + /// Social content for a file. The host owns placement and visibility effects. class FileSocialOverlay extends StatefulWidget { final EnteFile file; @@ -394,23 +400,50 @@ class _FileSocialOverlayState extends State { final latestComment = _latestComment; final latestCommentAuthor = _latestCommentAuthor; + final latestCommentPill = + latestComment != null && latestCommentAuthor != null + ? Padding( + key: ValueKey(latestComment.id), + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + maxWidth: MediaQuery.sizeOf(context).width * 0.6, + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), + ), + ) + : const SizedBox.shrink(); return IgnorePointer( ignoring: _loadedFileID != widget.file.uploadedFileID, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (latestComment != null && latestCommentAuthor != null) - Padding( - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: _LatestCommentPill( - comment: latestComment, - author: latestCommentAuthor, - maxWidth: MediaQuery.sizeOf(context).width * 0.6, - currentUserID: widget.currentUserID!, - onTap: () => _openComments(comment: latestComment), + AnimatedSwitcher( + duration: _motionDuration(context, 220), + reverseDuration: _motionDuration(context, 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.94, end: 1).animate(animation), + alignment: Alignment.bottomRight, + child: child, ), ), + layoutBuilder: (currentChild, previousChildren) => Stack( + // Right alignment keeps width changes from moving visible UI. + alignment: Alignment.bottomRight, + children: [ + for (final child in previousChildren) + IgnorePointer(child: child), + ?currentChild, + ], + ), + child: latestCommentPill, + ), Column( mainAxisSize: MainAxisSize.min, children: [ @@ -537,6 +570,7 @@ class _CommentBadgeIcon extends StatelessWidget { @override Widget build(BuildContext context) { + final displayCount = count > 99 ? "99+" : count.toString(); return Stack( clipBehavior: Clip.none, children: [ @@ -545,31 +579,55 @@ class _CommentBadgeIcon extends StatelessWidget { color: Colors.white, size: _socialIconSize, ), - if (count > 0) - Positioned( - right: -5, - top: -5, - child: Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(19)), - ), - child: Center( - child: Text( - count > 99 ? "99+" : count.toString(), - style: const TextStyle( - color: Colors.black, - fontSize: 10, - height: 1.2, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - ), + Positioned( + right: -5, + top: -5, + child: AnimatedSwitcher( + duration: _motionDuration(context, 160), + reverseDuration: _motionDuration(context, 120), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.9, end: 1).animate(animation), + child: child, ), ), + child: count > 0 + ? Container( + key: const ValueKey(true), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(19)), + ), + child: Center( + child: AnimatedSwitcher( + duration: _motionDuration(context, 120), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: Text( + displayCount, + key: ValueKey(displayCount), + style: const TextStyle( + color: Colors.black, + fontSize: 10, + height: 1.2, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + ), + ), + ) + : const SizedBox.shrink(key: ValueKey(false)), ), + ), ], ); } From ec826f7e10d16c61beaf88b2e40cefb1e872da3e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 12:05:58 +0530 Subject: [PATCH 36/91] Speed up paste password tests Use minimum Argon parameters for negative crypto cases while retaining production-strength round-trip coverage. Test CLI retry behavior with a fake decryptor instead of repeating the cryptographic work. --- rust/apps/cli/src/commands/paste.rs | 42 +++++++++++++---------------- rust/crates/paste/src/lib.rs | 22 ++++++++++----- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/rust/apps/cli/src/commands/paste.rs b/rust/apps/cli/src/commands/paste.rs index 15a3f5fc078..aa92a6a7764 100644 --- a/rust/apps/cli/src/commands/paste.rs +++ b/rust/apps/cli/src/commands/paste.rs @@ -95,25 +95,22 @@ fn decrypt_password_protected_paste( payload: &PastePayload, password: PastePasswordAttempt, ) -> Result { - decrypt_password_protected_paste_with_prompt( - paste_key, - payload, - password, - prompt_raw_paste_password, - ) + decrypt_password_protected_paste_with_prompt(password, prompt_raw_paste_password, |password| { + ente_paste::decrypt(payload, paste_key, Some(password)) + }) } -fn decrypt_password_protected_paste_with_prompt( - paste_key: &PasteKey, - payload: &PastePayload, +fn decrypt_password_protected_paste_with_prompt( mut password: PastePasswordAttempt, mut prompt_password: F, + mut decrypt: D, ) -> Result where F: FnMut() -> Result, + D: FnMut(&str) -> ente_paste::Result, { loop { - match ente_paste::decrypt(payload, paste_key, Some(password.value())) { + match decrypt(password.value()) { Err(ente_paste::Error::IncorrectPassword) if password.can_retry() => { eprintln!("Incorrect paste password. Try again."); password = PastePasswordAttempt::Prompted(prompt_valid_paste_password( @@ -288,6 +285,13 @@ impl PastePasswordAttempt { mod tests { use super::*; + fn test_decrypt_password_protected_paste(password: &str) -> ente_paste::Result { + match password { + "correct horse" => Ok("protected paste".to_string()), + _ => Err(ente_paste::Error::IncorrectPassword), + } + } + #[test] fn default_tty_output_escapes_controls() { let text = "ok\n\x1b]52;c;AAAA\x07\rhidden\tend\u{85}"; @@ -312,14 +316,11 @@ mod tests { #[test] fn prompted_password_retry_can_recover() { - let (paste_key, payload) = - ente_paste::encrypt("protected paste", Some("correct horse")).unwrap(); let mut retry_passwords = ["correct horse"].into_iter(); let text = decrypt_password_protected_paste_with_prompt( - &paste_key, - &payload, PastePasswordAttempt::Prompted("wrong horse".to_string()), || Ok::<_, Error>(retry_passwords.next().expect("retry password").to_string()), + test_decrypt_password_protected_paste, ) .unwrap(); @@ -329,14 +330,11 @@ mod tests { #[test] fn prompted_password_retry_ignores_empty_password() { - let (paste_key, payload) = - ente_paste::encrypt("protected paste", Some("correct horse")).unwrap(); let mut retry_passwords = ["", "correct horse"].into_iter(); let text = decrypt_password_protected_paste_with_prompt( - &paste_key, - &payload, PastePasswordAttempt::Prompted("wrong horse".to_string()), || Ok::<_, Error>(retry_passwords.next().expect("retry password").to_string()), + test_decrypt_password_protected_paste, ) .unwrap(); @@ -346,12 +344,10 @@ mod tests { #[test] fn wrong_env_password_fails_without_retry() { - let (paste_key, payload) = - ente_paste::encrypt("protected paste", Some("correct horse")).unwrap(); - let error = decrypt_password_protected_paste( - &paste_key, - &payload, + let error = decrypt_password_protected_paste_with_prompt( PastePasswordAttempt::Env("wrong horse".to_string()), + || panic!("environment passwords must not be retried"), + test_decrypt_password_protected_paste, ) .unwrap_err(); diff --git a/rust/crates/paste/src/lib.rs b/rust/crates/paste/src/lib.rs index 3384d88fab8..7694f731f95 100644 --- a/rust/crates/paste/src/lib.rs +++ b/rust/crates/paste/src/lib.rs @@ -395,11 +395,9 @@ mod tests { } #[test] - fn encrypt_then_decrypt_password_protected_paste_payload() { + fn encrypt_password_protected_paste_payload_uses_moderate_kdf() { let (paste_key, payload) = encrypt("protected paste", Some("correct horse")).unwrap(); - let text = decrypt(&payload, &paste_key, Some("correct horse")).unwrap(); - assert_eq!(text, "protected paste"); assert!(paste_key.password_required); assert!( paste_key @@ -410,9 +408,19 @@ mod tests { assert_eq!(payload.kdf_ops_limit, argon::Params::MODERATE.ops_limit); } + #[test] + fn decrypt_password_protected_paste_payload() { + let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); + let payload = test_payload("protected paste", &paste_key, Some("correct horse")); + let text = decrypt(&payload, &paste_key, Some("correct horse")).unwrap(); + + assert_eq!(text, "protected paste"); + } + #[test] fn reject_wrong_paste_password() { - let (paste_key, payload) = encrypt("protected paste", Some("correct horse")).unwrap(); + let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); + let payload = test_payload("protected paste", &paste_key, Some("correct horse")); let error = decrypt(&payload, &paste_key, Some("wrong horse")).unwrap_err(); assert!(matches!(error, Error::IncorrectPassword)); @@ -420,7 +428,8 @@ mod tests { #[test] fn structural_payload_errors_are_not_incorrect_password() { - let (paste_key, mut payload) = encrypt("protected paste", Some("correct horse")).unwrap(); + let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); + let mut payload = test_payload("protected paste", &paste_key, Some("correct horse")); payload.kdf_nonce = "not base64".to_string(); let error = decrypt(&payload, &paste_key, Some("correct horse")).unwrap_err(); @@ -432,7 +441,8 @@ mod tests { #[test] fn wrapped_key_payload_errors_are_not_incorrect_password() { - let (paste_key, mut payload) = encrypt("protected paste", Some("correct horse")).unwrap(); + let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); + let mut payload = test_payload("protected paste", &paste_key, Some("correct horse")); payload.encrypted_paste_key = "not base64".to_string(); let error = decrypt(&payload, &paste_key, Some("correct horse")).unwrap_err(); From 551b5baa77a29955f31259121d789b57476132f7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 13:01:40 +0530 Subject: [PATCH 37/91] Remove redundant sensitive KEK test The Interactive wrapper test already covers the GeneratedKek shape, while the account creation test executes the Sensitive derivation and asserts its production work factor. Repeating it here added 25 seconds locally without distinct coverage. --- rust/crates/core/src/auth/api.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/rust/crates/core/src/auth/api.rs b/rust/crates/core/src/auth/api.rs index ab02ff27bf8..c70bbbe997f 100644 --- a/rust/crates/core/src/auth/api.rs +++ b/rust/crates/core/src/auth/api.rs @@ -420,16 +420,6 @@ mod tests { assert!(matches!(result, Err(AuthError::IncorrectPassword))); } - #[test] - fn test_generate_sensitive_kek() { - let generated = generate_sensitive_kek("test_password").unwrap(); - - assert_eq!(generated.key.len(), 32); - assert_eq!(generated.salt.len(), 16); - assert!(generated.mem_limit > 0); - assert!(generated.ops_limit > 0); - } - #[test] fn test_generated_kek_debug_redacts_secret_material() { let generated = GeneratedKek { From a37674577ab8177328092bbee441ce45d3e854a2 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 13:05:25 +0530 Subject: [PATCH 38/91] Animate latest comment bubble width --- .../social/widgets/file_social_overlay.dart | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index a2d34f0bc1d..8c0f185bebf 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -403,7 +403,7 @@ class _FileSocialOverlayState extends State { final latestCommentPill = latestComment != null && latestCommentAuthor != null ? Padding( - key: ValueKey(latestComment.id), + key: const ValueKey("latest-comment"), padding: const EdgeInsets.only(right: 4, bottom: 4), child: _LatestCommentPill( comment: latestComment, @@ -505,12 +505,26 @@ class _LatestCommentPill extends StatelessWidget { @override Widget build(BuildContext context) { + final textStyle = component.TextStyles.mini.copyWith(color: Colors.black); + final textPainter = TextPainter( + text: TextSpan(text: comment.data, style: textStyle), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + ellipsis: "…", + )..layout(maxWidth: maxWidth - 32); + final targetWidth = textPainter.width + 32; + textPainter.dispose(); + return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), + child: AnimatedContainer( + duration: _motionDuration(context, 220), + curve: Curves.easeOutCubic, + width: targetWidth, child: Stack( + fit: StackFit.passthrough, clipBehavior: Clip.none, children: [ DecoratedBox( @@ -532,9 +546,7 @@ class _LatestCommentPill extends StatelessWidget { comment.data, maxLines: 1, overflow: TextOverflow.ellipsis, - style: component.TextStyles.mini.copyWith( - color: Colors.black, - ), + style: textStyle, ), ), ), From e7fd2787f84e24f25da5e8bc69d19f20c96d7b8f Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Tue, 21 Jul 2026 13:05:54 +0530 Subject: [PATCH 39/91] Check recovery image dimensions before decode --- .../legacy/src/features/legacy-kit/scan.ts | 285 ++++++++++++++++-- 1 file changed, 266 insertions(+), 19 deletions(-) diff --git a/web/apps/legacy/src/features/legacy-kit/scan.ts b/web/apps/legacy/src/features/legacy-kit/scan.ts index 7d01d975e26..6acc7e04662 100644 --- a/web/apps/legacy/src/features/legacy-kit/scan.ts +++ b/web/apps/legacy/src/features/legacy-kit/scan.ts @@ -25,6 +25,11 @@ interface DecodableImage { width: number; } +interface ImageDimensions { + height: number; + width: number; +} + interface DrawableImage { dispose: () => void; height: number; @@ -75,6 +80,255 @@ const assertFileSize = (file: File, maxBytes: number) => { } }; +const assertImageDimensions = ({ height, width }: ImageDimensions) => { + if ( + width <= 0 || + height <= 0 || + width > maxInputImageSide || + height > maxInputImageSide || + width * height > maxInputImagePixels + ) { + throw new Error(tooLargeMessage); + } +}; + +const imageOutputDimensions = ({ height, width }: ImageDimensions) => { + const scale = Math.min( + 1, + maxCanvasSide / Math.max(width, height), + Math.sqrt(maxCanvasPixels / (width * height)), + ); + return { + height: Math.max(1, Math.floor(height * scale)), + width: Math.max(1, Math.floor(width * scale)), + }; +}; + +const imageBitmapOptions = ( + dimensions: ImageDimensions, +): ImageBitmapOptions => { + const { height, width } = imageOutputDimensions(dimensions); + return { resizeHeight: height, resizeQuality: "high", resizeWidth: width }; +}; + +const byteAt = (bytes: Uint8Array, offset: number) => bytes[offset] ?? 0; + +const hasBytes = (bytes: Uint8Array, offset: number, values: number[]) => + values.every((value, index) => byteAt(bytes, offset + index) === value); + +const hasASCII = (bytes: Uint8Array, offset: number, value: string) => { + for (let index = 0; index < value.length; index++) { + if (byteAt(bytes, offset + index) !== value.charCodeAt(index)) { + return false; + } + } + return true; +}; + +const readUInt24LE = (bytes: Uint8Array, offset: number) => + byteAt(bytes, offset) | + (byteAt(bytes, offset + 1) << 8) | + (byteAt(bytes, offset + 2) << 16); + +const jpegStartOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, + 0xcf, +]); + +const jpegDimensions = ( + bytes: Uint8Array, + view: DataView, +): ImageDimensions | undefined => { + if (!hasBytes(bytes, 0, [0xff, 0xd8])) return undefined; + + let offset = 2; + while (offset + 3 < bytes.length) { + while (byteAt(bytes, offset) === 0xff) offset += 1; + const marker = byteAt(bytes, offset); + offset += 1; + + if (marker === 0xda || marker === 0xd9) break; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (offset + 2 > bytes.length) break; + + const segmentLength = view.getUint16(offset, false); + if (segmentLength < 2) break; + if (jpegStartOfFrameMarkers.has(marker)) { + if (offset + 7 > bytes.length) break; + return { + height: view.getUint16(offset + 3, false), + width: view.getUint16(offset + 5, false), + }; + } + offset += segmentLength; + } + + throw new Error("Could not read that image."); +}; + +const webPDimensions = ( + bytes: Uint8Array, + view: DataView, +): ImageDimensions | undefined => { + if ( + bytes.length < 20 || + !hasASCII(bytes, 0, "RIFF") || + !hasASCII(bytes, 8, "WEBP") + ) { + return undefined; + } + + if (bytes.length >= 30 && hasASCII(bytes, 12, "VP8X")) { + return { + height: 1 + readUInt24LE(bytes, 27), + width: 1 + readUInt24LE(bytes, 24), + }; + } + if ( + bytes.length >= 30 && + hasASCII(bytes, 12, "VP8 ") && + hasBytes(bytes, 23, [0x9d, 0x01, 0x2a]) + ) { + return { + height: view.getUint16(28, true) & 0x3fff, + width: view.getUint16(26, true) & 0x3fff, + }; + } + if ( + bytes.length >= 25 && + hasASCII(bytes, 12, "VP8L") && + byteAt(bytes, 20) === 0x2f + ) { + return { + height: + 1 + + (((byteAt(bytes, 24) & 0x0f) << 10) | + (byteAt(bytes, 23) << 2) | + ((byteAt(bytes, 22) & 0xc0) >> 6)), + width: 1 + (((byteAt(bytes, 22) & 0x3f) << 8) | byteAt(bytes, 21)), + }; + } + + throw new Error("Could not read that image."); +}; + +const isoBMFFContainerBoxes = new Set([ + "meta", + "iprp", + "ipco", + "moov", + "trak", + "mdia", + "minf", + "stbl", +]); + +const isoBMFFDimensions = ( + bytes: Uint8Array, + view: DataView, +): ImageDimensions | undefined => { + if (bytes.length < 16 || !hasASCII(bytes, 4, "ftyp")) return undefined; + + const scanBoxes = ( + start: number, + end: number, + depth: number, + ): ImageDimensions | undefined => { + if (depth > 8) return undefined; + let offset = start; + while (offset + 8 <= end) { + let boxSize = view.getUint32(offset, false); + const boxTypeOffset = offset + 4; + let contentOffset = offset + 8; + if (boxSize === 1) { + if ( + offset + 16 > end || + view.getUint32(offset + 8, false) > 0 + ) { + return undefined; + } + boxSize = view.getUint32(offset + 12, false); + contentOffset = offset + 16; + } else if (boxSize === 0) { + boxSize = end - offset; + } + + if (boxSize < contentOffset - offset || offset + boxSize > end) { + return undefined; + } + + const boxType = String.fromCharCode( + byteAt(bytes, boxTypeOffset), + byteAt(bytes, boxTypeOffset + 1), + byteAt(bytes, boxTypeOffset + 2), + byteAt(bytes, boxTypeOffset + 3), + ); + const boxEnd = offset + boxSize; + if (boxType === "ispe" && contentOffset + 12 <= boxEnd) { + return { + height: view.getUint32(contentOffset + 8, false), + width: view.getUint32(contentOffset + 4, false), + }; + } + + const childOffset = + boxType === "meta" ? contentOffset + 4 : contentOffset; + if (isoBMFFContainerBoxes.has(boxType) && childOffset < boxEnd) { + const dimensions = scanBoxes(childOffset, boxEnd, depth + 1); + if (dimensions) return dimensions; + } + offset = boxEnd; + } + return undefined; + }; + + return scanBoxes(0, bytes.length, 0); +}; + +const imageDimensionsFromBytes = (bytes: Uint8Array) => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + if ( + bytes.length >= 24 && + hasBytes(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ) { + return { + height: view.getUint32(20, false), + width: view.getUint32(16, false), + }; + } + if ( + bytes.length >= 10 && + (hasASCII(bytes, 0, "GIF87a") || hasASCII(bytes, 0, "GIF89a")) + ) { + return { + height: view.getUint16(8, true), + width: view.getUint16(6, true), + }; + } + if (bytes.length >= 26 && hasASCII(bytes, 0, "BM")) { + return { + height: Math.abs(view.getInt32(22, true)), + width: view.getInt32(18, true), + }; + } + + return ( + jpegDimensions(bytes, view) ?? + webPDimensions(bytes, view) ?? + isoBMFFDimensions(bytes, view) + ); +}; + +// Keep this before browser image decode; compressed images can expand to huge bitmaps. +const imageDimensionsFromFile = async (file: File) => { + const bytes = new Uint8Array(await file.arrayBuffer()); + const dimensions = imageDimensionsFromBytes(bytes); + if (!dimensions) throw new Error("Could not read that image."); + assertImageDimensions(dimensions); + return dimensions; +}; + const findJsonObject = (value: string) => { const start = value.indexOf('{"pv"'); if (start < 0) return undefined; @@ -174,10 +428,16 @@ const loadImageFromFile = (file: File) => image.src = objectURL; }); -const loadDrawableFromFile = async (file: File): Promise => { +const loadDrawableFromFile = async ( + file: File, + dimensions: ImageDimensions, +): Promise => { if (typeof createImageBitmap === "function") { try { - const bitmap = await createImageBitmap(file); + const bitmap = await createImageBitmap( + file, + imageBitmapOptions(dimensions), + ); return { dispose: () => bitmap.close(), height: bitmap.height, @@ -193,23 +453,9 @@ const loadDrawableFromFile = async (file: File): Promise => { }; const imageDataFromDrawable = (drawable: DrawableImage) => { - if ( - drawable.width <= 0 || - drawable.height <= 0 || - drawable.width > maxInputImageSide || - drawable.height > maxInputImageSide || - drawable.width * drawable.height > maxInputImagePixels - ) { - throw new Error(tooLargeMessage); - } + assertImageDimensions(drawable); - const scale = Math.min( - 1, - maxCanvasSide / Math.max(drawable.width, drawable.height), - Math.sqrt(maxCanvasPixels / (drawable.width * drawable.height)), - ); - const width = Math.max(1, Math.floor(drawable.width * scale)); - const height = Math.max(1, Math.floor(drawable.height * scale)); + const { height, width } = imageOutputDimensions(drawable); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -222,7 +468,8 @@ const imageDataFromDrawable = (drawable: DrawableImage) => { }; const imageDataFromFile = async (file: File) => { - const drawable = await loadDrawableFromFile(file); + const dimensions = await imageDimensionsFromFile(file); + const drawable = await loadDrawableFromFile(file, dimensions); try { return imageDataFromDrawable(drawable); } finally { From b00ded94c8e481c5b49735e2d7eca9388f3add59 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 13:06:11 +0530 Subject: [PATCH 40/91] Use test-strength KDF in password change flow Exercise the password-change and SRP update behavior with the existing Interactive test KDF. The public path still selects Sensitive, whose production work factor remains exercised by the account creation test. --- rust/crates/accounts/src/flow.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/rust/crates/accounts/src/flow.rs b/rust/crates/accounts/src/flow.rs index 6443e1e20b3..21b2d28b35d 100644 --- a/rust/crates/accounts/src/flow.rs +++ b/rust/crates/accounts/src/flow.rs @@ -410,12 +410,22 @@ where pub async fn change_password( &self, params: ChangePasswordParams, + ) -> Result { + self.change_password_with_strength(params, KeyDerivationStrength::Sensitive) + .await + } + + async fn change_password_with_strength( + &self, + params: ChangePasswordParams, + key_derivation_strength: KeyDerivationStrength, ) -> Result { let (updated_key_attributes_core, login_key) = - auth::generate_key_attributes_for_new_password( + auth::generate_key_attributes_for_new_password_with_strength( ¶ms.master_key, &to_core_key_attributes(¶ms.key_attributes), ¶ms.password, + key_derivation_strength, )?; let updated_key_attributes = to_api_key_attributes(&updated_key_attributes_core); @@ -2159,13 +2169,16 @@ mod tests { let flow = AuthFlow::new(&client, &mut ui); let result = flow - .change_password(ChangePasswordParams { - email: "user@example.org".into(), - password: Zeroizing::new("new-password".into()), - master_key: SecretVec::new(master_key), - key_attributes, - log_out_other_devices: true, - }) + .change_password_with_strength( + ChangePasswordParams { + email: "user@example.org".into(), + password: Zeroizing::new("new-password".into()), + master_key: SecretVec::new(master_key), + key_attributes, + log_out_other_devices: true, + }, + KeyDerivationStrength::Interactive, + ) .await .unwrap(); From c6f6af6ba7c1cb613fa1c210dc1f742c5b939e02 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 13:13:14 +0530 Subject: [PATCH 41/91] Crossfade latest comment text --- .../social/widgets/file_social_overlay.dart | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 8c0f185bebf..1c37c9e39ff 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -542,11 +542,21 @@ class _LatestCommentPill extends StatelessWidget { horizontal: 16, vertical: 12, ), - child: Text( - comment.data, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textStyle, + child: AnimatedSwitcher( + duration: _motionDuration(context, 180), + switchInCurve: const Interval(0.25, 1, curve: Curves.easeOut), + switchOutCurve: const Interval(0.6, 1, curve: Curves.easeIn), + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: AlignmentDirectional.centerStart, + children: [...previousChildren, ?currentChild], + ), + child: Text( + comment.data, + key: ValueKey(comment.id), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), ), ), ), From fc58345e68432f83a889db5eb58f79171effb33d Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 13:14:35 +0530 Subject: [PATCH 42/91] Stop highlighting latest comment in sheet --- .../apps/photos/lib/ui/social/widgets/file_social_overlay.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 1c37c9e39ff..6ff07a83b51 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -376,7 +376,6 @@ class _FileSocialOverlayState extends State { context, collectionID: initialCollection.id, fileID: fileID, - highlightCommentID: commentCollection == null ? null : comment?.id, sharedCollections: sharedCollections, ); }); From b65bd01512656579826312714093accf8c7443f0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 13:25:06 +0530 Subject: [PATCH 43/91] Refine latest comment transitions --- .../social/widgets/file_social_overlay.dart | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 6ff07a83b51..3337ddf8d3b 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -421,7 +421,7 @@ class _FileSocialOverlayState extends State { children: [ AnimatedSwitcher( duration: _motionDuration(context, 220), - reverseDuration: _motionDuration(context, 160), + reverseDuration: Duration.zero, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeIn, transitionBuilder: (child, animation) => FadeTransition( @@ -541,21 +541,11 @@ class _LatestCommentPill extends StatelessWidget { horizontal: 16, vertical: 12, ), - child: AnimatedSwitcher( - duration: _motionDuration(context, 180), - switchInCurve: const Interval(0.25, 1, curve: Curves.easeOut), - switchOutCurve: const Interval(0.6, 1, curve: Curves.easeIn), - layoutBuilder: (currentChild, previousChildren) => Stack( - alignment: AlignmentDirectional.centerStart, - children: [...previousChildren, ?currentChild], - ), - child: Text( - comment.data, - key: ValueKey(comment.id), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textStyle, - ), + child: Text( + comment.data, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, ), ), ), From 641ad234b367ccc42e19787a764d3775b9406c41 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Tue, 21 Jul 2026 13:27:17 +0530 Subject: [PATCH 44/91] Preserve recovery image aspect ratio --- web/apps/legacy/src/features/legacy-kit/scan.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/apps/legacy/src/features/legacy-kit/scan.ts b/web/apps/legacy/src/features/legacy-kit/scan.ts index 6acc7e04662..301d5955017 100644 --- a/web/apps/legacy/src/features/legacy-kit/scan.ts +++ b/web/apps/legacy/src/features/legacy-kit/scan.ts @@ -108,7 +108,9 @@ const imageBitmapOptions = ( dimensions: ImageDimensions, ): ImageBitmapOptions => { const { height, width } = imageOutputDimensions(dimensions); - return { resizeHeight: height, resizeQuality: "high", resizeWidth: width }; + return dimensions.width >= dimensions.height + ? { resizeQuality: "high", resizeWidth: width } + : { resizeHeight: height, resizeQuality: "high" }; }; const byteAt = (bytes: Uint8Array, offset: number) => bytes[offset] ?? 0; From 3143272489f69d155ebc0900c0d50304da838941 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 13:36:02 +0530 Subject: [PATCH 45/91] Refine latest comment resize animation --- .../social/widgets/file_social_overlay.dart | 162 +++++++++++------- 1 file changed, 103 insertions(+), 59 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 3337ddf8d3b..4727ee4d0e2 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -407,7 +407,11 @@ class _FileSocialOverlayState extends State { child: _LatestCommentPill( comment: latestComment, author: latestCommentAuthor, - maxWidth: MediaQuery.sizeOf(context).width * 0.6, + width: _latestCommentPillWidth( + context, + latestComment.data, + MediaQuery.sizeOf(context).width * 0.6, + ), currentUserID: widget.currentUserID!, onTap: () => _openComments(comment: latestComment), ), @@ -487,88 +491,128 @@ class _FileSocialOverlayState extends State { } } -class _LatestCommentPill extends StatelessWidget { +double _latestCommentPillWidth( + BuildContext context, + String comment, + double maxWidth, +) { + final textPainter = TextPainter( + text: TextSpan( + text: comment, + style: component.TextStyles.mini.copyWith(color: Colors.black), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + ellipsis: "…", + )..layout(maxWidth: maxWidth - 32); + final width = textPainter.width + 32; + textPainter.dispose(); + return width; +} + +class _LatestCommentPill extends StatefulWidget { final Comment comment; final User author; - final double maxWidth; + final double width; final int currentUserID; final VoidCallback onTap; const _LatestCommentPill({ required this.comment, required this.author, - required this.maxWidth, + required this.width, required this.currentUserID, required this.onTap, }); + @override + State<_LatestCommentPill> createState() => _LatestCommentPillState(); +} + +class _LatestCommentPillState extends State<_LatestCommentPill> { + bool _showText = true; + + @override + void didUpdateWidget(_LatestCommentPill oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.comment.id != widget.comment.id || + oldWidget.comment.data != widget.comment.data) { + _showText = oldWidget.width == widget.width; + } + } + + void _showTextAfterResize() { + if (!_showText) { + setState(() => _showText = true); + } + } + @override Widget build(BuildContext context) { final textStyle = component.TextStyles.mini.copyWith(color: Colors.black); - final textPainter = TextPainter( - text: TextSpan(text: comment.data, style: textStyle), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - maxLines: 1, - ellipsis: "…", - )..layout(maxWidth: maxWidth - 32); - final targetWidth = textPainter.width + 32; - textPainter.dispose(); return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onTap, - child: AnimatedContainer( - duration: _motionDuration(context, 220), - curve: Curves.easeOutCubic, - width: targetWidth, - child: Stack( - fit: StackFit.passthrough, - clipBehavior: Clip.none, - children: [ - DecoratedBox( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(6), - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), + onTap: widget.onTap, + child: Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(6), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Text( - comment.data, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textStyle, + ), + child: AnimatedSize( + duration: _motionDuration(context, 220), + curve: Curves.easeOutExpo, + alignment: Alignment.bottomRight, + onEnd: _showTextAfterResize, + child: SizedBox( + width: widget.width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Opacity( + opacity: _showText ? 1 : 0, + child: Text( + widget.comment.data, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), ), ), ), - Positioned( - left: -3, - top: -6, - child: Container( - width: 22, - height: 22, - padding: const EdgeInsets.all(1), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: UserAvatarWidget( - author, - type: AvatarType.small, - currentUserID: currentUserID, - ), + ), + Positioned( + left: -3, + top: -6, + child: Container( + width: 22, + height: 22, + padding: const EdgeInsets.all(1), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: UserAvatarWidget( + widget.author, + type: AvatarType.small, + currentUserID: widget.currentUserID, ), ), - ], - ), + ), + ], ), ); } From 6525de81081ee027bfcc22648130331c798c59b7 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 14:28:14 +0530 Subject: [PATCH 46/91] Align latest comment bubble animation --- .../social/widgets/file_social_overlay.dart | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 4727ee4d0e2..c04865fa376 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -403,17 +403,20 @@ class _FileSocialOverlayState extends State { latestComment != null && latestCommentAuthor != null ? Padding( key: const ValueKey("latest-comment"), - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: _LatestCommentPill( - comment: latestComment, - author: latestCommentAuthor, - width: _latestCommentPillWidth( - context, - latestComment.data, - MediaQuery.sizeOf(context).width * 0.6, + padding: const EdgeInsets.only(right: 4), + child: Transform.translate( + offset: const Offset(0, 8), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + width: _latestCommentPillWidth( + context, + latestComment.data, + MediaQuery.sizeOf(context).width * 0.6, + ), + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), ), - currentUserID: widget.currentUserID!, - onTap: () => _openComments(comment: latestComment), ), ) : const SizedBox.shrink(); @@ -427,24 +430,14 @@ class _FileSocialOverlayState extends State { duration: _motionDuration(context, 220), reverseDuration: Duration.zero, switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeIn, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween(begin: 0.94, end: 1).animate(animation), - alignment: Alignment.bottomRight, + alignment: Alignment.topRight, child: child, ), ), - layoutBuilder: (currentChild, previousChildren) => Stack( - // Right alignment keeps width changes from moving visible UI. - alignment: Alignment.bottomRight, - children: [ - for (final child in previousChildren) - IgnorePointer(child: child), - ?currentChild, - ], - ), child: latestCommentPill, ), Column( From 126ac17b73a4f2dafaec39d411f2a115217a972b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 14:36:36 +0530 Subject: [PATCH 47/91] Preserve coverage in faster Rust tests Exercise password-protected paste encryption and CLI retry wiring through production code while using cheap KDF parameters where policy is not under test. Share KEK mapping and existing login keys to avoid duplicate work without losing coverage. --- rust/apps/cli/src/commands/paste.rs | 28 ++++++++--- rust/crates/accounts/src/flow.rs | 21 ++++---- rust/crates/core/src/auth/api.rs | 15 +++--- rust/crates/paste/src/lib.rs | 77 +++++++++++++---------------- 4 files changed, 69 insertions(+), 72 deletions(-) diff --git a/rust/apps/cli/src/commands/paste.rs b/rust/apps/cli/src/commands/paste.rs index aa92a6a7764..c386c093912 100644 --- a/rust/apps/cli/src/commands/paste.rs +++ b/rust/apps/cli/src/commands/paste.rs @@ -85,22 +85,31 @@ where let payload = client.consume(access_token).await?; match password { - Some(password) => decrypt_password_protected_paste(paste_key, &payload, password), + Some(password) => decrypt_password_protected_paste_with_prompt( + paste_key, + &payload, + password, + prompt_raw_paste_password, + ), None => Ok(ente_paste::decrypt(&payload, paste_key, None)?), } } -fn decrypt_password_protected_paste( +fn decrypt_password_protected_paste_with_prompt( paste_key: &PasteKey, payload: &PastePayload, password: PastePasswordAttempt, -) -> Result { - decrypt_password_protected_paste_with_prompt(password, prompt_raw_paste_password, |password| { + prompt_password: F, +) -> Result +where + F: FnMut() -> Result, +{ + decrypt_password_protected_paste_with(password, prompt_password, |password| { ente_paste::decrypt(payload, paste_key, Some(password)) }) } -fn decrypt_password_protected_paste_with_prompt( +fn decrypt_password_protected_paste_with( mut password: PastePasswordAttempt, mut prompt_password: F, mut decrypt: D, @@ -316,11 +325,14 @@ mod tests { #[test] fn prompted_password_retry_can_recover() { + let (paste_key, payload) = + ente_paste::encrypt("protected paste", Some("correct horse")).unwrap(); let mut retry_passwords = ["correct horse"].into_iter(); let text = decrypt_password_protected_paste_with_prompt( + &paste_key, + &payload, PastePasswordAttempt::Prompted("wrong horse".to_string()), || Ok::<_, Error>(retry_passwords.next().expect("retry password").to_string()), - test_decrypt_password_protected_paste, ) .unwrap(); @@ -331,7 +343,7 @@ mod tests { #[test] fn prompted_password_retry_ignores_empty_password() { let mut retry_passwords = ["", "correct horse"].into_iter(); - let text = decrypt_password_protected_paste_with_prompt( + let text = decrypt_password_protected_paste_with( PastePasswordAttempt::Prompted("wrong horse".to_string()), || Ok::<_, Error>(retry_passwords.next().expect("retry password").to_string()), test_decrypt_password_protected_paste, @@ -344,7 +356,7 @@ mod tests { #[test] fn wrong_env_password_fails_without_retry() { - let error = decrypt_password_protected_paste_with_prompt( + let error = decrypt_password_protected_paste_with( PastePasswordAttempt::Env("wrong horse".to_string()), || panic!("environment passwords must not be retried"), test_decrypt_password_protected_paste, diff --git a/rust/crates/accounts/src/flow.rs b/rust/crates/accounts/src/flow.rs index 21b2d28b35d..fea61340460 100644 --- a/rust/crates/accounts/src/flow.rs +++ b/rust/crates/accounts/src/flow.rs @@ -10,7 +10,7 @@ use base64::{ use ente_core::{ auth::{ self, DecryptedSecrets, GeneratedSrpSetup, KeyAttributes as CoreKeyAttributes, - KeyDerivationStrength, derive_kek, generate_keys_with_strength, generate_srp_setup, + KeyDerivationStrength, derive_kek, generate_keys_with_strength, generate_srp_setup_with_login_key, get_recovery_key, }, crypto::{self, SecretVec, secretbox}, @@ -433,14 +433,13 @@ where let srp_user_id = Uuid::new_v4(); let srp_setup = generate_srp_setup_with_login_key(&login_key, &srp_user_id.to_string())?; - let update = self - .complete_srp_update( - &srp_user_id, - &srp_setup, - &updated_key_attr, - params.log_out_other_devices, - ) - .await?; + self.complete_srp_update( + &srp_user_id, + &srp_setup, + &updated_key_attr, + params.log_out_other_devices, + ) + .await?; let srp_attributes = self.client.get_srp_attributes(¶ms.email).await?; @@ -468,8 +467,6 @@ where ))); } - let _ = update; - Ok(ChangePasswordResult { key_attributes: updated_key_attributes, srp_attributes, @@ -701,7 +698,7 @@ where let key_gen_result = generate_keys_with_strength(&password, key_derivation_strength)?; let srp_user_id = Uuid::new_v4(); let srp_setup = - generate_srp_setup(&key_gen_result.key_encryption_key, &srp_user_id.to_string())?; + generate_srp_setup_with_login_key(&key_gen_result.login_key, &srp_user_id.to_string())?; let key_attributes = to_api_key_attributes(&key_gen_result.key_attributes); self.client diff --git a/rust/crates/core/src/auth/api.rs b/rust/crates/core/src/auth/api.rs index c70bbbe997f..6d7b7d75215 100644 --- a/rust/crates/core/src/auth/api.rs +++ b/rust/crates/core/src/auth/api.rs @@ -171,24 +171,23 @@ pub fn generate_sensitive_kek(password: &str) -> Result { _ => AuthError::InsufficientMemory, })?; - Ok(GeneratedKek { - key: SecretVec::new(derived.key.as_bytes().to_vec()), - salt: derived.salt.as_bytes().to_vec(), - mem_limit: derived.params.mem_limit, - ops_limit: derived.params.ops_limit, - }) + Ok(generated_kek(derived)) } /// Generate a KEK using the current interactive web policy. pub fn generate_interactive_kek(password: &str) -> Result { let derived = argon::derive_interactive_key(password)?; - Ok(GeneratedKek { + Ok(generated_kek(derived)) +} + +fn generated_kek(derived: argon::DerivedKey) -> GeneratedKek { + GeneratedKek { key: SecretVec::new(derived.key.as_bytes().to_vec()), salt: derived.salt.as_bytes().to_vec(), mem_limit: derived.params.mem_limit, ops_limit: derived.params.ops_limit, - }) + } } /// Generate the SRP setup payload for a given KEK and SRP user ID. diff --git a/rust/crates/paste/src/lib.rs b/rust/crates/paste/src/lib.rs index 7694f731f95..ae4bb51aae5 100644 --- a/rust/crates/paste/src/lib.rs +++ b/rust/crates/paste/src/lib.rs @@ -284,6 +284,19 @@ pub fn decrypt(payload: &PastePayload, key: &PasteKey, password: Option<&str>) - } pub fn encrypt(text: &str, password: Option<&str>) -> Result<(PasteKey, PastePayload)> { + let params = if password.is_some() { + argon::Params::MODERATE + } else { + argon::Params::INTERACTIVE + }; + encrypt_with_kdf_params(text, password, params) +} + +fn encrypt_with_kdf_params( + text: &str, + password: Option<&str>, + params: argon::Params, +) -> Result<(PasteKey, PastePayload)> { let paste_key = Key::generate(); let key_reference = PasteKey { fragment_secret: fragment_secret(), @@ -296,12 +309,9 @@ pub fn encrypt(text: &str, password: Option<&str>) -> Result<(PasteKey, PastePay &paste_key, )?; let kdf_secret = key_reference.kdf_secret(password)?; - let key_encryption_key = if password.is_some() { - argon::derive_moderate_key(&kdf_secret)? - } else { - argon::derive_interactive_key(&kdf_secret)? - }; - let encrypted_paste_key = secretbox::encrypt(paste_key.as_bytes(), &key_encryption_key.key); + let salt = crypto::Salt::generate(); + let key_encryption_key = argon::derive_key(&kdf_secret, &salt, params)?; + let encrypted_paste_key = secretbox::encrypt(paste_key.as_bytes(), &key_encryption_key); Ok(( key_reference, @@ -310,9 +320,9 @@ pub fn encrypt(text: &str, password: Option<&str>) -> Result<(PasteKey, PastePay decryption_header: crypto::encode_b64(encrypted.decryption_header.as_bytes()), encrypted_paste_key: crypto::encode_b64(&encrypted_paste_key.encrypted_data), encrypted_paste_key_nonce: crypto::encode_b64(encrypted_paste_key.nonce.as_bytes()), - kdf_nonce: crypto::encode_b64(key_encryption_key.salt.as_bytes()), - kdf_mem_limit: key_encryption_key.params.mem_limit, - kdf_ops_limit: key_encryption_key.params.ops_limit, + kdf_nonce: crypto::encode_b64(salt.as_bytes()), + kdf_mem_limit: params.mem_limit, + kdf_ops_limit: params.ops_limit, }, )) } @@ -410,8 +420,9 @@ mod tests { #[test] fn decrypt_password_protected_paste_payload() { - let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); - let payload = test_payload("protected paste", &paste_key, Some("correct horse")); + let (paste_key, payload) = + encrypt_with_kdf_params("protected paste", Some("correct horse"), argon::Params::MIN) + .unwrap(); let text = decrypt(&payload, &paste_key, Some("correct horse")).unwrap(); assert_eq!(text, "protected paste"); @@ -419,8 +430,9 @@ mod tests { #[test] fn reject_wrong_paste_password() { - let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); - let payload = test_payload("protected paste", &paste_key, Some("correct horse")); + let (paste_key, payload) = + encrypt_with_kdf_params("protected paste", Some("correct horse"), argon::Params::MIN) + .unwrap(); let error = decrypt(&payload, &paste_key, Some("wrong horse")).unwrap_err(); assert!(matches!(error, Error::IncorrectPassword)); @@ -428,8 +440,9 @@ mod tests { #[test] fn structural_payload_errors_are_not_incorrect_password() { - let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); - let mut payload = test_payload("protected paste", &paste_key, Some("correct horse")); + let (paste_key, mut payload) = + encrypt_with_kdf_params("protected paste", Some("correct horse"), argon::Params::MIN) + .unwrap(); payload.kdf_nonce = "not base64".to_string(); let error = decrypt(&payload, &paste_key, Some("correct horse")).unwrap_err(); @@ -441,8 +454,9 @@ mod tests { #[test] fn wrapped_key_payload_errors_are_not_incorrect_password() { - let paste_key = PasteKey::parse("p-AbCd1234EfGh").unwrap(); - let mut payload = test_payload("protected paste", &paste_key, Some("correct horse")); + let (paste_key, mut payload) = + encrypt_with_kdf_params("protected paste", Some("correct horse"), argon::Params::MIN) + .unwrap(); payload.encrypted_paste_key = "not base64".to_string(); let error = decrypt(&payload, &paste_key, Some("correct horse")).unwrap_err(); @@ -522,8 +536,8 @@ mod tests { #[tokio::test] async fn consume_uses_guard_cookie() { let access_token = "ABC123"; - let paste_key = PasteKey::parse("AbCd1234EfGh").unwrap(); - let payload = test_payload("guarded paste", &paste_key, None); + let (paste_key, payload) = + encrypt_with_kdf_params("guarded paste", None, argon::Params::MIN).unwrap(); let mut server = Server::new_async().await; let guard = server @@ -557,29 +571,4 @@ mod tests { guard.assert_async().await; consume.assert_async().await; } - - fn test_payload(text: &str, key_reference: &PasteKey, password: Option<&str>) -> PastePayload { - let paste_key = Key::from_bytes([7u8; Key::BYTES]); - let encrypted = blob::encrypt_json( - &PasteText { - text: text.to_string(), - }, - &paste_key, - ) - .unwrap(); - let salt = crypto::Salt::from_bytes([9u8; crypto::Salt::BYTES]); - let kdf_secret = key_reference.kdf_secret(password).unwrap(); - let key_encryption_key = argon::derive_key(&kdf_secret, &salt, argon::Params::MIN).unwrap(); - let encrypted_paste_key = secretbox::encrypt(paste_key.as_bytes(), &key_encryption_key); - - PastePayload { - encrypted_data: crypto::encode_b64(&encrypted.encrypted_data), - decryption_header: crypto::encode_b64(encrypted.decryption_header.as_bytes()), - encrypted_paste_key: crypto::encode_b64(&encrypted_paste_key.encrypted_data), - encrypted_paste_key_nonce: crypto::encode_b64(encrypted_paste_key.nonce.as_bytes()), - kdf_nonce: crypto::encode_b64(salt.as_bytes()), - kdf_mem_limit: argon::Params::MIN.mem_limit, - kdf_ops_limit: argon::Params::MIN.ops_limit, - } - } } From 5ee243565c01dfe11da4b046f91b1be60bb8b10f Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 14:41:38 +0530 Subject: [PATCH 48/91] Raise gallery social overlay --- mobile/apps/photos/lib/ui/viewer/file/detail_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index 75d3784176a..f9a013bd45d 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -39,7 +39,7 @@ import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/utils/dialog_util.dart'; const _socialRightInset = 24.0; -const _socialBottomBarClearance = 74.0; +const _socialBottomBarClearance = 154.0; enum DetailPageMode { minimalistic, full } From 8f29c531d08faa4e11d968d0d06e79285186cec2 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 14:50:42 +0530 Subject: [PATCH 49/91] Adjust social overlay spacing --- mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart | 2 +- .../apps/photos/lib/ui/social/widgets/file_social_overlay.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 64920f189a4..0b2539a095e 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -45,7 +45,7 @@ import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/share_util.dart"; const _socialRightInset = 24.0; -const _socialToActionBarGap = 14.0; +const _socialToActionBarGap = 38.0; //There are two states of variables that FullScreenMemory depends on: //1. The list of memories diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index c04865fa376..71fee16c26d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -464,7 +464,7 @@ class _FileSocialOverlayState extends State { ), ), ), - const SizedBox(height: 8), + const SizedBox(height: 10), Tooltip( message: AppLocalizations.of(context).comments, child: SizedBox.square( From a15e44d55c8211fa9f8907e04ddee74ae0e2b9a7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 15:11:20 +0530 Subject: [PATCH 50/91] Update CODEOWNERS to include mnvr-sudo --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8c413f88e0b..57c71d97659 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ -/.github/ @mnvr @ua741 -/server/ @ua741 @mnvr +/.github/ @mnvr @ua741 @mnvr-sudo +/server/ @ua741 @mnvr @mnvr-sudo /mobile/packages/ente_components/ @ashilkn @AmanRajSinghMourya From 430d9b2a61042e4559b1de3e4cdb05d0f1f4ac41 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 15:29:58 +0530 Subject: [PATCH 51/91] Compact gallery video controls --- .../photos/changes/compact-video-controls.md | 1 + .../lib/ui/viewer/file/detail_page.dart | 2 +- .../seek_bar.dart | 4 +- .../file/video_widget_media_kit_common.dart | 96 +++++++++---------- .../ui/viewer/file/video_widget_native.dart | 85 ++++++++-------- 5 files changed, 95 insertions(+), 93 deletions(-) create mode 100644 mobile/apps/photos/changes/compact-video-controls.md diff --git a/mobile/apps/photos/changes/compact-video-controls.md b/mobile/apps/photos/changes/compact-video-controls.md new file mode 100644 index 00000000000..202c08138df --- /dev/null +++ b/mobile/apps/photos/changes/compact-video-controls.md @@ -0,0 +1 @@ +- Made video captions and playback controls more compact in the photo viewer. diff --git a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart index f9a013bd45d..7f021d0e0eb 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/detail_page.dart @@ -39,7 +39,7 @@ import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/utils/dialog_util.dart'; const _socialRightInset = 24.0; -const _socialBottomBarClearance = 154.0; +const _socialBottomBarClearance = 130.0; enum DetailPageMode { minimalistic, full } diff --git a/mobile/apps/photos/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart index f21bf10821c..99856b2eb3d 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/native_video_player_controls/seek_bar.dart @@ -70,8 +70,8 @@ class _SeekBarState extends State with SingleTickerProviderStateMixin { data: SliderTheme.of(context).copyWith( trackHeight: 1.0, tickMarkShape: SliderTickMarkShape.noTickMark, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12.0), activeTrackColor: backgroundElevatedLight, inactiveTrackColor: fillMutedDark, thumbColor: backgroundElevatedLight, diff --git a/mobile/apps/photos/lib/ui/viewer/file/video_widget_media_kit_common.dart b/mobile/apps/photos/lib/ui/viewer/file/video_widget_media_kit_common.dart index 05479e08c2b..645b7a4f02e 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/video_widget_media_kit_common.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/video_widget_media_kit_common.dart @@ -309,66 +309,64 @@ class SeekBarAndDuration extends StatelessWidget { @override Widget build(BuildContext context) { + final caption = file.caption; + final textStyle = getEnteTextTheme( + context, + ).mini.copyWith(color: textBaseDark); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Container( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all(color: strokeFaintDark, width: 1), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - file.caption != null && file.caption!.isNotEmpty - ? Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), - child: GestureDetector( - onTap: () { - showDetailsSheet(context, file); - }, - child: Text( - file.caption!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), + if (caption != null && caption.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: GestureDetector( + onTap: () => showDetailsSheet(context, file), + child: Row( + children: [ + Text('"', style: textStyle), + Flexible( + child: Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), ), - ), - ) - : const SizedBox.shrink(), - Row( - children: [ - StreamBuilder( - stream: controller?.player.stream.position, - builder: (context, snapshot) { - if (snapshot.data == null) { - return Text( - "0:00", - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), - ); - } - return Text( - secondsToDuration(snapshot.data!.inSeconds), - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), - ); - }, - ), - Expanded(child: SeekBar(controller!, isSeekingNotifier)), - Text( - _secondsToDuration( - controller!.player.state.duration.inSeconds, + Text('"', style: textStyle), + ], ), - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), ), - ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + StreamBuilder( + stream: controller?.player.stream.position, + builder: (context, snapshot) => Text( + snapshot.data == null + ? "0:00" + : secondsToDuration(snapshot.data!.inSeconds), + style: textStyle, + ), + ), + Expanded(child: SeekBar(controller!, isSeekingNotifier)), + Text( + _secondsToDuration( + controller!.player.state.duration.inSeconds, + ), + style: textStyle, + ), + ], + ), ), ], ), @@ -443,8 +441,8 @@ class _SeekBarState extends State { return SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 1.0, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12.0), activeTrackColor: backgroundElevatedLight, inactiveTrackColor: fillMutedDark, thumbColor: backgroundElevatedLight, diff --git a/mobile/apps/photos/lib/ui/viewer/file/video_widget_native.dart b/mobile/apps/photos/lib/ui/viewer/file/video_widget_native.dart index 14e90c07367..bb67735689e 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/video_widget_native.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/video_widget_native.dart @@ -877,6 +877,10 @@ class _SeekBarAndDuration extends StatelessWidget { @override Widget build(BuildContext context) { + final caption = file.caption; + final textStyle = getEnteTextTheme( + context, + ).mini.copyWith(color: textBaseDark); return ValueListenableBuilder( valueListenable: showControls, builder: (BuildContext context, bool value, _) { @@ -889,58 +893,57 @@ class _SeekBarAndDuration extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Container( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all(color: strokeFaintDark, width: 1), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - file.caption != null && file.caption!.isNotEmpty - ? Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), - child: GestureDetector( - onTap: () { - showDetailsSheet(context, file); - }, - child: Text( - file.caption!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), + if (caption != null && caption.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: GestureDetector( + onTap: () => showDetailsSheet(context, file), + child: Row( + children: [ + Text('"', style: textStyle), + Flexible( + child: Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), ), - ), - ) - : const SizedBox.shrink(), - Row( - children: [ - AnimatedSize( - duration: const Duration(seconds: 5), - curve: Curves.easeInOut, - child: Text( - secondsToDuration(position ~/ 1000), - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), + Text('"', style: textStyle), + ], ), ), - Expanded( - child: SeekBar( - controller!, - durationToSeconds(duration), - isSeeking, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + AnimatedSize( + duration: const Duration(seconds: 5), + curve: Curves.easeInOut, + child: Text( + secondsToDuration(position ~/ 1000), + style: textStyle, + ), ), - ), - Text( - duration ?? "0:00", - style: getEnteTextTheme( - context, - ).mini.copyWith(color: textBaseDark), - ), - ], + Expanded( + child: SeekBar( + controller!, + durationToSeconds(duration), + isSeeking, + ), + ), + Text(duration ?? "0:00", style: textStyle), + ], + ), ), ], ), From b49c92e5c70f092f4fc8c7cdaa257432dd8323d8 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 16:04:43 +0530 Subject: [PATCH 52/91] Polish memories overlay contrast --- .../apps/photos/changes/subtle-social-icon-shadows.md | 1 + .../lib/ui/home/memories/full_screen_memory.dart | 2 +- .../lib/ui/social/widgets/file_social_overlay.dart | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 mobile/apps/photos/changes/subtle-social-icon-shadows.md diff --git a/mobile/apps/photos/changes/subtle-social-icon-shadows.md b/mobile/apps/photos/changes/subtle-social-icon-shadows.md new file mode 100644 index 00000000000..dc7473020f9 --- /dev/null +++ b/mobile/apps/photos/changes/subtle-social-icon-shadows.md @@ -0,0 +1 @@ +- Improve memories top spacing and add subtle shadows to social controls. diff --git a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart index 0b2539a095e..4548ddc40dd 100644 --- a/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/apps/photos/lib/ui/home/memories/full_screen_memory.dart @@ -1013,7 +1013,7 @@ class _MemoryTopOverlay extends StatelessWidget { final currentFile = inheritedData.memories[safeIndex].file; final showFavorite = currentFile.isOwner && !isLocalGalleryMode; return Padding( - padding: EdgeInsets.only(top: safePadding.top), + padding: EdgeInsets.only(top: max(safePadding.top, 40)), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 71fee16c26d..8b1016ebd08 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -27,6 +27,13 @@ const _likedColor = Color(0xFF08C225); const _socialControlsSize = 48.0; const _socialIconSize = 32.0; const _socialIconPadding = EdgeInsets.fromLTRB(12, 12, 4, 4); +const _socialIconShadows = [Shadow(color: Color(0x33000000), blurRadius: 16)]; +const _countBadgeShadows = [ + BoxShadow(color: Color(0x33000000), blurRadius: 16), +]; +const _latestCommentShadows = [ + BoxShadow(color: Color(0x14000000), blurRadius: 4), +]; Duration _motionDuration(BuildContext context, int milliseconds) { return MediaQuery.disableAnimationsOf(context) @@ -459,6 +466,7 @@ class _FileSocialOverlayState extends State { _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, color: _hasLiked ? _likedColor : Colors.white, size: _socialIconSize, + shadows: _socialIconShadows, ), ), ), @@ -555,6 +563,7 @@ class _LatestCommentPillState extends State<_LatestCommentPill> { DecoratedBox( decoration: const BoxDecoration( color: Colors.white, + boxShadow: _latestCommentShadows, borderRadius: BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(6), @@ -626,6 +635,7 @@ class _CommentBadgeIcon extends StatelessWidget { EnteIcons.commentBubbleStroke, color: Colors.white, size: _socialIconSize, + shadows: _socialIconShadows, ), Positioned( right: -5, @@ -652,6 +662,7 @@ class _CommentBadgeIcon extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6), decoration: const BoxDecoration( color: Colors.white, + boxShadow: _countBadgeShadows, borderRadius: BorderRadius.all(Radius.circular(19)), ), child: Center( From 262fc9c5e04c702a43fbbbb999fba1be1c015b68 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 16:21:13 +0530 Subject: [PATCH 53/91] Show add to album in shared viewer --- .../show-add-to-album-in-shared-viewer.md | 1 + .../lib/ui/viewer/file/file_app_bar.dart | 42 ------------------- .../lib/ui/viewer/file/file_bottom_bar.dart | 4 +- 3 files changed, 2 insertions(+), 45 deletions(-) create mode 100644 mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md diff --git a/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md b/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md new file mode 100644 index 00000000000..c556956cdb3 --- /dev/null +++ b/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md @@ -0,0 +1 @@ +- Show Add to album directly in the gallery viewer for shared photos. 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 62c533cd981..daa6b2b1406 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 @@ -74,7 +74,6 @@ class FileAppBarState extends State { bool isGuestView = false; bool shouldLoopVideo = localSettings.shouldLoopVideo(); bool _reloadActions = false; - ValueNotifier? _isInSharedCollectionNotifier; ValueNotifier? _showingThumbnailFallbackNotifier; @override @@ -103,31 +102,15 @@ class FileAppBarState extends State { void didChangeDependencies() { super.didChangeDependencies(); final detailPageState = InheritedDetailPageState.maybeOf(context); - _updateIsInSharedCollectionNotifier( - detailPageState?.isInSharedCollectionNotifier, - ); _updateShowingThumbnailFallbackNotifier( detailPageState?.showingThumbnailFallbackNotifier, ); } - void _onSharedCollectionChanged() { - _requestActionsReload(); - } - void _onThumbnailFallbackChanged() { _requestActionsReload(); } - void _updateIsInSharedCollectionNotifier(ValueNotifier? notifier) { - if (_isInSharedCollectionNotifier == notifier) { - return; - } - _isInSharedCollectionNotifier?.removeListener(_onSharedCollectionChanged); - _isInSharedCollectionNotifier = notifier; - _isInSharedCollectionNotifier?.addListener(_onSharedCollectionChanged); - } - void _updateShowingThumbnailFallbackNotifier( ValueNotifier? notifier, ) { @@ -143,7 +126,6 @@ class FileAppBarState extends State { @override void dispose() { - _isInSharedCollectionNotifier?.removeListener(_onSharedCollectionChanged); _showingThumbnailFallbackNotifier?.removeListener( _onThumbnailFallbackChanged, ); @@ -274,11 +256,6 @@ class FileAppBarState extends State { final Collection? collection = collectionID != null ? CollectionsService.instance.getCollectionByID(collectionID) : null; - final isInSharedCollection = - InheritedDetailPageState.maybeOf( - context, - )?.isInSharedCollectionNotifier.value ?? - false; bool isFileHidden = false; if (isOwnedByUser && isFileUploaded) { isFileHidden = collection?.isHidden() ?? false; @@ -353,17 +330,6 @@ class FileAppBarState extends State { ), ); } - // Add to Album option - shown when file is in shared collection - // (moved from bottom bar to make room for social icons) - if (isInSharedCollection && isFileUploaded && !isFileHidden) { - items.add( - _fileMenuOption( - AppLocalizations.of(context).addToAlbum, - value: 10, - hugeIcon: HugeIcons.strokeRoundedImageAdd01, - ), - ); - } // options for files owned by the user if (isOwnedByUser && !isFileHidden && isFileUploaded) { final bool isArchived = @@ -562,14 +528,6 @@ class FileAppBarState extends State { if (collection != null) { await _handleSuggestDelete(collection); } - } else if (value == 10) { - final selectedFiles = SelectedFiles(); - selectedFiles.files.add(widget.file); - await showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.addFiles, - ); } } diff --git a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart index e179696946e..51a56505479 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/file_bottom_bar.dart @@ -168,9 +168,7 @@ class FileBottomBarState extends State { ), ); - // Add to album button for uploaded, non-hidden files - // Hide when in shared collection (moved to app bar popup menu) - if (widget.file.isUploaded && !isFileHidden && !isInSharedCollection) { + if (widget.file.isUploaded && !isFileHidden) { children.add( Tooltip( message: AppLocalizations.of(context).addToAlbum, From 7326b319748ef5e035637b5431624f79709b3997 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 17:46:16 +0530 Subject: [PATCH 54/91] Update social overlay icons --- .../changes/align-social-icon-sizing.md | 1 + .../social/widgets/file_social_overlay.dart | 29 +++--- mobile/packages/ente_icons/config.json | 98 +++++++++--------- .../packages/ente_icons/fonts/EnteIcons.ttf | Bin 4224 -> 4300 bytes .../ente_icons/lib/src/ente_icons.dart | 6 +- 5 files changed, 66 insertions(+), 68 deletions(-) create mode 100644 mobile/apps/photos/changes/align-social-icon-sizing.md diff --git a/mobile/apps/photos/changes/align-social-icon-sizing.md b/mobile/apps/photos/changes/align-social-icon-sizing.md new file mode 100644 index 00000000000..aadcae882af --- /dev/null +++ b/mobile/apps/photos/changes/align-social-icon-sizing.md @@ -0,0 +1 @@ +- Align social controls and comment badges with the memories viewer design. diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 8b1016ebd08..13cc02a0db4 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -25,8 +25,8 @@ final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); const _socialControlsSize = 48.0; -const _socialIconSize = 32.0; -const _socialIconPadding = EdgeInsets.fromLTRB(12, 12, 4, 4); +const _socialIconSize = 24.0; +const _socialIconPadding = EdgeInsets.all(12); const _socialIconShadows = [Shadow(color: Color(0x33000000), blurRadius: 16)]; const _countBadgeShadows = [ BoxShadow(color: Color(0x33000000), blurRadius: 16), @@ -408,22 +408,19 @@ class _FileSocialOverlayState extends State { final latestCommentAuthor = _latestCommentAuthor; final latestCommentPill = latestComment != null && latestCommentAuthor != null - ? Padding( + ? Transform.translate( key: const ValueKey("latest-comment"), - padding: const EdgeInsets.only(right: 4), - child: Transform.translate( - offset: const Offset(0, 8), - child: _LatestCommentPill( - comment: latestComment, - author: latestCommentAuthor, - width: _latestCommentPillWidth( - context, - latestComment.data, - MediaQuery.sizeOf(context).width * 0.6, - ), - currentUserID: widget.currentUserID!, - onTap: () => _openComments(comment: latestComment), + offset: const Offset(0, 8), + child: _LatestCommentPill( + comment: latestComment, + author: latestCommentAuthor, + width: _latestCommentPillWidth( + context, + latestComment.data, + MediaQuery.sizeOf(context).width * 0.6, ), + currentUserID: widget.currentUserID!, + onTap: () => _openComments(comment: latestComment), ), ) : const SizedBox.shrink(); diff --git a/mobile/packages/ente_icons/config.json b/mobile/packages/ente_icons/config.json index ea818733a8e..2d54543490c 100644 --- a/mobile/packages/ente_icons/config.json +++ b/mobile/packages/ente_icons/config.json @@ -7,129 +7,129 @@ "ascent": 850, "glyphs": [ { - "uid": "975277113eb9b94fedb4305035e69d0c", - "css": "lightning_icon", - "code": 59394, + "uid": "30831225dfe28c8e913e9e7fa551b4b6", + "css": "favorite_stroke", + "code": 59399, "src": "custom_icons", "selected": true, "svg": { - "path": "M0.8 566C-19.3 507.6 364.9-41.3 456 2.5 547.2 46.3 429.9 387.2 446 429.4 462.2 471.7 640.7 444.9 642.8 520.5 645 596.2 280.8 1017 186.4 999.5 92 981.9 202.4 662 196.6 620.8 190.7 579.6 20.8 624.4 0.8 566Z", - "width": 643 + "path": "M424.3 150.3C459.9 81.1 556.7 80.8 594.4 146.5L594.5 146.8 679.1 295.4 847.9 323H848.2C923.6 335.5 955.8 426.7 904 483.7L903.9 483.8 788.6 610.2 814.5 779.1C826.3 855.1 748.9 914.2 678.6 882.1L523 811.7 370.2 888.6C301.5 923.2 222.1 867.6 230.5 791.4L249.4 621.3 129.1 499.8C74.9 445 103.2 352.3 178.3 336.8L345.9 302.2 424.3 150.3ZM522.1 188C516.8 178.7 503.2 178.9 498.3 188.5L401.7 375.9V375.8L195.2 418.4C184.7 420.6 180.7 433.5 188.3 441.2L336.6 591 313.3 800.6C312.3 809.9 320.7 817 329.1 815.4L332.8 814.2 521.1 719.3 713.2 806.3C723 810.7 733.8 802.5 732.2 791.9L700.3 583.5 842.3 427.6C849.5 419.7 845.1 407 834.5 405.2L626.4 371.2 522.1 188Z", + "width": 1000 }, "search": [ - "lightning_icon" + "favorite_stroke" ] }, { - "uid": "d64231e356f6b808f10332864d8d00bc", - "css": "add_to_album", - "code": 59397, + "uid": "9469665b7faa4f27bdbdd75ea725dcb5", + "css": "send_stroke", + "code": 59395, "src": "custom_icons", "selected": true, "svg": { - "path": "M500 306C508.2 306 516.1 309.3 521.9 315.1 527.6 320.9 530.9 328.8 530.9 337V469.2H663.1C671.3 469.2 679.1 472.4 684.9 478.2 690.7 484 694 491.8 694 500 694 508.2 690.7 516.1 684.9 521.9 679.1 527.7 671.3 531 663.1 531H530.9V663.2C530.9 671.4 527.6 679.2 521.9 685 516.1 690.8 508.2 694.1 500 694.1 491.8 694.1 483.9 690.8 478.1 685 472.3 679.2 469.1 671.4 469.1 663.2V531H336.9C328.7 531 320.8 527.7 315 521.9 309.2 516.1 305.9 508.2 305.9 500 306 491.8 309.2 484 315 478.2 320.8 472.4 328.7 469.2 336.9 469.2H469.1V337C469.1 328.8 472.3 320.9 478.1 315.1 483.9 309.3 491.8 306 500 306ZM292.8 135.8C430.5 120.5 569.5 120.5 707.2 135.8 788 144.8 853.3 208.5 862.7 289.8 879.1 429.4 879.1 570.5 862.7 710.2 853.2 791.5 788 855.1 707.2 864.2 569.5 879.4 430.5 879.4 292.8 864.2 212 855.1 146.7 791.5 137.2 710.2 120.9 570.5 120.9 429.4 137.2 289.8 146.7 208.5 212 144.8 292.8 135.8ZM699.8 201.7C567 187 433 187 300.1 201.7 275.6 204.4 252.6 215.4 235 232.8 217.4 250.1 206.2 273 203.1 297.5 187.4 432 187.4 568 203.1 702.5 206.2 727.1 217.4 749.9 235 767.2 252.6 784.6 275.6 795.5 300.1 798.3 431.9 812.9 568.1 812.9 699.8 798.3 724.4 795.5 747.3 784.6 764.9 767.2 782.5 749.8 793.8 727 796.8 702.5 812.6 568 812.6 432 796.8 297.5 793.8 273 782.5 250.2 764.9 232.8 747.3 215.5 724.4 204.5 699.8 201.8M292.8 135.8L292.3 131.4 292.3 131.4 292.8 135.8ZM707.2 135.8L707.7 131.4 707.7 131.4 707.2 135.8ZM862.7 289.8L858.4 290.3 858.4 290.3 862.7 289.8ZM862.7 710.2L858.4 709.7 858.4 709.7 862.7 710.2ZM707.2 864.2L707.7 868.6 707.7 868.6 707.2 864.2ZM292.8 864.2L292.3 868.6 292.3 868.6 292.8 864.2ZM137.2 710.2L141.6 709.7 141.6 709.7 137.2 710.2ZM137.2 289.8L141.6 290.3 141.6 290.3 137.2 289.8ZM300.1 201.7L299.7 197.3 299.7 197.3 300.1 201.7ZM203.1 297.5L198.7 297 198.7 297 203.1 297.5ZM203.1 702.5L198.7 703 198.7 703.1 203.1 702.5ZM300.1 798.3L300.6 793.9 300.6 793.9 300.1 798.3ZM699.8 798.3L699.4 793.9 699.4 793.9 699.8 798.3ZM796.8 702.5L801.2 703.1 801.2 703 796.8 702.5ZM796.8 297.5L801.2 297 801.2 297 796.8 297.5ZM292.8 135.8L293.3 140.2C430.7 125 569.3 125 706.7 140.2L707.2 135.8 707.7 131.4C569.6 116.1 430.3 116.1 292.3 131.4L292.8 135.8ZM707.2 135.8L706.7 140.2C785.5 149 849.1 211.1 858.4 290.3L862.7 289.8 867.1 289.3C857.4 205.9 790.5 140.7 707.7 131.4L707.2 135.8ZM862.7 289.8L858.4 290.3C874.7 429.6 874.7 570.4 858.4 709.7L862.7 710.2 867.1 710.7C883.5 570.7 883.5 429.3 867.1 289.3L862.7 289.8ZM862.7 710.2L858.4 709.7C849.1 788.9 785.5 850.9 706.7 859.8L707.2 864.2 707.7 868.6C790.5 859.3 857.4 794.1 867.1 710.7L862.7 710.2ZM707.2 864.2L706.7 859.8C569.3 875 430.7 875 293.3 859.8L292.8 864.2 292.3 868.6C430.3 883.9 569.6 883.9 707.7 868.6L707.2 864.2ZM292.8 864.2L293.3 859.8C214.5 850.9 150.9 788.9 141.6 709.7L137.2 710.2 132.8 710.7C142.6 794.1 209.5 859.3 292.3 868.6L292.8 864.2ZM137.2 710.2L141.6 709.7C125.4 570.4 125.4 429.6 141.6 290.3L137.2 289.8 132.8 289.3C116.5 429.3 116.5 570.7 132.8 710.7L137.2 710.2ZM137.2 289.8L141.6 290.3C150.9 211.1 214.5 149 293.3 140.2L292.8 135.8 292.3 131.4C209.5 140.7 142.6 205.9 132.8 289.3L137.2 289.8ZM699.8 201.7L700.3 197.3C567.2 182.6 432.8 182.6 299.7 197.3L300.1 201.7 300.6 206.1C433.1 191.4 566.9 191.4 699.4 206.1L699.8 201.7ZM300.1 201.7L299.7 197.3C274.1 200.2 250.2 211.5 231.9 229.6L235 232.8 238.1 235.9C255 219.2 277 208.7 300.6 206.1L300.1 201.7ZM235 232.8L231.9 229.6C213.6 247.7 201.9 271.4 198.7 297L203.1 297.5 207.5 298.1C210.4 274.5 221.2 252.6 238.1 235.9L235 232.8ZM203.1 297.5L198.7 297C183 431.9 183 568.1 198.7 703L203.1 702.5 207.5 702C191.8 567.8 191.8 432.2 207.5 298L203.1 297.5ZM203.1 702.5L198.7 703.1C201.9 728.6 213.6 752.3 231.9 770.4L235 767.2 238.1 764.1C221.2 747.4 210.4 725.5 207.5 702L203.1 702.5ZM235 767.2L231.9 770.4C250.2 788.4 274.1 799.8 299.7 802.6L300.1 798.3 300.6 793.9C277 791.2 255 780.7 238.1 764.1L235 767.2ZM300.1 798.3L299.7 802.6C431.7 817.4 568.3 817.4 700.3 802.6L699.8 798.3 699.4 793.9C568 808.5 432 808.5 300.6 793.9L300.1 798.3ZM699.8 798.3L700.3 802.6C725.9 799.8 749.8 788.4 768.1 770.4L764.9 767.2 761.8 764.1C744.9 780.7 722.9 791.2 699.4 793.9L699.8 798.3ZM764.9 767.2L768.1 770.4C786.4 752.3 798 728.6 801.2 703.1L796.8 702.5 792.4 702C789.5 725.5 778.7 747.4 761.8 764.1L764.9 767.2ZM796.8 702.5L801.2 703C817 568.1 817 431.9 801.2 297L796.8 297.5 792.4 298C808.1 432.2 808.1 567.8 792.4 702L796.8 702.5ZM796.8 297.5L801.2 297C798 271.4 786.3 247.7 768 229.7L764.9 232.8 761.8 236C778.7 252.6 789.5 274.5 792.4 298.1L796.8 297.5ZM764.9 232.8L768 229.7C749.7 211.6 725.9 200.3 700.3 197.4L699.8 201.8 699.4 206.2C722.9 208.8 744.9 219.3 761.8 236L764.9 232.8Z", + "path": "M160.2 125.2C179.4 124 199.8 128 219.7 133.8 259.3 145.4 311.7 168.6 376.2 196.9L737 355.8C787.3 378 829.4 396.4 858.4 414.5 886.4 432 916.7 457.9 916.7 500 916.7 542.1 886.4 568.1 858.4 585.5 829.4 603.6 787.3 622 737 644.2L376.2 803.1C311.7 831.5 259.3 854.6 219.7 866.2 199.8 872 179.4 876 160.2 874.8 142.1 873.7 124.2 867.8 109.5 854.5L103.3 848.4C71.2 812.3 84.2 765.4 98.3 730.7 113.9 692.3 142.3 642.4 177.3 580.9L202.2 536.8 212.3 518.9C214.9 514.1 216.8 510.4 218.3 507 219.6 504.1 220.2 502.3 220.5 501.3 220.7 500.5 220.7 500.1 220.7 500 220.7 499.9 220.7 499.6 220.5 498.7 220.2 497.7 219.6 495.9 218.3 493 215.4 486.3 210.6 477.8 202.2 463.1L177.3 419.2 177.2 419.1C142.3 357.5 113.9 307.6 98.3 269.3 90.4 250 84.3 230.2 83.5 210.9 82.5 190.3 87.7 169.2 103.3 151.6L109.5 145.5C124.2 132.3 142.1 126.3 160.2 125.2ZM304 500C304 515.5 299.8 528.9 294.4 540.9 289.5 552.2 282.3 564.7 274.7 578L274.7 578 249.7 622 249.7 622C213.5 685.9 188.5 730 175.5 762.1 168.9 778.3 167.2 787.2 166.8 791.6 172.1 791.7 181.4 790.5 196.4 786.1 229.4 776.5 275.7 756.3 342.6 726.8L703.4 567.9C756.3 544.6 791.6 528.9 814.3 514.8 826.5 507.2 831.2 502.3 832.8 500 831.2 497.7 826.5 492.8 814.3 485.2 791.6 471.1 756.3 455.4 703.4 432.1L342.6 273.2C275.7 243.7 229.4 223.5 196.4 213.9 181.4 209.5 172.2 208.3 166.8 208.4 167.3 213.8 169.4 223.1 175.5 237.9 188.5 270 213.5 314.1 249.7 378L274.7 422C282.3 435.3 289.5 447.9 294.4 459.1 299.8 471.1 304 484.5 304 500Z", "width": 1000 }, "search": [ - "add_to_album" + "send_stroke" ] }, { - "uid": "c3b91fb7d2fa92a472374f66fe4fffdb", - "css": "favorite_filled", - "code": 59398, + "uid": "edb5fcf33b096715106c82a3e2e1be71", + "css": "reply", + "code": 59400, "src": "custom_icons", "selected": true, "svg": { - "path": "M424.3 150.3C459.9 81.1 556.7 80.8 594.4 146.5L594.5 146.8 679.1 295.4 847.9 323H848.2C923.6 335.5 955.8 426.7 904 483.7L903.9 483.8 788.6 610.2 814.5 779.1C826.3 855.1 748.9 914.2 678.6 882.1L523 811.7 370.2 888.6C301.5 923.2 222 867.6 230.5 791.4L249.4 621.3 129.1 499.8C74.9 445 103.2 352.3 178.3 336.8L345.9 302.2 424.3 150.3Z", + "path": "M399.8 190.6C406.9 198 410.9 207.9 410.9 218.2 410.9 228.6 406.9 238.5 399.8 245.8L210.4 439.6H627.3C675.9 439.6 747.8 454.2 808.7 499.1 871.6 545.4 920.1 622.3 920.1 739.2 920.1 749.5 916.1 759.5 908.9 766.8 901.7 774.1 892 778.2 881.9 778.2 871.8 778.2 862.1 774.1 854.9 766.8 847.7 759.5 843.7 749.5 843.7 739.2 843.7 647.7 807.3 594.3 764.1 562.5 719 529.2 663.6 517.8 627.3 517.8H210.4L399.8 711.5C403.5 715.1 406.5 719.4 408.6 724.2 410.7 729 411.8 734.2 411.9 739.4 412 744.7 411.1 749.9 409.1 754.8 407.2 759.6 404.4 764 400.7 767.8 397.1 771.5 392.8 774.4 388 776.4 383.3 778.3 378.2 779.3 373.1 779.2 367.9 779.1 362.9 778 358.2 775.8 353.5 773.7 349.3 770.6 345.8 766.8L91.2 506.3C84.1 499 80.1 489 80.1 478.7 80.1 468.3 84.1 458.4 91.2 451.1L345.8 190.6C353 183.3 362.7 179.2 372.8 179.2 382.9 179.2 392.6 183.3 399.8 190.6Z", "width": 1000 }, "search": [ - "favorite_filled" + "reply" ] }, { - "uid": "30831225dfe28c8e913e9e7fa551b4b6", - "css": "favorite_stroke", - "code": 59399, + "uid": "d64231e356f6b808f10332864d8d00bc", + "css": "add_to_album", + "code": 59397, "src": "custom_icons", "selected": true, "svg": { - "path": "M424.3 150.3C459.9 81.1 556.7 80.8 594.4 146.5L594.5 146.8 679.1 295.4 847.9 323H848.2C923.6 335.5 955.8 426.7 904 483.7L903.9 483.8 788.6 610.2 814.5 779.1C826.3 855.1 748.9 914.2 678.6 882.1L523 811.7 370.2 888.6C301.5 923.2 222.1 867.6 230.5 791.4L249.4 621.3 129.1 499.8C74.9 445 103.2 352.3 178.3 336.8L345.9 302.2 424.3 150.3ZM522.1 188C516.8 178.7 503.2 178.9 498.3 188.5L401.7 375.9V375.8L195.2 418.4C184.7 420.6 180.7 433.5 188.3 441.2L336.6 591 313.3 800.6C312.3 809.9 320.7 817 329.1 815.4L332.8 814.2 521.1 719.3 713.2 806.3C723 810.7 733.8 802.5 732.2 791.9L700.3 583.5 842.3 427.6C849.5 419.7 845.1 407 834.5 405.2L626.4 371.2 522.1 188Z", + "path": "M500 306C508.2 306 516.1 309.3 521.9 315.1 527.6 320.9 530.9 328.8 530.9 337V469.2H663.1C671.3 469.2 679.1 472.4 684.9 478.2 690.7 484 694 491.8 694 500 694 508.2 690.7 516.1 684.9 521.9 679.1 527.7 671.3 531 663.1 531H530.9V663.2C530.9 671.4 527.6 679.2 521.9 685 516.1 690.8 508.2 694.1 500 694.1 491.8 694.1 483.9 690.8 478.1 685 472.3 679.2 469.1 671.4 469.1 663.2V531H336.9C328.7 531 320.8 527.7 315 521.9 309.2 516.1 305.9 508.2 305.9 500 306 491.8 309.2 484 315 478.2 320.8 472.4 328.7 469.2 336.9 469.2H469.1V337C469.1 328.8 472.3 320.9 478.1 315.1 483.9 309.3 491.8 306 500 306ZM292.8 135.8C430.5 120.5 569.5 120.5 707.2 135.8 788 144.8 853.3 208.5 862.7 289.8 879.1 429.4 879.1 570.5 862.7 710.2 853.2 791.5 788 855.1 707.2 864.2 569.5 879.4 430.5 879.4 292.8 864.2 212 855.1 146.7 791.5 137.2 710.2 120.9 570.5 120.9 429.4 137.2 289.8 146.7 208.5 212 144.8 292.8 135.8ZM699.8 201.7C567 187 433 187 300.1 201.7 275.6 204.4 252.6 215.4 235 232.8 217.4 250.1 206.2 273 203.1 297.5 187.4 432 187.4 568 203.1 702.5 206.2 727.1 217.4 749.9 235 767.2 252.6 784.6 275.6 795.5 300.1 798.3 431.9 812.9 568.1 812.9 699.8 798.3 724.4 795.5 747.3 784.6 764.9 767.2 782.5 749.8 793.8 727 796.8 702.5 812.6 568 812.6 432 796.8 297.5 793.8 273 782.5 250.2 764.9 232.8 747.3 215.5 724.4 204.5 699.8 201.8M292.8 135.8L292.3 131.4 292.3 131.4 292.8 135.8ZM707.2 135.8L707.7 131.4 707.7 131.4 707.2 135.8ZM862.7 289.8L858.4 290.3 858.4 290.3 862.7 289.8ZM862.7 710.2L858.4 709.7 858.4 709.7 862.7 710.2ZM707.2 864.2L707.7 868.6 707.7 868.6 707.2 864.2ZM292.8 864.2L292.3 868.6 292.3 868.6 292.8 864.2ZM137.2 710.2L141.6 709.7 141.6 709.7 137.2 710.2ZM137.2 289.8L141.6 290.3 141.6 290.3 137.2 289.8ZM300.1 201.7L299.7 197.3 299.7 197.3 300.1 201.7ZM203.1 297.5L198.7 297 198.7 297 203.1 297.5ZM203.1 702.5L198.7 703 198.7 703.1 203.1 702.5ZM300.1 798.3L300.6 793.9 300.6 793.9 300.1 798.3ZM699.8 798.3L699.4 793.9 699.4 793.9 699.8 798.3ZM796.8 702.5L801.2 703.1 801.2 703 796.8 702.5ZM796.8 297.5L801.2 297 801.2 297 796.8 297.5ZM292.8 135.8L293.3 140.2C430.7 125 569.3 125 706.7 140.2L707.2 135.8 707.7 131.4C569.6 116.1 430.3 116.1 292.3 131.4L292.8 135.8ZM707.2 135.8L706.7 140.2C785.5 149 849.1 211.1 858.4 290.3L862.7 289.8 867.1 289.3C857.4 205.9 790.5 140.7 707.7 131.4L707.2 135.8ZM862.7 289.8L858.4 290.3C874.7 429.6 874.7 570.4 858.4 709.7L862.7 710.2 867.1 710.7C883.5 570.7 883.5 429.3 867.1 289.3L862.7 289.8ZM862.7 710.2L858.4 709.7C849.1 788.9 785.5 850.9 706.7 859.8L707.2 864.2 707.7 868.6C790.5 859.3 857.4 794.1 867.1 710.7L862.7 710.2ZM707.2 864.2L706.7 859.8C569.3 875 430.7 875 293.3 859.8L292.8 864.2 292.3 868.6C430.3 883.9 569.6 883.9 707.7 868.6L707.2 864.2ZM292.8 864.2L293.3 859.8C214.5 850.9 150.9 788.9 141.6 709.7L137.2 710.2 132.8 710.7C142.6 794.1 209.5 859.3 292.3 868.6L292.8 864.2ZM137.2 710.2L141.6 709.7C125.4 570.4 125.4 429.6 141.6 290.3L137.2 289.8 132.8 289.3C116.5 429.3 116.5 570.7 132.8 710.7L137.2 710.2ZM137.2 289.8L141.6 290.3C150.9 211.1 214.5 149 293.3 140.2L292.8 135.8 292.3 131.4C209.5 140.7 142.6 205.9 132.8 289.3L137.2 289.8ZM699.8 201.7L700.3 197.3C567.2 182.6 432.8 182.6 299.7 197.3L300.1 201.7 300.6 206.1C433.1 191.4 566.9 191.4 699.4 206.1L699.8 201.7ZM300.1 201.7L299.7 197.3C274.1 200.2 250.2 211.5 231.9 229.6L235 232.8 238.1 235.9C255 219.2 277 208.7 300.6 206.1L300.1 201.7ZM235 232.8L231.9 229.6C213.6 247.7 201.9 271.4 198.7 297L203.1 297.5 207.5 298.1C210.4 274.5 221.2 252.6 238.1 235.9L235 232.8ZM203.1 297.5L198.7 297C183 431.9 183 568.1 198.7 703L203.1 702.5 207.5 702C191.8 567.8 191.8 432.2 207.5 298L203.1 297.5ZM203.1 702.5L198.7 703.1C201.9 728.6 213.6 752.3 231.9 770.4L235 767.2 238.1 764.1C221.2 747.4 210.4 725.5 207.5 702L203.1 702.5ZM235 767.2L231.9 770.4C250.2 788.4 274.1 799.8 299.7 802.6L300.1 798.3 300.6 793.9C277 791.2 255 780.7 238.1 764.1L235 767.2ZM300.1 798.3L299.7 802.6C431.7 817.4 568.3 817.4 700.3 802.6L699.8 798.3 699.4 793.9C568 808.5 432 808.5 300.6 793.9L300.1 798.3ZM699.8 798.3L700.3 802.6C725.9 799.8 749.8 788.4 768.1 770.4L764.9 767.2 761.8 764.1C744.9 780.7 722.9 791.2 699.4 793.9L699.8 798.3ZM764.9 767.2L768.1 770.4C786.4 752.3 798 728.6 801.2 703.1L796.8 702.5 792.4 702C789.5 725.5 778.7 747.4 761.8 764.1L764.9 767.2ZM796.8 702.5L801.2 703C817 568.1 817 431.9 801.2 297L796.8 297.5 792.4 298C808.1 432.2 808.1 567.8 792.4 702L796.8 702.5ZM796.8 297.5L801.2 297C798 271.4 786.3 247.7 768 229.7L764.9 232.8 761.8 236C778.7 252.6 789.5 274.5 792.4 298.1L796.8 297.5ZM764.9 232.8L768 229.7C749.7 211.6 725.9 200.3 700.3 197.4L699.8 201.8 699.4 206.2C722.9 208.8 744.9 219.3 761.8 236L764.9 232.8Z", "width": 1000 }, "search": [ - "favorite_stroke" + "add_to_album" ] }, { - "uid": "36519d6d85563c23f5c2b8b38ee57af9", - "css": "like_stroke", - "code": 59396, + "uid": "975277113eb9b94fedb4305035e69d0c", + "css": "lightning_icon", + "code": 59394, "src": "custom_icons", "selected": true, "svg": { - "path": "M828.1 378.5C828.1 292.8 766.3 227.1 683.8 227.1 641.4 227.1 595.8 239.7 528 307.5 512.5 322.9 487.5 322.9 472 307.5 404.2 239.7 358.7 227.1 316.2 227.1 233.7 227.1 172 292.8 172 378.5 172 443.1 214.7 517.8 278 590.5 339.7 661.5 414.6 723.4 465.3 761.3 486.1 776.9 513.9 776.9 534.8 761.3 585.4 723.4 660.3 661.6 722.1 590.5 785.3 517.8 828.1 443.1 828.1 378.5ZM907.2 378.5C907.2 473.5 847.3 567.2 781.8 642.5 714.9 719.4 635.2 785 582.2 824.7 533.2 861.3 466.8 861.3 417.9 824.7 364.9 785 285.1 719.4 218.2 642.5 152.8 567.2 92.8 473.5 92.8 378.5 92.8 253.3 185.8 147.9 316.2 147.9 375.9 148 433.1 166.7 500 225.2 566.9 166.7 624.2 147.9 683.8 147.9 814.2 148 907.2 253.3 907.2 378.5Z", - "width": 1000 + "path": "M0.8 566C-19.3 507.6 364.9-41.3 456 2.5 547.2 46.3 429.9 387.2 446 429.4 462.2 471.7 640.7 444.9 642.8 520.5 645 596.2 280.8 1017 186.4 999.5 92 981.9 202.4 662 196.6 620.8 190.7 579.6 20.8 624.4 0.8 566Z", + "width": 643 }, "search": [ - "like_stroke" + "lightning_icon" ] }, { - "uid": "9392d386fb35ebeedce0f6d13fa0aaee", - "css": "comment_bubble_stroke", - "code": 59392, + "uid": "c3b91fb7d2fa92a472374f66fe4fffdb", + "css": "favorite_filled", + "code": 59398, "src": "custom_icons", "selected": true, "svg": { - "path": "M357.3 155.5C425.5 127.3 500.5 119.9 572.8 134.3 645.1 148.7 711.6 184.2 763.7 236.4 815.9 288.5 851.4 354.9 865.8 427.3 880.2 499.6 872.8 574.6 844.6 642.7 816.3 710.9 768.6 769.1 707.2 810.1 645.9 851.1 573.8 873 500 873 443.6 873 390 860.4 341.9 837.8L174.7 872.1C161.6 874.8 148.1 870.8 138.7 861.4 129.3 852 125.3 838.4 127.9 825.4L162.3 658.1C139.7 610.1 127.1 556.5 127.1 500 127.1 426.3 149 354.2 190 292.8 231 231.5 289.2 183.7 357.3 155.5ZM206.3 500C206.3 548.7 218 594.4 238.9 634.7 243.1 642.7 244.3 652 242.5 660.8L217.5 782.5 339.2 757.6 345.9 756.8C352.7 756.5 359.4 758.1 365.4 761.2 405.7 782 451.4 793.8 500 793.8 558.1 793.8 614.9 776.6 663.2 744.3 711.6 712 749.2 666.1 771.4 612.5 793.7 558.8 799.5 499.7 788.1 442.7 776.8 385.8 748.8 333.4 707.8 292.3 666.7 251.2 614.3 223.3 557.3 212 500.4 200.6 441.3 206.4 387.6 228.6 333.9 250.9 288.1 288.5 255.8 336.8 223.5 385.1 206.3 441.9 206.3 500Z", + "path": "M424.3 150.3C459.9 81.1 556.7 80.8 594.4 146.5L594.5 146.8 679.1 295.4 847.9 323H848.2C923.6 335.5 955.8 426.7 904 483.7L903.9 483.8 788.6 610.2 814.5 779.1C826.3 855.1 748.9 914.2 678.6 882.1L523 811.7 370.2 888.6C301.5 923.2 222 867.6 230.5 791.4L249.4 621.3 129.1 499.8C74.9 445 103.2 352.3 178.3 336.8L345.9 302.2 424.3 150.3Z", "width": 1000 }, "search": [ - "comment_bubble_stroke" + "favorite_filled" ] }, { - "uid": "a6e3ec0a038d9ecea4859a7b17967ad4", - "css": "like_filled", - "code": 59393, + "uid": "fcb3fb8e3fae24571505c26554612a35", + "css": "like_stroke", + "code": 59401, "src": "custom_icons", "selected": true, "svg": { - "path": "M433.7 833.9C316.2 746 83.3 545 83.3 364.2 83.3 244.6 171 147.7 291.6 147.7 354.1 147.7 416.6 168.6 499.9 251.9 583.3 168.6 645.8 147.7 708.3 147.7 828.9 147.7 916.6 244.6 916.6 364.2 916.6 545 683.7 746 566.2 833.9 526.6 863.5 473.3 863.5 433.7 833.9Z", - "width": 1000 + "path": "M890 378.1C890 271.5 806.6 188.1 700 188.1 639.5 188.1 580.9 216.5 542.9 261.2 537.2 267.9 528.8 271.7 520 271.7 511.2 271.7 502.8 267.9 497.1 261.2 459.1 216.5 400.5 188.1 340 188.1 233.4 188.1 150 271.5 150 378.1 150 443.9 179.3 505.9 237 576.4 280.5 629.6 338.4 685.6 408.3 749.9L482.1 817.1 482.2 817.2 486.6 821.1C505.6 838.4 534.6 838.5 553.7 821.2L557.9 817.5C661.5 723.4 745 647.4 803 576.5 860.7 505.9 890 443.9 890 378.1ZM950 378.1C950 463.6 911.3 538.8 849.5 614.4 788 689.6 700.6 768.9 598.2 861.9L598.1 862 593.9 865.8C551.9 903.7 488 903.6 446.2 865.5L441.8 861.6 368 794.4C297.3 729.4 236.7 670.8 190.5 614.4 128.7 538.8 90 463.6 90 378.1 90 238.4 200.2 128.1 340 128.1 407.3 128.1 472 154.9 520 198.8 568 154.9 632.7 128.1 700 128.1 839.8 128.1 950 238.4 950 378.1Z", + "width": 1040 }, "search": [ - "like_filled" + "like_stroke" ] }, { - "uid": "9469665b7faa4f27bdbdd75ea725dcb5", - "css": "send_stroke", - "code": 59395, + "uid": "42e079e44c10c9243232979e43878ca0", + "css": "comment_bubble_stroke", + "code": 59402, "src": "custom_icons", "selected": true, "svg": { - "path": "M160.2 125.2C179.4 124 199.8 128 219.7 133.8 259.3 145.4 311.7 168.6 376.2 196.9L737 355.8C787.3 378 829.4 396.4 858.4 414.5 886.4 432 916.7 457.9 916.7 500 916.7 542.1 886.4 568.1 858.4 585.5 829.4 603.6 787.3 622 737 644.2L376.2 803.1C311.7 831.5 259.3 854.6 219.7 866.2 199.8 872 179.4 876 160.2 874.8 142.1 873.7 124.2 867.8 109.5 854.5L103.3 848.4C71.2 812.3 84.2 765.4 98.3 730.7 113.9 692.3 142.3 642.4 177.3 580.9L202.2 536.8 212.3 518.9C214.9 514.1 216.8 510.4 218.3 507 219.6 504.1 220.2 502.3 220.5 501.3 220.7 500.5 220.7 500.1 220.7 500 220.7 499.9 220.7 499.6 220.5 498.7 220.2 497.7 219.6 495.9 218.3 493 215.4 486.3 210.6 477.8 202.2 463.1L177.3 419.2 177.2 419.1C142.3 357.5 113.9 307.6 98.3 269.3 90.4 250 84.3 230.2 83.5 210.9 82.5 190.3 87.7 169.2 103.3 151.6L109.5 145.5C124.2 132.3 142.1 126.3 160.2 125.2ZM304 500C304 515.5 299.8 528.9 294.4 540.9 289.5 552.2 282.3 564.7 274.7 578L274.7 578 249.7 622 249.7 622C213.5 685.9 188.5 730 175.5 762.1 168.9 778.3 167.2 787.2 166.8 791.6 172.1 791.7 181.4 790.5 196.4 786.1 229.4 776.5 275.7 756.3 342.6 726.8L703.4 567.9C756.3 544.6 791.6 528.9 814.3 514.8 826.5 507.2 831.2 502.3 832.8 500 831.2 497.7 826.5 492.8 814.3 485.2 791.6 471.1 756.3 455.4 703.4 432.1L342.6 273.2C275.7 243.7 229.4 223.5 196.4 213.9 181.4 209.5 172.2 208.3 166.8 208.4 167.3 213.8 169.4 223.1 175.5 237.9 188.5 270 213.5 314.1 249.7 378L274.7 422C282.3 435.3 289.5 447.9 294.4 459.1 299.8 471.1 304 484.5 304 500Z", + "path": "M500 120C708.6 120 880 282.1 880 484.8 880 687.6 708.6 849.6 500 849.6 475.4 849.7 450.9 847.4 426.8 842.9 418.4 841.3 413.3 840.4 409.5 839.8 408.5 839.7 407.7 839.6 407.2 839.6 406.6 839.8 405.7 840.2 404.6 840.7 400.7 842.6 395.4 845.4 387.2 849.8 336.4 876.8 277 886.4 219.8 875.7 209.4 873.8 200.8 866.5 197.1 856.6 193.5 846.7 195.3 835.6 202 827.3 218 807.7 229 784 233.8 758.5L234.1 756.3C234.2 750.8 231.5 743.2 223.8 735.4 159.6 670.2 120 582.1 120 484.8 120 282.1 291.4 120 500 120ZM500 180C322 180 180 317.7 180 484.8 180 565.3 212.7 638.6 266.5 693.2 283.6 710.6 298.8 738.1 292.8 769.6L292.8 769.7C289.5 787 284.1 803.8 276.8 819.6 305.4 818.1 333.6 810.3 359 796.8 366.7 792.7 373.4 789.1 378.8 786.6 383.9 784.1 390.7 781.2 398 780.1 405.2 779 412.1 779.6 417.6 780.4 423.2 781.2 430.1 782.5 437.8 783.9 458.3 787.8 479.1 789.7 500 789.7H500C678 789.7 820 651.9 820 484.8 820 317.7 678 180 500 180Z", "width": 1000 }, "search": [ - "send_stroke" + "comment_bubble_stroke" ] }, { - "uid": "edb5fcf33b096715106c82a3e2e1be71", - "css": "reply", - "code": 59400, + "uid": "7c955dc3f2d48dde4f765cbb145242a5", + "css": "like_filled", + "code": 59403, "src": "custom_icons", "selected": true, "svg": { - "path": "M399.8 190.6C406.9 198 410.9 207.9 410.9 218.2 410.9 228.6 406.9 238.5 399.8 245.8L210.4 439.6H627.3C675.9 439.6 747.8 454.2 808.7 499.1 871.6 545.4 920.1 622.3 920.1 739.2 920.1 749.5 916.1 759.5 908.9 766.8 901.7 774.1 892 778.2 881.9 778.2 871.8 778.2 862.1 774.1 854.9 766.8 847.7 759.5 843.7 749.5 843.7 739.2 843.7 647.7 807.3 594.3 764.1 562.5 719 529.2 663.6 517.8 627.3 517.8H210.4L399.8 711.5C403.5 715.1 406.5 719.4 408.6 724.2 410.7 729 411.8 734.2 411.9 739.4 412 744.7 411.1 749.9 409.1 754.8 407.2 759.6 404.4 764 400.7 767.8 397.1 771.5 392.8 774.4 388 776.4 383.3 778.3 378.2 779.3 373.1 779.2 367.9 779.1 362.9 778 358.2 775.8 353.5 773.7 349.3 770.6 345.8 766.8L91.2 506.3C84.1 499 80.1 489 80.1 478.7 80.1 468.3 84.1 458.4 91.2 451.1L345.8 190.6C353 183.3 362.7 179.2 372.8 179.2 382.9 179.2 392.6 183.3 399.8 190.6Z", - "width": 1000 + "path": "M700 128.1C839.8 128.1 950 238.4 950 378.1 950 463.6 911.3 538.8 849.5 614.4 788 689.6 700.6 768.9 598.2 861.9L598.1 862 593.9 865.8C551.9 903.7 488 903.6 446.2 865.5L441.8 861.6 368 794.4C297.3 729.4 236.7 670.8 190.5 614.4 128.7 538.8 90 463.6 90 378.1 90 238.4 200.2 128.1 340 128.1 407.3 128.1 472 154.9 520 198.8 568 154.9 632.7 128.1 700 128.1Z", + "width": 1040 }, "search": [ - "reply" + "like_filled" ] } ] diff --git a/mobile/packages/ente_icons/fonts/EnteIcons.ttf b/mobile/packages/ente_icons/fonts/EnteIcons.ttf index e1189f014a03c6d0fc2155303457255879fe3747..b4bdefb02599040bf75b0e27a8dd9dc1134e6e66 100644 GIT binary patch delta 1048 zcmaJ<&rcIU6n?Wa-Q9Nk3sbf=EzmB|LR*k-+pW=pTGCi0dO&G80SzFaMTq5~9;^pF z$w5=k#zg-EqKOHfjEOPED0)yyj0a=(;K2hPjGFqUjnRwF=G&R~eKYgE_jWIbo`tT5 zYJEr60LVH36la&qt1p*&GXR1eIdh?M;ndq(TMq#k#`2HF`SRS5ZQ%~?{eUnQk&p~} z1=pL1p2g+0^S$32-|*~5jQc7pvt{RuQURbl7^}h9$ zl@8tyF)T*+ zqWPxZZY>gy5%ldOSQr+PPBop^Qn)s}8uHCby{eQDC8JfW;Z1tgWJ1TPQ&=Y1S(q&8 zsgz!tT|O+S!3Jja!6w10hkf2H5GOI5vo>R}cl5<-}W{N45b zDF2s!f3_g6vlHF_=d%c#*rUI?{-^2Pyz9ai_Wz%Kg^f-7D6NOw{(8Zkv@4aRGxO8c QwR0{Jb3UGy%?f#O}u*WLY=MUp!?^W|DXA1=KFSbUvt;Fb*|_? zJ_|r@0zfj8PcQx$>)8dMOc?tzJ-2+}<<`a)0K)+2wr8^G^C83SlbHJj5ud?;Hcp?x z^K*oECV#o$dsP07wO`Q}<`yz(V@Q7wfc}ZTJ)bTtl5S$ddk6ZS`E)+Jai8T)SWTN2 z7cO0XpP{Y-n0N5@DG-c~mvd;9jUA7RU%T^U%G~n{jI@IH@a=~1ZhtQ^WR1Kkt&<}( zD7w-c;wY*~DyY@+@BQ=tJCgk!E~5q2 zqK}nmDa>WCBMVyCT%cT}h~bJ>wW12l0;LL-S*b$pp;V!&QmVjipj3f5DJQ1hs z8|-G%OX3aoNErQuOhRMYo83|663=U4Rtjv#kY8xw=_aUc1#G#MART>F5*c(tG?rvhMM};g; z9!OIDvku!+`=N0<= zP0sMpqQ!5ybS&&_8oPF4$(Qmiow(NB8S52;qTJDewl~(zanM|iAx}t~0!un*&=}b+o7v!AVW33GaYpou4ZgW?o)okhy^wFgU7QQrOacYcn Pvlp|aDNDEdZv*@R_MFQK diff --git a/mobile/packages/ente_icons/lib/src/ente_icons.dart b/mobile/packages/ente_icons/lib/src/ente_icons.dart index 5b2891cc012..3f8173aad5a 100644 --- a/mobile/packages/ente_icons/lib/src/ente_icons.dart +++ b/mobile/packages/ente_icons/lib/src/ente_icons.dart @@ -7,12 +7,12 @@ class EnteIcons { static const String _kFontPkg = 'ente_icons'; static const IconData commentBubbleStroke = IconData( - 0xe800, + 0xe80a, fontFamily: _kFontFam, fontPackage: _kFontPkg, ); static const IconData likeFilled = IconData( - 0xe801, + 0xe80b, fontFamily: _kFontFam, fontPackage: _kFontPkg, ); @@ -27,7 +27,7 @@ class EnteIcons { fontPackage: _kFontPkg, ); static const IconData likeStroke = IconData( - 0xe804, + 0xe809, fontFamily: _kFontFam, fontPackage: _kFontPkg, ); From 505a66b8108bab2cbbd82b5aa690092062e37cc9 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 18:08:11 +0530 Subject: [PATCH 55/91] Refine social overlay shadows --- mobile/apps/photos/changes/align-social-icon-sizing.md | 2 +- .../photos/lib/ui/social/widgets/file_social_overlay.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/apps/photos/changes/align-social-icon-sizing.md b/mobile/apps/photos/changes/align-social-icon-sizing.md index aadcae882af..ea953751cb4 100644 --- a/mobile/apps/photos/changes/align-social-icon-sizing.md +++ b/mobile/apps/photos/changes/align-social-icon-sizing.md @@ -1 +1 @@ -- Align social controls and comment badges with the memories viewer design. +- Align social controls and comment badge sizing and positioning with the memories viewer design. diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 13cc02a0db4..083a6d3dcea 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -27,9 +27,9 @@ const _likedColor = Color(0xFF08C225); const _socialControlsSize = 48.0; const _socialIconSize = 24.0; const _socialIconPadding = EdgeInsets.all(12); -const _socialIconShadows = [Shadow(color: Color(0x33000000), blurRadius: 16)]; +const _socialIconShadows = [Shadow(color: Color(0x26000000), blurRadius: 12)]; const _countBadgeShadows = [ - BoxShadow(color: Color(0x33000000), blurRadius: 16), + BoxShadow(color: Color(0x1F000000), blurRadius: 4), ]; const _latestCommentShadows = [ BoxShadow(color: Color(0x14000000), blurRadius: 4), @@ -635,8 +635,8 @@ class _CommentBadgeIcon extends StatelessWidget { shadows: _socialIconShadows, ), Positioned( - right: -5, - top: -5, + right: -7, + top: -7, child: AnimatedSwitcher( duration: _motionDuration(context, 160), reverseDuration: _motionDuration(context, 120), From 05be462d35f4c8b3f1b3f24b24b32977439957f1 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 21 Jul 2026 18:24:49 +0530 Subject: [PATCH 56/91] Balance social icon sizing --- .../photos/changes/balance-social-icon-sizes.md | 1 + .../ui/social/widgets/file_social_overlay.dart | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 mobile/apps/photos/changes/balance-social-icon-sizes.md diff --git a/mobile/apps/photos/changes/balance-social-icon-sizes.md b/mobile/apps/photos/changes/balance-social-icon-sizes.md new file mode 100644 index 00000000000..b71221ab0d7 --- /dev/null +++ b/mobile/apps/photos/changes/balance-social-icon-sizes.md @@ -0,0 +1 @@ +- Improve the visual balance of the like and comment controls. diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index 083a6d3dcea..c36e9112488 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -25,8 +25,12 @@ final _logger = Logger("FileSocialOverlay"); const _likedColor = Color(0xFF08C225); const _socialControlsSize = 48.0; -const _socialIconSize = 24.0; -const _socialIconPadding = EdgeInsets.all(12); +// TODO: Restore both icons to 24px after updating their ente_icons assets to +// follow HugeIcons sizing guidelines. +const _likeIconSize = 25.0; +const _commentIconSize = 26.0; +const _likeIconPadding = EdgeInsets.all(11.5); +const _commentIconPadding = EdgeInsets.all(11); const _socialIconShadows = [Shadow(color: Color(0x26000000), blurRadius: 12)]; const _countBadgeShadows = [ BoxShadow(color: Color(0x1F000000), blurRadius: 4), @@ -454,7 +458,7 @@ class _FileSocialOverlayState extends State { child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: _socialIconPadding, + padding: _likeIconPadding, style: IconButton.styleFrom( overlayColor: WidgetStateColor.transparent, ), @@ -462,7 +466,7 @@ class _FileSocialOverlayState extends State { icon: Icon( _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, color: _hasLiked ? _likedColor : Colors.white, - size: _socialIconSize, + size: _likeIconSize, shadows: _socialIconShadows, ), ), @@ -475,7 +479,7 @@ class _FileSocialOverlayState extends State { child: SizedBox.square( dimension: _socialControlsSize, child: IconButton( - padding: _socialIconPadding, + padding: _commentIconPadding, onPressed: _openComments, icon: _CommentBadgeIcon(count: _commentCount), ), @@ -631,7 +635,7 @@ class _CommentBadgeIcon extends StatelessWidget { const Icon( EnteIcons.commentBubbleStroke, color: Colors.white, - size: _socialIconSize, + size: _commentIconSize, shadows: _socialIconShadows, ), Positioned( From 21b10b4f0dc7bf32691782832fb9c82350e456db Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Tue, 21 Jul 2026 18:09:48 +0530 Subject: [PATCH 57/91] Harden CLI export and passkey handling --- rust/apps/cli/src/commands/account.rs | 30 ++++- rust/apps/cli/src/commands/export.rs | 112 ++++-------------- rust/apps/cli/src/lib.rs | 1 + rust/apps/cli/src/live_photo.rs | 164 ++++++++++++++++++++++++++ rust/apps/cli/src/sync/download.rs | 79 +------------ 5 files changed, 218 insertions(+), 168 deletions(-) create mode 100644 rust/apps/cli/src/live_photo.rs diff --git a/rust/apps/cli/src/commands/account.rs b/rust/apps/cli/src/commands/account.rs index 385002983c0..0b81d857d69 100644 --- a/rust/apps/cli/src/commands/account.rs +++ b/rust/apps/cli/src/commands/account.rs @@ -187,12 +187,13 @@ impl AuthFlowUi for DialoguerAuthFlowUi { println!("\nPasskey verification required"); println!("Open this URL in your browser to verify your passkey:\n{url}"); - if !self.passkey_presented { - if let Err(error) = open::that(url) { - log::error!("failed to open browser: {error}"); - } - self.passkey_presented = true; + if !self.passkey_presented + && can_open_automatically(url) + && let Err(error) = open::that(url) + { + log::error!("failed to open browser: {error}"); } + self.passkey_presented = true; Ok(()) } @@ -588,6 +589,12 @@ fn is_retryable_password_error(error: &AccountsError) -> bool { ) || error.is_http_status(&[401]) } +fn can_open_automatically(url: &str) -> bool { + url.split_once("://").is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http") + }) +} + fn read_six_digit_code(prompt: &str) -> Result { Input::new() .with_prompt(prompt) @@ -605,3 +612,16 @@ fn read_six_digit_code(prompt: &str) -> Result { fn read_six_digit_code_for_accounts(prompt: &str) -> AccountsResult { read_six_digit_code(prompt).map_err(|error| AccountsError::InvalidInput(error.to_string())) } + +#[cfg(test)] +mod tests { + use super::can_open_automatically; + + #[test] + fn automatic_opening_is_limited_to_web_urls() { + assert!(can_open_automatically("https://accounts.ente.io")); + assert!(can_open_automatically("HTTP://localhost:3000")); + assert!(!can_open_automatically("ente-cli://passkey")); + assert!(!can_open_automatically("file:///etc/hosts")); + } +} diff --git a/rust/apps/cli/src/commands/export.rs b/rust/apps/cli/src/commands/export.rs index 5c0ece84447..36626f39da1 100644 --- a/rust/apps/cli/src/commands/export.rs +++ b/rust/apps/cli/src/commands/export.rs @@ -1,6 +1,7 @@ use crate::Result; use crate::api::AppClient; use crate::api::methods::ApiMethods; +use crate::live_photo::extract_live_photo; use crate::models::{ account::Account, export_metadata::{AlbumMetadata, DiskFileMetadata}, @@ -12,7 +13,6 @@ use crate::sync::SyncEngine; use base64::Engine; use ente_core::crypto; use std::collections::HashMap; -use std::io::Cursor; use std::path::{Path, PathBuf}; use tokio::fs; use tokio::io::AsyncWriteExt; @@ -80,7 +80,9 @@ async fn load_album_metadata( // Check if the actual file exists for filename in &disk_metadata.info.file_names { - let file_path = export_path.join(album_name).join(filename); + let file_path = export_path + .join(album_name) + .join(sanitize_filename(filename)); if file_path.exists() { existing_files.insert( disk_metadata.info.id, @@ -1027,31 +1029,42 @@ fn decrypt_file_key(encrypted_key: &str, nonce: &str, collection_key: &[u8]) -> // Removed generate_fallback_filename - Go CLI panics if no title, we return error instead +/// Reduce a sanitized name to a single path component, never `.`, `..`, or empty. +fn safe_path_component(name: String) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + "_".to_string() + } else { + trimmed.to_string() + } +} + /// Sanitize a filename for the filesystem fn sanitize_filename(name: &str) -> String { - name.chars() + let sanitized = name + .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', '\0' => '_', c if c.is_control() => '_', c => c, }) - .collect::() - .trim() - .to_string() + .collect::(); + safe_path_component(sanitized) } -/// Sanitize an album/collection name for filesystem (matching Go CLI's logic) +/// Sanitize an album/collection name into a safe single path component fn sanitize_album_name(name: &str) -> String { - // Go CLI replaces : and / with _ in album names - name.chars() + let sanitized = name + .chars() .map(|c| match c { - ':' | '/' => '_', + '/' | '\\' | ':' => '_', + '\0' => '_', + c if c.is_control() => '_', c => c, }) - .collect::() - .trim() - .to_string() + .collect::(); + safe_path_component(sanitized) } /// Decrypt file metadata @@ -1109,79 +1122,6 @@ fn decrypt_magic_metadata( Ok(Some(metadata)) } -/// Extract live photo components from a ZIP file -async fn extract_live_photo(zip_data: &[u8], output_path: &Path) -> Result<()> { - use zip::ZipArchive; - - // Parse the ZIP archive - let cursor = Cursor::new(zip_data); - let mut archive = ZipArchive::new(cursor)?; - - // Get the parent directory and base name - let parent_dir = output_path - .parent() - .ok_or_else(|| crate::Error::Generic("Invalid output path".into()))?; - - let base_name = output_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| crate::Error::Generic("Invalid filename".into()))?; - - // Extract each file from the ZIP - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let file_name = file.name().to_string(); - - // Determine the output filename preserving original extension - // Following Go CLI's approach: use the actual extension from the file in the ZIP - let output_file_path = if file_name.to_lowercase().contains("image") { - // Image component - preserve its original extension - let ext = std::path::Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| { - crate::Error::Generic(format!( - "Live photo image component has no extension: {}", - file_name - )) - })?; - parent_dir.join(format!("{}.{}", base_name, ext)) - } else if file_name.to_lowercase().contains("video") { - // Video component - preserve its original extension - let ext = std::path::Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| { - crate::Error::Generic(format!( - "Live photo video component has no extension: {}", - file_name - )) - })?; - parent_dir.join(format!("{}.{}", base_name, ext)) - } else { - // Go CLI returns error for unexpected files in live photo ZIP - return Err(crate::Error::Generic(format!( - "Unexpected file in live photo ZIP: {}", - file_name - ))); - }; - - // Read the file contents - let mut contents = Vec::new(); - use std::io::Read; - file.read_to_end(&mut contents)?; - - // Write to disk - let mut output_file = fs::File::create(&output_file_path).await?; - output_file.write_all(&contents).await?; - output_file.sync_all().await?; - - log::debug!("Extracted live photo component: {:?}", output_file_path); - } - - Ok(()) -} - /// Check if a collection is hidden based on its metadata fn check_collection_visibility( collection: &crate::api::models::Collection, diff --git a/rust/apps/cli/src/lib.rs b/rust/apps/cli/src/lib.rs index 16669ca82c7..161733f46d3 100644 --- a/rust/apps/cli/src/lib.rs +++ b/rust/apps/cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod auth_flow; pub mod cli; pub mod commands; +mod live_photo; pub mod models; pub mod storage; pub mod sync; diff --git a/rust/apps/cli/src/live_photo.rs b/rust/apps/cli/src/live_photo.rs new file mode 100644 index 00000000000..666ddbeca2a --- /dev/null +++ b/rust/apps/cli/src/live_photo.rs @@ -0,0 +1,164 @@ +use std::io::{Cursor, Read}; +use std::path::Path; + +use tokio::fs; +use tokio::io::AsyncWriteExt; +use zip::ZipArchive; + +use crate::{Error, Result}; + +const MAX_EXPANDED_RATIO: u64 = 20; +const MAX_EXPANDED_OVERHEAD: u64 = 16 * 1024 * 1024; + +struct Entry { + index: usize, + extension: String, + size: u64, +} + +pub(crate) async fn extract_live_photo(zip_data: &[u8], output_path: &Path) -> Result<()> { + let mut archive = ZipArchive::new(Cursor::new(zip_data))?; + if archive.len() != 2 { + return Err(Error::Generic( + "Live photo ZIP must contain one image and one video".into(), + )); + } + + let mut image = None; + let mut video = None; + for index in 0..archive.len() { + let file = archive.by_index(index)?; + let (kind, extension) = parse_entry_name(file.name())?; + let entry = Entry { + index, + extension: extension.to_string(), + size: file.size(), + }; + let slot = if kind == "image" { + &mut image + } else { + &mut video + }; + if slot.replace(entry).is_some() { + return Err(Error::Generic(format!( + "Live photo ZIP contains multiple {kind} entries" + ))); + } + } + + let image = image.ok_or_else(|| Error::Generic("Live photo ZIP has no image".into()))?; + let video = video.ok_or_else(|| Error::Generic("Live photo ZIP has no video".into()))?; + let max_expanded = zip_data.len() as u64 * MAX_EXPANDED_RATIO + MAX_EXPANDED_OVERHEAD; + if image.size + video.size > max_expanded { + return Err(Error::Generic("Live photo ZIP expands beyond limit".into())); + } + + let parent = output_path + .parent() + .ok_or_else(|| Error::Generic("Invalid output path".into()))?; + let base = output_path + .file_stem() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Generic("Invalid filename".into()))?; + let mut expanded = 0; + for entry in [image, video] { + let mut input = archive.by_index(entry.index)?; + let path = parent.join(format!("{base}.{}", entry.extension)); + let mut output = fs::File::create(&path).await?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = input.read(&mut buffer)?; + if count == 0 { + break; + } + expanded += count as u64; + if expanded > max_expanded { + return Err(Error::Generic("Live photo ZIP expands beyond limit".into())); + } + output.write_all(&buffer[..count]).await?; + } + output.sync_all().await?; + log::debug!("Extracted live photo component: {path:?}"); + } + Ok(()) +} + +fn parse_entry_name(name: &str) -> Result<(&str, &str)> { + let (kind, extension) = name + .split_once('.') + .ok_or_else(|| Error::Generic(format!("Invalid live photo entry: {name}")))?; + if !matches!(kind, "image" | "video") + || extension.is_empty() + || extension.len() > 16 + || !extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err(Error::Generic(format!("Invalid live photo entry: {name}"))); + } + Ok((kind, extension)) +} + +#[cfg(test)] +mod tests { + use std::io::{Cursor, Write}; + use std::path::Path; + + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + + use super::{extract_live_photo, parse_entry_name}; + + fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + for (name, data) in entries { + writer + .start_file( + *name, + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated), + ) + .unwrap(); + writer.write_all(data).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + #[test] + fn validates_entry_names() { + assert_eq!(parse_entry_name("image.heic").unwrap(), ("image", "heic")); + assert_eq!(parse_entry_name("video.mov").unwrap(), ("video", "mov")); + assert!(parse_entry_name("thumbnail.jpg").is_err()); + assert!(parse_entry_name("image.jpg.exe").is_err()); + assert!(parse_entry_name("video../clip").is_err()); + } + + #[tokio::test] + async fn extracts_image_and_video() { + let data = archive(&[("image.jpg", b"image"), ("video.mov", b"video")]); + let directory = + std::env::temp_dir().join(format!("ente-live-photo-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + + extract_live_photo(&data, &directory.join("photo.zip")) + .await + .unwrap(); + assert_eq!( + std::fs::read(directory.join("photo.jpg")).unwrap(), + b"image" + ); + assert_eq!( + std::fs::read(directory.join("photo.mov")).unwrap(), + b"video" + ); + + std::fs::remove_dir_all(directory).unwrap(); + } + + #[tokio::test] + async fn rejects_excessive_expansion() { + let oversized = vec![0; super::MAX_EXPANDED_OVERHEAD as usize + 1024 * 1024]; + let data = archive(&[("image.jpg", &oversized), ("video.mov", b"video")]); + assert!( + extract_live_photo(&data, Path::new("photo.zip")) + .await + .is_err() + ); + } +} diff --git a/rust/apps/cli/src/sync/download.rs b/rust/apps/cli/src/sync/download.rs index be1df4d1e4f..4b92f6e7617 100644 --- a/rust/apps/cli/src/sync/download.rs +++ b/rust/apps/cli/src/sync/download.rs @@ -1,12 +1,12 @@ use crate::Result; use crate::api::client::AppClient; use crate::api::methods::ApiMethods; +use crate::live_photo::extract_live_photo; use crate::models::file::RemoteFile; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use ente_core::crypto; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::collections::HashMap; -use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; @@ -100,7 +100,7 @@ impl DownloadManager { if is_live_photo { // Extract live photo components - if let Err(e) = self.extract_live_photo(&decrypted_data, destination).await { + if let Err(e) = extract_live_photo(&decrypted_data, destination).await { log::warn!( "Failed to extract live photo components for file {}, saving as ZIP: {}", file.id, @@ -328,78 +328,3 @@ impl DownloadStats { } } } - -impl DownloadManager { - /// Extract live photo components from a ZIP file - async fn extract_live_photo(&self, zip_data: &[u8], output_path: &Path) -> Result<()> { - use std::io::Read; - use zip::ZipArchive; - - // Parse the ZIP archive - let cursor = Cursor::new(zip_data); - let mut archive = ZipArchive::new(cursor)?; - - // Get the parent directory and base name - let parent_dir = output_path - .parent() - .ok_or_else(|| crate::Error::Generic("Invalid output path".into()))?; - - let base_name = output_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| crate::Error::Generic("Invalid filename".into()))?; - - // Extract each file from the ZIP - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let file_name = file.name().to_string(); - - // Determine the output filename based on the content - // Match Go CLI behavior: check for "image" or "video" in filename - let output_file_path = if file_name.to_lowercase().contains("image") { - // Image component - preserve original extension - let ext = std::path::Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| { - crate::Error::Generic(format!( - "Live photo image component has no extension: {}", - file_name - )) - })?; - parent_dir.join(format!("{}.{}", base_name, ext)) - } else if file_name.to_lowercase().contains("video") { - // Video component - preserve original extension - let ext = std::path::Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| { - crate::Error::Generic(format!( - "Live photo video component has no extension: {}", - file_name - )) - })?; - parent_dir.join(format!("{}.{}", base_name, ext)) - } else { - // Go CLI returns error for unexpected files in live photo ZIP - return Err(crate::Error::Generic(format!( - "Unexpected file in live photo ZIP: {}", - file_name - ))); - }; - - // Read the file contents - let mut contents = Vec::new(); - file.read_to_end(&mut contents)?; - - // Write to disk - let mut output_file = fs::File::create(&output_file_path).await?; - output_file.write_all(&contents).await?; - output_file.sync_all().await?; - - log::info!("Extracted live photo component: {:?}", output_file_path); - } - - Ok(()) - } -} From 35a88092b75b40d0ae179ac350fc4311394c62b6 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Tue, 21 Jul 2026 20:38:54 +0530 Subject: [PATCH 58/91] Guard date picker bounds --- .../apps/photos/lib/ui/common/date_input.dart | 14 +++-- .../test/ui/common/date_input_test.dart | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/mobile/apps/photos/lib/ui/common/date_input.dart b/mobile/apps/photos/lib/ui/common/date_input.dart index a8592366e7d..36569ab7a18 100644 --- a/mobile/apps/photos/lib/ui/common/date_input.dart +++ b/mobile/apps/photos/lib/ui/common/date_input.dart @@ -104,12 +104,20 @@ class _DatePickerFieldState extends State { Future _showDatePicker() async { final locale = await getFormatLocale(); if (!mounted) return; + final firstDate = widget.firstDate ?? DateTime(1900); + final lastDate = widget.lastDate ?? DateTime(2100); + final selectedDate = _selectedDate ?? DateTime.now(); + final initialDate = selectedDate.isBefore(firstDate) + ? firstDate + : selectedDate.isAfter(lastDate) + ? lastDate + : selectedDate; final picked = await showDatePicker( context: context, locale: locale, - initialDate: _selectedDate ?? DateTime.now(), - firstDate: widget.firstDate ?? DateTime(1900), - lastDate: widget.lastDate ?? DateTime(2100), + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, ); if (picked != null && mounted) { _controller.text = _formatDate(picked); diff --git a/mobile/apps/photos/test/ui/common/date_input_test.dart b/mobile/apps/photos/test/ui/common/date_input_test.dart index 12101a1fda5..fec6155241f 100644 --- a/mobile/apps/photos/test/ui/common/date_input_test.dart +++ b/mobile/apps/photos/test/ui/common/date_input_test.dart @@ -2,8 +2,13 @@ import "package:ente_components/ente_components.dart"; import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; import "package:photos/ui/common/date_input.dart"; +import "package:shared_preferences/shared_preferences.dart"; void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('supports typed optional dates', (tester) async { DateTime? selectedDate; bool? isValid; @@ -42,4 +47,51 @@ void main() { expect(isValid, isTrue); expect(find.text('MM/DD/YYYY'), findsNothing); }); + + testWidgets('opens with a stale maximum date', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ComponentTheme.lightTheme(), + home: Scaffold( + body: DatePickerField( + firstDate: DateTime(100), + lastDate: DateUtils.dateOnly( + DateTime.now().subtract(const Duration(days: 1)), + ), + isRequired: false, + ), + ), + ), + ); + + final input = find.byType(TextInputComponent); + final inputRect = tester.getRect(input); + await tester.tapAt(Offset(inputRect.right - 24, inputRect.center.dy)); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsOneWidget); + }); + + testWidgets('opens with an out-of-range initial date', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ComponentTheme.lightTheme(), + home: Scaffold( + body: DatePickerField( + initialValue: '2200-01-01', + firstDate: DateTime(100), + lastDate: DateTime.now(), + isRequired: false, + ), + ), + ), + ); + + final input = find.byType(TextInputComponent); + final inputRect = tester.getRect(input); + await tester.tapAt(Offset(inputRect.right - 24, inputRect.center.dy)); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsOneWidget); + }); } From bd539340bad55666208fc802459424f765229466 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Tue, 21 Jul 2026 20:49:59 +0530 Subject: [PATCH 59/91] Improve account deletion recovery --- server/cmd/museum/main.go | 2 + server/ente/jwt/jwt.go | 9 +- server/ente/user.go | 15 + server/pkg/api/user.go | 28 ++ .../pkg/controller/user/account_recovery.go | 199 ++++++++++++ server/pkg/controller/user/jwt.go | 13 +- server/pkg/controller/user/user.go | 93 ------ .../pkg/controller/user/user_recovery_test.go | 298 ++++++++++++++++++ server/pkg/middleware/event_test.go | 15 + server/pkg/middleware/rate_limit.go | 2 + server/pkg/middleware/request_logger.go | 3 + .../email/storage_warning_template_test.go | 2 +- web/apps/accounts/src/pages/family.tsx | 2 +- .../accounts/src/pages/recover-account.tsx | 228 ++++++++++++++ .../accounts/src/services/account-recovery.ts | 50 +++ .../base/locales/en-US/translation.json | 12 + 16 files changed, 871 insertions(+), 100 deletions(-) create mode 100644 server/pkg/controller/user/account_recovery.go create mode 100644 web/apps/accounts/src/pages/recover-account.tsx create mode 100644 web/apps/accounts/src/services/account-recovery.ts diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 21b91d97d4b..7eefa403993 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -717,6 +717,8 @@ func main() { privateAPI.GET("/users/delete-challenge", userHandler.GetDeleteChallenge) privateAPI.DELETE("/users/delete", userHandler.DeleteUser) publicAPI.GET("/users/recover-account", userHandler.SelfAccountRecovery) + publicAPI.POST("/users/recover-account/validate", userHandler.ValidateSelfAccountRecovery) + publicAPI.POST("/users/recover-account", userHandler.RecoverSelfAccount) accountsJwtAuthAPI := server.Group("/") accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index fc53a84bcd0..adbae414724 100644 --- a/server/ente/jwt/jwt.go +++ b/server/ente/jwt/jwt.go @@ -2,9 +2,12 @@ package jwt import ( "errors" + "github.com/ente/museum/pkg/utils/time" ) +var ErrTokenExpired = errors.New("token expired") + type ClaimScope string const ( @@ -35,7 +38,7 @@ func (w *WebCommonJWTClaim) GetScope() ClaimScope { func (w WebCommonJWTClaim) Valid() error { if w.ExpiryTime < time.Microseconds() { - return errors.New("token expired") + return ErrTokenExpired } return nil } @@ -48,7 +51,7 @@ type LinkPasswordClaim struct { func (c LinkPasswordClaim) Valid() error { if c.ExpiryTime < time.Microseconds() { - return errors.New("token expired") + return ErrTokenExpired } return nil } @@ -62,7 +65,7 @@ type PasteGuardClaim struct { func (c PasteGuardClaim) Valid() error { if c.ExpiryTime < time.Microseconds() { - return errors.New("token expired") + return ErrTokenExpired } return nil } diff --git a/server/ente/user.go b/server/ente/user.go index 4b5178aec0d..91cabfaa8e7 100644 --- a/server/ente/user.go +++ b/server/ente/user.go @@ -179,6 +179,21 @@ type DeleteAccountResponse struct { UserID int64 `json:"userID"` } +type AccountRecoveryRequest struct { + Token string `json:"token" binding:"required"` +} + +type AccountRecoveryStatus string + +const ( + AccountRecoveryReady AccountRecoveryStatus = "ready" + AccountRecoveryRecovered AccountRecoveryStatus = "recovered" +) + +type AccountRecoveryResponse struct { + Status AccountRecoveryStatus `json:"status"` +} + // TwoFactorSecret represents the two factor secret generator value, user enters in his authenticator app type TwoFactorSecret struct { SecretCode string `json:"secretCode"` diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index 14b03556544..c8609c75877 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -584,6 +584,34 @@ func (h *UserHandler) SelfAccountRecovery(c *gin.Context) { c.HTML(http.StatusOK, "account_recovered.html", gin.H{}) } +func (h *UserHandler) ValidateSelfAccountRecovery(c *gin.Context) { + var request ente.AccountRecoveryRequest + if err := handler.BindJSON(c, &request); err != nil { + handler.Error(c, stacktrace.Propagate(err, "Could not bind request params")) + return + } + response, err := h.UserController.ValidateSelfAccountRecovery(request.Token) + if err != nil { + handler.Error(c, err) + return + } + c.JSON(http.StatusOK, response) +} + +func (h *UserHandler) RecoverSelfAccount(c *gin.Context) { + var request ente.AccountRecoveryRequest + if err := handler.BindJSON(c, &request); err != nil { + handler.Error(c, stacktrace.Propagate(err, "Could not bind request params")) + return + } + response, err := h.UserController.RecoverSelfAccount(c, request.Token) + if err != nil { + handler.Error(c, err) + return + } + c.JSON(http.StatusOK, response) +} + // GetSRPAttributes returns the SRP attributes for a user func (h *UserHandler) GetSRPAttributes(c *gin.Context) { var request ente.GetSRPAttributesRequest diff --git a/server/pkg/controller/user/account_recovery.go b/server/pkg/controller/user/account_recovery.go new file mode 100644 index 00000000000..37d0c768be2 --- /dev/null +++ b/server/pkg/controller/user/account_recovery.go @@ -0,0 +1,199 @@ +package user + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/ente/museum/ente" + enteJWT "github.com/ente/museum/ente/jwt" + "github.com/ente/museum/pkg/utils/crypto" + "github.com/ente/museum/pkg/utils/email" + "github.com/ente/museum/pkg/utils/time" + "github.com/ente/stacktrace" + "github.com/gin-gonic/gin" + "github.com/lib/pq" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +const ( + accountRecoveryLinkValidityDays = 7 + accountRecoveryInvalidLinkCode ente.ErrorCode = "ACCOUNT_RECOVERY_INVALID_LINK" + accountRecoveryLinkExpiredCode ente.ErrorCode = "ACCOUNT_RECOVERY_LINK_EXPIRED" + accountRecoveryEmailInUseCode ente.ErrorCode = "ACCOUNT_RECOVERY_EMAIL_IN_USE" + accountRecoveryUnavailableCode ente.ErrorCode = "ACCOUNT_RECOVERY_UNAVAILABLE" +) + +var ( + ErrAccountRecoveryInvalidLink = &ente.ApiError{ + Code: accountRecoveryInvalidLinkCode, + Message: "invalid account recovery link", + HttpStatusCode: http.StatusBadRequest, + } + ErrAccountRecoveryLinkExpired = &ente.ApiError{ + Code: accountRecoveryLinkExpiredCode, + Message: "expired account recovery link", + HttpStatusCode: http.StatusGone, + } + ErrAccountRecoveryEmailInUse = &ente.ApiError{ + Code: accountRecoveryEmailInUseCode, + Message: "account recovery email already in use", + HttpStatusCode: http.StatusConflict, + } + ErrAccountRecoveryUnavailable = &ente.ApiError{ + Code: accountRecoveryUnavailableCode, + Message: "account can no longer be recovered", + HttpStatusCode: http.StatusGone, + } +) + +func (c *UserController) getAccountRecoveryLink(userID int64, userEmail string) (string, error) { + recoverToken, err := c.GetJWTTokenForClaim(&enteJWT.WebCommonJWTClaim{ + UserID: userID, + ExpiryTime: time.MicrosecondsAfterDays(accountRecoveryLinkValidityDays), + ClaimScope: enteJWT.RestoreAccount.Ptr(), + Email: userEmail, + }) + if err != nil { + return "", err + } + accountsURL := strings.TrimRight(viper.GetString("apps.accounts"), "/") + return fmt.Sprintf("%s/recover-account#recoveryToken=%s", accountsURL, url.QueryEscape(recoverToken)), nil +} + +func (c *UserController) ValidateSelfAccountRecovery(token string) (ente.AccountRecoveryResponse, error) { + req, err := c.accountRecoveryRequest(token) + if err != nil { + return ente.AccountRecoveryResponse{}, err + } + status, err := c.accountRecoveryStatus(req, true) + if err != nil { + return ente.AccountRecoveryResponse{}, err + } + return ente.AccountRecoveryResponse{Status: status}, nil +} + +func (c *UserController) RecoverSelfAccount(ctx *gin.Context, token string) (ente.AccountRecoveryResponse, error) { + req, err := c.accountRecoveryRequest(token) + if err != nil { + return ente.AccountRecoveryResponse{}, err + } + status, err := c.recoverAccount(ctx, req, true) + if err != nil { + return ente.AccountRecoveryResponse{}, err + } + return ente.AccountRecoveryResponse{Status: status}, nil +} + +// HandleSelfAccountRecovery keeps the legacy recovery link behavior until all +// previously issued links have expired. +func (c *UserController) HandleSelfAccountRecovery(ctx *gin.Context, token string) error { + _, err := c.RecoverSelfAccount(ctx, token) + return err +} + +func (c *UserController) accountRecoveryRequest(token string) (ente.RecoverAccountRequest, error) { + jwtToken, err := c.ValidateJWTToken(token, enteJWT.RestoreAccount) + if err != nil { + if errors.Is(err, errJWTExpired) { + return ente.RecoverAccountRequest{}, stacktrace.Propagate(ErrAccountRecoveryLinkExpired, "failed to validate jwt token") + } + return ente.RecoverAccountRequest{}, stacktrace.Propagate(ErrAccountRecoveryInvalidLink, "failed to validate jwt token: %s", err.Error()) + } + if jwtToken.UserID == 0 || jwtToken.Email == "" { + return ente.RecoverAccountRequest{}, stacktrace.Propagate(ErrAccountRecoveryInvalidLink, "userID or email is empty") + } + return ente.RecoverAccountRequest{UserID: jwtToken.UserID, EmailID: jwtToken.Email}, nil +} + +func (c *UserController) accountRecoveryStatus(req ente.RecoverAccountRequest, allowAlreadyRecovered bool) (ente.AccountRecoveryStatus, error) { + user, err := c.UserRepo.Get(req.UserID) + if err == nil { + if allowAlreadyRecovered && strings.EqualFold(email.NormalizeEmail(user.Email), email.NormalizeEmail(req.EmailID)) { + return ente.AccountRecoveryRecovered, nil + } + if !allowAlreadyRecovered { + return "", stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ + Message: "account is already recovered or userID is linked to another active account", + }), "") + } + return "", stacktrace.Propagate(ErrAccountRecoveryUnavailable, "userID is linked to another active account") + } + if errors.Is(err, sql.ErrNoRows) { + return "", stacktrace.Propagate(ErrAccountRecoveryUnavailable, "user no longer exists") + } + if !errors.Is(err, ente.ErrUserDeleted) { + return "", stacktrace.Propagate(err, "error while getting the user") + } + + if _, err := c.UserRepo.GetKeyAttributes(req.UserID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", stacktrace.Propagate(ErrAccountRecoveryUnavailable, "key attributes have been deleted") + } + return "", stacktrace.Propagate(err, "failed to get key attributes") + } + + recoveryEmail := email.NormalizeEmail(req.EmailID) + if userID, err := c.UserRepo.GetUserIDWithEmailUnrestricted(recoveryEmail); err == nil { + if userID != req.UserID { + return "", stacktrace.Propagate(ErrAccountRecoveryEmailInUse, "email is already used by user %d", userID) + } + } else if !errors.Is(err, sql.ErrNoRows) { + return "", stacktrace.Propagate(err, "failed to look up the recovery email") + } + return ente.AccountRecoveryReady, nil +} + +func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.RecoverAccountRequest) error { + _, err := c.recoverAccount(ctx, req, false) + return err +} + +func (c *UserController) recoverAccount(ctx *gin.Context, req ente.RecoverAccountRequest, allowAlreadyRecovered bool) (ente.AccountRecoveryStatus, error) { + logger := logrus.WithFields(logrus.Fields{ + "req_id": ctx.GetString("req_id"), + "req_ctx": "account_recovery", + "email": req.EmailID, + "userID": req.UserID, + }) + logger.Info("initiating account recovery") + + status, err := c.accountRecoveryStatus(req, allowAlreadyRecovered) + if err != nil { + return "", err + } + if status == ente.AccountRecoveryRecovered { + return status, nil + } + + recoveryEmail := email.NormalizeEmail(req.EmailID) + encryptedEmail, err := crypto.Encrypt(recoveryEmail, c.SecretEncryptionKey) + if err != nil { + return "", stacktrace.Propagate(err, "") + } + emailHash, err := crypto.GetHash(recoveryEmail, c.HashingKey) + if err != nil { + return "", stacktrace.Propagate(err, "") + } + if err := c.UserRepo.UpdateEmail(req.UserID, encryptedEmail, emailHash); err != nil { + if isEmailHashUniqueConstraint(err) { + return "", stacktrace.Propagate(ErrAccountRecoveryEmailInUse, "email was claimed while recovering the account") + } + return "", stacktrace.Propagate(err, "failed to update email") + } + c.touchContactsAfterEmailUpdate(ctx, req.UserID) + if err := c.DataCleanupRepo.RemoveScheduledDelete(ctx, req.UserID); err != nil { + logger.WithError(err).Error("failed to remove scheduled delete") + return "", stacktrace.Propagate(err, "") + } + return ente.AccountRecoveryRecovered, nil +} + +func isEmailHashUniqueConstraint(err error) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && pqErr.Code == "23505" +} diff --git a/server/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index 0a50bc36f08..8a7df40c73c 100644 --- a/server/pkg/controller/user/jwt.go +++ b/server/pkg/controller/user/jwt.go @@ -1,7 +1,10 @@ package user import ( + "errors" "fmt" + "net/http" + "github.com/ente/museum/ente" enteJWT "github.com/ente/museum/ente/jwt" "github.com/ente/museum/pkg/utils/time" @@ -12,6 +15,12 @@ import ( // jwt token validity = 1 day const ValidForDays = 1 +var errJWTExpired = &ente.ApiError{ + Code: ente.BadRequest, + Message: "token expired", + HttpStatusCode: http.StatusBadRequest, +} + func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (string, error) { tokenExpiry := time.NDaysFromNow(1) if scope == enteJWT.ACCOUNTS { @@ -46,8 +55,8 @@ func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimSc return c.JwtSecret, nil }) if err != nil { - if ve, ok := err.(*jwt.ValidationError); ok && ve.Error() == "token expired" { - return nil, stacktrace.Propagate(ente.NewBadRequestWithMessage("token expired"), "") + if errors.Is(err, enteJWT.ErrTokenExpired) { + return nil, stacktrace.Propagate(errJWTExpired, "") } return nil, stacktrace.Propagate(err, "JWT parsed failed") } diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index d0bd63c8515..5d11775f3a7 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -2,16 +2,12 @@ package user import ( "context" - "database/sql" "errors" "fmt" - enteJWT "github.com/ente/museum/ente/jwt" "github.com/ente/museum/pkg/controller/collections" "github.com/ente/museum/pkg/repo/two_factor_recovery" util "github.com/ente/museum/pkg/utils" - "github.com/ente/museum/pkg/utils/time" "github.com/ulule/limiter/v3" - "strings" cache2 "github.com/ente/museum/ente/cache" "github.com/ente/museum/pkg/controller/discord" @@ -116,8 +112,6 @@ const ( AccountDeletedEmailTemplate = "account_deleted.html" AccountDeletedWithActiveSubscriptionEmailTemplate = "account_deleted_active_sub.html" AccountDeletedEmailSubject = "Your Ente account has been deleted" - accountRecoveryLinkHost = "https://api.ente.com" - accountRecoveryLinkValidityDays = 7 ) func NewUserController( @@ -397,93 +391,6 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i } } -func (c *UserController) getAccountRecoveryLink(userID int64, userEmail string) (string, error) { - recoverToken, err := c.GetJWTTokenForClaim(&enteJWT.WebCommonJWTClaim{ - UserID: userID, - ExpiryTime: time.MicrosecondsAfterDays(accountRecoveryLinkValidityDays), - ClaimScope: enteJWT.RestoreAccount.Ptr(), - Email: userEmail, - }) - if err != nil { - return "", err - } - return fmt.Sprintf("%s/users/recover-account?token=%s", accountRecoveryLinkHost, recoverToken), nil -} - -func (c *UserController) HandleSelfAccountRecovery(ctx *gin.Context, token string) error { - jwtToken, err := c.ValidateJWTToken(token, enteJWT.RestoreAccount) - if err != nil { - return stacktrace.Propagate(ente.NewPermissionDeniedError("invalid token"), "failed to validate jwt token: %s", err.Error()) - } - if jwtToken.UserID == 0 || jwtToken.Email == "" { - return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "Invalid token", - }), "userID or email is empty") - } - if jwtToken.ExpiryTime < time.Microseconds() { - return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "Token expired", - }), "") - } - // check if account is already recovered - if user, userErr := c.UserRepo.Get(jwtToken.UserID); userErr == nil { - if strings.EqualFold(user.Email, jwtToken.Email) { - logrus.WithField("userID", jwtToken.UserID).Error("account is already recovered") - return nil - } - } - return c.HandleAccountRecovery(ctx, ente.RecoverAccountRequest{ - UserID: jwtToken.UserID, - EmailID: jwtToken.Email, - }) -} - -func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.RecoverAccountRequest) error { - logger := logrus.WithFields(logrus.Fields{ - "req_id": ctx.GetString("req_id"), - "req_ctx": "account_recovery", - "email": req.EmailID, - "userID": req.UserID, - }) - logger.Info("initiating account recovery") - _, err := c.UserRepo.Get(req.UserID) - if err == nil { - return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "account is already recovered or userID is linked to another active account", - }), "") - } - if !errors.Is(err, ente.ErrUserDeleted) { - return stacktrace.Propagate(err, "error while getting the user") - } - // check if the user keyAttributes are still available - if _, keyErr := c.UserRepo.GetKeyAttributes(req.UserID); keyErr != nil { - if errors.Is(keyErr, sql.ErrNoRows) { - return stacktrace.Propagate(ente.NewBadRequestWithMessage("account can not be recovered now"), "") - } - return stacktrace.Propagate(keyErr, "keyAttributes missing? Account can not be recovered") - } - email := email.NormalizeEmail(req.EmailID) - encryptedEmail, err := crypto.Encrypt(email, c.SecretEncryptionKey) - if err != nil { - return stacktrace.Propagate(err, "") - } - emailHash, err := crypto.GetHash(email, c.HashingKey) - if err != nil { - return stacktrace.Propagate(err, "") - } - err = c.UserRepo.UpdateEmail(req.UserID, encryptedEmail, emailHash) - if err != nil { - return stacktrace.Propagate(err, "failed to update email") - } - c.touchContactsAfterEmailUpdate(ctx, req.UserID) - err = c.DataCleanupRepo.RemoveScheduledDelete(ctx, req.UserID) - if err != nil { - logrus.WithError(err).Error("failed to remove scheduled delete") - return stacktrace.Propagate(err, "") - } - return stacktrace.Propagate(err, "") -} - func (c *UserController) attachFreeSubscription(userID int64) (ente.Subscription, error) { subscription := billing.GetFreeSubscription(userID) generatedID, err := c.BillingRepo.AddSubscription(subscription) diff --git a/server/pkg/controller/user/user_recovery_test.go b/server/pkg/controller/user/user_recovery_test.go index 742b61f6e9c..9fa93e681f4 100644 --- a/server/pkg/controller/user/user_recovery_test.go +++ b/server/pkg/controller/user/user_recovery_test.go @@ -2,21 +2,261 @@ package user import ( "database/sql" + "errors" + "net/http" "net/http/httptest" + "net/url" "testing" "github.com/ente/museum/ente" contactmodel "github.com/ente/museum/ente/contact" cleanupentity "github.com/ente/museum/ente/data_cleanup" + enteJWT "github.com/ente/museum/ente/jwt" "github.com/ente/museum/internal/testutil" "github.com/ente/museum/pkg/repo" contactrepo "github.com/ente/museum/pkg/repo/contact" cleanuprepo "github.com/ente/museum/pkg/repo/datacleanup" "github.com/ente/museum/pkg/utils/config" + "github.com/ente/museum/pkg/utils/time" "github.com/gin-gonic/gin" "github.com/spf13/viper" ) +func TestGetAccountRecoveryLinkExpiresAfterSevenDays(t *testing.T) { + viper.Reset() + viper.Set("apps.accounts", "https://accounts.example.com/") + t.Cleanup(viper.Reset) + + controller := &UserController{JwtSecret: []byte("test-jwt-secret")} + + before := time.Microseconds() + recoveryLink, err := controller.getAccountRecoveryLink(42, "recover@ente.com") + if err != nil { + t.Fatalf("getAccountRecoveryLink() error = %v", err) + } + after := time.Microseconds() + + parsedLink, err := url.Parse(recoveryLink) + if err != nil { + t.Fatalf("failed to parse recovery link: %v", err) + } + if parsedLink.Scheme != "https" || parsedLink.Host != "accounts.example.com" || parsedLink.Path != "/recover-account" { + t.Fatalf("recovery link = %q, want configured accounts recovery page", recoveryLink) + } + if parsedLink.RawQuery != "" { + t.Fatalf("recovery link query = %q, want token only in fragment", parsedLink.RawQuery) + } + fragment, err := url.ParseQuery(parsedLink.Fragment) + if err != nil { + t.Fatalf("failed to parse recovery link fragment: %v", err) + } + claim, err := controller.ValidateJWTToken(fragment.Get("recoveryToken"), enteJWT.RestoreAccount) + if err != nil { + t.Fatalf("ValidateJWTToken() error = %v", err) + } + + const sevenDays = int64(7*24) * time.MicroSecondsInOneHour + if claim.ExpiryTime < before+sevenDays || claim.ExpiryTime > after+sevenDays { + t.Fatalf("token expiry = %d, want approximately 7 days after issuance", claim.ExpiryTime) + } + if claim.UserID != 42 || claim.Email != "recover@ente.com" { + t.Fatalf("recovery token claim = %+v, want user 42 and recover@ente.com", claim) + } +} + +func TestValidateSelfAccountRecoveryClassifiesTokenErrors(t *testing.T) { + controller := &UserController{JwtSecret: []byte("test-jwt-secret")} + expiredToken := accountRecoveryToken(t, controller, &enteJWT.WebCommonJWTClaim{ + UserID: 42, + Email: "recover@ente.com", + ExpiryTime: time.Microseconds() - 1, + ClaimScope: enteJWT.RestoreAccount.Ptr(), + }) + wrongScopeToken := accountRecoveryToken(t, controller, &enteJWT.WebCommonJWTClaim{ + UserID: 42, + Email: "recover@ente.com", + ExpiryTime: time.MicrosecondsAfterDays(1), + ClaimScope: enteJWT.DELETE_ACCOUNT.Ptr(), + }) + + tests := []struct { + name string + token string + wantError *ente.ApiError + wantCode ente.ErrorCode + wantStatus int + }{ + { + name: "malformed", + token: "not-a-jwt", + wantError: ErrAccountRecoveryInvalidLink, + wantCode: accountRecoveryInvalidLinkCode, + wantStatus: http.StatusBadRequest, + }, + { + name: "wrong scope", + token: wrongScopeToken, + wantError: ErrAccountRecoveryInvalidLink, + wantCode: accountRecoveryInvalidLinkCode, + wantStatus: http.StatusBadRequest, + }, + { + name: "expired", + token: expiredToken, + wantError: ErrAccountRecoveryLinkExpired, + wantCode: accountRecoveryLinkExpiredCode, + wantStatus: http.StatusGone, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := controller.ValidateSelfAccountRecovery(test.token) + if !errors.Is(err, test.wantError) { + t.Fatalf("ValidateSelfAccountRecovery() error = %v, want %v", err, test.wantError) + } + var apiErr *ente.ApiError + if !errors.As(err, &apiErr) { + t.Fatalf("ValidateSelfAccountRecovery() error = %v, want ApiError", err) + } + if apiErr.Code != test.wantCode || apiErr.HttpStatusCode != test.wantStatus { + t.Fatalf("ApiError = %+v, want code %s and status %d", apiErr, test.wantCode, test.wantStatus) + } + }) + } +} + +func TestSelfAccountRecoveryStatusAndRecovery(t *testing.T) { + testutil.WithServerRoot(t) + db := testutil.RequireTestDB(t) + testutil.ResetTables(t, db) + t.Cleanup(func() { testutil.ResetTables(t, db) }) + + userID := testutil.InsertUser(t, db, testutil.UserFixture{ + UserID: 82, + Email: "recover@example.com", + CreationTime: 1, + }) + insertKeyAttributes(t, db, userID) + insertScheduledDelete(t, db, userID) + + userRepo := &repo.UserRepository{ + DB: db, + SecretEncryptionKey: testutil.SecretEncryptionKey(), + HashingKey: testutil.HashingKey(), + } + if err := userRepo.Delete(userID); err != nil { + t.Fatalf("failed to delete user: %v", err) + } + controller := &UserController{ + UserRepo: userRepo, + DataCleanupRepo: &cleanuprepo.Repository{DB: db}, + SecretEncryptionKey: testutil.SecretEncryptionKey(), + HashingKey: testutil.HashingKey(), + JwtSecret: []byte("test-jwt-secret"), + } + token := accountRecoveryToken(t, controller, &enteJWT.WebCommonJWTClaim{ + UserID: userID, + Email: "recover@example.com", + ExpiryTime: time.MicrosecondsAfterDays(1), + ClaimScope: enteJWT.RestoreAccount.Ptr(), + }) + + response, err := controller.ValidateSelfAccountRecovery(token) + if err != nil { + t.Fatalf("ValidateSelfAccountRecovery() error = %v", err) + } + if response.Status != ente.AccountRecoveryReady { + t.Fatalf("validation status = %q, want %q", response.Status, ente.AccountRecoveryReady) + } + + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + response, err = controller.RecoverSelfAccount(ctx, token) + if err != nil { + t.Fatalf("RecoverSelfAccount() error = %v", err) + } + if response.Status != ente.AccountRecoveryRecovered { + t.Fatalf("recovery status = %q, want %q", response.Status, ente.AccountRecoveryRecovered) + } + if _, err := userRepo.Get(userID); err != nil { + t.Fatalf("recovered user is not active: %v", err) + } + var cleanupRows int + if err := db.QueryRow(`SELECT COUNT(*) FROM data_cleanup WHERE user_id = $1`, userID).Scan(&cleanupRows); err != nil { + t.Fatalf("failed to count cleanup rows: %v", err) + } + if cleanupRows != 0 { + t.Fatalf("cleanup row count = %d, want 0", cleanupRows) + } + + response, err = controller.ValidateSelfAccountRecovery(token) + if err != nil { + t.Fatalf("ValidateSelfAccountRecovery() after recovery error = %v", err) + } + if response.Status != ente.AccountRecoveryRecovered { + t.Fatalf("validation status after recovery = %q, want %q", response.Status, ente.AccountRecoveryRecovered) + } + + err = controller.HandleAccountRecovery(ctx, ente.RecoverAccountRequest{ + UserID: userID, + EmailID: "recover@example.com", + }) + var apiErr *ente.ApiError + if !errors.As(err, &apiErr) || apiErr.Code != ente.BadRequest || apiErr.HttpStatusCode != http.StatusBadRequest { + t.Fatalf("HandleAccountRecovery() for active account error = %v, want bad request", err) + } +} + +func TestValidateSelfAccountRecoveryReportsUnavailableAfterKeyCleanup(t *testing.T) { + testutil.WithServerRoot(t) + db := testutil.RequireTestDB(t) + testutil.ResetTables(t, db) + t.Cleanup(func() { testutil.ResetTables(t, db) }) + + userID := testutil.InsertUser(t, db, testutil.UserFixture{ + UserID: 82, + Email: "recover@example.com", + CreationTime: 1, + }) + userRepo := &repo.UserRepository{ + DB: db, + SecretEncryptionKey: testutil.SecretEncryptionKey(), + HashingKey: testutil.HashingKey(), + } + if err := userRepo.Delete(userID); err != nil { + t.Fatalf("failed to delete user: %v", err) + } + controller := &UserController{ + UserRepo: userRepo, + JwtSecret: []byte("test-jwt-secret"), + } + token := accountRecoveryToken(t, controller, &enteJWT.WebCommonJWTClaim{ + UserID: userID, + Email: "recover@example.com", + ExpiryTime: time.MicrosecondsAfterDays(1), + ClaimScope: enteJWT.RestoreAccount.Ptr(), + }) + + _, err := controller.ValidateSelfAccountRecovery(token) + if !errors.Is(err, ErrAccountRecoveryUnavailable) { + t.Fatalf("ValidateSelfAccountRecovery() error = %v, want ErrAccountRecoveryUnavailable", err) + } + var apiErr *ente.ApiError + if !errors.As(err, &apiErr) || apiErr.Code != accountRecoveryUnavailableCode || apiErr.HttpStatusCode != http.StatusGone { + t.Fatalf("ValidateSelfAccountRecovery() error = %v, want unavailable ApiError", err) + } +} + +func accountRecoveryToken(t *testing.T, controller *UserController, claim *enteJWT.WebCommonJWTClaim) string { + t.Helper() + token, err := controller.GetJWTTokenForClaim(claim) + if err != nil { + t.Fatalf("failed to create account recovery token: %v", err) + } + return token +} + func TestHandleAccountRecoveryTouchesContactsForResolvedEmailSync(t *testing.T) { testutil.WithServerRoot(t) viper.Reset() @@ -109,6 +349,64 @@ func TestHandleAccountRecoveryTouchesContactsForResolvedEmailSync(t *testing.T) } } +func TestHandleAccountRecoveryReportsEmailInUse(t *testing.T) { + testutil.WithServerRoot(t) + viper.Reset() + if err := config.ConfigureViper("local"); err != nil { + t.Fatalf("failed to configure viper: %v", err) + } + t.Cleanup(viper.Reset) + + db := testutil.RequireTestDB(t) + testutil.ResetTables(t, db) + t.Cleanup(func() { + testutil.ResetTables(t, db) + }) + + deletedUserID := testutil.InsertUser(t, db, testutil.UserFixture{ + UserID: 82, + Email: "recover@example.com", + CreationTime: 1, + }) + insertKeyAttributes(t, db, deletedUserID) + insertScheduledDelete(t, db, deletedUserID) + + userRepo := &repo.UserRepository{ + DB: db, + SecretEncryptionKey: testutil.SecretEncryptionKey(), + HashingKey: testutil.HashingKey(), + } + if err := userRepo.Delete(deletedUserID); err != nil { + t.Fatalf("failed to delete user: %v", err) + } + testutil.InsertUser(t, db, testutil.UserFixture{ + UserID: 83, + Email: "recover@example.com", + CreationTime: 1, + }) + + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + controller := &UserController{ + UserRepo: userRepo, + DataCleanupRepo: &cleanuprepo.Repository{DB: db}, + SecretEncryptionKey: testutil.SecretEncryptionKey(), + HashingKey: testutil.HashingKey(), + } + + err := controller.HandleAccountRecovery(ctx, ente.RecoverAccountRequest{ + UserID: deletedUserID, + EmailID: "recover@example.com", + }) + if !errors.Is(err, ErrAccountRecoveryEmailInUse) { + t.Fatalf("HandleAccountRecovery() error = %v, want ErrAccountRecoveryEmailInUse", err) + } + var apiErr *ente.ApiError + if !errors.As(err, &apiErr) || apiErr.Code != accountRecoveryEmailInUseCode || apiErr.HttpStatusCode != http.StatusConflict { + t.Fatalf("HandleAccountRecovery() error = %v, want email-in-use ApiError", err) + } +} + func insertKeyAttributes(t *testing.T, db *sql.DB, userID int64) { t.Helper() _, err := db.Exec( diff --git a/server/pkg/middleware/event_test.go b/server/pkg/middleware/event_test.go index 88c70eb5d52..3b1d7290b6e 100644 --- a/server/pkg/middleware/event_test.go +++ b/server/pkg/middleware/event_test.go @@ -28,6 +28,12 @@ func TestShouldSkipBodyLogForSpaceKeyRoutes(t *testing.T) { require.False(t, shouldSkipBodyLog(http.MethodGet, "/user-entity/key")) } +func TestAccountRecoveryTokensAreNotLogged(t *testing.T) { + require.True(t, shouldSkipBodyLog(http.MethodPost, "/users/recover-account/validate")) + require.True(t, shouldSkipBodyLog(http.MethodPost, "/users/recover-account")) + require.False(t, shouldSkipBodyLog(http.MethodGet, "/users/recover-account")) +} + func TestEventsUse120PerHourGlobalRateLimit(t *testing.T) { limit := util.NewRateLimiter("120-H") rateLimiter := &RateLimitMiddleware{limit120ReqPerHour: limit} @@ -69,6 +75,15 @@ func TestSpaceRoutesUseRouteSpecificRateLimits(t *testing.T) { require.Nil(t, rateLimiter.getLimiter("/account/space", http.MethodGet)) } +func TestAccountRecoveryRoutesUsePublicSensitiveRateLimit(t *testing.T) { + limit := util.NewRateLimiter("10-M") + rateLimiter := &RateLimitMiddleware{limit10ReqPerMin: limit} + + require.Same(t, limit, rateLimiter.getLimiter("/users/recover-account/validate", http.MethodPost)) + require.Same(t, limit, rateLimiter.getLimiter("/users/recover-account", http.MethodPost)) + require.Nil(t, rateLimiter.getLimiter("/users/recover-account", http.MethodGet)) +} + func TestEventsShareGlobalRateLimitAcrossPublicAndAuthenticatedRoutes(t *testing.T) { gin.SetMode(gin.TestMode) rateLimiter := &RateLimitMiddleware{ diff --git a/server/pkg/middleware/rate_limit.go b/server/pkg/middleware/rate_limit.go index 82899342778..34266d911f0 100644 --- a/server/pkg/middleware/rate_limit.go +++ b/server/pkg/middleware/rate_limit.go @@ -268,6 +268,8 @@ func (r *RateLimitMiddleware) getLimiter(reqPath string, reqMethod string) *limi reqPath == "/public-collection/verify-password" || reqPath == "/file-link/verify-password" || reqPath == "/family/accept-invite" || + reqPath == "/users/recover-account/validate" || + (reqPath == "/users/recover-account" && reqMethod == http.MethodPost) || reqPath == "/users/srp/attributes" || (reqPath == "/cast/device-info" && reqMethod == "POST") || (reqPath == "/cast/device-info/" && reqMethod == "POST") || diff --git a/server/pkg/middleware/request_logger.go b/server/pkg/middleware/request_logger.go index b2dd1a0982f..de3bc1b5be8 100644 --- a/server/pkg/middleware/request_logger.go +++ b/server/pkg/middleware/request_logger.go @@ -58,6 +58,9 @@ func shouldSkipBodyLog(method string, path string) bool { if method == http.MethodPost && (path == "/paste/create" || path == "/paste/guard" || path == "/paste/consume") { return true } + if method == http.MethodPost && (path == "/users/recover-account" || path == "/users/recover-account/validate") { + return true + } if !isReadOnly && (strings.HasPrefix(path, "/space") || strings.HasPrefix(path, "/account/space")) { return true } diff --git a/server/pkg/utils/email/storage_warning_template_test.go b/server/pkg/utils/email/storage_warning_template_test.go index 561ea346d62..6747f45c7fc 100644 --- a/server/pkg/utils/email/storage_warning_template_test.go +++ b/server/pkg/utils/email/storage_warning_template_test.go @@ -35,7 +35,7 @@ func TestStorageWarningTemplatesIncludeAccountEmail(t *testing.T) { func TestInactiveUserDeletionFinalTemplateIncludesRecoveryLink(t *testing.T) { testutil.WithServerRoot(t) - recoveryLink := "https://api.ente.com/users/recover-account?token=test-token" + recoveryLink := "https://accounts.ente.com/recover-account#recoveryToken=test-token" body, err := getMailBodyWithBase("ente_base.html", "inactive-user-deletion/confirm_13m.html", map[string]interface{}{ "Email": "inactive@example.com", "DeletionDate": "01 Apr 2026", diff --git a/web/apps/accounts/src/pages/family.tsx b/web/apps/accounts/src/pages/family.tsx index 7764b48a7e0..1172f2baee8 100644 --- a/web/apps/accounts/src/pages/family.tsx +++ b/web/apps/accounts/src/pages/family.tsx @@ -32,7 +32,6 @@ const Page: React.FC = () => { setPhase("loading"); try { setInvite(await getFamilyInviteInfo(token)); - window.history.replaceState(null, "", window.location.pathname); setPhase("ready"); } catch (e) { const invalid = @@ -61,6 +60,7 @@ const Page: React.FC = () => { setPhase("accepting"); try { setInvite(await acceptFamilyInvite(token!)); + window.history.replaceState(null, "", window.location.pathname); setPhase("accepted"); } catch (e) { const invalid = diff --git a/web/apps/accounts/src/pages/recover-account.tsx b/web/apps/accounts/src/pages/recover-account.tsx new file mode 100644 index 00000000000..54503289cd7 --- /dev/null +++ b/web/apps/accounts/src/pages/recover-account.tsx @@ -0,0 +1,228 @@ +import { + accountRecoveryErrorCode, + recoverAccount, + validateAccountRecovery, + type AccountRecoveryErrorCode, +} from "@/services/account-recovery"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Box, Stack, Typography } from "@mui/material"; +import { AccountsPageContents } from "ente-accounts/components/layouts/centered-paper"; +import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator"; +import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton"; +import { LoadingButton } from "ente-base/components/mui/LoadingButton"; +import { useBaseContext } from "ente-base/context"; +import { t } from "i18next"; +import React, { useCallback, useEffect, useState } from "react"; + +type Phase = + | "loading" + | "ready" + | "recovering" + | "recovered" + | AccountRecoveryErrorPhase + | "failed"; + +type AccountRecoveryErrorPhase = + | "invalid" + | "expired" + | "emailInUse" + | "unavailable"; + +const accountRecoveryErrorPhases: Record< + AccountRecoveryErrorCode, + AccountRecoveryErrorPhase +> = { + ACCOUNT_RECOVERY_INVALID_LINK: "invalid", + ACCOUNT_RECOVERY_LINK_EXPIRED: "expired", + ACCOUNT_RECOVERY_EMAIL_IN_USE: "emailInUse", + ACCOUNT_RECOVERY_UNAVAILABLE: "unavailable", +}; + +const Page: React.FC = () => { + const { onGenericError } = useBaseContext(); + const [phase, setPhase] = useState("loading"); + const [token, setToken] = useState(); + + const handleError = useCallback( + async (error: unknown) => { + const code = await accountRecoveryErrorCode(error); + if (code) { + setPhase(accountRecoveryErrorPhases[code]); + } else { + setPhase("failed"); + onGenericError(error); + } + }, + [onGenericError], + ); + + const validate = useCallback( + async (token: string) => { + setPhase("loading"); + try { + const response = await validateAccountRecovery(token); + const recovered = response.status == "recovered"; + if (recovered) { + window.history.replaceState( + null, + "", + window.location.pathname, + ); + } + setPhase(recovered ? "recovered" : "ready"); + } catch (error) { + await handleError(error); + } + }, + [handleError], + ); + + useEffect(() => { + const reloadForNewToken = () => window.location.reload(); + window.addEventListener("hashchange", reloadForNewToken); + + const token = new URLSearchParams(window.location.hash.slice(1)).get( + "recoveryToken", + ); + if (!token) { + setPhase("invalid"); + } else { + setToken(token); + void validate(token); + } + + return () => + window.removeEventListener("hashchange", reloadForNewToken); + }, [validate]); + + const recover = async () => { + setPhase("recovering"); + try { + await recoverAccount(token!); + window.history.replaceState(null, "", window.location.pathname); + setPhase("recovered"); + } catch (error) { + await handleError(error); + } + }; + + return ( + + {phase == "loading" ? ( + + + + ) : phase == "failed" ? ( + + + {t("generic_error_retry")} + + void validate(token!)}> + {t("retry")} + + + ) : phase == "recovered" ? ( + + + + + {t("open_ente")} + + + ) : phase == "invalid" ? ( + + + + ) : phase == "expired" ? ( + + + + ) : phase == "emailInUse" ? ( + + + + + ) : phase == "unavailable" ? ( + + + + ) : ( + + + + void recover()} + > + {t("recover_account")} + + + )} + + ); +}; + +const RecoveryIcon = () => ( + +); + +const RecoveryMessage: React.FC<{ title: string; description: string }> = ({ + title, + description, +}) => ( + + {title} + {description} + +); + +const RecoveryContents: React.FC = ({ children }) => ( + + {children} + +); + +export default Page; diff --git a/web/apps/accounts/src/services/account-recovery.ts b/web/apps/accounts/src/services/account-recovery.ts new file mode 100644 index 00000000000..fc5a294e332 --- /dev/null +++ b/web/apps/accounts/src/services/account-recovery.ts @@ -0,0 +1,50 @@ +import { ensureOk, HTTPError, publicRequestHeaders } from "ente-base/http"; +import { apiURL } from "ente-base/origins"; +import { z } from "zod"; + +const AccountRecoveryResponse = z.object({ + status: z.enum(["ready", "recovered"]), +}); + +export type AccountRecoveryResponse = z.infer; + +const AccountRecoveryErrorCode = z.enum([ + "ACCOUNT_RECOVERY_INVALID_LINK", + "ACCOUNT_RECOVERY_LINK_EXPIRED", + "ACCOUNT_RECOVERY_EMAIL_IN_USE", + "ACCOUNT_RECOVERY_UNAVAILABLE", +]); + +export type AccountRecoveryErrorCode = z.infer; + +const postAccountRecoveryToken = async (path: string, token: string) => { + const res = await fetch(await apiURL(path), { + method: "POST", + headers: { + ...publicRequestHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + ensureOk(res); + return AccountRecoveryResponse.parse(await res.json()); +}; + +export const validateAccountRecovery = (token: string) => + postAccountRecoveryToken("/users/recover-account/validate", token); + +export const recoverAccount = (token: string) => + postAccountRecoveryToken("/users/recover-account", token); + +export const accountRecoveryErrorCode = async (error: unknown) => { + if (!(error instanceof HTTPError)) return undefined; + + try { + const payload = z + .object({ code: AccountRecoveryErrorCode }) + .parse(await error.res.clone().json()); + return payload.code; + } catch { + return undefined; + } +}; diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 1b3a68b2e99..af98cb8a71b 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -257,6 +257,18 @@ "family_invite_accepted_description": "You are now sharing {{email}}'s plan.", "family_invite_invalid": "This invite is invalid or no longer available.", "open_ente": "Open Ente", + "account_recovery_title": "Recover your Ente account", + "account_recovery_description": "Restore your account and cancel its scheduled deletion.", + "account_recovery_success": "Your account has been recovered", + "account_recovery_success_description": "You can now sign in to Ente again.", + "account_recovery_invalid_title": "Invalid recovery link", + "account_recovery_invalid_description": "This recovery link is invalid.", + "account_recovery_expired_title": "Recovery link expired", + "account_recovery_expired_description": "This recovery link has expired.", + "account_recovery_unavailable_title": "Account recovery unavailable", + "account_recovery_unavailable_description": "This account can no longer be recovered.", + "account_recovery_email_in_use": "This email is already in use", + "account_recovery_email_in_use_description": "To recover your deleted account, first sign in to the account using this email and delete it. Then reopen this recovery link before it expires.", "invite": "Invite", "enter_valid_email": "Enter a valid email address", "family_user_not_found": "No Ente account was found for this email address.", From 4f9265e6cfd91f143832490fef042f76e0856b3b Mon Sep 17 00:00:00 2001 From: Neeraj Gupta Date: Wed, 22 Jul 2026 09:56:22 +0530 Subject: [PATCH 60/91] Update golang.org/x/text --- server/go.mod | 2 +- server/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/go.mod b/server/go.mod index 89dc206534a..6f83440be67 100644 --- a/server/go.mod +++ b/server/go.mod @@ -39,7 +39,7 @@ require ( github.com/zsais/go-gin-prometheus v0.1.0 golang.org/x/crypto v0.53.0 golang.org/x/sync v0.21.0 - golang.org/x/text v0.38.0 + golang.org/x/text v0.39.0 google.golang.org/api v0.126.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) diff --git a/server/go.sum b/server/go.sum index 52164dc5b21..9625a3ee8be 100644 --- a/server/go.sum +++ b/server/go.sum @@ -872,6 +872,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 8967f989c6e67b7061fcc2f22c9b64ae2e1251f1 Mon Sep 17 00:00:00 2001 From: blue Date: Wed, 22 Jul 2026 07:37:23 +0200 Subject: [PATCH 61/91] [auth] Optimization of Capcom and Warner Bros. custom icons (#11598) ## Issue Despite the Capcom and Warner Bros. `.svg` files not having been updated in pull #11546, they do not appear properly in [its icon preview](https://neeraj-pilot.github.io/preview-icons-pr/?pr=11546). I assume this is due to the usage of ` - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + From 57f1e3b6f3dcbc5c9bda9de666c9a97085cf5a50 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 22 Jul 2026 05:56:10 +0000 Subject: [PATCH 62/91] New Crowdin translations by GitHub Action --- .../base/locales/af-ZA/translation.json | 17 + .../base/locales/ar-SA/translation.json | 17 + .../base/locales/be-BY/translation.json | 17 + .../base/locales/bg-BG/translation.json | 17 + .../base/locales/ca-ES/translation.json | 17 + .../base/locales/ckb-IR/translation.json | 933 ++++++++++++++++++ .../base/locales/cs-CZ/translation.json | 17 + .../base/locales/da-DK/translation.json | 17 + .../base/locales/de-DE/translation.json | 17 + .../base/locales/el-GR/translation.json | 17 + .../base/locales/es-ES/translation.json | 17 + .../base/locales/et-EE/translation.json | 17 + .../base/locales/fa-IR/translation.json | 291 +++--- .../base/locales/fi-FI/translation.json | 17 + .../base/locales/fr-FR/translation.json | 93 +- .../base/locales/gu-IN/translation.json | 17 + .../base/locales/hi-IN/translation.json | 17 + .../base/locales/hu-HU/translation.json | 17 + .../base/locales/id-ID/translation.json | 17 + .../base/locales/is-IS/translation.json | 17 + .../base/locales/it-IT/translation.json | 17 + .../base/locales/ja-JP/translation.json | 17 + .../base/locales/km-KH/translation.json | 17 + .../base/locales/kn-IN/translation.json | 17 + .../base/locales/ko-KR/translation.json | 17 + .../base/locales/lt-LT/translation.json | 93 +- .../base/locales/lv-LV/translation.json | 17 + .../base/locales/ml-IN/translation.json | 17 + .../base/locales/ms-BN/translation.json | 17 + .../base/locales/ms-MY/translation.json | 17 + .../base/locales/nl-NL/translation.json | 17 + .../base/locales/pl-PL/translation.json | 17 + .../base/locales/pt-BR/translation.json | 87 +- .../base/locales/pt-PT/translation.json | 17 + .../base/locales/ro-RO/translation.json | 17 + .../base/locales/ru-RU/translation.json | 19 +- .../base/locales/sk-SK/translation.json | 17 + .../base/locales/sl-SI/translation.json | 17 + .../base/locales/sr-SP/translation.json | 17 + .../base/locales/sv-SE/translation.json | 17 + .../base/locales/ta-IN/translation.json | 17 + .../base/locales/te-IN/translation.json | 17 + .../base/locales/th-TH/translation.json | 17 + .../base/locales/ti-ER/translation.json | 17 + .../base/locales/tr-TR/translation.json | 17 + .../base/locales/uk-UA/translation.json | 17 + .../base/locales/ur-IN/translation.json | 17 + .../base/locales/vi-VN/translation.json | 17 + .../base/locales/zh-CN/translation.json | 17 + .../base/locales/zh-HK/translation.json | 17 + .../base/locales/zh-TW/translation.json | 87 +- 51 files changed, 2067 insertions(+), 284 deletions(-) create mode 100644 web/packages/base/locales/ckb-IR/translation.json diff --git a/web/packages/base/locales/af-ZA/translation.json b/web/packages/base/locales/af-ZA/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/af-ZA/translation.json +++ b/web/packages/base/locales/af-ZA/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ar-SA/translation.json b/web/packages/base/locales/ar-SA/translation.json index 45f1b01aa32..93cf95ce31a 100644 --- a/web/packages/base/locales/ar-SA/translation.json +++ b/web/packages/base/locales/ar-SA/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "سجل الدخول للانضمام للألبوم", "sign_up": "تسجيل", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "سجل اشتراكك للانضمام للألبوم", "new_to_ente": "جديد في Ente", "existing_user": "مستخدم موجود", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "متصفحك أو إضافة ما تمنع Ente من استخدام eTags لتحميل الملفات الكبيرة.", "large_files": "ملفات كبيرة جدًا", "large_files_hint": "لم يتم تحميل هذه الملفات لأنها تتجاوز الحد الأقصى لحجم الملف المسموح به لدينا", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "مساحة تخزين غير كافية", "insufficient_storage_hint": "لم يتم تحميل هذه الملفات لأنها تتجاوز الحد الأقصى للحجم المسموح به في خطة التخزين الخاصة بك", "uploads_in_progress": "التحميلات قيد التقدم", + "wait_for_active_upload_to_finish": "", "successful_uploads": "التحميلات الناجحة", "upload_to_album": "تحميل إلى الألبوم", "add_to_album": "إضافة إلى الألبوم", @@ -874,6 +890,7 @@ "search_location": "ابحث عن موقع", "app_lock": "قفل التطبيق", "app_locked": "التطبيق مقفل", + "app_lock_unavailable": "", "enter_pin": "أدخل رمز PIN", "enter_password": "أدخل كلمة المرور", "confirm_pin": "تأكيد رمز PIN", diff --git a/web/packages/base/locales/be-BY/translation.json b/web/packages/base/locales/be-BY/translation.json index 4d85bde4995..5d0edef746e 100644 --- a/web/packages/base/locales/be-BY/translation.json +++ b/web/packages/base/locales/be-BY/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Рэгістрацыя", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Навічок у Ente", "existing_user": "Існуючы карыстальнік", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/bg-BG/translation.json b/web/packages/base/locales/bg-BG/translation.json index 05891d1ac2c..473751dcf7c 100644 --- a/web/packages/base/locales/bg-BG/translation.json +++ b/web/packages/base/locales/bg-BG/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ca-ES/translation.json b/web/packages/base/locales/ca-ES/translation.json index c38f23751c8..08698518695 100644 --- a/web/packages/base/locales/ca-ES/translation.json +++ b/web/packages/base/locales/ca-ES/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Inicia sessió per unir-te a l'àlbum", "sign_up": "Registra't", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Registra't per unir-te a l'àlbum", "new_to_ente": "Nou a Ente?", "existing_user": "Usuari existent", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "El seu navegador o un complement està impedint que Ente utilitzi eTags per pujar fitxers grans.", "large_files": "Fitxers grans", "large_files_hint": "Aquests fitxers no s'han pujat perquè superen el nostre límit màxim de mida de fitxer", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Emmagatzematge insuficient", "insufficient_storage_hint": "Aquests fitxers no s'han pujat perquè superen el límit màxim de mida per al teu pla d'emmagatzematge", "uploads_in_progress": "Pujades en curs", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Pujades exitoses", "upload_to_album": "Puja a l'àlbum", "add_to_album": "Afegeix a l'àlbum", @@ -874,6 +890,7 @@ "search_location": "Cerca una ubicació", "app_lock": "Bloqueig de l'aplicació", "app_locked": "Aplicació bloquejada", + "app_lock_unavailable": "", "enter_pin": "Introdueix el teu PIN", "enter_password": "Introdueix la teva contrasenya", "confirm_pin": "Confirma el teu PIN", diff --git a/web/packages/base/locales/ckb-IR/translation.json b/web/packages/base/locales/ckb-IR/translation.json new file mode 100644 index 00000000000..dbc9d057bf7 --- /dev/null +++ b/web/packages/base/locales/ckb-IR/translation.json @@ -0,0 +1,933 @@ +{ + "intro_slide_1_title": "", + "intro_slide_1": "", + "intro_slide_2_title": "", + "intro_slide_2": "", + "intro_slide_3_title": "", + "intro_slide_3": "", + "login": "", + "login_to_join_album": "", + "sign_up": "", + "get_ente_photos": "", + "join_ente": "", + "signup_to_join_album": "", + "new_to_ente": "", + "existing_user": "", + "enter_email": "", + "invalid_email_error": "", + "required": "", + "email_not_registered": "", + "account_setup_incomplete_create_account": "", + "email_already_registered": "", + "email_sent": "", + "check_inbox_hint": "", + "verification_code": "", + "resend_code": "", + "verify": "", + "send_otp": "", + "generic_error": "", + "generic_error_retry": "", + "invalid_code_error": "", + "expired_code_error": "", + "status_sending": "", + "status_sent": "", + "password": "", + "link_password_description": "", + "unlock": "", + "set_password": "", + "sign_in": "", + "incorrect_password": "", + "incorrect_password_or_no_account": "", + "pick_password_hint": "", + "pick_password_caution": "", + "key_generation_in_progress": "", + "confirm_password": "", + "referral_source_hint": "", + "referral_source_info": "", + "password_mismatch_error": "", + "show_or_hide_password": "", + "welcome_to_ente_title": "", + "welcome_to_ente_subtitle": "", + "new_album": "", + "create_albums": "", + "album_name": "", + "album_created": "", + "join_album": "", + "joined_album": "", + "album_join_failed": "", + "close": "", + "yes": "", + "no": "", + "nothing_here": "", + "upload": "", + "import": "", + "add_photos": "", + "add_more_photos": "", + "add_photos_count_one": "", + "add_photos_count": "", + "select_photos": "", + "file_upload": "", + "preparing": "", + "processed_counts": "", + "upload_reading_metadata_files": "", + "upload_cancelling": "", + "upload_done": "", + "upload_skipped": "", + "initial_load_delay_warning": "", + "no_account": "", + "existing_account": "", + "create": "", + "files_count": "", + "download": "", + "download_album": "", + "download_favorites": "", + "download_uncategorized": "", + "clean_uncategorized": "", + "clean_uncategorized_message": "", + "clean_uncategorized_success_one": "", + "clean_uncategorized_success_other": "", + "download_hidden_items": "", + "audio": "", + "more": "", + "mouse_scroll": "", + "pan": "", + "pinch": "", + "drag": "", + "tap_inside_image": "", + "tap_outside_image": "", + "shortcuts": "", + "show_shortcuts": "", + "zoom_preset": "", + "toggle_controls": "", + "toggle_live": "", + "toggle_audio": "", + "toggle_favorite": "", + "toggle_archive": "", + "view_info": "", + "copy_as_png": "", + "toggle_fullscreen": "", + "exit_fullscreen": "", + "go_fullscreen": "", + "zoom": "", + "zoom_in": "", + "zoom_out": "", + "play": "", + "pause": "", + "previous": "", + "next": "", + "video_seek": "", + "quality": "", + "auto": "", + "original": "", + "speed": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "title_locker": "", + "upload_first_photo": "", + "import_your_folders": "", + "upload_dropzone_hint": "", + "upload_dropzone_hint_public_album": "", + "watch_folder_dropzone_hint": "", + "trash_files_title": "", + "trash_file_title": "", + "delete_files_title": "", + "delete_files_message": "", + "selected_count": "", + "selected_and_yours_count": "", + "delete": "", + "like": "", + "unlike": "", + "reply": "", + "like_count_one": "", + "like_count": "", + "loading": "", + "no_activity_yet": "", + "no_comments_yet": "", + "no_likes_yet": "", + "set_your_name": "", + "set_your_name_subtitle": "", + "like_photo": "", + "select_album_to_like": "", + "say_something_nice": "", + "say_something_nice_placeholder": "", + "give_it_a_like": "", + "set_name": "", + "set_name_and_like": "", + "anonymous": "", + "unknown_user": "", + "user": "", + "yourself": "", + "liked_a_photo": "", + "liked_a_video": "", + "commented_on_a_photo": "", + "commented_on_a_video": "", + "replied_to_a_comment": "", + "liked_a_comment": "", + "just_now": "", + "minutes_ago": "", + "hours_ago": "", + "days_ago": "", + "like_anonymously": "", + "join_album_and_like": "", + "public_like_subtitle": "", + "public_comment_subtitle": "", + "comment_anonymously": "", + "join_album_and_comment": "", + "like_all": "", + "favorite": "", + "un_favorite": "", + "convert": "", + "multi_folder_upload": "", + "upload_to_choice": "", + "upload_to_single_album": "", + "upload_to_album_per_folder": "", + "session_expired": "", + "session_expired_message": "", + "password_generation_failed": "", + "change_password": "", + "password_changed_elsewhere": "", + "password_changed_elsewhere_message": "", + "go_back": "", + "account": "", + "recovery_key": "", + "do_this_later": "", + "save_key": "", + "recovery_key_description": "", + "key_not_stored_note": "", + "recovery_key_generation_failed": "", + "forgot_password": "", + "recover_account": "", + "recover": "", + "no_recovery_key_title": "", + "incorrect_recovery_key": "", + "sorry": "", + "no_recovery_key_message": "", + "no_two_factor_recovery_key_message": "", + "contact_support": "", + "help": "", + "ente_help": "", + "blog": "", + "request_feature": "", + "support": "", + "cancel": "", + "logout": "", + "logout_message": "", + "delete_account": "", + "delete_account_multi_app_warning": "", + "delete_account_manually_message": "", + "change_email": "", + "active_sessions": "", + "active_sessions_hint": "", + "this_device": "", + "terminate_session": "", + "terminate_session_confirm_message": "", + "terminate_session_confirm_message_self": "", + "terminate": "", + "terminate_session_failed": "", + "ok": "", + "success": "", + "error": "", + "note": "", + "offline_message": "", + "network_error": "", + "install": "", + "install_mobile_app": "", + "download_app": "", + "download_app_message": "", + "subscription": "", + "manage_payment_method": "", + "manage_family": "", + "family_plan": "", + "family_plan_description": "", + "view_plans": "", + "family_members": "", + "family_manager": "", + "family_member": "", + "family_invited": "", + "family_storage": "", + "family_storage_usage": "", + "add_family_member": "", + "invite_family_member": "", + "family_invite_hint": "", + "family_invite_title": "", + "family_invite_description": "", + "accept_family_invite": "", + "family_invite_accepted": "", + "family_invite_accepted_description": "", + "family_invite_invalid": "", + "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", + "invite": "", + "enter_valid_email": "", + "family_user_not_found": "", + "family_already_member": "", + "family_paid_subscriber": "", + "family_size_limit": "", + "family_paid_plan_required": "", + "resend_family_invite": "", + "revoke_family_invite": "", + "revoke_family_invite_confirm": "", + "revoke": "", + "remove_family_member": "", + "remove_family_member_confirm": "", + "set_storage_limit": "", + "storage_limit_gb": "", + "storage_limit_hint": "", + "family_storage_limit_invalid": "", + "leave_family_plan": "", + "leave": "", + "leave_family_plan_confirm": "", + "choose_plan": "", + "manage_plan": "", + "current_usage": "", + "two_months_free": "", + "free_plan_option": "", + "free_plan_description": "", + "active": "", + "subscription_info_free": "", + "subscription_info_family": "", + "subscription_info_expired": "", + "subscription_info_renewal_cancelled": "", + "subscription_info_storage_quota_exceeded": "", + "subscription_status_renewal_active": "", + "subscription_status_renewal_cancelled": "", + "add_on_valid_till": "", + "subscription_expired": "", + "storage_quota_exceeded": "", + "subscription_purchase_success": "", + "subscription_purchase_cancelled": "", + "subscription_purchase_failed": "", + "subscription_verification_error": "", + "update_payment_method_message": "", + "payment_method_authentication_failed": "", + "update_payment_method": "", + "monthly": "", + "yearly": "", + "month_short": "", + "year": "", + "update_subscription": "", + "update_subscription_title": "", + "update_subscription_message": "", + "cancel_subscription": "", + "cancel_subscription_message": "", + "cancel_subscription_with_addon_message": "", + "subscription_cancel_success": "", + "reactivate_subscription": "", + "reactivate_subscription_message": "", + "subscription_activate_success": "", + "thank_you": "", + "cancel_subscription_on_mobile": "", + "cancel_subscription_on_mobile_message": "", + "mail_to_manage_subscription": "", + "rename": "", + "rename_file": "", + "rename_album": "", + "set_cover": "", + "select_cover_photo": "", + "reset_to_default": "", + "delete_album": "", + "delete_album_title": "", + "delete_album_message": "", + "delete_album_message_no_photos": "", + "delete_photos": "", + "keep_photos": "", + "share_album": "", + "share_action": "", + "activity": "", + "sharing_with_self": "", + "sharing_already_shared": "", + "sharing_album_not_allowed": "", + "sharing_disabled_for_free_accounts": "", + "link_edit_disabled_for_free_accounts": "", + "sharing_user_does_not_exist": "", + "search": "", + "search_results": "", + "no_results": "", + "no_photos_found_here": "", + "zoom_out_to_see_photos": "", + "no_geotagged_photos": "", + "search_hint": "", + "albums_search_hint": "", + "album": "", + "date": "", + "description": "", + "file_type": "", + "settings": "", + "magic": "", + "photos_count_zero": "", + "photos_count_one": "", + "photos_count": "", + "terms_and_conditions": "", + "people": "", + "indexing_scheduled": "", + "indexing_photos": "", + "indexing_fetching": "", + "indexing_people": "", + "syncing_wait": "", + "people_empty_too_few": "", + "unnamed_person": "", + "add_a_name": "", + "add_a_person": "", + "new_person": "", + "add_name": "", + "rename_person": "", + "reset_person_confirm": "", + "reset_person_confirm_message": "", + "ignore": "", + "ignore_person_confirm": "", + "ignore_person_confirm_message": "", + "ignored": "", + "show_person": "", + "review_suggestions": "", + "saved_choices": "", + "discard_changes": "", + "discard_changes_message": "", + "discard_changes_confirm_message": "", + "discard": "", + "people_suggestions_finding": "", + "people_suggestions_empty": "", + "info": "", + "comments": "", + "file_name": "", + "caption_placeholder": "", + "location": "", + "view_on_map": "", + "map": "", + "enable_map": "", + "disable_map": "", + "details": "", + "view_exif": "", + "no_exif": "", + "use_shortcut_to_navigate": "", + "exif": "", + "two_factor": "", + "two_factor_authentication": "", + "two_factor_qr_help": "", + "two_factor_manual_entry_title": "", + "two_factor_manual_entry_message": "", + "scan_qr_title": "", + "enable_two_factor": "", + "enable": "", + "enabled": "", + "lost_2fa_device": "", + "incorrect_code": "", + "two_factor_info": "", + "disable": "", + "reconfigure": "", + "reconfigure_two_factor_hint": "", + "update_two_factor": "", + "update_two_factor_message": "", + "update": "", + "disable_two_factor": "", + "disable_two_factor_message": "", + "export_data": "", + "select_folder": "", + "select_zips": "", + "faq": "", + "takeout_hint": "", + "destination": "", + "start": "", + "last_export_time": "", + "export_again": "", + "local_storage_not_accessible": "", + "email_already_taken": "", + "live_photos_detected": "", + "ignored_uploads": "", + "ignored_uploads_hint": "", + "hidden_files": "", + "hidden_files_hint": "", + "unreadable_zip_files": "", + "unreadable_zip_files_hint": "", + "file_not_uploaded_list": "", + "failed_uploads": "", + "failed_uploads_hint": "", + "retry_failed_uploads": "", + "thumbnail_generation_failed": "", + "canvas_blocked_upload_failed_title": "", + "canvas_blocked_upload_failed_description": "", + "thumbnail_generation_failed_hint": "", + "learn_how": "", + "unsupported_files": "", + "unsupported_files_hint": "", + "zero_size_files": "", + "zero_size_files_hint": "", + "blocked_uploads": "", + "blocked_uploads_hint": "", + "large_files": "", + "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", + "insufficient_storage": "", + "insufficient_storage_hint": "", + "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", + "successful_uploads": "", + "upload_to_album": "", + "add_to_album": "", + "added_to_album": "", + "added_to_person": "", + "move_to_album": "", + "cannot_move_to_shared_albums": "", + "cannot_move_to_shared_albums_message": "", + "unhide_to_album": "", + "restore_to_album": "", + "section_all": "", + "section_uncategorized": "", + "section_archive": "", + "section_hidden": "", + "section_trash": "", + "favorites": "", + "archive": "", + "archive_album": "", + "unarchive": "", + "unarchive_album": "", + "hide_collection": "", + "unhide_collection": "", + "move": "", + "add": "", + "remove": "", + "yes_remove": "", + "remove_from_album": "", + "remove_from_album_others_message": "", + "move_to_trash": "", + "trash_files_message": "", + "trash_file_message": "", + "delete_permanently": "", + "restore": "", + "empty_trash": "", + "empty_trash_title": "", + "empty_trash_message": "", + "leave_album": "", + "leave_shared_album_title": "", + "leave_shared_album_message": "", + "leave_shared_album": "", + "confirm_remove_message": "", + "confirm_remove_incl_others_message": "", + "oldest": "", + "newest": "", + "last_updated": "", + "name": "", + "created": "", + "updated": "", + "sort_asc_indicator": "", + "sort_desc_indicator": "", + "fix_creation_time": "", + "fix_creation_time_in_progress": "", + "fix_creation_time_file_updated": "", + "fix_creation_time_completed": "", + "fix_creation_time_completed_with_errors": "", + "fix_creation_time_options": "", + "exif_date_time_original": "", + "exif_date_time_digitized": "", + "exif_metadata_date": "", + "custom_time": "", + "caption_character_limit": "", + "sharing_details": "", + "modify_sharing": "", + "add_collaborators": "", + "add_new_email": "", + "shared_with_people_count_zero": "", + "shared_with_people_count_one": "", + "shared_with_people_count": "", + "participants_count_zero": "", + "participants_count_one": "", + "participants_count": "", + "add_viewers": "", + "change_permission_to_viewer": "", + "change_permission_to_collaborator": "", + "change_permission_title": "", + "confirm_convert_to_viewer": "", + "confirm_convert_to_collaborator": "", + "manage": "", + "added_as": "", + "collaborator_hint": "", + "remove_participant": "", + "remove_participant_title": "", + "remove_participant_message": "", + "confirm_remove": "", + "owner": "", + "collaborators": "", + "viewers": "", + "add_more": "", + "or_add_existing": "", + "not_found": "", + "link_expired": "", + "link_expired_message": "", + "manage_link": "", + "link_request_limit_exceeded": "", + "allow_downloads": "", + "allow_adding_photos": "", + "allow_adding_photos_hint": "", + "enable_comments": "", + "allow_joining_album": "", + "device_limit": "", + "none": "", + "link_expiry": "", + "never": "", + "after_time": { + "hour": "", + "day": "", + "week": "", + "month": "", + "year": "" + }, + "copy_link": "", + "copy_embed_html": "", + "done": "", + "share_link_section_title": "", + "remove_link": "", + "link_created": "", + "create_public_link": "", + "public_link_created": "", + "public_link_enabled": "", + "collect_photos": "", + "disable_file_download": "", + "disable_file_download_message": "", + "shared_using": "", + "sharing_referral_code": "", + "disable_password": "", + "disable_password_message": "", + "password_lock": "", + "lock": "", + "file": "", + "files": "", + "folder": "", + "google_takeout": "", + "free_up_space": "", + "deduplicate_files": "", + "remove_duplicates": "", + "total_size": "", + "count": "", + "deselect_all": "", + "no_duplicates": "", + "duplicate_group_description": "", + "remove_duplicates_button_count": "", + "large_files_title": "", + "no_large_files": "", + "delete_files_button": "", + "all": "", + "photos": "", + "videos": "", + "select_all": "", + "select_all_photos": "", + "select_all_videos": "", + "deselect_all_photos": "", + "deselect_all_videos": "", + "stop_uploads_title": "", + "stop_uploads_message": "", + "yes_stop_uploads": "", + "stop_downloads_title": "", + "stop_downloads_message": "", + "yes_stop_downloads": "", + "albums": "", + "albums_count_one": "", + "albums_count": "", + "all_albums": "", + "all_hidden_albums": "", + "hidden_albums": "", + "hidden_items": "", + "enter_two_factor_otp": "", + "create_account": "", + "copied": "", + "upgrade": "", + "upgrade_now": "", + "renew_now": "", + "storage": "", + "used": "", + "you": "", + "family": "", + "free": "", + "of": "", + "watch_folders": "", + "watched_folders": "", + "no_folders_added": "", + "watch_folders_hint_1": "", + "watch_folders_hint_2": "", + "watch_folders_hint_3": "", + "add_folder": "", + "stop_watching": "", + "stop_watching_folder_title": "", + "stop_watching_folder_message": "", + "yes_stop": "", + "folder_not_accessible": "", + "retry_watching": "", + "change_folder": "", + "view_logs": "", + "view_logs_message": "", + "weak_device_hint": "", + "drag_and_drop_hint": "", + "authenticate": "", + "uploaded_to_single_collection": "", + "uploaded_to_separate_collections": "", + "nevermind": "", + "update_available": "", + "update_installable_message": "", + "install_now": "", + "install_on_next_launch": "", + "update_available_message": "", + "download_and_install": "", + "ignore_this_version": "", + "today": "", + "yesterday": "", + "enter_name": "", + "uploader_name_hint": "", + "name_placeholder": "", + "more_details": "", + "ml_search": "", + "ml_search_description": "", + "ml_search_footnote": "", + "indexing": "", + "processed": "", + "indexing_status_running": "", + "indexing_status_fetching": "", + "indexing_status_scheduled": "", + "indexing_status_done": "", + "ml_search_disable": "", + "ml_search_disable_confirm": "", + "ml_consent": "", + "ml_consent_title": "", + "ml_consent_description": "", + "ml_consent_confirmation": "", + "labs": "", + "password_strength_weak": "", + "password_strength_moderate": "", + "password_strength_strong": "", + "preferences": "", + "language": "", + "advanced": "", + "export_directory_does_not_exist": "", + "export_directory_does_not_exist_message": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "stop": "", + "sync_continuously": "", + "export_starting": "", + "export_preparing": "", + "export_renaming_album_folders": "", + "export_trashing_deleted_files": "", + "export_trashing_deleted_albums": "", + "export_progress": "", + "pending_items": "", + "delete_account_reason_label": "", + "delete_account_reason_placeholder": "", + "delete_reason": { + "missing_feature": "", + "behaviour": "", + "found_another_service": "", + "not_listed": "" + }, + "delete_account_feedback_label": "", + "delete_account_feedback_placeholder": "", + "delete_account_confirm_checkbox_label": "", + "delete_account_confirm": "", + "delete_account_confirm_message": "", + "feedback_required": "", + "feedback_required_found_another_service": "", + "recover_two_factor": "", + "at": "", + "auth_next": "", + "auth_download_mobile_app": "", + "no_codes_added_yet": "", + "hide": "", + "unhide": "", + "sort_by": "", + "newest_first": "", + "oldest_first": "", + "most_relevant": "", + "pin_album": "", + "unpin_album": "", + "pin_person": "", + "unpin_person": "", + "unpreviewable_file_message": "", + "download_complete": "", + "downloading_album": "", + "creating_zip": "", + "downloading": "", + "preparing_part": "", + "downloading_part": "", + "downloading_file": "", + "downloading_files": "", + "download_failed": "", + "download_failed_network_offline": "", + "download_failed_file_error": "", + "retry": "", + "download_progress": "", + "christmas": "", + "christmas_eve": "", + "new_year": "", + "new_year_eve": "", + "image": "", + "video": "", + "live_photo": "", + "live": "", + "edit_image": "", + "photo_editor": "", + "brightness": "", + "contrast": "", + "saturation": "", + "blur": "", + "transform": "", + "crop": "", + "aspect_ratio": "", + "square": "", + "freehand": "", + "apply_crop": "", + "rotation": "", + "rotate_left": "", + "rotate_right": "", + "flip": "", + "flip_vertically": "", + "flip_horizontally": "", + "download_edited": "", + "save_a_copy_to_ente": "", + "restore_original": "", + "colors": "", + "invert_colors": "", + "reset": "", + "faster_upload": "", + "faster_upload_description": "", + "open_ente_on_startup": "", + "cast_album_to_tv": "", + "cast_to_tv": "", + "enter_cast_pin_code": "", + "code": "", + "pair_device_to_tv": "", + "tv_not_found": "", + "cast_auto_pair": "", + "cast_auto_pair_description": "", + "choose_device_from_browser": "", + "cast_auto_pair_failed": "", + "pair_with_pin": "", + "pair_with_pin_description": "", + "visit_cast_url": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "created_at": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "totp_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "", + "developer_settings": "", + "server_endpoint": "", + "more_information": "", + "save": "", + "theme": "", + "system": "", + "light": "", + "dark": "", + "streamable_videos": "", + "processing_videos_status": "", + "share_favorites": "", + "person_favorites": "", + "shared_favorites": "", + "added_by_name": "", + "unowned_files_not_processed": "", + "unsupported_shared_favorites_not_processed": "", + "custom_domains": "", + "custom_domains_desc": "", + "link_your_domain": "", + "domain": "", + "domain_help": "", + "invalid_domain": "", + "already_linked_domain": "", + "add_dns_entry": "", + "add_dns_entry_hint": "", + "custom_domains_help": "", + "num_1": "", + "num_2": "", + "masonry": "", + "grouped": "", + "continuous": "", + "trip": "", + "album_layout": "", + "maps_privacy_notice": "", + "edit_location": "", + "add_location": "", + "add_location_button": "", + "save_location": "", + "search_location": "", + "app_lock": "", + "app_locked": "", + "app_lock_unavailable": "", + "enter_pin": "", + "enter_password": "", + "confirm_pin": "", + "confirm": "", + "incorrect_pin_or_password": "", + "app_lock_disable_confirm": "", + "lock_type": "", + "auto_lock": "", + "auto_lock_hint": "", + "pin_mismatch": "", + "password_mismatch": "", + "app_lock_password": "", + "app_lock_enter_password": "", + "app_lock_confirm_password": "", + "app_lock_set_password": "", + "app_lock_password_mismatch": "", + "app_lock_enter_pin_to_unlock": "", + "app_lock_pin_digit_label": "", + "app_lock_please_try_again_in": "", + "app_lock_set_pin": "", + "device_lock": "", + "device_lock_not_supported": "", + "device_lock_setup_failed": "", + "device_lock_setup_cancelled": "", + "device_lock_login_failed": "", + "device_lock_login_cancelled": "", + "device_lock_login_credential_missing": "", + "device_lock_login_instructions": "", + "device_lock_login": "", + "device_lock_verify_description": "", + "PIN": "", + "auto_lock_immediately": "", + "auto_lock_5_seconds": "", + "auto_lock_15_seconds": "", + "auto_lock_1_minute": "", + "auto_lock_5_minutes": "", + "auto_lock_30_minutes": "", + "wrong_unlock_code_one": "", + "wrong_unlock_code_other": "" +} diff --git a/web/packages/base/locales/cs-CZ/translation.json b/web/packages/base/locales/cs-CZ/translation.json index 83c6c1d03bf..b2b0db04784 100644 --- a/web/packages/base/locales/cs-CZ/translation.json +++ b/web/packages/base/locales/cs-CZ/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Přihlaste se pro připojení k albu", "sign_up": "Registrace", "get_ente_photos": "Získejte Ente Fotky", + "join_ente": "", "signup_to_join_album": "Zaregistrujte se pro připojení k albu", "new_to_ente": "Nový uživatel Ente", "existing_user": "Existující uživatel", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Váš prohlížeč nebo doplněk brání Ente v používání eTags k nahrávání velkých souborů.", "large_files": "Velké soubory", "large_files_hint": "Tyto soubory nebyly nahrány, protože překračují náš maximální limit velikosti souboru", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Nedostatečné úložiště", "insufficient_storage_hint": "Tyto soubory nebyly nahrány, protože překračují maximální limit velikosti Vašeho úložného plánu", "uploads_in_progress": "Probíhající nahrávání", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Úspěšně nahrané", "upload_to_album": "Nahrát do alba", "add_to_album": "Přidat do alba", @@ -874,6 +890,7 @@ "search_location": "Vyhledat polohu", "app_lock": "Zámek aplikace", "app_locked": "Aplikace uzamčena", + "app_lock_unavailable": "", "enter_pin": "Zadejte Váš PIN", "enter_password": "Zadejte své heslo", "confirm_pin": "Potvrďte svůj PIN", diff --git a/web/packages/base/locales/da-DK/translation.json b/web/packages/base/locales/da-DK/translation.json index e7dec215f51..30359d8f23f 100644 --- a/web/packages/base/locales/da-DK/translation.json +++ b/web/packages/base/locales/da-DK/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Ny bruger", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/de-DE/translation.json b/web/packages/base/locales/de-DE/translation.json index 3df3e02e8d8..eb846b8d128 100644 --- a/web/packages/base/locales/de-DE/translation.json +++ b/web/packages/base/locales/de-DE/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Bitte melden Sie sich an, um dem Album beizutreten", "sign_up": "Registrieren", "get_ente_photos": "Ente Photos erhalten", + "join_ente": "", "signup_to_join_album": "Registrieren Sie sich, um dem Album beizutreten", "new_to_ente": "Neu bei Ente", "existing_user": "Existierender Benutzer", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Ihr Browser oder ein Add-on hindert Ente daran, große Dateien mit eTags hochzuladen.", "large_files": "Große Dateien", "large_files_hint": "Diese Dateien wurden nicht hochgeladen, da sie unsere maximale Dateigröße überschreiten", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Zu wenig Speicher", "insufficient_storage_hint": "Diese Dateien wurden nicht hochgeladen, da sie die maximale Größe für Ihren Speicherplan überschreiten", "uploads_in_progress": "Upload läuft", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Erfolgreiche Uploads", "upload_to_album": "In Album hochladen", "add_to_album": "Zum Album hinzufügen", @@ -874,6 +890,7 @@ "search_location": "Nach einem Standort suchen", "app_lock": "App-Sperre", "app_locked": "App gesperrt", + "app_lock_unavailable": "", "enter_pin": "Geben Sie Ihre PIN ein", "enter_password": "Geben Sie Ihr Passwort ein", "confirm_pin": "Bestätigen Sie Ihre PIN", diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json index f10b88e19e0..03ad6cd74bc 100644 --- a/web/packages/base/locales/el-GR/translation.json +++ b/web/packages/base/locales/el-GR/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Εγγραφή", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Νέος/α στο Ente", "existing_user": "Υπάρχων χρήστης", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Το πρόγραμμα περιήγησής σας ή ένα πρόσθετο εμποδίζει το Ente να χρησιμοποιήσει eTags για να ανεβάσει μεγάλα αρχεία.", "large_files": "Μεγάλα αρχεία", "large_files_hint": "Αυτά τα αρχεία δεν μεταφορτώθηκαν καθώς υπερβαίνουν το μέγιστο όριο μεγέθους αρχείου", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Ανεπαρκής χώρος αποθήκευσης", "insufficient_storage_hint": "Αυτά τα αρχεία δεν φορτώθηκαν καθώς υπερβαίνουν τη χωρητικότητα του προγράμματός σας", "uploads_in_progress": "Μεταφορτώσεις σε εξέλιξη", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Επιτυχημένες μεταφορτώσεις", "upload_to_album": "Μεταφόρτωση στο άλμπουμ", "add_to_album": "Προσθήκη στο άλμπουμ", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/es-ES/translation.json b/web/packages/base/locales/es-ES/translation.json index 9b7d61b77a0..629fea0e86e 100644 --- a/web/packages/base/locales/es-ES/translation.json +++ b/web/packages/base/locales/es-ES/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Inicia sesión para unirte al álbum", "sign_up": "Registro", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Regístrate para unirte al álbum", "new_to_ente": "Nuevo en Ente", "existing_user": "Usuario existente", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Tu navegador o un complemento impide a Ente usar eTags para subir archivos grandes.", "large_files": "Archivos grandes", "large_files_hint": "Estos archivos no se han subido porque exceden nuestro límite máximo de tamaño de archivo", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Espacio insuficiente", "insufficient_storage_hint": "Estos archivos no se han subido porque exceden el límite de tamaño máximo para tu plan de almacenamiento", "uploads_in_progress": "Subidas en progreso", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Subidas exitosas", "upload_to_album": "Subir al álbum", "add_to_album": "Añadir al álbum", @@ -874,6 +890,7 @@ "search_location": "Buscar una ubicación", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/et-EE/translation.json b/web/packages/base/locales/et-EE/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/et-EE/translation.json +++ b/web/packages/base/locales/et-EE/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/fa-IR/translation.json b/web/packages/base/locales/fa-IR/translation.json index f6297717cea..729ecdac2b5 100644 --- a/web/packages/base/locales/fa-IR/translation.json +++ b/web/packages/base/locales/fa-IR/translation.json @@ -1,47 +1,48 @@ { "intro_slide_1_title": "پشتیبان گیری خصوصی
برای خاطرات شما", "intro_slide_1": "به صورت پیش‌فرض رمزگذاری سرتاسر", - "intro_slide_2_title": "", - "intro_slide_2": "", - "intro_slide_3_title": "", + "intro_slide_2_title": "حفاظت کاملا مطمئن
در حریم امن و شخصی", + "intro_slide_2": "ماندگاری دائمی بدون تاریخ انقضا", + "intro_slide_3_title": "دسترسی آسان
در هر لحظه و هر مکان", "intro_slide_3": "اندروید، آی‌اواس، وب، رایانه رومیزی", "login": "ورود", - "login_to_join_album": "", + "login_to_join_album": "برای پیوستن به آلبوم، وارد شوید", "sign_up": "ثبت نام", - "get_ente_photos": "", - "signup_to_join_album": "", - "new_to_ente": "", - "existing_user": "", - "enter_email": "", - "invalid_email_error": "", - "required": "", - "email_not_registered": "", - "account_setup_incomplete_create_account": "", - "email_already_registered": "", - "email_sent": "", - "check_inbox_hint": "", + "get_ente_photos": "دریافت برنامه Ente Photos", + "join_ente": "به Ente بپیوندید", + "signup_to_join_album": "برای پیوستن به آلبوم، ثبت‌نام کنید", + "new_to_ente": "کاربر جدید Ente", + "existing_user": "کاربر قدیمی", + "enter_email": "آدرس ایمیل خود را وارد کنید", + "invalid_email_error": "یک ایمیل معتبر وارد کنید", + "required": "ضروری", + "email_not_registered": "ایمیل ثبت نشده است", + "account_setup_incomplete_create_account": "راه‌اندازی حساب کامل نشده است. برای تکمیل، حساب کاربری خود را بسازید.", + "email_already_registered": "ایمیل قبلاً ثبت شده است", + "email_sent": "کد تأیید به آدرس {{email}} ارسال شد", + "check_inbox_hint": "لطفاً برای تکمیل تأیید، صندوق ورودی (و اسپم) ایمیل خود را بررسی کنید", "verification_code": "کد تایید", "resend_code": "ارسال مجدد کد", "verify": "تایید", "send_otp": "ارسال رمز یک‌بار مصرف", - "generic_error": "یک مشکلی پیش آمده", + "generic_error": "مشکلی پیش آمده", "generic_error_retry": "مشکلی پیش آمده، لطفا دوباره تلاش کنید", - "invalid_code_error": "", + "invalid_code_error": "کد تایید نامعتبر", "expired_code_error": "کد تایید شما باطل شد", "status_sending": "در حال ارسال...", "status_sent": "ارسال شد!", "password": "رمز عبور", - "link_password_description": "رمز عبور خودرا جهت باز شدن آلبوم بنویسید", + "link_password_description": "برای باز شدن آلبوم، رمز عبور خود را وارد کنید", "unlock": "بازکردن", "set_password": "تنظیم رمز عبور", "sign_in": "ورود", - "incorrect_password": "رمز عبور نادرست", - "incorrect_password_or_no_account": "رمز عبور نادرست یا ایمیل ثبت نام نشده", - "pick_password_hint": "", - "pick_password_caution": "", - "key_generation_in_progress": "", + "incorrect_password": "رمز عبور نادرست است", + "incorrect_password_or_no_account": "رمز عبور نادرست یا ایمیل ثبت‌نام نشده", + "pick_password_hint": "لطفا گذرواژه‌ای را وارد کنید که بتوانیم از آن برای رمزنگاری اطلاعات شما استفاده کنیم", + "pick_password_caution": "کلمه عبور شما ذخیره نمی‌شود؛ لذا در صورت فراموشی آن، بدون کلید بازیابی امکان برگرداندن داده‌هایتان وجود نخواهد داشت.", + "key_generation_in_progress": "در حال ایجاد کلیدهای رمزنگاری...", "confirm_password": "تایید رمز عبور", - "referral_source_hint": "", + "referral_source_hint": "از کجا در مورد Ente شنیدی؟ (اختیاری)", "referral_source_info": "", "password_mismatch_error": "", "show_or_hide_password": "", @@ -57,27 +58,27 @@ "close": "بستن", "yes": "بله", "no": "خیر", - "nothing_here": "هیچی درحال حاضر اینجا نیست", + "nothing_here": "در حال حاضر، چیزی اینجا نیست", "upload": "بارگذاری", "import": "وارد کردن", "add_photos": "", "add_more_photos": "", - "add_photos_count_one": "", + "add_photos_count_one": "اضافه کردن ۱ مورد", "add_photos_count": "", "select_photos": "", - "file_upload": "", - "preparing": "", + "file_upload": "بارگذاری فایل", + "preparing": "درحال آماده‌سازی", "processed_counts": "", "upload_reading_metadata_files": "", "upload_cancelling": "", "upload_done": "", "upload_skipped": "", "initial_load_delay_warning": "", - "no_account": "", + "no_account": "حساب کاربری ندارید", "existing_account": "", - "create": "", + "create": "ایجاد", "files_count": "", - "download": "", + "download": "بارگیری", "download_album": "", "download_favorites": "", "download_uncategorized": "", @@ -103,48 +104,48 @@ "toggle_favorite": "", "toggle_archive": "", "view_info": "", - "copy_as_png": "", - "toggle_fullscreen": "", - "exit_fullscreen": "", + "copy_as_png": "کپی به عنوان PNG", + "toggle_fullscreen": "تغيير حالت به تمام‌‌صفحه", + "exit_fullscreen": "خروج از حالت تمام‌صفحه", "go_fullscreen": "", - "zoom": "", - "zoom_in": "", - "zoom_out": "", - "play": "", - "pause": "", - "previous": "", - "next": "", + "zoom": "مقیاس تصویر", + "zoom_in": "بزرگ‌نمایی", + "zoom_out": "کوچک‌نمایی", + "play": "پخش", + "pause": "توقف", + "previous": "قبلی", + "next": "بعدی", "video_seek": "", - "quality": "", - "auto": "", - "original": "", - "speed": "", - "title_photos": "", - "title_auth": "", - "title_accounts": "", - "title_locker": "", - "upload_first_photo": "", + "quality": "کیفیت", + "auto": "خودکار", + "original": "اصلی", + "speed": "سرعت", + "title_photos": "Ente Photos", + "title_auth": "Ente Auth", + "title_accounts": "حساب‌های Ente", + "title_locker": "Ente Locker", + "upload_first_photo": "اولین عکس خود را بارگزاری کنید", "import_your_folders": "", "upload_dropzone_hint": "", "upload_dropzone_hint_public_album": "", "watch_folder_dropzone_hint": "", - "trash_files_title": "", - "trash_file_title": "", + "trash_files_title": "فایل ها حذف شوند؟", + "trash_file_title": "فایل حذف شود؟", "delete_files_title": "", "delete_files_message": "", "selected_count": "", "selected_and_yours_count": "", - "delete": "", + "delete": "حذف", "like": "", "unlike": "", - "reply": "", + "reply": "پاسخ", "like_count_one": "", "like_count": "", - "loading": "", + "loading": "درحال بارگذاری...", "no_activity_yet": "", - "no_comments_yet": "", + "no_comments_yet": "تاکنون نظری درج نشده", "no_likes_yet": "", - "set_your_name": "", + "set_your_name": "نام خود را تعیین کنید", "set_your_name_subtitle": "", "like_photo": "", "select_album_to_like": "", @@ -153,20 +154,20 @@ "give_it_a_like": "", "set_name": "", "set_name_and_like": "", - "anonymous": "", - "unknown_user": "", - "user": "", - "yourself": "", + "anonymous": "ناشناس", + "unknown_user": "ناشناخته", + "user": "کاربر", + "yourself": "خودتان", "liked_a_photo": "", "liked_a_video": "", "commented_on_a_photo": "", "commented_on_a_video": "", "replied_to_a_comment": "", "liked_a_comment": "", - "just_now": "", - "minutes_ago": "", - "hours_ago": "", - "days_ago": "", + "just_now": "همین الان", + "minutes_ago": "{{count}} دقیقه پیش", + "hours_ago": "{{count}} ساعت پیش", + "days_ago": "{{count}} روز پیش", "like_anonymously": "", "join_album_and_like": "", "public_like_subtitle": "", @@ -174,9 +175,9 @@ "comment_anonymously": "", "join_album_and_comment": "", "like_all": "", - "favorite": "", + "favorite": "مورد علاقه", "un_favorite": "", - "convert": "", + "convert": "تبدیل", "multi_folder_upload": "", "upload_to_choice": "", "upload_to_single_album": "", @@ -184,35 +185,35 @@ "session_expired": "", "session_expired_message": "", "password_generation_failed": "", - "change_password": "", + "change_password": "تغییر گذرواژه", "password_changed_elsewhere": "", "password_changed_elsewhere_message": "", - "go_back": "", - "account": "", - "recovery_key": "", - "do_this_later": "", - "save_key": "", + "go_back": "بازگشت", + "account": "حساب کاربری", + "recovery_key": "کلید بازیابی", + "do_this_later": "بعداً انجام شود", + "save_key": "ذخیره کلید", "recovery_key_description": "", "key_not_stored_note": "", "recovery_key_generation_failed": "", - "forgot_password": "", - "recover_account": "", - "recover": "", - "no_recovery_key_title": "", - "incorrect_recovery_key": "", - "sorry": "", + "forgot_password": "فراموشی رمز عبور", + "recover_account": "بازیابی حساب کاربری", + "recover": "بازیابی", + "no_recovery_key_title": "کلید بازیابی ندارید؟", + "incorrect_recovery_key": "کلید بازیابی اشتباه است", + "sorry": "متاسفیم", "no_recovery_key_message": "", "no_two_factor_recovery_key_message": "", - "contact_support": "", - "help": "", + "contact_support": "ارتباط با پشتیبانی", + "help": "کمک", "ente_help": "", - "blog": "", - "request_feature": "", - "support": "", - "cancel": "", - "logout": "", - "logout_message": "", - "delete_account": "", + "blog": "وبلاگ", + "request_feature": "درخواست ویژگی", + "support": "پشتیبانی", + "cancel": "لغو", + "logout": "خروج", + "logout_message": "آیا برای خروج، مطمئن هستید؟", + "delete_account": "حذف حساب کاربری", "delete_account_multi_app_warning": "", "delete_account_manually_message": "", "change_email": "", @@ -224,41 +225,53 @@ "terminate_session_confirm_message_self": "", "terminate": "", "terminate_session_failed": "", - "ok": "", - "success": "", - "error": "", - "note": "", + "ok": "تأیید", + "success": "موفقیت‌آمیز", + "error": "خطا", + "note": "یادداشت", "offline_message": "", "network_error": "", - "install": "", + "install": "نصب", "install_mobile_app": "", - "download_app": "", + "download_app": "بارگیری اپلیکیشن دسکتاپ", "download_app_message": "", - "subscription": "", - "manage_payment_method": "", - "manage_family": "", - "family_plan": "", + "subscription": "اشتراک", + "manage_payment_method": "مدیریت روش پرداخت", + "manage_family": "مدیریت خانواده", + "family_plan": "اشتراک خانواده", "family_plan_description": "", - "view_plans": "", - "family_members": "", - "family_manager": "", - "family_member": "", - "family_invited": "", - "family_storage": "", - "family_storage_usage": "", - "add_family_member": "", - "invite_family_member": "", - "family_invite_hint": "", - "family_invite_title": "", + "view_plans": "مشاهده اشتراک‌‌ها", + "family_members": "اعضا", + "family_manager": "مدیر خانواده", + "family_member": "عضو", + "family_invited": "دعوت شده", + "family_storage": "فضای خانواده", + "family_storage_usage": "{{used}} از {{total}} استفاده شده", + "add_family_member": "اضافه کردن عضو خانواده", + "invite_family_member": "دعوت کردن عضوی در خانواده", + "family_invite_hint": "باید حتما یک حساب کاربری Ente داشته باشند.", + "family_invite_title": "عضو یک خانواده در Ente شوید", "family_invite_description": "", - "accept_family_invite": "", - "family_invite_accepted": "", + "accept_family_invite": "قبول دعوت", + "family_invite_accepted": "به خانواده خوش آمدید", "family_invite_accepted_description": "", "family_invite_invalid": "", - "open_ente": "", + "open_ente": "باز کردن Ente", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", - "enter_valid_email": "", - "family_user_not_found": "", + "enter_valid_email": "یک ایمیل معتبر وارد کنید", + "family_user_not_found": "هیچ حساب کاربری Ente با این ایمیل پیدا نشد.", "family_already_member": "", "family_paid_subscriber": "", "family_size_limit": "", @@ -282,8 +295,8 @@ "two_months_free": "", "free_plan_option": "", "free_plan_description": "", - "active": "", - "subscription_info_free": "", + "active": "فعال", + "subscription_info_free": "شما در حال استفاده از طرح رایگان هستید", "subscription_info_family": "", "subscription_info_expired": "", "subscription_info_renewal_cancelled": "", @@ -299,15 +312,15 @@ "subscription_verification_error": "", "update_payment_method_message": "", "payment_method_authentication_failed": "", - "update_payment_method": "", - "monthly": "", - "yearly": "", - "month_short": "", - "year": "", - "update_subscription": "", - "update_subscription_title": "", - "update_subscription_message": "", - "cancel_subscription": "", + "update_payment_method": "بروزرسانی روش پرداخت", + "monthly": "ماهیانه", + "yearly": "سالانه", + "month_short": "ماه", + "year": "سال", + "update_subscription": "تغییر طرح", + "update_subscription_title": "تایید تغییر طرح", + "update_subscription_message": "آیا از تغییر طرح خود اطمینان دارید؟", + "cancel_subscription": "لغو اشتراک", "cancel_subscription_message": "", "cancel_subscription_with_addon_message": "", "subscription_cancel_success": "", @@ -318,18 +331,18 @@ "cancel_subscription_on_mobile": "", "cancel_subscription_on_mobile_message": "", "mail_to_manage_subscription": "", - "rename": "", + "rename": "تغییر نام", "rename_file": "", - "rename_album": "", + "rename_album": "تغییر نام آلبوم", "set_cover": "", "select_cover_photo": "", "reset_to_default": "", - "delete_album": "", - "delete_album_title": "", + "delete_album": "حذف آلبوم", + "delete_album_title": "آلبوم حذف شود؟", "delete_album_message": "", "delete_album_message_no_photos": "", - "delete_photos": "", - "keep_photos": "", + "delete_photos": "حذف عکس‌ها", + "keep_photos": "نگه داشتن عکس‌ها", "share_album": "", "share_action": "", "activity": "", @@ -350,7 +363,7 @@ "album": "آلبوم", "date": "تاریخ", "description": "توضیحات", - "file_type": "نوع پرونده", + "file_type": "نوع فایل", "settings": "", "magic": "", "photos_count_zero": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -870,10 +886,11 @@ "edit_location": "", "add_location": "", "add_location_button": "", - "save_location": "", - "search_location": "", - "app_lock": "", - "app_locked": "", + "save_location": "ذخیره‌سازی محل", + "search_location": "جستجوی محل", + "app_lock": "قفل برنامه", + "app_locked": "برنامه قفل شده است", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/fi-FI/translation.json b/web/packages/base/locales/fi-FI/translation.json index 03675b6d33e..3cab63d16d2 100644 --- a/web/packages/base/locales/fi-FI/translation.json +++ b/web/packages/base/locales/fi-FI/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Kirjaudu sisään liittyäksesi albumiin", "sign_up": "Rekisteröidy", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Luo käyttäjä liittyäksesi albumiin", "new_to_ente": "Uusi Ente-käyttäjä", "existing_user": "Jo valmiiksi olemassaoleva käyttäjä", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Selaimesi tai lisäosa estää Enteä käyttämästä eTags ladatakseen suuria tiedostoja.", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/fr-FR/translation.json b/web/packages/base/locales/fr-FR/translation.json index 44db33bd6ac..42ec94af0c8 100644 --- a/web/packages/base/locales/fr-FR/translation.json +++ b/web/packages/base/locales/fr-FR/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Connectez-vous pour rejoindre l'album", "sign_up": "Inscription", "get_ente_photos": "Découvrez Ente Photos", + "join_ente": "Rejoindre Ente", "signup_to_join_album": "Inscrivez-vous pour rejoindre l'album", "new_to_ente": "Nouveau sur Ente", "existing_user": "Utilisateur existant", @@ -238,41 +239,53 @@ "manage_payment_method": "Gérer le mode de paiement", "manage_family": "Gérer la famille", "family_plan": "Plan famille", - "family_plan_description": "", - "view_plans": "", - "family_members": "", - "family_manager": "", - "family_member": "", - "family_invited": "", - "family_storage": "", - "family_storage_usage": "", - "add_family_member": "", - "invite_family_member": "", - "family_invite_hint": "", - "family_invite_title": "", - "family_invite_description": "", - "accept_family_invite": "", - "family_invite_accepted": "", - "family_invite_accepted_description": "", - "family_invite_invalid": "", - "open_ente": "", - "invite": "", - "enter_valid_email": "", - "family_user_not_found": "", - "family_already_member": "", - "family_paid_subscriber": "", - "family_size_limit": "", - "family_paid_plan_required": "", - "resend_family_invite": "", - "revoke_family_invite": "", - "revoke_family_invite_confirm": "", - "revoke": "", - "remove_family_member": "", - "remove_family_member_confirm": "", - "set_storage_limit": "", - "storage_limit_gb": "", - "storage_limit_hint": "", - "family_storage_limit_invalid": "", + "family_plan_description": "Partagez votre abonnement avec jusqu'à cinq membres de votre famille. Tout le monde a son propre espace privé.", + "view_plans": "Voir les abonnements", + "family_members": "Membres", + "family_manager": "Responsable de la famille", + "family_member": "Membre", + "family_invited": "Invités", + "family_storage": "Stockage familial", + "family_storage_usage": "{{used}} utilisé sur {{total}}", + "add_family_member": "Ajouter un membre de la famille", + "invite_family_member": "Inviter un membre de la famille", + "family_invite_hint": "Ils doivent déjà avoir un compte Ente.", + "family_invite_title": "Rejoindre une famille sur Ente", + "family_invite_description": "{{email}} vous a invité à partager son abonnement. Vos photos et vos vidéos resteront privées.", + "accept_family_invite": "Accepter l'invitation", + "family_invite_accepted": "Bienvenue dans la Famille", + "family_invite_accepted_description": "Vous partagez maintenant le forfait de {{email}}.", + "family_invite_invalid": "Cette invitation n'est pas valide ou n'est plus disponible.", + "open_ente": "Ouvrir Ente", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", + "invite": "Inviter", + "enter_valid_email": "Saisissez une adresse e-mail valide", + "family_user_not_found": "Aucun compte Ente n'a été trouvé pour cette adresse e-mail.", + "family_already_member": "Cette personne fait déjà partie d'un abonnement familial.", + "family_paid_subscriber": "Une personne ayant un abonnement actif ne peut pas se joindre à une famille.", + "family_size_limit": "Votre famille a déjà le nombre maximum de membres.", + "family_paid_plan_required": "Un abonnement payant est nécessaire pour créer une famille.", + "resend_family_invite": "Renvoyer une invitation", + "revoke_family_invite": "Révoquer l'invitation", + "revoke_family_invite_confirm": "Retirer l'invitation envoyée à {{email}}?", + "revoke": "Révoquer", + "remove_family_member": "Supprimer le membre de la famille", + "remove_family_member_confirm": "Supprimer {{email}} de votre famille ?", + "set_storage_limit": "Définir la limite de stockage", + "storage_limit_gb": "Limite de stockage (GB)", + "storage_limit_hint": "Laisser vide pour aucune limite.", + "family_storage_limit_invalid": "La limite doit être entre l'utilisation actuelle du membre et le stockage total de la famille.", "leave_family_plan": "Quitter le plan famille", "leave": "Quitter", "leave_family_plan_confirm": "Êtes-vous certains de vouloir quitter le plan famille?", @@ -397,7 +410,7 @@ "details": "Détails", "view_exif": "Visualiser toutes les données Exif", "no_exif": "Aucune donnée Exif", - "use_shortcut_to_navigate": "", + "use_shortcut_to_navigate": "Utilisez pour naviguer", "exif": "Exif", "two_factor": "Double authentification", "two_factor_authentication": "Authentification double-facteur", @@ -442,8 +455,8 @@ "failed_uploads_hint": "Il y aura une option pour retenter lorsque le chargement sera terminé", "retry_failed_uploads": "Réessayer les chargements ayant échoués", "thumbnail_generation_failed": "Échec de création d'une miniature", - "canvas_blocked_upload_failed_title": "", - "canvas_blocked_upload_failed_description": "", + "canvas_blocked_upload_failed_title": "Échec du chargement", + "canvas_blocked_upload_failed_description": "Les paramètres du navigateur bloquent la génération des miniatures. Corrigez ceci avant de télécharger à nouveau.", "thumbnail_generation_failed_hint": "Ces fichiers sont bien chargés, mais nous ne pouvons pas créer de miniatures pour eux.", "learn_how": "Découvrez comment", "unsupported_files": "Fichiers non supportés", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Votre navigateur ou un addon empêche Ente d'utiliser eTags pour charger des fichiers volumineux.", "large_files": "Gros fichiers", "large_files_hint": "Ces fichiers n'ont pas été chargés car ils dépassent notre taille limite par fichier", + "partner_shared_files": "Fichiers partagés des partenaires", + "partner_shared_files_hint": "Ces fichiers ont été ignorés car ils ont été partagés avec vous par le partage de partenaires dans Google Photos", "insufficient_storage": "Stockage insuffisant", "insufficient_storage_hint": "Ces fichiers n'ont pas été chargés car ils dépassent la taille maximale de votre plan de stockage", "uploads_in_progress": "Chargements en cours", + "wait_for_active_upload_to_finish": "Veuillez attendre la fin du téléchargement actif.", "successful_uploads": "Chargements réussis", "upload_to_album": "Charger dans l'album", "add_to_album": "Ajouter à l'album", @@ -874,6 +890,7 @@ "search_location": "Rechercher un lieu", "app_lock": "Verrouillage de l'application", "app_locked": "Application verrouillée", + "app_lock_unavailable": "Verrouillage de l'application indisponible. Vérifiez le trousseau du système.", "enter_pin": "Saisir votre code PIN", "enter_password": "Indiquez votre mot de passe", "confirm_pin": "Confirmez votre PIN", diff --git a/web/packages/base/locales/gu-IN/translation.json b/web/packages/base/locales/gu-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/gu-IN/translation.json +++ b/web/packages/base/locales/gu-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/hi-IN/translation.json b/web/packages/base/locales/hi-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/hi-IN/translation.json +++ b/web/packages/base/locales/hi-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/hu-HU/translation.json b/web/packages/base/locales/hu-HU/translation.json index 6ce33e0a929..c99adab1578 100644 --- a/web/packages/base/locales/hu-HU/translation.json +++ b/web/packages/base/locales/hu-HU/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Regisztráció", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Új Ente felhasználó", "existing_user": "Meglévő felhasználó", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/id-ID/translation.json b/web/packages/base/locales/id-ID/translation.json index e735344a354..4b9551366db 100644 --- a/web/packages/base/locales/id-ID/translation.json +++ b/web/packages/base/locales/id-ID/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Daftar", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Baru di Ente", "existing_user": "Pengguna yang sudah ada", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/is-IS/translation.json b/web/packages/base/locales/is-IS/translation.json index e2cc924d97a..f34ea3033a0 100644 --- a/web/packages/base/locales/is-IS/translation.json +++ b/web/packages/base/locales/is-IS/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/it-IT/translation.json b/web/packages/base/locales/it-IT/translation.json index 98d5d4734c3..3253ed8ef36 100644 --- a/web/packages/base/locales/it-IT/translation.json +++ b/web/packages/base/locales/it-IT/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Accedi per unirti ad un album", "sign_up": "Registrati", "get_ente_photos": "Ottieni Ente", + "join_ente": "", "signup_to_join_album": "Registrati per unirti all'album", "new_to_ente": "Prima volta con Ente", "existing_user": "Accedi", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "File pesanti", "large_files_hint": "Questi file non sono stati caricati perché superano il nostro limite di pesantezza di un file", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Spazio insufficiente", "insufficient_storage_hint": "Questi file non sono stati caricati perché supererebbero la capacità massima del tuo piano di spazio d'archiviazione", "uploads_in_progress": "Caricamenti in corso", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Caricamenti eseguiti con successo", "upload_to_album": "Carica nell'album", "add_to_album": "Aggiungi all'album", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ja-JP/translation.json b/web/packages/base/locales/ja-JP/translation.json index 91425078dd2..64d6c14df9c 100644 --- a/web/packages/base/locales/ja-JP/translation.json +++ b/web/packages/base/locales/ja-JP/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "サインアップ", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "新しいユーザー", "existing_user": "存在するユーザー", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "ブラウザまたはアドオンが eTags を使用して大きなファイルをアップロードすることを妨げています。", "large_files": "ファイルサイズが大きい", "large_files_hint": "ファイルサイズの上限を超えているため、これらのファイルはアップロードされませんでした", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "ストレージ容量が足りません", "insufficient_storage_hint": "ストレージプランの最大サイズ制限を超えているため、これらのファイルはアップロードされませんでした", "uploads_in_progress": "アップロード進行中", + "wait_for_active_upload_to_finish": "", "successful_uploads": "アップロード成功", "upload_to_album": "アルバムにアップロード", "add_to_album": "アルバムに追加", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/km-KH/translation.json b/web/packages/base/locales/km-KH/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/km-KH/translation.json +++ b/web/packages/base/locales/km-KH/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/kn-IN/translation.json b/web/packages/base/locales/kn-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/kn-IN/translation.json +++ b/web/packages/base/locales/kn-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ko-KR/translation.json b/web/packages/base/locales/ko-KR/translation.json index 78d45871505..e7b2c8282aa 100644 --- a/web/packages/base/locales/ko-KR/translation.json +++ b/web/packages/base/locales/ko-KR/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "회원가입", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Ente를 처음 사용합니다", "existing_user": "기존 회원입니다", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/lt-LT/translation.json b/web/packages/base/locales/lt-LT/translation.json index cf0c1106672..679ddc74291 100644 --- a/web/packages/base/locales/lt-LT/translation.json +++ b/web/packages/base/locales/lt-LT/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Prisijunkite, kad jungtumėtės prie albumo", "sign_up": "Registruotis", "get_ente_photos": "Gaukite „Ente“ nuotraukas", + "join_ente": "Jungtis prie „Ente“", "signup_to_join_album": "Registruokitės, kad jungtumėtės prie albumo", "new_to_ente": "Naujas platformoje „Ente“", "existing_user": "Esamas naudotojas", @@ -24,7 +25,7 @@ "resend_code": "Siųsti kodą iš naujo", "verify": "Patvirtinti", "send_otp": "Siųsti OTP", - "generic_error": "Kažkas nutiko ne taip", + "generic_error": "Kažkas nutiko ne taip.", "generic_error_retry": "Kažkas nutiko ne taip. Bandykite dar kartą.", "invalid_code_error": "Netinkamas patvirtinimo kodas.", "expired_code_error": "Jūsų patvirtinimo kodas nebegaliojantis.", @@ -238,48 +239,60 @@ "manage_payment_method": "Tvarkyti mokėjimo būdą", "manage_family": "Tvarkyti šeimą", "family_plan": "Šeimos planas", - "family_plan_description": "", - "view_plans": "", - "family_members": "", - "family_manager": "", - "family_member": "", - "family_invited": "", - "family_storage": "", - "family_storage_usage": "", - "add_family_member": "", - "invite_family_member": "", - "family_invite_hint": "", - "family_invite_title": "", - "family_invite_description": "", - "accept_family_invite": "", - "family_invite_accepted": "", - "family_invite_accepted_description": "", - "family_invite_invalid": "", - "open_ente": "", - "invite": "", - "enter_valid_email": "", - "family_user_not_found": "", - "family_already_member": "", - "family_paid_subscriber": "", - "family_size_limit": "", - "family_paid_plan_required": "", - "resend_family_invite": "", - "revoke_family_invite": "", - "revoke_family_invite_confirm": "", - "revoke": "", - "remove_family_member": "", - "remove_family_member_confirm": "", - "set_storage_limit": "", - "storage_limit_gb": "", - "storage_limit_hint": "", - "family_storage_limit_invalid": "", + "family_plan_description": "Pasidalinkite savo planu su iki penkių šeimos narių. Kiekvienas gaus savo privatų vietą.", + "view_plans": "Peržiūrėti planus", + "family_members": "Nariai", + "family_manager": "Šeimos valdytojas", + "family_member": "Narys", + "family_invited": "Pakviestas", + "family_storage": "Šeimos saugykla", + "family_storage_usage": "{{used}} iš {{total}} naudojama", + "add_family_member": "Įtraukti šeimos narį", + "invite_family_member": "Kviesti šeimos narį", + "family_invite_hint": "Jie jau turi turėti „Ente“ paskyrą.", + "family_invite_title": "Junkitės prie šeimos platformoje „Ente“", + "family_invite_description": "{{email}} pakvietė jus pasidalinti savo planu. Jūsų nuotraukos ir vaizdo įrašai liks privatūs.", + "accept_family_invite": "Priimti kvietimą", + "family_invite_accepted": "Sveiki atvykę į šeimą", + "family_invite_accepted_description": "Dabar dalinatės {{email}} planu.", + "family_invite_invalid": "Šis kvietimas yra netinkamas arba nebegalioja.", + "open_ente": "Atverti „Ente“", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", + "invite": "Kviesti", + "enter_valid_email": "Įveskite tinkamą el. pašto adresą", + "family_user_not_found": "Šiam el. pašto adresui nerasta jokios „Ente“ paskyros.", + "family_already_member": "Šis asmuo jau yra šeimos plano dalis.", + "family_paid_subscriber": "Asmuo su aktyviu mokamu planu negali jungtis prie šeimos.", + "family_size_limit": "Jūsų šeima jau turi didžiausią narių skaičių.", + "family_paid_plan_required": "Privalomas mokamas planas norint sukurti šeimą.", + "resend_family_invite": "Siųsti kvietimą dar kartą", + "revoke_family_invite": "Atšaukti kvietimą", + "revoke_family_invite_confirm": "Atšaukti adresu {{email}} išsiųstą kvietimą?", + "revoke": "Atšaukti", + "remove_family_member": "Šalinti šeimos narį", + "remove_family_member_confirm": "Pašalinti {{email}} iš savo šeimos?", + "set_storage_limit": "Nustatyti saugyklos ribą", + "storage_limit_gb": "Saugyklos riba (GB)", + "storage_limit_hint": "Palikite tuščią dėl jokios ribos.", + "family_storage_limit_invalid": "Riba turi būti tarp nario dabartinio naudojimo ir šeimos bendro saugyklos.", "leave_family_plan": "Palikti šeimos planą", "leave": "Palikti", "leave_family_plan_confirm": "Ar tikrai norite palikti šeimos planą?", "choose_plan": "Pasirinkite planą", "manage_plan": "Tvarkyti prenumeratą", "current_usage": "Dabartinis naudojimas – {{usage}}", - "two_months_free": "Gaukite 2 mėnesius nemokamai metiniuose planuose", + "two_months_free": "Gaukite 2 mėnesius nemokamai metiniuose planuose.", "free_plan_option": "Tęsti su nemokamu planu", "free_plan_description": "{{storage}} nemokamai amžinai.", "active": "Aktyvi", @@ -303,7 +316,7 @@ "monthly": "Mėnesinis", "yearly": "Metinis", "month_short": "mėn.", - "year": "metus", + "year": "m.", "update_subscription": "Keisti planą", "update_subscription_title": "Patvirtinkite plano pakeitimą", "update_subscription_message": "Ar tikrai norite keisti planą?", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Jūsų naršyklė arba priedas neleidžia „Ente“ naudoti „eTags“ dideliems failams įkelti.", "large_files": "Dideli failai", "large_files_hint": "Šie failai nebuvo įkelti, nes jie viršija didžiausio failo dydžio ribą.", + "partner_shared_files": "Partnerio bendrinti failai", + "partner_shared_files_hint": "Šie failai buvo praleisti, kadangi jie buvo bendrinti su jumis per „Google“ nuotraukos“ partnerių bendrinimą.", "insufficient_storage": "Nepakankama saugykla", "insufficient_storage_hint": "Šie failai nebuvo įkelti, nes jie viršija jūsų saugyklos plano didžiausio dydžio ribą.", "uploads_in_progress": "Vykdomi įkėlimai", + "wait_for_active_upload_to_finish": "Palaukite, kol baigsis aktyvius įkėlimas.", "successful_uploads": "Sėkmingi įkėlimai", "upload_to_album": "Įkelti į albumą", "add_to_album": "Įtraukti į albumą", @@ -874,6 +890,7 @@ "search_location": "Ieškokite vietovės", "app_lock": "Programos užraktas", "app_locked": "Programa užrakinta", + "app_lock_unavailable": "Programos užraktas nepasiekiamas. Patikrinkite sistemos raktų grandinę.", "enter_pin": "Įveskite savo PIN", "enter_password": "Įveskite savo slaptažodį", "confirm_pin": "Patvirtinkite savo PIN", diff --git a/web/packages/base/locales/lv-LV/translation.json b/web/packages/base/locales/lv-LV/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/lv-LV/translation.json +++ b/web/packages/base/locales/lv-LV/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ml-IN/translation.json b/web/packages/base/locales/ml-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ml-IN/translation.json +++ b/web/packages/base/locales/ml-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ms-BN/translation.json b/web/packages/base/locales/ms-BN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ms-BN/translation.json +++ b/web/packages/base/locales/ms-BN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ms-MY/translation.json b/web/packages/base/locales/ms-MY/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ms-MY/translation.json +++ b/web/packages/base/locales/ms-MY/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/nl-NL/translation.json b/web/packages/base/locales/nl-NL/translation.json index 848b62d8827..29697405470 100644 --- a/web/packages/base/locales/nl-NL/translation.json +++ b/web/packages/base/locales/nl-NL/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Log in om album toe te voegen", "sign_up": "Registreren", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Meld u aan om aan een album deel te nemen", "new_to_ente": "Nieuw bij Ente", "existing_user": "Bestaande gebruiker", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Uw browser of een addon verhindert Ente te gebruiken met eTags om grote bestanden te uploaden.", "large_files": "Grote bestanden", "large_files_hint": "Deze bestanden zijn niet geüpload omdat ze onze limiet voor bestandsgrootte overschrijden", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Onvoldoende opslagruimte", "insufficient_storage_hint": "Deze bestanden zijn niet geüpload omdat ze de maximale grootte van uw opslagplan overschrijden", "uploads_in_progress": "Bezig met uploaden", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Succesvolle uploads", "upload_to_album": "Uploaden naar album", "add_to_album": "Toevoegen aan album", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json index 641736c7435..4b9355745ad 100644 --- a/web/packages/base/locales/pl-PL/translation.json +++ b/web/packages/base/locales/pl-PL/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Zarejestruj się", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "Nowy/a do Ente", "existing_user": "Istniejący użytkownik", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Twoja przeglądarka lub dodatek uniemożliwia Ente z używania eTags do przesyłania dużych plików.", "large_files": "Duże pliki", "large_files_hint": "Te pliki nie zostały przesłane, ponieważ przekraczają maksymalny limit rozmiaru pliku", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Niewystarczająca ilość miejsca", "insufficient_storage_hint": "Te pliki nie zostały przesłane, ponieważ przekraczają maksymalny limit rozmiaru dla Twojego planu pojemności", "uploads_in_progress": "Przesyłanie w toku", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Pomyślne przesłania", "upload_to_album": "Prześlij do albumu", "add_to_album": "Dodaj do albumu", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json index 2f61b98dc22..ea3197ee20e 100644 --- a/web/packages/base/locales/pt-BR/translation.json +++ b/web/packages/base/locales/pt-BR/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Entre para juntar-se ao álbum", "sign_up": "Registrar", "get_ente_photos": "Obter Ente Fotos", + "join_ente": "Juntar-se ao Ente", "signup_to_join_album": "Registre-se para juntar-se ao álbum", "new_to_ente": "Novo no Ente", "existing_user": "Usuário existente", @@ -238,41 +239,53 @@ "manage_payment_method": "Gerenciar método de pagamento", "manage_family": "Gerenciar família", "family_plan": "Plano familiar", - "family_plan_description": "", - "view_plans": "", - "family_members": "", - "family_manager": "", - "family_member": "", - "family_invited": "", - "family_storage": "", - "family_storage_usage": "", - "add_family_member": "", - "invite_family_member": "", - "family_invite_hint": "", - "family_invite_title": "", - "family_invite_description": "", - "accept_family_invite": "", - "family_invite_accepted": "", - "family_invite_accepted_description": "", - "family_invite_invalid": "", - "open_ente": "", - "invite": "", - "enter_valid_email": "", - "family_user_not_found": "", - "family_already_member": "", - "family_paid_subscriber": "", - "family_size_limit": "", - "family_paid_plan_required": "", - "resend_family_invite": "", - "revoke_family_invite": "", - "revoke_family_invite_confirm": "", - "revoke": "", - "remove_family_member": "", - "remove_family_member_confirm": "", - "set_storage_limit": "", - "storage_limit_gb": "", - "storage_limit_hint": "", - "family_storage_limit_invalid": "", + "family_plan_description": "Compartilhe seu plano com até cinco membros da família. Todos obtém seu próprio espaço privado.", + "view_plans": "Ver planos", + "family_members": "Membros", + "family_manager": "Gerente de família", + "family_member": "Membro", + "family_invited": "Convidado", + "family_storage": "Armazenamento familiar", + "family_storage_usage": "{{used}} de {{total}} usado", + "add_family_member": "Adicionar membro da família", + "invite_family_member": "Convidar membro da família", + "family_invite_hint": "Eles já devem ter uma conta Ente.", + "family_invite_title": "Junte-se a uma família no Ente", + "family_invite_description": "{{email}} convidou você a compartilhar o plano deles. Suas fotos e vídeos permanecerão privados.", + "accept_family_invite": "Aceitar convite", + "family_invite_accepted": "Bem-vindo à família", + "family_invite_accepted_description": "Agora você está compartilhando o plano de {{email}}.", + "family_invite_invalid": "Este convite é inválido ou não está mais disponível.", + "open_ente": "Abrir Ente", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", + "invite": "Convidar", + "enter_valid_email": "Insira um endereço de e-mail válido", + "family_user_not_found": "Nenhuma conta Ente encontrada com este endereço de e-mail.", + "family_already_member": "Esta pessoa já faz parte de um plano familiar.", + "family_paid_subscriber": "Uma pessoa com um plano pago ativo não pode participar de uma família.", + "family_size_limit": "Sua família já tem o número máximo de membros.", + "family_paid_plan_required": "É necessário um plano pago para criar uma família.", + "resend_family_invite": "Reenviar convite", + "revoke_family_invite": "Cancelar convite", + "revoke_family_invite_confirm": "Cancelar convite enviado para {{email}}?", + "revoke": "Cancelar", + "remove_family_member": "Remover membro da família", + "remove_family_member_confirm": "Remover {{email}} da sua família?", + "set_storage_limit": "Definir limite de armazenamento", + "storage_limit_gb": "Limite de armazenamento (GB)", + "storage_limit_hint": "Deixe em branco para sem limite.", + "family_storage_limit_invalid": "O limite deve ser entre o uso atual do membro e o armazenamento total da família.", "leave_family_plan": "Sair do plano familiar", "leave": "Sair", "leave_family_plan_confirm": "Deseja sair do plano familiar?", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Seu navegador ou uma extensão está bloqueando o Ente de usar eTags para enviar arquivos grandes.", "large_files": "Arquivos grandes", "large_files_hint": "Os arquivos não foram enviados, já que, eles excedem o limite máximo do arquivo.", + "partner_shared_files": "Arquivos compartilhados por um parceiro", + "partner_shared_files_hint": "Estes arquivos foram ignorados já que foram compartilhados com você através do compartilhamento de parceiro no Google Photos", "insufficient_storage": "Armazenamento insuficiente", "insufficient_storage_hint": "Os arquivos não foram enviados, já que, eles ultrapassam o limite máximo do seu plano de armazenamento.", "uploads_in_progress": "Envios em andamento", + "wait_for_active_upload_to_finish": "Por favor, aguarde a conclusão do envio ativo.", "successful_uploads": "Envios sucedidos", "upload_to_album": "Enviar ao álbum", "add_to_album": "Adicionar ao álbum", @@ -874,6 +890,7 @@ "search_location": "Pesquisar por localização", "app_lock": "Bloqueio do aplicativo", "app_locked": "Aplicativo bloqueado", + "app_lock_unavailable": "O bloqueio de aplicativo está indisponível. Verifique o keychain do sistema.", "enter_pin": "Insira seu PIN", "enter_password": "Insira sua senha", "confirm_pin": "Confirme seu PIN", diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json index e3dfc4446f7..6d41d4648eb 100644 --- a/web/packages/base/locales/pt-PT/translation.json +++ b/web/packages/base/locales/pt-PT/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Inicie sessão para se juntar ao álbum", "sign_up": "Registar", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Crie conta para se juntar ao álbum", "new_to_ente": "Novo no Ente", "existing_user": "Utilizador existente", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Seu navegador ou uma extensão está bloqueando o Ente de usar eTags para enviar arquivos grandes.", "large_files": "Arquivos grandes", "large_files_hint": "Estes ficheiros não foram carregados porque excedem o nosso limite máximo de tamanho de ficheiro", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Armazenamento insuficiente", "insufficient_storage_hint": "Estes ficheiros não foram carregados porque excedem o limite máximo de tamanho do seu plano de armazenamento", "uploads_in_progress": "Uploads em andamento", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Envios bem sucedidos", "upload_to_album": "Carregar para o álbum", "add_to_album": "Adicionar ao álbum", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ro-RO/translation.json b/web/packages/base/locales/ro-RO/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ro-RO/translation.json +++ b/web/packages/base/locales/ro-RO/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json index ddf9f548fef..412178bfd9c 100644 --- a/web/packages/base/locales/ru-RU/translation.json +++ b/web/packages/base/locales/ru-RU/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Войдите, чтобы присоединиться к альбому", "sign_up": "Регистрация", "get_ente_photos": "Получить Ente Photos", + "join_ente": "", "signup_to_join_album": "Зарегистрируйтесь, чтобы присоединиться к альбому", "new_to_ente": "Новенький в Ente", "existing_user": "Существующий пользователь", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Ваш браузер или расширение не позволяет Ente использовать eTags для загрузки больших файлов.", "large_files": "Большие файлы", "large_files_hint": "Эти файлы не были загружены, так как они превышают наш максимальный размер файла", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Недостаточный объем памяти", "insufficient_storage_hint": "Эти файлы не были загружены, так как они превышают максимальный размер, установленный для вашего плана хранения", "uploads_in_progress": "Выполняется загрузка данных", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Успешные загрузки", "upload_to_album": "Загрузить в альбом", "add_to_album": "Добавить в альбом", @@ -542,7 +558,7 @@ "remove_participant_message": "

{{selectedEmail}} будут удалены из альбома

Все добавленные ими фотографии также будут удалены из альбома

", "confirm_remove": "Да, удалить", "owner": "Владелец", - "collaborators": "Коллаборационисты", + "collaborators": "Соавторы", "viewers": "Зрителей", "add_more": "Добавить еще", "or_add_existing": "Или выберите уже существующий вариант", @@ -874,6 +890,7 @@ "search_location": "Поиск местоположения", "app_lock": "Блокировка приложения", "app_locked": "Приложение заблокировано", + "app_lock_unavailable": "", "enter_pin": "Введите ваш PIN", "enter_password": "Введите ваш пароль", "confirm_pin": "Подтвердите PIN-код", diff --git a/web/packages/base/locales/sk-SK/translation.json b/web/packages/base/locales/sk-SK/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/sk-SK/translation.json +++ b/web/packages/base/locales/sk-SK/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/sl-SI/translation.json b/web/packages/base/locales/sl-SI/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/sl-SI/translation.json +++ b/web/packages/base/locales/sl-SI/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/sr-SP/translation.json b/web/packages/base/locales/sr-SP/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/sr-SP/translation.json +++ b/web/packages/base/locales/sr-SP/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/sv-SE/translation.json b/web/packages/base/locales/sv-SE/translation.json index 3f8a71f323f..49211ac3a2c 100644 --- a/web/packages/base/locales/sv-SE/translation.json +++ b/web/packages/base/locales/sv-SE/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "Registrera dig", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Registrera dig för att ansluta till album", "new_to_ente": "Ny hos Ente", "existing_user": "Befintlig användare", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "Lägg till i album", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ta-IN/translation.json b/web/packages/base/locales/ta-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ta-IN/translation.json +++ b/web/packages/base/locales/ta-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/te-IN/translation.json b/web/packages/base/locales/te-IN/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/te-IN/translation.json +++ b/web/packages/base/locales/te-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/th-TH/translation.json b/web/packages/base/locales/th-TH/translation.json index b2429d00539..2ba7b8cf651 100644 --- a/web/packages/base/locales/th-TH/translation.json +++ b/web/packages/base/locales/th-TH/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "เข้าสู่ระบบเพื่อเข้าร่วมอัลบั้ม", "sign_up": "ลงทะเบียน", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "ลงทะเบียนเพื่อเข้าร่วมอัลบั้ม", "new_to_ente": "ผู้ใช้ใหม่", "existing_user": "เคยใช้อยู่แล้ว", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/ti-ER/translation.json b/web/packages/base/locales/ti-ER/translation.json index 21515e60b3a..dbc9d057bf7 100644 --- a/web/packages/base/locales/ti-ER/translation.json +++ b/web/packages/base/locales/ti-ER/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "", "existing_user": "", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/tr-TR/translation.json b/web/packages/base/locales/tr-TR/translation.json index c10979e690d..0a38599dc8c 100644 --- a/web/packages/base/locales/tr-TR/translation.json +++ b/web/packages/base/locales/tr-TR/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Albüme katılmak için giriş yap", "sign_up": "Hesap aç", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Albüme katılmak için kayıt ol", "new_to_ente": "Yeni ente kullanıcısı", "existing_user": "Mevcut kullanıcı", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Tarayıcınız veya bir eklenti, Ente’nin büyük dosyaları yüklemek için eTags kullanmasını engelliyor.", "large_files": "Büyük dosyalar", "large_files_hint": "Bu dosyalar, maksimum dosya boyutu sınırını aştığı için yüklenmedi", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Yetersiz depolama alanı", "insufficient_storage_hint": "Bu dosyalar, depolama planınızın maksimum boyut sınırını aştığı için yüklenmedi", "uploads_in_progress": "Devam eden yüklemeler", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Başarılı yüklemeler", "upload_to_album": "Albüme yükle", "add_to_album": "Albüme ekle", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/uk-UA/translation.json b/web/packages/base/locales/uk-UA/translation.json index 9b571620662..0bfd6162b01 100644 --- a/web/packages/base/locales/uk-UA/translation.json +++ b/web/packages/base/locales/uk-UA/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Увійдіть, щоб приєднатися до альбому", "sign_up": "Зареєструватися", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "Зареєструйтеся, щоб приєднатися до альбому", "new_to_ente": "Уперше на Ente", "existing_user": "Наявний користувач", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Ваш браузер або доповнення не дозволяє Ente використовувати eTags для завантаження великих файлів.", "large_files": "Великі файли", "large_files_hint": "Ці файли не були завантажені, оскільки вони перевищують наш максимальний розмір файлу", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Недостатньо місця для зберігання", "insufficient_storage_hint": "Ці файли не було завантажено, оскільки вони перевищують максимальний розмір для вашого тарифного плану", "uploads_in_progress": "Вивантаження триває", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Успішні вивантаження", "upload_to_album": "Завантажити до альбому", "add_to_album": "Додати до альбому", @@ -874,6 +890,7 @@ "search_location": "Шукати розташування", "app_lock": "Блокування застосунку", "app_locked": "Застосунок заблоковано", + "app_lock_unavailable": "", "enter_pin": "Введіть ваш PIN-код", "enter_password": "Введіть пароль", "confirm_pin": "Підтвердьте ваш PIN-код", diff --git a/web/packages/base/locales/ur-IN/translation.json b/web/packages/base/locales/ur-IN/translation.json index e08f95770e6..173fd41ac89 100644 --- a/web/packages/base/locales/ur-IN/translation.json +++ b/web/packages/base/locales/ur-IN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "البم میں شامل ہونے کے لیے لاگ ان کریں", "sign_up": "سائن اپ", "get_ente_photos": "Ente Photos حاصل کریں", + "join_ente": "", "signup_to_join_album": "البم میں شامل ہونے کے لیے سائن اپ کریں", "new_to_ente": "Ente پر نیا", "existing_user": "موجودہ صارف", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "آپ کا براؤزر یا ایڈ آن Ente کو بڑی فائلوں کے لیے eTags استعمال کرنے سے روک رہا ہے۔", "large_files": "بڑی فائلیں", "large_files_hint": "یہ فائلیں اپ لوڈ نہیں کی گئیں کیونکہ وہ ہماری زیادہ سے زیادہ فائل سائز کی حد سے زیادہ ہیں", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "ناکافی سٹوریج", "insufficient_storage_hint": "یہ فائلیں اپ لوڈ نہیں کی گئیں کیونکہ وہ آپ کے سٹوریج پلان کی زیادہ سے زیادہ حد سے زیادہ ہیں", "uploads_in_progress": "اپ لوڈز جاری ہیں", + "wait_for_active_upload_to_finish": "", "successful_uploads": "کامیاب اپ لوڈز", "upload_to_album": "البم میں اپ لوڈ کریں", "add_to_album": "البم میں شامل کریں", @@ -874,6 +890,7 @@ "search_location": "مقام تلاش کریں", "app_lock": "ایپ لاک", "app_locked": "ایپ لاک ہے", + "app_lock_unavailable": "", "enter_pin": "اپنا PIN درج کریں", "enter_password": "اپنا پاس ورڈ درج کریں", "confirm_pin": "اپنا PIN تصدیق کریں", diff --git a/web/packages/base/locales/vi-VN/translation.json b/web/packages/base/locales/vi-VN/translation.json index abf2fa7f135..48e3a42347d 100644 --- a/web/packages/base/locales/vi-VN/translation.json +++ b/web/packages/base/locales/vi-VN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "Đăng nhập để tham gia album", "sign_up": "Đăng ký", "get_ente_photos": "Tải Ente Photos", + "join_ente": "", "signup_to_join_album": "Đăng ký để tham gia album", "new_to_ente": "Mới dùng Ente", "existing_user": "Đã có tài khoản", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "Trình duyệt của bạn hoặc một tiện ích mở rộng đang chặn Ente sử dụng eTags để tải lên các tệp lớn.", "large_files": "Tệp lớn", "large_files_hint": "Các tệp này không thể tải lên vì chúng vượt quá dung lượng tệp tối đa của chúng tôi", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "Không đủ dung lượng lưu trữ", "insufficient_storage_hint": "Các tệp này không thể tải lên vì chúng vượt quá dung lượng tối đa gói của bạn", "uploads_in_progress": "Đang tải lên", + "wait_for_active_upload_to_finish": "", "successful_uploads": "Tải lên thành công", "upload_to_album": "Tải lên album", "add_to_album": "Thêm vào album", @@ -874,6 +890,7 @@ "search_location": "Tìm kiếm một vị trí", "app_lock": "Khóa ứng dụng", "app_locked": "Đã khóa ứng dụng", + "app_lock_unavailable": "", "enter_pin": "Nhập mã PIN", "enter_password": "Nhập mật khẩu của bạn", "confirm_pin": "Xác nhận mã PIN của bạn", diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json index 76706871cf9..fc9dc8d3ad1 100644 --- a/web/packages/base/locales/zh-CN/translation.json +++ b/web/packages/base/locales/zh-CN/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "登录以加入相册", "sign_up": "注册", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "注册以加入相册", "new_to_ente": "初来 Ente", "existing_user": "现有用户", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "您的浏览器或插件阻止 Ente 使用 eTags 上传大文件。", "large_files": "大文件", "large_files_hint": "这些文件没有上传,因为它们超过了我们的最大文件大小限制", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "存储空间不足", "insufficient_storage_hint": "这些文件没有上传,因为它们超过了您的存储计划的最大大小限制", "uploads_in_progress": "上传进行中", + "wait_for_active_upload_to_finish": "", "successful_uploads": "上传成功", "upload_to_album": "上传至相册", "add_to_album": "添加到相册", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/zh-HK/translation.json b/web/packages/base/locales/zh-HK/translation.json index d534443d1eb..08e9d5c7242 100644 --- a/web/packages/base/locales/zh-HK/translation.json +++ b/web/packages/base/locales/zh-HK/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "", "sign_up": "註冊", "get_ente_photos": "", + "join_ente": "", "signup_to_join_album": "", "new_to_ente": "新用戶", "existing_user": "現有用戶", @@ -256,6 +257,18 @@ "family_invite_accepted_description": "", "family_invite_invalid": "", "open_ente": "", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", "invite": "", "enter_valid_email": "", "family_user_not_found": "", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "", "large_files": "", "large_files_hint": "", + "partner_shared_files": "", + "partner_shared_files_hint": "", "insufficient_storage": "空間不足", "insufficient_storage_hint": "", "uploads_in_progress": "", + "wait_for_active_upload_to_finish": "", "successful_uploads": "", "upload_to_album": "", "add_to_album": "", @@ -874,6 +890,7 @@ "search_location": "", "app_lock": "", "app_locked": "", + "app_lock_unavailable": "", "enter_pin": "", "enter_password": "", "confirm_pin": "", diff --git a/web/packages/base/locales/zh-TW/translation.json b/web/packages/base/locales/zh-TW/translation.json index 93f5921b367..b75b30d5c59 100644 --- a/web/packages/base/locales/zh-TW/translation.json +++ b/web/packages/base/locales/zh-TW/translation.json @@ -9,6 +9,7 @@ "login_to_join_album": "登入以加入相簿", "sign_up": "註冊", "get_ente_photos": "取得 Ente Photos", + "join_ente": "加入 Ente", "signup_to_join_album": "註冊以加入相簿", "new_to_ente": "初次使用 Ente", "existing_user": "既有使用者", @@ -238,41 +239,53 @@ "manage_payment_method": "管理付款方式", "manage_family": "管理家庭", "family_plan": "家庭方案", - "family_plan_description": "", - "view_plans": "", - "family_members": "", - "family_manager": "", - "family_member": "", - "family_invited": "", - "family_storage": "", - "family_storage_usage": "", - "add_family_member": "", - "invite_family_member": "", - "family_invite_hint": "", - "family_invite_title": "", - "family_invite_description": "", - "accept_family_invite": "", - "family_invite_accepted": "", - "family_invite_accepted_description": "", - "family_invite_invalid": "", - "open_ente": "", - "invite": "", - "enter_valid_email": "", - "family_user_not_found": "", - "family_already_member": "", - "family_paid_subscriber": "", - "family_size_limit": "", - "family_paid_plan_required": "", - "resend_family_invite": "", - "revoke_family_invite": "", - "revoke_family_invite_confirm": "", - "revoke": "", - "remove_family_member": "", - "remove_family_member_confirm": "", - "set_storage_limit": "", - "storage_limit_gb": "", - "storage_limit_hint": "", - "family_storage_limit_invalid": "", + "family_plan_description": "跟最多五個家庭成員分享您的訂購方案。每一位家庭成員都可以有自己的私人空間。", + "view_plans": "查看方案", + "family_members": "成員", + "family_manager": "家庭管理員", + "family_member": "成員", + "family_invited": "已邀請", + "family_storage": "家庭儲存空間", + "family_storage_usage": "已使用 {{used}} / {{total}}", + "add_family_member": "新增家庭成員", + "invite_family_member": "邀請家庭成員", + "family_invite_hint": "他們必須已經擁有 Ente 帳號。", + "family_invite_title": "加入 Ente 的家庭分享方案", + "family_invite_description": "{{email}} 邀請您一同分享家庭方案。您的照片及影片會維持只有您能看到。", + "accept_family_invite": "接受邀請", + "family_invite_accepted": "歡迎來到這個大家庭", + "family_invite_accepted_description": "您現在正與 {{email}} 分享家庭方案。", + "family_invite_invalid": "這個邀請無效或是已經失效。", + "open_ente": "打開 Ente", + "account_recovery_title": "", + "account_recovery_description": "", + "account_recovery_success": "", + "account_recovery_success_description": "", + "account_recovery_invalid_title": "", + "account_recovery_invalid_description": "", + "account_recovery_expired_title": "", + "account_recovery_expired_description": "", + "account_recovery_unavailable_title": "", + "account_recovery_unavailable_description": "", + "account_recovery_email_in_use": "", + "account_recovery_email_in_use_description": "", + "invite": "邀請", + "enter_valid_email": "輸入有效的電子信箱位址", + "family_user_not_found": "這個電子郵件位置沒有相對應的 Ente 帳號。", + "family_already_member": "對方已經加入其他的家庭方案。", + "family_paid_subscriber": "正在使用其他付費方案的人無法加入家庭方案。", + "family_size_limit": "您的家庭已經達到人數上限。", + "family_paid_plan_required": "要啟用家庭方案必須先有使用中的付費方案。", + "resend_family_invite": "重新發送邀請", + "revoke_family_invite": "撤銷邀請", + "revoke_family_invite_confirm": "撤銷寄給 {{email}} 的邀請?", + "revoke": "撤銷", + "remove_family_member": "移除家庭成員", + "remove_family_member_confirm": "將 {{email}} 從您的家庭中移除?", + "set_storage_limit": "設定儲存空間上限", + "storage_limit_gb": "儲存空間上限 (GB)", + "storage_limit_hint": "留空表示無限制。", + "family_storage_limit_invalid": "容量限制必須介於該成員的目前用量及家庭的總容量。", "leave_family_plan": "離開家庭方案", "leave": "離開", "leave_family_plan_confirm": "確定要離開家庭方案嗎?", @@ -454,9 +467,12 @@ "blocked_uploads_hint": "您的瀏覽器或外掛正在阻擋 Ente 使用 eTags 以上傳大型檔案。", "large_files": "大型檔案", "large_files_hint": "這些檔案因超過我們的檔案大小上限而未被上傳", + "partner_shared_files": "伴侶所分享的檔案", + "partner_shared_files_hint": "", "insufficient_storage": "儲存空間不足", "insufficient_storage_hint": "這些檔案因超過您方案的儲存上限而未被上傳", "uploads_in_progress": "上傳進行中", + "wait_for_active_upload_to_finish": "請等候上傳結束。", "successful_uploads": "上傳成功", "upload_to_album": "上傳到相簿", "add_to_album": "加入到相簿", @@ -874,6 +890,7 @@ "search_location": "搜尋位置", "app_lock": "App 鎖定", "app_locked": "App 已鎖定", + "app_lock_unavailable": "App 鎖無法使用。請檢查系統金鑰。", "enter_pin": "輸入您的 PIN", "enter_password": "輸入您的密碼", "confirm_pin": "確認您的 PIN", From 635067e1c963fb88d59cc7170f2136b1e8efd7d0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Wed, 22 Jul 2026 12:46:41 +0530 Subject: [PATCH 63/91] Scope social state to visible collections --- ...ope-social-state-to-visible-collections.md | 1 + mobile/apps/photos/lib/db/social_db.dart | 29 +++++++-- .../models/social/social_data_provider.dart | 15 +++-- .../social/widgets/file_social_overlay.dart | 8 ++- .../apps/photos/test/db/social_db_test.dart | 65 +++++++++++++++++-- 5 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 mobile/apps/photos/changes/scope-social-state-to-visible-collections.md diff --git a/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md b/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md new file mode 100644 index 00000000000..305ab875741 --- /dev/null +++ b/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md @@ -0,0 +1 @@ +- Keep like and comment indicators consistent with the shared albums visible from the current viewer. diff --git a/mobile/apps/photos/lib/db/social_db.dart b/mobile/apps/photos/lib/db/social_db.dart index bb6a019fc29..7ae5d80ced7 100644 --- a/mobile/apps/photos/lib/db/social_db.dart +++ b/mobile/apps/photos/lib/db/social_db.dart @@ -180,12 +180,21 @@ class SocialDB { return rows.isEmpty ? null : _rowToComment(rows.first); } - Future getCommentCountForFile(int fileID) async { + Future getCommentCountForFileInCollections( + int fileID, + List collectionIDs, + ) async { + if (collectionIDs.isEmpty) { + return 0; + } + + final placeholders = List.filled(collectionIDs.length, '?').join(','); final db = await database; final result = await db.rawQuery( 'SELECT COUNT(*) as count FROM $_commentsTable ' - 'WHERE file_id = ? AND is_deleted = 0', - [fileID], + 'WHERE file_id = ? AND collection_id IN ($placeholders) ' + 'AND is_deleted = 0', + [fileID, ...collectionIDs], ); return Sqflite.firstIntValue(result) ?? 0; } @@ -296,16 +305,26 @@ class SocialDB { return rows.map(_rowToReaction).toList(); } - Future hasUserReactedToFile(int fileID, int userID) async { + Future hasUserReactedToFileInCollections( + int fileID, + int userID, + List collectionIDs, + ) async { + if (collectionIDs.isEmpty) { + return false; + } + + final placeholders = List.filled(collectionIDs.length, '?').join(','); final db = await database; final rows = await db.rawQuery( ''' SELECT 1 FROM $_reactionsTable WHERE file_id = ? AND user_id = ? + AND collection_id IN ($placeholders) AND comment_id IS NULL AND is_deleted = 0 LIMIT 1 ''', - [fileID, userID], + [fileID, userID, ...collectionIDs], ); return rows.isNotEmpty; } diff --git a/mobile/apps/photos/lib/models/social/social_data_provider.dart b/mobile/apps/photos/lib/models/social/social_data_provider.dart index 2b297f6f4fa..dd2c41871d1 100644 --- a/mobile/apps/photos/lib/models/social/social_data_provider.dart +++ b/mobile/apps/photos/lib/models/social/social_data_provider.dart @@ -36,8 +36,11 @@ class SocialDataProvider { ); } - Future getCommentCountForFile(int fileID) { - return _db.getCommentCountForFile(fileID); + Future getCommentCountForFileInCollections( + int fileID, + List collectionIDs, + ) { + return _db.getCommentCountForFileInCollections(fileID, collectionIDs); } Future getCommentCountForFileInCollection(int fileID, int collectionID) { @@ -48,8 +51,12 @@ class SocialDataProvider { return _db.getReactionsForFile(fileID); } - Future hasUserReactedToFile(int fileID, int userID) { - return _db.hasUserReactedToFile(fileID, userID); + Future hasUserReactedToFileInCollections( + int fileID, + int userID, + List collectionIDs, + ) { + return _db.hasUserReactedToFileInCollections(fileID, userID, collectionIDs); } Future> getReactionsForFileInCollection( diff --git a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart index c36e9112488..aac2bb6ac3d 100644 --- a/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -147,8 +147,12 @@ class _FileSocialOverlayState extends State { final provider = SocialDataProvider.instance; final results = await Future.wait([ - provider.hasUserReactedToFile(fileID, currentUserID), - provider.getCommentCountForFile(fileID), + provider.hasUserReactedToFileInCollections( + fileID, + currentUserID, + collectionIDs, + ), + provider.getCommentCountForFileInCollections(fileID, collectionIDs), provider.getLatestCommentForFile( fileID, candidateCollectionIDs: collectionIDs, diff --git a/mobile/apps/photos/test/db/social_db_test.dart b/mobile/apps/photos/test/db/social_db_test.dart index 097c37a7817..c4161c342ec 100644 --- a/mobile/apps/photos/test/db/social_db_test.dart +++ b/mobile/apps/photos/test/db/social_db_test.dart @@ -29,7 +29,7 @@ void main() { await tempDir.delete(recursive: true); }); - test("heart state is file-wide across collections", () async { + test("heart state stays inside the requested collection scope", () async { await SocialDB.instance.upsertReactions([ _reaction(id: "visible", collectionID: 10, userID: 1), _reaction(id: "hidden", collectionID: 20, userID: 2), @@ -42,10 +42,43 @@ void main() { ), ]); - expect(await SocialDB.instance.hasUserReactedToFile(100, 1), isTrue); - expect(await SocialDB.instance.hasUserReactedToFile(100, 2), isTrue); - expect(await SocialDB.instance.hasUserReactedToFile(100, 3), isFalse); - expect(await SocialDB.instance.hasUserReactedToFile(100, 4), isFalse); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, [10]), + isTrue, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [10]), + isFalse, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [20]), + isTrue, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [ + 10, + 20, + ]), + isTrue, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 3, [ + 10, + 20, + ]), + isFalse, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 4, [ + 10, + 20, + ]), + isFalse, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, []), + isFalse, + ); }); test("latest comment stays inside the eligible collection scope", () async { @@ -74,14 +107,32 @@ void main() { ); }); - test("comment count is aggregated across collections", () async { + test("comment count stays inside the requested collection scope", () async { await SocialDB.instance.upsertComments([ _comment(id: "visible", collectionID: 10, createdAt: 10), _comment(id: "hidden", collectionID: 20, createdAt: 20), _comment(id: "deleted", collectionID: 20, createdAt: 30, isDeleted: true), ]); - expect(await SocialDB.instance.getCommentCountForFile(100), 2); + expect( + await SocialDB.instance.getCommentCountForFileInCollections(100, [10]), + 1, + ); + expect( + await SocialDB.instance.getCommentCountForFileInCollections(100, [20]), + 1, + ); + expect( + await SocialDB.instance.getCommentCountForFileInCollections(100, [ + 10, + 20, + ]), + 2, + ); + expect( + await SocialDB.instance.getCommentCountForFileInCollections(100, []), + 0, + ); }); } From 46e868dfda5fa56932171227bf92ec3dc80cdd13 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Wed, 22 Jul 2026 12:56:00 +0530 Subject: [PATCH 64/91] Trim memories viewer tests --- .../apps/photos/test/db/social_db_test.dart | 51 +------- .../memory_progress_indicator_test.dart | 116 +++++------------- 2 files changed, 34 insertions(+), 133 deletions(-) diff --git a/mobile/apps/photos/test/db/social_db_test.dart b/mobile/apps/photos/test/db/social_db_test.dart index c4161c342ec..56c1347b59a 100644 --- a/mobile/apps/photos/test/db/social_db_test.dart +++ b/mobile/apps/photos/test/db/social_db_test.dart @@ -31,54 +31,34 @@ void main() { test("heart state stays inside the requested collection scope", () async { await SocialDB.instance.upsertReactions([ - _reaction(id: "visible", collectionID: 10, userID: 1), - _reaction(id: "hidden", collectionID: 20, userID: 2), - _reaction(id: "deleted", collectionID: 20, userID: 3, isDeleted: true), + _reaction(id: "hidden", collectionID: 20, userID: 1), + _reaction(id: "deleted", collectionID: 20, userID: 2, isDeleted: true), _reaction( id: "comment-reaction", collectionID: 10, - userID: 4, + userID: 2, commentID: "comment", ), ]); expect( await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, [10]), - isTrue, - ); - expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [10]), isFalse, ); expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [20]), - isTrue, - ); - expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [ + await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, [ 10, 20, ]), isTrue, ); expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 3, [ - 10, - 20, - ]), - isFalse, - ); - expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 4, [ + await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [ 10, 20, ]), isFalse, ); - expect( - await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, []), - isFalse, - ); }); test("latest comment stays inside the eligible collection scope", () async { @@ -91,20 +71,7 @@ void main() { 100, candidateCollectionIDs: [10], ); - final hiddenLatest = await SocialDB.instance.getLatestCommentForFile( - 100, - candidateCollectionIDs: [20], - ); - expect(visibleLatest?.id, "visible"); - expect(hiddenLatest?.id, "hidden"); - expect( - await SocialDB.instance.getLatestCommentForFile( - 100, - candidateCollectionIDs: const [], - ), - isNull, - ); }); test("comment count stays inside the requested collection scope", () async { @@ -118,10 +85,6 @@ void main() { await SocialDB.instance.getCommentCountForFileInCollections(100, [10]), 1, ); - expect( - await SocialDB.instance.getCommentCountForFileInCollections(100, [20]), - 1, - ); expect( await SocialDB.instance.getCommentCountForFileInCollections(100, [ 10, @@ -129,10 +92,6 @@ void main() { ]), 2, ); - expect( - await SocialDB.instance.getCommentCountForFileInCollections(100, []), - 0, - ); }); } diff --git a/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart index ac604be9059..d82499aaf2f 100644 --- a/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart +++ b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart @@ -3,99 +3,41 @@ import "package:flutter_test/flutter_test.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; void main() { - group("memory progress layout", () { - test("derives equal segment widths from the available width", () { - expect( - memoryProgressSegmentWidthForLayout(totalSteps: 6, availableWidth: 343), - closeTo(48.833333, 0.000001), - ); - expect( - memoryProgressSegmentWidthForLayout( - totalSteps: 18, - availableWidth: 343, - ), - closeTo(9.611111, 0.000001), - ); - }); - - test( - "switches when equal chunks would be narrower than the legibility limit", - () { - expect( - memoryProgressUsesContinuousTrack( - totalSteps: 19, - availableWidth: 343, - ), - isFalse, - ); - expect( - memoryProgressUsesContinuousTrack( - totalSteps: 20, - availableWidth: 343, - ), - isTrue, - ); - expect( - memoryProgressUsesContinuousTrack( - totalSteps: 41, - availableWidth: 736, - ), - isFalse, - ); - expect( - memoryProgressUsesContinuousTrack( - totalSteps: 42, - availableWidth: 736, - ), - isTrue, - ); - }, - ); - }); - - for (final availableWidth in [343.0, 736.0]) { - for (final totalSteps in [6, 18]) { - testWidgets("fills ${availableWidth}px with $totalSteps equal chunks", ( - tester, - ) async { - final currentIndex = totalSteps ~/ 2; + testWidgets("fills the available width with equal chunks", (tester) async { + const totalSteps = 6; + const availableWidth = 343.0; + const currentIndex = 3; - await tester.pumpWidget( - MaterialApp( - home: Center( - child: SizedBox( - width: availableWidth, - child: MemoryProgressIndicator( - totalSteps: totalSteps, - currentIndex: currentIndex, - ), - ), + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: availableWidth, + child: MemoryProgressIndicator( + totalSteps: totalSteps, + currentIndex: currentIndex, ), ), - ); + ), + ), + ); - expect(find.byType(Row), findsOneWidget); - final widths = [ - for (var index = 0; index < totalSteps; index++) - tester - .getSize(find.byKey(ValueKey("memory-progress-segment-$index"))) - .width, - ]; - final segmentWidth = widths.first; - final currentWidth = widths[currentIndex]; + final widths = [ + for (var index = 0; index < totalSteps; index++) + tester + .getSize(find.byKey(ValueKey("memory-progress-segment-$index"))) + .width, + ]; - expect(currentWidth / segmentWidth, closeTo(1.0, 0.000001)); - for (final width in widths) { - expect(width, closeTo(segmentWidth, 0.000001)); - } - expect( - widths.reduce((left, right) => left + right) + - ((totalSteps - 1) * kMemoryProgressGap), - closeTo(availableWidth, 0.000001), - ); - }); + for (final width in widths.skip(1)) { + expect(width, widths.first); } - } + expect( + widths.reduce((left, right) => left + right) + + ((totalSteps - 1) * kMemoryProgressGap), + availableWidth, + ); + }); testWidgets("uses the full track when segmented chunks become too narrow", ( tester, From 792640857ff8146bb8e14a904851c66020e9f19c Mon Sep 17 00:00:00 2001 From: ashilkn Date: Wed, 22 Jul 2026 12:59:45 +0530 Subject: [PATCH 65/91] Consolidate memories change note --- mobile/apps/photos/changes/align-social-icon-sizing.md | 1 - mobile/apps/photos/changes/animate-social-overlay.md | 1 - mobile/apps/photos/changes/balance-social-icon-sizes.md | 1 - mobile/apps/photos/changes/compact-video-controls.md | 1 - mobile/apps/photos/changes/memories-comments-and-reactions.md | 1 + mobile/apps/photos/changes/memories-social-actions.md | 1 - mobile/apps/photos/changes/memories-viewer-redesign.md | 1 - mobile/apps/photos/changes/memory-video-progress.md | 1 - mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md | 1 - .../photos/changes/scope-social-state-to-visible-collections.md | 1 - mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md | 1 - mobile/apps/photos/changes/subtle-social-icon-shadows.md | 1 - 12 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 mobile/apps/photos/changes/align-social-icon-sizing.md delete mode 100644 mobile/apps/photos/changes/animate-social-overlay.md delete mode 100644 mobile/apps/photos/changes/balance-social-icon-sizes.md delete mode 100644 mobile/apps/photos/changes/compact-video-controls.md create mode 100644 mobile/apps/photos/changes/memories-comments-and-reactions.md delete mode 100644 mobile/apps/photos/changes/memories-social-actions.md delete mode 100644 mobile/apps/photos/changes/memories-viewer-redesign.md delete mode 100644 mobile/apps/photos/changes/memory-video-progress.md delete mode 100644 mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md delete mode 100644 mobile/apps/photos/changes/scope-social-state-to-visible-collections.md delete mode 100644 mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md delete mode 100644 mobile/apps/photos/changes/subtle-social-icon-shadows.md diff --git a/mobile/apps/photos/changes/align-social-icon-sizing.md b/mobile/apps/photos/changes/align-social-icon-sizing.md deleted file mode 100644 index ea953751cb4..00000000000 --- a/mobile/apps/photos/changes/align-social-icon-sizing.md +++ /dev/null @@ -1 +0,0 @@ -- Align social controls and comment badge sizing and positioning with the memories viewer design. diff --git a/mobile/apps/photos/changes/animate-social-overlay.md b/mobile/apps/photos/changes/animate-social-overlay.md deleted file mode 100644 index ce36c37d30f..00000000000 --- a/mobile/apps/photos/changes/animate-social-overlay.md +++ /dev/null @@ -1 +0,0 @@ -- Added polished animations when comments and comment counts change in the photo viewer. diff --git a/mobile/apps/photos/changes/balance-social-icon-sizes.md b/mobile/apps/photos/changes/balance-social-icon-sizes.md deleted file mode 100644 index b71221ab0d7..00000000000 --- a/mobile/apps/photos/changes/balance-social-icon-sizes.md +++ /dev/null @@ -1 +0,0 @@ -- Improve the visual balance of the like and comment controls. diff --git a/mobile/apps/photos/changes/compact-video-controls.md b/mobile/apps/photos/changes/compact-video-controls.md deleted file mode 100644 index 202c08138df..00000000000 --- a/mobile/apps/photos/changes/compact-video-controls.md +++ /dev/null @@ -1 +0,0 @@ -- Made video captions and playback controls more compact in the photo viewer. diff --git a/mobile/apps/photos/changes/memories-comments-and-reactions.md b/mobile/apps/photos/changes/memories-comments-and-reactions.md new file mode 100644 index 00000000000..657eebff7ce --- /dev/null +++ b/mobile/apps/photos/changes/memories-comments-and-reactions.md @@ -0,0 +1 @@ +- Add comments and reactions to memories. diff --git a/mobile/apps/photos/changes/memories-social-actions.md b/mobile/apps/photos/changes/memories-social-actions.md deleted file mode 100644 index 992a0406db1..00000000000 --- a/mobile/apps/photos/changes/memories-social-actions.md +++ /dev/null @@ -1 +0,0 @@ -- Added likes, comments, and latest comment previews to memories. diff --git a/mobile/apps/photos/changes/memories-viewer-redesign.md b/mobile/apps/photos/changes/memories-viewer-redesign.md deleted file mode 100644 index 5a5367a9c8c..00000000000 --- a/mobile/apps/photos/changes/memories-viewer-redesign.md +++ /dev/null @@ -1 +0,0 @@ -- Redesigned the memories viewer with edge-to-edge media, clearer controls, and add-to-album support. diff --git a/mobile/apps/photos/changes/memory-video-progress.md b/mobile/apps/photos/changes/memory-video-progress.md deleted file mode 100644 index 5d130a9d8e7..00000000000 --- a/mobile/apps/photos/changes/memory-video-progress.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed memory progress remaining paused when a video starts playing. diff --git a/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md b/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md deleted file mode 100644 index 43cc7cf6323..00000000000 --- a/mobile/apps/photos/changes/reuse-social-overlay-in-gallery.md +++ /dev/null @@ -1 +0,0 @@ -- Added consistent likes and comments controls to the gallery photo viewer. diff --git a/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md b/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md deleted file mode 100644 index 305ab875741..00000000000 --- a/mobile/apps/photos/changes/scope-social-state-to-visible-collections.md +++ /dev/null @@ -1 +0,0 @@ -- Keep like and comment indicators consistent with the shared albums visible from the current viewer. diff --git a/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md b/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md deleted file mode 100644 index c556956cdb3..00000000000 --- a/mobile/apps/photos/changes/show-add-to-album-in-shared-viewer.md +++ /dev/null @@ -1 +0,0 @@ -- Show Add to album directly in the gallery viewer for shared photos. diff --git a/mobile/apps/photos/changes/subtle-social-icon-shadows.md b/mobile/apps/photos/changes/subtle-social-icon-shadows.md deleted file mode 100644 index dc7473020f9..00000000000 --- a/mobile/apps/photos/changes/subtle-social-icon-shadows.md +++ /dev/null @@ -1 +0,0 @@ -- Improve memories top spacing and add subtle shadows to social controls. From 0831bd77d305c4890d673bb1fb10cbddfbb352d7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 22 Jul 2026 13:18:37 +0530 Subject: [PATCH 66/91] Target Android API 36 --- .../android/apps/ensu/app/build.gradle.kts | 4 ++-- .../native/android/apps/ensu/build.gradle.kts | 4 ++-- .../ensu/gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 3 ++- mobile/native/android/apps/ensu/gradlew | 5 ++++- mobile/native/android/apps/ensu/gradlew.bat | 3 ++- .../android/apps/ensu/rust/build.gradle.kts | 2 +- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mobile/native/android/apps/ensu/app/build.gradle.kts b/mobile/native/android/apps/ensu/app/build.gradle.kts index 115a37a36e7..6f44385b1ed 100644 --- a/mobile/native/android/apps/ensu/app/build.gradle.kts +++ b/mobile/native/android/apps/ensu/app/build.gradle.kts @@ -54,7 +54,7 @@ val debugAbis = listOf(connectedDeviceAbi() ?: hostAbi()) android { namespace = "io.ente.ensu" - compileSdk = 35 + compileSdk = 36 signingConfigs { create("release") { @@ -75,7 +75,7 @@ android { defaultConfig { applicationId = "io.ente.ensu" minSdk = 24 - targetSdk = 35 + targetSdk = 36 versionCode = (project.findProperty("versionCode") as? String)?.toInt() ?: 33 versionName = "0.1.18" } diff --git a/mobile/native/android/apps/ensu/build.gradle.kts b/mobile/native/android/apps/ensu/build.gradle.kts index c3918bb0e97..bf1cdb8b920 100644 --- a/mobile/native/android/apps/ensu/build.gradle.kts +++ b/mobile/native/android/apps/ensu/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("com.android.application") version "8.7.3" apply false - id("com.android.library") version "8.7.3" apply false + id("com.android.application") version "8.10.1" apply false + id("com.android.library") version "8.10.1" apply false id("org.jetbrains.kotlin.android") version "1.9.23" apply false id("org.jetbrains.kotlin.jvm") version "1.9.23" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.23" apply false diff --git a/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.jar b/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.jar index 61285a659d17295f1de7c53e24fdf13ad755c379..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 35498 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 xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍U=YZ;=-zZAgMtk6W|B6MQ%mLGOi!VUaes}cKdr_muGq32|u=mv-jYNgoNsOp@ zB;K72fu=13R*PL^aT$!#sVQGJ?f~HB!%Ib9tE@^1WK1dYJ7R5DOp{+4-+E-}$8FT@ zyL1$dsV3Yi*RoD%Pd%6nB|H~}^YKF^EW{ZID$iO!8{-Fgd>S0m`RThtkJ*neX0c2s zx0$b4PKdu^^5G{7j1+45W96FJBE!-2{^S&n0M8pY4s8ecvB0N^Bls;;h>z)k{)uRawEo87!8`(;s^s+}iCoVW8wG*9`O#GKq~&;Izsux{YH0QP!kVr1Q!N&Nz2#>9mxer6DN6DN zGDzbyF)74=YyIaGP6tt1vI`p*!QDtLNG-MsPdc6Ecg7u)wU1|@&WKa21K;=A+r8_5 z&npWTw=nKJZ|Gaz$y7WU5VP}(G$XJJs>QyrHCo$Gq|^xaitx0K#@)d7=JWe6gRp)m zvty%aMlK3*m#oUd3*z|xQF#iTfiuLjJ7Mzzv%|NHHnaw^^{jFkm<$n$rSA=Rvr2(O zs>LK+>Tubw@yD|{m<4WvGSt@qwCkOmVH}d4b!!o^ITEh-K@1ASIGXnZ+ztGnSm!K~ zl5KyY-(mlgA4rd?Ww?I~5&M6Jq$#ojbrF0GwXpxXaHhNn5X*Cxg%@EJ6a{-GX9crl z(l|PGg)@aIFLJc#npiv2G~|7A@x4*PNsLBfh(@A-ucULvhH*+$rc087sqQEYi7qbQ z?_;fOCA-`DW4$QHu^P0&y1V5w+k3L*G533}^W^z1;~I$elK~dA`Et3w=`T+2n5FDrp(1XtkIn(K3nBNyxff9Qa@)? z%r8vJ9TxBDw=#)cqqC^rUB)D;J8m5X2AnN;L>^j?vhlYgb4qNPISaPn-WixPS}-qg zk=FN1!&tym)rREl6UH&w=$fwsvwTNam-I+To1OylH}9TSBePl`TOdh?LX6%TN#@S2 zX@VwxvgEsCFLNwg*C~QFsdlxb#4>UnDZxEXR*u(vMxX%?-yq`oi>n}brS&$L|; zLnoEiNEBQ-8ta?fIn7&P+!)fK(MIh6w2C?Et^yMTEbU%v6d5e}G@EMsDUisl1rZrV z`Q%*w#seI%;fZTQGDdd5rz5f4sOICMFL3}7jUOru7xiBu?BWaMY8|X~`DR|Gcp@?B zBa=iqwyq8>#B!NeN8M1?FZB_0_B_gs6BPV(%(Wm8wU_iUcB^fWgd%l(5%tT17ckZ4 z<;zc`A`J*^2nBL9p14lH0qBg;+tH&Id!2{`0_iT;&nzh7BU_!oTMHvJv0SpgRnC?V zQdOPCB=Dm})O%9k#{Jk)V{#R~GLVO)_heQ}3C3TG| zS_XXN+kyg{>!a9?3!%HrH+lI&-_P{;AN`Y55{=2)^J8oo6lsO$X(g&o*moCWJnC{= zGwBDgNsqo6{yoOPspolVo|~W8=ErGe_}j#Y`&v8p+Y&#^V4N|-)JZsJ*b*`corF_L zLZR7D?+{dekFL84kSLFR(j5QZLCd)*rqDf08O}UAFCmoH5Mte`C7W_RSA#J?#0n;A z$mFGu;yKrhTgrB@`?l@fA?>Gmh)*#lqP?5w6n6@xXUs7UMEMDc-SS(aJ}2QwYW@$g za}Awh4fzktI8`i;;>TP)YPah03B3C32sJ|`Y@Jgd2Hp-3xi?eG9CTr&XhTugGGB?e z|B0^?#M-`>=6X4ipu{7xXB__)^AVatw})zt>oi0$ttymFMqiz(H3?l2Qy8&<27x z+M!q*J`4KJHZ60GC7t>k;*^qayQ&-Kk-x>CCfq(5rXH>L+GlmSUprYNbZpx;I(G8+^M3zPjj_hsXZxUP zuA0|W{mWH3HIR@vKKIu^kvwhQ9r2(5h@gKk+0gVDf<>%{;?D=He$MTQtL?y?QGa_`0J}F>T%-%%h~vZ*z=b|*<5}j2jv=B~`7-~&DU@q-mHO3Msxi|a z+@LHR>!iLVD|3rYZLaH!Girz9?=(0x-2lqN9bc9#nmQXL{3-4SlfJJ&I zU+IB_YDoFFUBTy3td2G9DAo*jt&QT|=jf{x(92#KoJMj~vt$ts_NgUlD35D&G&|mO zv+gwY(SvKpBAa+OledW_ro(k<^e6Uu=0Z~iNRNLz6{iggne3B9s9(But{CK(Sw%~D z3IPL+zurXdn&S=CL)#Lqru@_rz(sfX7P?qeR_=Q~Yw3C^K2jj6RyfyOblBh=+OajV zZ(eSGZwME!rs0oO5@3>+U+ptUk;QVIYda?(lLo6UFtdXHIggVh@CW2+{u)f51`9fE zC&+d1DdJMQTkEW5MP&@$QO8+PA}3u~L6XoLXUX)> zn6A0BdsvtlV3G7^px-P3a1@JwsJZD1bUh7OZv>K+m?L-C&gl%KCxe(@7B2H5@|hJy zFPU8IKq1|JJOKSj84^muaA~S1W_!zEduI`3i4s)GKwzQ|X*uEVxegulFqBK~R1>t%rTGqgY^?8O&aSSEP4{KvEK>r@%@ z2zib>Y$CqD{A1dVRh4 za=PPtt}W_yhvY{-(?5}}_*%mr_Cr_zt#MX;qA_s{T-n$Ppk;V_ggTv2T5jQsJbV2j z{O-b7?Bc{$Tjq0weZNHS+>P!rb|=3@Kl*iq$;2=H1z#Z+;Q z`oXy<$+1NN@W2t08b#hS)>ncR=^)1w^$6wbf<0^eYtLaWILs<#6#HmZwgcZqGA@vp z@?t@R;{h2YU$CnCX3Bj z%uHo)vApBwbC4z=a4U+!$&ni8GbO z!y8+xhcJadT#9=F#PmKIYcGB>v-xbxH*Ifj0(Q{K zoBdh1rAn_Fez#hA+3!&(fH^NOYk)ieDEZ{{W+_r6v<$snvUNtllOxmrhy@5wELelC zl{t=v|PQa*DnQM3ex>m#f-U;N3^u`Zpq!}*{b?pB%!OEyZj3j$a z9RQ@S&T_|3Z!V=ecaHpSB-SJjiuHwG5y!{=YnHRs) z&BL=xOhW(UaCKUaU)yera$PGurH!O`TmWmbqLhFMNeeSMvmx2Xp@V(dDcN^a^QC_8 ziE!Ng=78xN#|^@Bb`pujb1^~&{VH`Bh{Mm&XZJjpw+EOQozXdzN zsBkCGl|g_4={mX$jyO%~)xZT4Y08L}rh!IRrF)KmHC~l(j`&n@Th_Yj&>CJ8ExKog9a}K*KJf~KbL@1txe4Trm0tZu%TGmy|jWOI4xGKnw1 zuM}GiGLZD+8vD;sXo*ioX>>LF$l!UiGiD*zgjO;{^+NY-T(pQ#b{!2bS>HKvqhIulB|1Y7o) z2$z*~Aze{6z;a=1>3SUlG&BYLG! znOp^X#x^XDjA-N$#fn%Dy2^E+9Y+maYdTQH3Oi}Boe7rE1W;KH6U%lP zTJoW%D&n70S%J~o$QhC07HGab<5-F-t~+B2D)Kk4(wG>AIVKd+hSn-Yp4O5r$>+0I z+NNK5q?y-QlMx7+wqIZ3a?*+(yk8EQL*@J;kYntaUBz2FX})89ilS(`A~{3ZN=2x` ztC2=;vKU4J43fdR-gOm998eoeZjvEq;>vQhz+hcE&Aq&cF-gHQNKKmIq!dM@sIW;_ zXty7{Uzp#r0#gWS;{a8LVbu(}qq3RAjI5QBp0IMJwnuCBI5bE?qGDCCk3Y*R{#W9gK`(lD3KXAK;&vC@+57Qeg zna|k>Gcz^#iNg{U1i)o0Jq8;)Cf2A#stEO$Qzu6b6NN(4y{RuoKI1KN4}a5Q+Bv)L=5y$>!Xnnn1)YL!WEh>nbNldr%U6mhbXBFe-t_AGRDX=eRvZyyeRz6AILCQLkq26( zf53r@h4+-3D#cwkEk_R3$$=7h&bPU93SWMCRY?zO@_+E>+~hA7#)FSKh|7$pA^o{2Z7u?`~|Fvwzm!TOI1w8c88_Oju82$ zI&*K!e91=c*3FB^eruo=_o7~%JS0d5kpMAhoW`9!f_tMezkfm2?s}GM7mfZ+fU;1` zSY&ul+$+uOjb4bKaW8(j(NkJhX4V0u%)p+FCU{MoCNzIAHi2~)mSg-;N*!!4D5`@^ z`Qlid_yOP@zJ(_5u!nj z*BGkbzpI$miIu|^W)J3M8e1U)lrX=xXq@^4D*-bmuZYmW2I!eG;|hMjEW0J2;$^#$ z3t9wBN!YFp=hxzOQT~*j4JwZSS(eerh4`4^qQdWQf?-JDzmPe(m^Be9thZ8VngFRg zN+M9HA5j1X*>~V)0#l6~W)Brk#_74kFAQ(6;^9DVy+_1O1Iv><kaT0{P+rMNNC5l`a_P4EhWQdkFf(_adEK^GWA!cQmhrKG;bieE2>s`^Hl!#sH)1 z_HG_sOZ9ti-~cD%KjjM%q4~6UX~4U_B*sTIA@jzu!eD$_lio3k{^lY5PPF=2Au0Q0U(V8dT+st&jd#SHD^9SrCzL?Ma&QhT5{7_moH!* zggcE@z0_;Io(et>wSzhy&Fo$6*nk>*j=Pinkq&j%nU+SUlbV&^nJ>}$?I&g>KvaIX z4wwzdVCxMQ)>{y`LDdPoCkHtgMCNH$Zx0eNtXnh9|HxCJS%!~$d|1Cc8CBGvhojqD zi5k$2&?E{EFRMR5Y2PIvzXF*o81N6n7&jQ?mR?K z2k|gKAD~5vbbpa7=<$&@sHYMo^s!j+8;Ld+;vdL+_DHhGNMD%Px;M^5pKlFw?8$Qa zTfi;1P!Yy(RVodbsuT5l66-!v@J^cHc*vR$6g4Sqxf;`+J~(1pmHfo;ulhB4{YyD#!~;NtZ|FSTtcjlz zwQGb9Jf#+09n2*y7seCASAn(o32pBZDz!U)ivcsj$wqCm(#Fx+e53>BpVuvD6s_30 zQd-68FTh*>Z|!~)YE5YGh8^?bE+ zWl+|DH|IS7pX)vp+d7KcZSvTjeF@e*EDSP=YA? z#97>m0JWcoNnx@pX26ym&Q+%&yI+#NNtp^wMei+m0QXGjbz#x-fg<)KZ(1?>ik6GhGq$QJ`7vo4vk$WMe{IA!af>T zvKxwHB4x{5_f&0#NJs{bdw3e_VmD|OoL;pm^%cJ6Hv-BV!Qv?Wefb{Sj6G`(H`yqV zk{0>Nl9Pw-7nTY~DVLC97SuC6+;?OUvGHjG839nisRgtk15O(TmVn)Eg~{1aQ^}9z zs7&h9Ieh7H&k-n-#Chqb7cBkCKR*J09|{HY zXt$&JS^hA7Y6QHKWOF86po@xeXF@4lfu7%fBIe{-z!Bnp6Z%)*%UGHo<%pPU$>P?b zta{z;Bmk}g7LP-*u1$G-62e^lwF(NoOg%WJ{+qBCu};VY(A!Z~~u!%-9gIqui`!G~9tG#B+w3*rrjA zaj(PTv8Udb`;cvvoPwW?Gsdkzy*1AjAq z=tackkd9&BRk{yy*V2CMNY9i{B6kHaXpB*?Q7you!N+O`^|s3KofJ42IBV$tI)^A% z(DIuVL}^#I4GX7&4h!_j{-$pVXcW~jr&XgeprRsqrz?u-NorJg+G8%!)~tmHEFM;n z`X>{jW{|jBXvq2IDpxH*KOQ@pX;!oE(vA+Un-=4KbuPcd`dCd!8xb3xs4N|5oUK1d z$!OK+l0Vjq4+>HO;w;&iw<*9nTfqWA522zdGMKrCb1ZYjfSXrofpaEO;KevFh7~It zk)@bZdCdvLK(q~vp=1)GLzyyG!MlT+a>!HT+ zw_(FQJ*8m7jM3x_W#ZelRCZ?|$@sEv{Owq;vG1{ACfGpZR|2|KenuGuzSt@lK>(m~ zlMUy{K*k*=K24aNIWPy$y%W=Z#_ zNf{10oX3~48PEnp(Kz@>orVLXA%IrqUFzl+3YLA;qN8i1Hi5J~J{r%kx{YCn&N0xj z73TYV!tf-Rc|K>PO>a zHe>Q5e|(_rIsq96to$~(h3kUfe(^U@Z?kh<&W}6(omF}Kza$A^!14U+dh%|(!uiM1 z9KCZ8=PfeiAH3>By7P>@;Q+;;*0B)s52HPmA4bF!a=68eKpOeE|KyS1PmCvLv}NP4 ztmqS7162ew^C5VA_>Pb8+hJd~V_(`00&XU;{`KNO>Oha!u+0NT0wlY+WAh4N3r<8| zvLtU{iGE;9RooK&?);K{-BISiud66)>k`L7kDH1DU$YiMm&aASFTE?h-*a5=t5)}DU@dFyuWXF` z3=nv+Y}z3wYA)4VE_cZ2;ZwzlJw}6zp+}Rrb2`%G!NguJ{L8TzGwkHkOo4Z4hT?@6Zd_ zHnv&i<{thI;4!YRay&L`L>{Qwp=ZX6w-AnJY1;#KY!Kgu6P9ln3Mk!gc?R8#jgGCX zUj~3LK@mJKS44ozj`nFjY6d8)az#TEaYD9(sZK z!LWw{SCyIv!bI%i=X1nZ(TfKsC;V^58k{!V{o}iY3o?araW779{Q12@r&ok}k@vnc zS$y$QvG!K+ZUCoQ5N{`nv?LDk4&YJ}ZUT9aFAsLV5nFQh+o0cT|*r(4Sq$il67n>K4@tq$giRh*r|+(RLSpej?m1Yfv8 z%={ctt9eP(lX-)f~zN^Oe(Kk7ks|}6+@qLwi+aQa$MKCep zSxP-5+yaQveh%caLdRK{O5efXHxUQuxU3BdAQy}ZFz$UF;I%pR^Y`~<%=F25zIsp& z+Ti0U_FNn`Q}%HWI-PDeyT#|hj=#REz;nL3#Qsp=pBogkj#}}PZ1d;;`YxSErG!LV zQ`x;y+k9^ap{|5~#(%$h<`;j=DZAA;MZ?^J)CMS)0*T<&{WVb~Y!(8 zQx2cV6dFdgzSbyRJxbOpSb1P=IZ^ruld5Z|vArgRY+K1LdloWtKQlx^a&eycRR17l zWi+j_B2>gUPNw}xDFKZnq^$c(RpmUPeU%y($o?|o9@mI4sbL~CJkaPqqA}Q;z3|CZ zzz*o>-d2aNr=NsKz7{q)^gL~K0Y@KXCn!ZuP%)_6tf9oFhQmz?&D1OL6`A&Y|JE&Nx5_;PzxJjJdwl4D>S<)FwLIYo8 z4^l@A;1er}p`j;JKhGr2%>IE`Cw_;jSPA>=(l6Om>(nUu2-h!Uqfukj^gBlC5Ca6U z28&D4Lc{(7?uD8q-p~a(8wZ&~+Nf-du&iHmAoDBqyHo(0&IW?I*8%F4apDf1nc?b| zTPY1;lg8xLu+L63H7Gp}{L{DiNxVUuT!5G$AImf+sxjpgF>%Ky>Nkk@cbcZGzz>=_ z_>=OSao!c9lM0@+!#5)_^GmE^K?*Q<5F3<4v=E;)^hZokbxy12#4?ffhx)ZQ##UGX z#i~{;gK^B^j&ix7gmgU!ec)j>E+;o>Y%#x`WPUzUY`s8 z2lpAr?-K=jVpWeUjf_GbLzb>NJw6MWJk)~TE=^^~`|38>@?%^c!ZcQhv8UgYkJBGh z)*lf;=ukQjWcb5y0}?4tVIIvpv$RouaZS$tnB_=`p!9r#t5UI7^dCykvUFOa$u?@- zMn}5I&c-sfCgEhmeSuepV;ay#ZWVAHbJVmguCdlDm(q%9Rj@4{bwxwps!%|CZLe)y zS4iUnPv25kYWCcsYFAf=^Wk>XMp&rr6MS(+Y1Q#%(mb#uL3@ojNAnS9+43dUIq}V? zeQ2nCsVRDi=dXRMDGw|GYv*{CUxg37amFd0h1cU6bLi(p#o&Qjm7b z1-8e!TO^8TKqekIKVK(C8i8uh-+x-hYK?w8WcJuDrN;;?t&v!k@7HF0U$ zod`Dha=)68WzVRHd(@7I-c}Wu!<5$sHIncgIxMTv>T!p+TD*)r^=XO#!({$a_e59X zSw1bc<+(0?Z|~fcx1fr;yv$r>t%DuC-hOgmEkFa&vuDR|;^2YSF)GLc+}NDU=-dTw zH&WWc!>ygCKC6y2p+uBa!6j2q?U-G+YNM!E)*Tn-bX}!eIk6!RMFKk;Og&{}<(hQX}B zZfnU;+^x_SZro^`o*)BJ}0YaOCuuulw%sl3U%4{o1`&@PG6 zh>96Ui>jw#!OLT12+LpJFM_%zq-L^^;fXT3rf7_BR%5G;MDWK^m zn{hFYa-u9MwqaI{mh_9YK1hm#2o_pBcw<~SLZ}yn4_G&Ll{wl8!!S<$YATFr@rhc`7(Ot2X9-Wx69S$~hqlC?M&(c4&;HJC9r+irx*}(-Bx|FA7 z63cKxV`Cin_5vZ-kS;QJF1b-$qMN`0YR}D+R0>rW`ObACJM?&yawVE$f12c$zMBm7 z8LqnpOM2Q{gxk(417e4upP!n=A7`To@pl!K{gEt}`MM<3~S^qpoa8nM2y^l>1pUKE30_LO3) zmp>~|KKly9{Y*}}3!JdRl-0gu8JGM8YpK(-VP+}gHGL_cph7E85lU`+yr1$MI}+|- zQTUr0;2UZ*X3=C{>_TxA@_GSepCmp*E55C7SCl=8O(1Efk+@K zkATon9Sa@}r_E_pRl>T`U9=uuj_=(csJ%V|B-v6X4-bzrt0?s;AkY*oyN+?0s4Ycf=XjtFp&Lt)=e zB7oTw^3^Hu-CUX8ccbX7n&TAL959)veU=20xZ|U?8jU?xqqzbI{2qJ-W`ye);^7np zK-`f!ts8x&8=F9L45s8w=sID=p*vJ#s2tqo#cZx^xaS_~uq;KKW$yvZ5G&G_J&cMJG*Z@V>UQ!dMW4N44G>qWXg9gWp^Av>u>SqQhC7 zLFr4&dT9={X1{~%43eK{uYgMVOnT%4Y|=KD5q;C$yn%lUFK@}ZM5@F_^f}qaC(Cp# z?V7;z9izjuB!7DF5*iYIN;I6DPNr``b>14l^xEl-avP5mD175Gsvm%Vh86O)4Q>za z3}OzapDABRiTz+QLOP<0Ta6J=qI)S9S<<#bI8z+lhn>fu|nY zrTYJgsN*Y_SA_+&4mhynOR7u(Y_aYfCa$+X!ayt=t5Jr@6RfU}v08cMV)c>br(T@< zYME&t=~0FR%S|zmp?~I zvuBERxgCZg>YJs4i_{ngQSmTuz)P$UJ`F zV@NeIt}XF~KJilH48@uoETH6Y^fCsmmTNxK2C+prZIED{IXwgbE45<B!|FEDh-&nES)7<=aR+@KVZ?k*;rZ6Ryrsaw3E0ZHsQu?ffqDx3<- ziGn^Kr19{52R2%5+QG~&Et?yOd}=(JNn%`@S)*I}a-0!1wc3BpLJ&x^`6!fUJk#VeL1u+X66s9op zj7lwCfhpcuJThMFO+2bZ5vgZ94#GaqG9u#sJn`qRusqQzJOacFD&v8mSF&2YYBQV+{jut>{|JJ>0()ARO|sOW>S;KPDDBO# z+Pr0XH|0g0(x2K!9JZ#a?iP}=lO^>>SNIB4XKVgNiZYCA330r6YWr|3Vuoc6fi00d zJ{;z=aO9R`R}pLM?Ke~uJRy~U7>%)ca5XaDIk#We!K2p5!mA?1pHUeJ~~FgVZMuRUvMy~UYmMPSs4`np87Fb z>gMnL+-X?q5;*}{0J(cOC}9dYvyDFZ7vE7baC9tlZzSfi&Nr}FRI9LkO?T+c7@GtU4;gLRLw~9vS_AIKCGJlh#v$@0vYe3M+>Pv9xO>oUha1ZW3M_@U< zlJPsE{W=B55|n#+9x7aIMU<=@dV4U#`T<(u_sr_AYn^&iz*?}s|hk^-AbMokn zF-IiZ^d^2M>J7mp@%#@EcVMd~YD&cNaGE7R?Dga62Rb*D-G4YZsC7BhllTXoyrAnE z!mjNG!YZ4Z-X(=XKRa}&lUQy6#lig_0RjP>qZL=jql>TVoxo@fU~6m7 zmd;j__vWr)gJCP-jk~3cQV~2>PG;O9F>mvX{tjML%MUfBsj{rga z{|OKdF;mRU-%7?V6;B5qJB$qKBl-PHEmu+olVtY_b+y!ElUU|`b$5fT=6w-|{6)%r ze|L=v=u>WRxQT7dhD5Jl?N^U^8SN*R{?i@nHSWGPPyr^;f*F2%)W%H=5b#ZGT)W)HhPdC3OJb&odn_|6CG$rvh^+iJ0oz z)0itN^SDbdSPLV6o6*wPnucWm?(-cWXn@ck;-+|o3l1DBy*V>;|7I7~x{?L&Q&0WL z<40c$z)E9_t2S|6KDmeGXv<7`rf*~n=h-cgf8ibY-9JCnh5lyws)1l~fo0Ij)`5p;Y;nV#NHoe)Djuy8 zwfXv|piLNFNL$*C7-oI|=YGBs3A{YCPd%7QvgOkad#hqx&t|pGfYH)*2DTGn8zXIo zbxNLps-)N^%vOB5wH0x#djbJ|K_9lXc4yov+6{t;v(%gZT4Gc&Y4!~UW<>(=k zSEYbn=)zcG6=%hr6mfd$Q#8ERona{k)|xuMuYrx9C${x&xURs)OCY|ZT-zzc9;p~> z1)*0j+`FPA)w^6_?wUjDww(Icam+r5j9I-gqqg~b@R!^yw2tba$4K@;fU7Mi7 z_!=UFAEkH`OV4hZecT#*29w<)hs)5HC$AWK<85Da|FDJEzzv7(0aOdoO)zT zl#BAY16*eAgiG;$UmmPK%>E|AZmXMIu!xF0ad;sqq}xcr^+s4;1ZT@W`T`FDLKyBO z)pDF-{}$dQ`~2O=9)SI8nn@b+93gOIlD6e?_5_Aq^h{fvI4Mb_Xh7aQQB+j-52Bkxj z1D!*x&0sd|ZCJMHM)M=m7pF#w(>v8s@ll8)N~5MjI6-lqA%Ivwt5-cl${SXkd${GO zEa(x5w8B2Lh!9+AB_l7u&hEkdE4b+tMN&cHadv(*@UiLfVGAgKvwl?g(Wx^e=o2;F zn9j`UPOZi-?ZzEFAl@L~z*LW)nu@&-_H^AV(j!;=u3@^dRz~Mk^(pO)W$FWKHIoPS ztW5Y3jB=Cx9bm>z#LbAy{#Bh+1^}lf+`GVTopSxN(oq|BrlBRwA{Pn%4I1r3N}a{A zjF&+xyo3y|C{<%M-fD_^R(EnVZi#x4?yjp5=EhgGp$*XxC(2^KW`a+@&dzwDNh6*j^uI-yA1BtLyDXWLAv8hi#gEd$06b;i!T&fTdz0`yJ4(<+bD>Zu;PpxCl;#t*loL!-d}OKy#+Bhw3qkay5G#&!u^&SF&{x6YwU zR;_QwMQ7+ox7e=nsz0uN#-(7$H?SivKw>@Q&rd|vhQNZy>uFIptQaj-!5B3Oej($6 zm-yKWZPi(Bq~JSdH`x0W$Ut%yT89e!T%WVJ1eh1sJLuzWAXwnNfKjj)p&LxkmCf6QIVxrSl+%3Duk94Q~gORhu~<0LTxs zPrBgbp0P@;O)m{bE4!@abm%_CJuhU#7tpBQSiJY{-{s&Vts2D#zcs_n&p>wyBQ@b% zS?FF)=P~r=xn;7fnaM6h{S@ht;hSJ?x@&yI{-DSF>d>UHypGS6ur2r6W;kk+yx_Rq zY{N`W5wRBgW>evwqE=5P^tm~Y1oVS0TV#De9->n?VI2PRP`C3^sYlXQwhf~PXdIHD zG1W$BopyvR@!0%K;gGRKoaAegu8>gf4KG(|i8q%0Uigi6jaHID(P^9U7EJ8=PtC*M zgmakn24dHeEOOX%IDgAkl7x$ZG7LPXG8|}P;=)I2a2D?rEhph7o>;gR0=SZ%Z9jLC z<2e+0`J8Hzi`pXL1{wFPfh`Jtw`T~nA|8*@5h+D}$FI?~E(w+~sG?^d67Ws$+a3F> z=+bv;LG0W{6*Fs3979FebW!jsg{GP{1347(Bq>&SN~9 zI|&vA$-t)#^Bww0McOB0MJx(OiQ0YieopK&n?|^9*?dB-iEo{J1ng*SI(^$6P<6dM zUkEUKIe=3eW3hE^0Ur!oVOIAt>8hqNrseQp(@_^diYZ4E{gHq1Jj^V?VQIU83TG-8 zf%1h3HyNw(dgI=<2o(+8j5iM$tE!rEXzYQ5awUJVU)-wMh|ODG`TaYB7rTm{2xwyr za&+X8XS~AwfK!od1Q6f`LH-$JLmqd%;QWyeSs#O|<>x`nQhj<1w_E^5?U({-09^S; z1OG=)E|yq3Tqyt%bKV%m7-Wh2uMbmgG+C^y(wCQZCRTdk$XHV|?>k+{W?o5(Me(hT zCA!{?ElQnn6rqV7t8y_LWK{DFEBLI^X+N1BA^k$>P(STa9)RwP$T_zY<&JveeZVc3 zen3PkW;1Smb!x>t|&Z(3XoaaW^g+p|9 zQ4Z)Sb#8dn7}|A4u|gp1+%v!acJtM1SP9y!Kb)Kc`@i6{v{g#fRZME(`EbV=C%yObc!Sr!U$Ps9XBBmvpHB8zbqWP^eHdqrag3n|M`GryEdVxd# zs+hK3paX^>1wz~wJUEGCJ0b3iu{+#A?4S-|(AwxP9sqQ28&I@IeHmCU&Vtp@6HOh9 zYwyN4jMwZ^Tu#x|%|o(`t51=N=`wu*BY3Bu5o6cY%@D@VzlUYm58jw>lCL$gExA0) zM@G-=uWxg@EBb|Tk}tGpf#5Hd$9jvXx_L~WeW{CZws-brrSo2wN6A9mEW(gKHiJ9l zmr;)uK>(z<3I#&mb5N4rof&5Wg)4@Iq_TWvR;A*y->qebFh1l?#haHAa9%|sz0bq| z+w$+#LH$v&S+c$S-EN|mb|6^R1+!mVz<;KlZ3k~ptit@V1AAYrGK4*-l2ytT3(?6Q z@Fh`T_!NyF1`c9T%ii{CqtK{?I{SUmUO z?NFH?d!rJLpEj?x`x5cEtsmsF;jN-%@g`WgW}mfBx?MCdv~|W~5`|UlAa|!{9g9Z~ zye`hbw(sW8a@>^yVKw$A88bRw>RMeXe+W{$x8)V2P?c zPS-m`e%DF@cCTw5W&&5rtemj5cXR0cC{rEp9@)PYQf~;u_lnyXn=#LQEM8AR8nuu# z&Qrv7Xhx5VCmJHOkJhAhjy&1)C`a*|RRFP08>FK`+$5uV z4}0RICF0xH2&BfUd64KMaZWu=0}7#p4TGS#jLDUAxzBPpVpTBrmt2gkl;{{~o1Hm) zaK*%zer4i_wr|pQ`W0?6zNu4oq67Y!@qZV9$NMKKk!z#;#{M<*gL>VcVo4kM3^DK2 zOjXNG+ln#FcK`C8COS*Kf&JV=&$C=19zZNnJe zUu^c!x^B!A-wl%Jv~FhbwSLv4i8Zw*_<{>bU1s+t^nGNdkE1`7>Zv-Zp8M5O-AW}s z7~rlzYJA*9S5VbjW#0G*cDYFer%sE55d789bPx-X5DGt85Y&ko-q=I>H^S>LT;L3vVoCAXY%Hjd; zeJ(QxR8VSiKDRbgK{&ZQHkQT^POLTAZ}atHqYx3l?1={b0gdCpE?|-aXDRb&^N#B9 zCp8oe#hRvxVJN$&Dfhax)h>&gsLH2M6t*e1#zh8IJf0c`b@fFM53y&S$gXWE%Nsvl zOCFjs;#I*Ogbmy1rd1*k*j)3hd4{vPq%J$Hd8NkAKs#x~4@fG|Na6C;Px z6~g@|;(t1dR6x=x3NjGTBGLc!E0-t+fX#m(meAdTeqrilWkDoGUG&X?#w}KOHj2=p z;9`Aac=292kFe{3oi*5zf5yU37`Dg=!hew_Bu_ziaP-3UDLGf>&p8j>r?N9sFS`bP zz?y?7gY*0Q5BEAPGckPo|Jq0Et8&=N|D~F&J@ZZfOEvMP!uvxi6a`OUB@Ph5@=-bv zzj#ORAZf^lNIkh1Y$^$}s;#Nr(kof3_no*Uixm-G+S_3Mf|+gPBNpCllH@}&677&= zWUOUKWmCZ`zkTn=T3{1^hQAwg2OBIV)b2$87i;o<84no%^;GGgMWQ-4{i}NtvHiwz zb|G)YBLtcD%nXZ9g%D*wqZ@Do1?~sO_YpyF4ADA0=d=5K^$INFVbs+=98ZJR#yn<7 z1rNRsw}5pff#}?P@QN|0S>S!56Lri_lgOhXaxI4j`w!~L39gKL1h(G#`FXE{TW#E+MAr1OTKhR_Q>^M? z*Ezz6y^vt+Mm6qW1(Y_10aS3Viw2NTe{Yw@b5{j#%iKRier)f!5}5ErdfrdqgDQDU z_rUk##BuZLQ@XSiI`1~Y38cjW2N$1AwUdIO!==XxaL6oCxb)^V!$+A@f=L}ujgPrQ zwJH~wjdNBTGRes$gV(8pe6EW?_i|xE=fC(|q6fg^2t{+eisgADbE?w0M+N{@`LLo4 z&RkEB|NV26wIC7yWhra_U+7o;FT|ugO{gTiSV{nP)f%PZ^OE#pehpG36p1GaTF6dY zmQ~}mIjZ}jC(_41<&^SI5aOOl1nPWH(=WnZhrSFmfxaC9AVUxzIDPi4kF$u`5Z!`^ zx8zB1Lgx&NkcC3k4(LW+@dFGtm@wB27|b!W1eqvpbL&^m#1w*t^!M7uSkx%NUvK}4!d)Bo4ina4xb{c(I08Zkzbv9Dt{ks15G z6Ov>}_AQ}Ek}ZsNYza+vGWJr*mXz!vvWrQ!XKYy_Q)r6#P2*RddFJ)Hf8F=%^F8O> zx#ym9?mgensM?)k*PA zVV7XB^gGM`qlmM{Y;|enA9c_9X5^r3zbS~pvonU_whrHS%UN?yd6afx5M{aLp67Z} z*g9t45EtNcuB&d_yGBhO_gWsiKQi*0w;Zoj2mn1kI}BP6W=@>Xsv=aKEq^QTtRCIM z_C9y5L8;j*Gne1HVZm>FG(p(p`m5|YANf$+^JO81b%UHseXx?p*rQ@-3VND6hATa`^K~k%kBv>x^n`1UULQQ@q-CpbZpJv3ev-YCz&~7m5sUQWm_BE{ zMo8T`p)*(lyMti}HJy#zFxW@0!($VD4pWGwg8ot%7@R?6<8v2?FcI4d3+QY9B5rXhpHQ<7K1nIR%sNX~1)Pj0 z0etN=Ot_r^kvnKSF?Zs%#8I$wW@XOHeoy-tLsO=U2pDF?_LGgGBx8mVOgweeZxB34egjnFnimyT3M%7{jKm&<;CM_Fs0jC zH)CEn#~mjKhB2EVm*vzf5mA@WIF;sq)=CFi^OfadO?>pNuROvD8>dkVyY=c;hU-46 zH~LilIs#7|@=acAf%u}nTBmoXo@q%@>?EMHrrHw(+deUrg(S*Fm?CQChf zC6&o8R$rc`;;Jc;#o6AYdCI=U%pr|sz(UE3wztjN;6yY&#H?!GrtRTZmaewx`r6H} z>DM!LUn;vp&~COk|DqhIx9-BV6s48V15UL%0)7(F!_AXUQZ^_kl#6yMVbYAnsdTSs z|5!D47UyIbSH3HN7P=dMU$vAtwQ=`2_Y7pb%wvBze1ZCD;L1vMZNci>;h%OJk7?Fv zAs;hD;<~@;*J{V{&?YmwED3@(j0(|aCRi2ynHsOnPv6Fs>}NV1!@^{;Ox~Y(ihs&6 zJ>Gd{?;N+Q%Jvb|NrrFTti*6ULU?(OLp!VhoufJ;+pFrmn^pK_-3RpTNB3P0wO=m@ z`p#IF`^EXGh3MU$kbE0!^DT;7^pOMdSb31*+40kpteF*11xz34dtU8W$O+5#z>#!s{L7c04ZO`_sk? zYq!$=K1)+x|1{{Z8v)yZ54QXiHFfea=F_sCCuKP$0UNK*20w*znO-$?~p z%8z08G2$pV-x-T}rX4_u7QLm$eUUWkj@rnGl$4SeUf_y=zNzPe96$KNXdT?0U1sUk z!+CoYCK^UAFiy<7`G4BanaUggwYAe_0(jd-8)3rA`eX9gax}|8rm&IGg(|_mGrEn| zX6Bw^uhPp`knKTHkM3kML_s}E6QGtV(yJC`*5an9QCH~;PjkzMtqt*tu8KKX4sWqz zZ(oKyswh`*vUK-kj6Dm7bGV=j({9wYGMvP<{g}MwCYN#VlJ-wFfEJ(C;HqA#>P2%s zzEVz7?}4rE8U953bTl(H?8*el4lcIvYUpDPZwPf;S*_-X_-N1a6voW5c6ZQ7BLzBw zypK^r;Z6Y8YRx%nhHw8^yIlNnZZ**HNNOG`>p08Ijk0ynb8%|)MA>nuQgfx%!;5sp z8Mv(S$?w;^fEQJ8Ni{DF)0S@~0>*`=^70Q`nDsut1@oDwl&_oUA?=F zavrE`Eew*KO}ze1JBhy*Ww(>sNiXHLr$3v>`&Fh16yn7<`*N4qMxY_2WA%xl?w4At ze%%Er`nObl*iG*kD*_e8%7dC$6?z;kBjwUn6cm(xG76;H7{}?Y%Q)rUJ~^4g?1C3Q z`y-oa=lHIO78m4PB+w7fi9mZ1QUY9qT~QEJkwg182S;LC9*mrA$p34-NFCNp=pf z4S_xg{s+N|qP=QgKVtPdgSZyCPJVM+lQzP$P8x-rr#(oo!3+QbNme7jl$kIx8LFsK zg9d&bzJjzDoub2~22rn{sF+)8z`ew%2brNSx&`fCjVCqaq{oIQfX|1hYj(UKYS5&4 zL7@W#uCNXDudIZA?_oi&6+&)q?{wDAR9C>2^df-Z%{A20(wHZa*_ZIz zHV)pVCtTyURsQ_30dDEkT#c?p3EfSryEB?FoNq$s%K~@BZ0-fLL6dt^jms_lD=O{! z0{Wqj412})GQ%M`g}&%G?|F!#cK<^agxlMgBk~aNtGTf#6}wtL?Z^zLX)08Z`MHPH z=|-Q~lv+m8qlJ+cQhvj=%-+b!535Ve61v6=XZx=^W||H8=Jnr@g{tzq4!aw!T3dD_ zYPb5iOkNb3$#5~!xEB?QjZHDYt*@?)CKq{~jw*ZZbscb1Y5+{9skLWv9kRGHbjJ}u zH7P}(RF44gy2U?>p&}w|LL^XbK=UqsCo!6Cuz2ZZrHW%m>*?fosu-MRHAWZC3cUuv zGFL_5FOZXtK8iIlj_9VY^X%C+6T$FJ5eZW~i0V|=+L6jgfTB0(EiLX;#!S#8%y|$4hItGk24PEi>cm@j z)Z==c+~F@XU}bF+_hG>6wejb(b@`E2>s_Ox~Xi8VUDSf_#_lvJS2?Kf}q?!@)F zCm-uu<}1)C8O73@M>ruaO~W4>>eUhNn?b_)`%%(oBRH8sP67#SL{WS%`;7{@AjLTC zBVn0!S|!vr;L@PhwF@RGSv_qPg>I&q7OP!s&QrmrebKU0#}gTuC7(C?l<3FSpdWyr zS$S71ZQ#4J$G%wFpxQ>>f2e+yn(_2!j+?`m_p60_n!&UKKW`ivKUR~pn+e8G# z{Y;o>G5L9etBj4s{Ch6X)AO~!cMGqhS>dY1llN?wjKXp|-@Uh3oq|B@g{o-~waM@e z@ppIAa0E2(Gkl*fx2J{R>%PHE%Ue~KXP_B<$_i#?)!lxAuk}$=r_F4d-CUaQ`KZZwI4}*f+C>#^>t*Gp=ea4c`jBJ|xCgwvCTIpPj|epp>UR$Eq&u6SJ)& z-}rK=)Yy9S$ts4vL3it+U*qQd%IU?}dVDY6nb=E(8Q9pSr5FtdO@f+_Ua<0@Xhd9x z?azIXng@2n!Gka~=ptBR zbYZNMiZh5ad7|>#opA+U9;2O#e*$}UIE&4j%T(Ee#nmD{k@nQ!SA+~DA|*p&ik3PV zuW+>p^brtlfT= z#VU}d%q(=RYDvL9emwA9=eUu~gGR&P8iU(v%k_FujyQu&;GzkA&X*D9#ymsX?*()q znTo`{Xx)OFa%xRz4H>_?LGk&Cu#47K)s3ctxhq@I{V1WvA=jl5|DIs3d+ur z8ql2|Ixb(49eP_jJgm>z>LetWmc|koiR;GV#Fpt&mu3XhYwYz>k+EsCfqP_&6xwH| zIfbOBm<|Zjn|F!MbKsqI8UO%ziIIGu#dUIl?;(sdW%MB(2mmRa)ZZTy7-O2xg^>jF zVUqKixc2Od7L!QN!wFK-QDF)&n~4lEq=s zp(4TGLiYs8I$VGKdr75Npa{4oa=_7Istd%q4y{`MTf<*se>fL0D7g}UeL+c@ND9sP zQ{+m-6pC~GHgWVL0bQgfQ>TD3CCSiwp*+{W%+CJP_>l;VW|1cMU%MgTf3Hrr901cQ zEA_iT++?#H(uICPI!;l;E$WUm}8ULZ^Fs=stWX*r)P~)edZ=e4|12LRsywv23TWV!2N1s?9 zoH)p`#!Z$v+}8vEh*QWc+y0@YxDUSt1OQ+ZRQJvQ2YV>W{rAf?QiDiPP>bPzC?Mu$ zFef?k;rc89z(+yXM*g9XVKyJ~av!d$0RX%dB=`LTG9i)tx8?r3ibN^_<(=N2en4{F zR diff --git a/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.properties b/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.properties index dedd5d1e69e..4ca99e6a38c 100644 --- a/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip +distributionSha256Sum=62c3769155d7d17ea05084ad498067824c1804568a408a6faa78a5ef95ed67a8 networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/mobile/native/android/apps/ensu/gradlew b/mobile/native/android/apps/ensu/gradlew index adff685a034..23d15a93670 100755 --- a/mobile/native/android/apps/ensu/gradlew +++ b/mobile/native/android/apps/ensu/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,6 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -171,6 +172,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/mobile/native/android/apps/ensu/gradlew.bat b/mobile/native/android/apps/ensu/gradlew.bat index e509b2dd8fe..5eed7ee8452 100644 --- a/mobile/native/android/apps/ensu/gradlew.bat +++ b/mobile/native/android/apps/ensu/gradlew.bat @@ -70,10 +70,11 @@ goto fail :execute @rem Setup the command line +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/mobile/native/android/apps/ensu/rust/build.gradle.kts b/mobile/native/android/apps/ensu/rust/build.gradle.kts index 64171d8dcac..1c983d289fc 100644 --- a/mobile/native/android/apps/ensu/rust/build.gradle.kts +++ b/mobile/native/android/apps/ensu/rust/build.gradle.kts @@ -98,7 +98,7 @@ val buildRustJniRelease = registerBuildRustJni( android { namespace = "io.ente.ensu.rust" - compileSdk = 34 + compileSdk = 36 // Pin the NDK instead of relying on AGP defaults. GitHub-hosted Ubuntu // runners already ship 27.3.13750724, so this keeps CI lean while making // the requirement explicit for local builds too. From 712d601647fea1f089a279b40301188a4814a88e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 22 Jul 2026 13:57:59 +0530 Subject: [PATCH 67/91] Add Ensu API 36 change note --- rust/apps/ensu/changes/android-16-compatibility.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 rust/apps/ensu/changes/android-16-compatibility.md diff --git a/rust/apps/ensu/changes/android-16-compatibility.md b/rust/apps/ensu/changes/android-16-compatibility.md new file mode 100644 index 00000000000..a07d7be382a --- /dev/null +++ b/rust/apps/ensu/changes/android-16-compatibility.md @@ -0,0 +1 @@ +- Updated Android to API 36. From d50f968282b100d57225c6dbfe12e30905afda7b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 17 Jul 2026 13:52:15 +0530 Subject: [PATCH 68/91] Add checksum verification to the model downloader and use it for ML test assets --- rust/Cargo.lock | 5 +- rust/crates/model-download/src/download.rs | 233 ++++++++++++++++- rust/crates/model-download/src/downloader.rs | 2 + rust/crates/model-download/src/gguf.rs | 2 + rust/crates/photos/Cargo.toml | 4 +- .../photos/tests/support/ml_indexing.rs | 237 +++--------------- 6 files changed, 273 insertions(+), 210 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c689e59641d..11ce76b1dc0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2454,6 +2454,7 @@ version = "0.0.0" dependencies = [ "anyhow", "ente-image", + "ente-model-download", "fast_image_resize", "flate2", "half", @@ -2466,13 +2467,12 @@ dependencies = [ "plsfix", "quick-xml", "regex", - "reqwest 0.12.28", "serde", "serde_json", - "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", + "url", "usearch", ] @@ -7134,7 +7134,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", diff --git a/rust/crates/model-download/src/download.rs b/rust/crates/model-download/src/download.rs index be8984fa8a6..2afd97a63bd 100644 --- a/rust/crates/model-download/src/download.rs +++ b/rust/crates/model-download/src/download.rs @@ -9,6 +9,7 @@ use futures_util::future::try_join_all; use reqwest::header::{ACCEPT_RANGES, CONTENT_RANGE, ETAG, IF_RANGE, LAST_MODIFIED, RANGE}; use reqwest::{Client, Response, StatusCode}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::runtime::Builder; use tokio::time::timeout; @@ -70,6 +71,7 @@ pub struct Target { pub label: String, pub url: String, pub destination_path: String, + pub sha256: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -432,7 +434,7 @@ async fn download_file( .await { Ok(report) => return Ok(report), - Err(Error::Cancelled) => return Err(Error::Cancelled), + Err(err @ (Error::Cancelled | Error::Validation(_))) => return Err(err), Err(err) => { range_error = Some(err); cleanup_range_download(destination); @@ -660,6 +662,14 @@ async fn download_file_single( continue; } + if let Some(expected) = &target.sha256 + && let Err(err) = check_sha256(&tmp_path, expected, &target.label) + { + let _ = fs::remove_file(&tmp_path); + let _ = fs::remove_file(&partial_metadata_path); + return Err(err); + } + if let Err(err) = validate(target, &tmp_path) { let _ = fs::remove_file(&tmp_path); let _ = fs::remove_file(&partial_metadata_path); @@ -781,6 +791,14 @@ async fn download_file_ranged( emit_range_file_progress(total, file_started_at, &range_states, &on_progress); + if let Some(expected) = &target.sha256 + && let Err(err) = check_sha256(&tmp_path, expected, &target.label) + { + let _ = fs::remove_file(&tmp_path); + let _ = fs::remove_file(&range_metadata_path); + return Err(err); + } + if let Err(err) = validate(target, &tmp_path) { let _ = fs::remove_file(&tmp_path); let _ = fs::remove_file(&range_metadata_path); @@ -1518,6 +1536,13 @@ fn prepare_cached_download( if !destination.exists() || validate(target, destination).is_err() { return false; } + if let Some(expected) = &target.sha256 { + if check_sha256(destination, expected, &target.label).is_err() { + return false; + } + cleanup_range_download(destination); + return true; + } let adopted = if !metadata_path_for(destination).exists() { let size = file_size(destination).unwrap_or(0); let _ = write_download_metadata(destination, target, size, None); @@ -1604,6 +1629,35 @@ pub fn metadata_path_for(path: &Path) -> PathBuf { PathBuf::from(format!("{}.metadata.json", path.display())) } +pub fn sha256_file(path: &Path) -> Result { + use std::io::Read; + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 1024 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let mut hex = String::with_capacity(64); + for byte in hasher.finalize() { + hex.push_str(&format!("{byte:02x}")); + } + Ok(hex) +} + +fn check_sha256(path: &Path, expected: &str, label: &str) -> Result<(), Error> { + let actual = sha256_file(path)?; + if actual != expected.trim().to_ascii_lowercase() { + return Err(Error::Validation(format!( + "{label} checksum mismatch: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + fn range_metadata_path_for(path: &Path) -> PathBuf { PathBuf::from(format!("{}.tmp.ranges.json", path.display())) } @@ -1892,6 +1946,7 @@ mod tests { label: "Model".to_string(), url, destination_path: destination.display().to_string(), + sha256: None, }], |_, _| Ok(()), |_| {}, @@ -1941,6 +1996,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }], |_, _| Ok(()), |_| {}, @@ -1978,6 +2034,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }], |_, _| Err(Error::Validation("rejected".to_string())), |_| {}, @@ -2031,6 +2088,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fetch(vec![target.clone()], |_, _| Ok(()), |_| {}, || false) @@ -2069,6 +2127,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; let require_zero_header = |_: &Target, path: &Path| match fs::read(path) .ok() @@ -2120,6 +2179,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch adopts existing file"); @@ -2160,6 +2220,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch redownloads"); @@ -2197,6 +2258,7 @@ mod tests { label: "Model".to_string(), url: "http://127.0.0.1:1/old-model.bin".to_string(), destination_path: destination.display().to_string(), + sha256: None, }; write_download_metadata(&destination, &stale_target, bytes.len() as u64, None) .expect("write stale sidecar"); @@ -2205,6 +2267,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch redownloads"); @@ -2251,6 +2314,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2310,6 +2374,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fs::write(tmp_path_for(&destination), vec![0xAAu8; 256]).expect("place stale tmp"); write_partial_download_metadata( @@ -2359,6 +2424,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fresh download succeeds"); @@ -2402,6 +2468,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), + sha256: None, }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2495,6 +2562,7 @@ mod tests { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), destination_path: destination.display().to_string(), + sha256: None, }], |_, _| Ok(()), |_| {}, @@ -2520,6 +2588,7 @@ mod tests { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), destination_path: destination.display().to_string(), + sha256: None, }], |_, _| Ok(()), |_| {}, @@ -2535,6 +2604,168 @@ mod tests { let _ = fs::remove_dir_all(test_dir); } + #[test] + fn sha_pinned_download_verifies_content() { + let bytes = Arc::new(sample_bytes(2048)); + let get_count = Arc::new(AtomicUsize::new(0)); + let server = { + let bytes = Arc::clone(&bytes); + let get_count = Arc::clone(&get_count); + TestServer::spawn(move |stream| { + handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); + }) + }; + + let test_dir = scratch_dir("sha-download"); + let destination = test_dir.join("model.bin"); + let mut expected = String::new(); + for byte in Sha256::digest(bytes.as_slice()) { + expected.push_str(&format!("{byte:02x}")); + } + + fetch( + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination_path: destination.display().to_string(), + sha256: Some(expected), + }], + |_, _| Ok(()), + |_| {}, + || false, + ) + .expect("sha-pinned download succeeds"); + assert_eq!( + fs::read(&destination).expect("read downloaded file"), + *bytes + ); + + let _ = fs::remove_dir_all(test_dir); + } + + #[test] + fn sha_mismatch_fails_immediately_without_installing() { + let bytes = Arc::new(sample_bytes(2048)); + let get_count = Arc::new(AtomicUsize::new(0)); + let server = { + let bytes = Arc::clone(&bytes); + let get_count = Arc::clone(&get_count); + TestServer::spawn(move |stream| { + handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); + }) + }; + + let test_dir = scratch_dir("sha-mismatch"); + let destination = test_dir.join("model.bin"); + + let result = fetch( + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination_path: destination.display().to_string(), + sha256: Some("0".repeat(64)), + }], + |_, _| Ok(()), + |_| {}, + || false, + ); + + assert!(result.is_err()); + assert!(!destination.exists()); + assert_eq!(get_count.load(Ordering::SeqCst), 1); + + let _ = fs::remove_dir_all(test_dir); + } + + #[test] + fn ranged_sha_mismatch_fails_without_single_stream_fallback() { + let bytes = Arc::new(sample_bytes(MIN_RANGE_DOWNLOAD_BYTES as usize + 123)); + let head_count = Arc::new(AtomicUsize::new(0)); + let range_get_count = Arc::new(AtomicUsize::new(0)); + let full_get_count = Arc::new(AtomicUsize::new(0)); + let server = { + let bytes = Arc::clone(&bytes); + let head_count = Arc::clone(&head_count); + let range_get_count = Arc::clone(&range_get_count); + let full_get_count = Arc::clone(&full_get_count); + TestServer::spawn(move |stream| { + handle_range_test_request( + stream, + Arc::clone(&bytes), + Arc::clone(&head_count), + Arc::clone(&range_get_count), + Arc::clone(&full_get_count), + ); + }) + }; + + let test_dir = scratch_dir("ranged-sha-mismatch"); + let destination = test_dir.join("model.bin"); + + let result = fetch( + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination_path: destination.display().to_string(), + sha256: Some("0".repeat(64)), + }], + |_, _| Ok(()), + |_| {}, + || false, + ); + + assert!(result.is_err()); + assert!(!destination.exists()); + assert!(!tmp_path_for(&destination).exists()); + assert_eq!( + range_get_count.load(Ordering::SeqCst), + RANGE_DOWNLOAD_CONCURRENCY + ); + assert_eq!(full_get_count.load(Ordering::SeqCst), 0); + + let _ = fs::remove_dir_all(test_dir); + } + + #[test] + fn sha_pinned_target_adopts_cached_file_regardless_of_url() { + let test_dir = scratch_dir("sha-cached"); + let destination = test_dir.join("model.bin"); + fs::write(&destination, b"model-bytes").expect("write cached file"); + let sha = sha256_file(&destination).expect("hash cached file"); + let old_target = Target { + label: "Model".to_string(), + url: "http://cache.example.org/old-location".to_string(), + destination_path: destination.display().to_string(), + sha256: Some(sha.clone()), + }; + write_download_metadata(&destination, &old_target, 11, None).expect("write stale metadata"); + + fetch( + vec![Target { + url: "http://127.0.0.1:9/new-location".to_string(), + ..old_target.clone() + }], + |_, _| Ok(()), + |_| {}, + || false, + ) + .expect("cached file adopted by checksum despite changed URL"); + + fs::write(&destination, b"corrupted").expect("corrupt cached file"); + let result = fetch( + vec![Target { + url: "http://127.0.0.1:9/new-location".to_string(), + ..old_target + }], + |_, _| Ok(()), + |_| {}, + || false, + ); + assert!(result.is_err()); + + let _ = fs::remove_dir_all(test_dir); + } + fn sample_bytes(len: usize) -> Vec { (0..len).map(|index| (index % 251) as u8).collect() } diff --git a/rust/crates/model-download/src/downloader.rs b/rust/crates/model-download/src/downloader.rs index 8b010c5fcfb..695d19cb7c2 100644 --- a/rust/crates/model-download/src/downloader.rs +++ b/rust/crates/model-download/src/downloader.rs @@ -224,11 +224,13 @@ impl ModelDownloader { .join(format!("{id}.tar.gz")) .display() .to_string(), + sha256: None, }], ModelDownloadTarget::Onnx { url, .. } => vec![Target { label: "Model".to_string(), url: url.clone(), destination_path: self.model_path(target).display().to_string(), + sha256: None, }], } } diff --git a/rust/crates/model-download/src/gguf.rs b/rust/crates/model-download/src/gguf.rs index ad7558fe796..93038996ca5 100644 --- a/rust/crates/model-download/src/gguf.rs +++ b/rust/crates/model-download/src/gguf.rs @@ -20,12 +20,14 @@ pub(crate) fn expected_targets( destination_path: model_path(models_dir, id, url, mmproj_url) .display() .to_string(), + sha256: None, }]; if let Some(mmproj) = mmproj_path(models_dir, id, url, mmproj_url) { targets.push(Target { label: "Mmproj".to_string(), url: trimmed(mmproj_url).unwrap().to_string(), destination_path: mmproj.display().to_string(), + sha256: None, }); } targets diff --git a/rust/crates/photos/Cargo.toml b/rust/crates/photos/Cargo.toml index 5e2937fc50e..78b4f27c1bd 100644 --- a/rust/crates/photos/Cargo.toml +++ b/rust/crates/photos/Cargo.toml @@ -56,13 +56,13 @@ ort = { workspace = true, features = [ [dev-dependencies] anyhow = "1" +ente-model-download.workspace = true flate2 = "1.1" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots"] } serde = { version = "1", features = ["derive"] } serde_json.workspace = true -sha2 = "0.10" tar = "0.4" tempfile = "3.12" +url = "2.5.8" [[test]] name = "ml_indexing" diff --git a/rust/crates/photos/tests/support/ml_indexing.rs b/rust/crates/photos/tests/support/ml_indexing.rs index 14b8d5bc887..47ca04c51fd 100644 --- a/rust/crates/photos/tests/support/ml_indexing.rs +++ b/rust/crates/photos/tests/support/ml_indexing.rs @@ -1,12 +1,11 @@ use std::{ collections::{BTreeSet, HashMap, HashSet}, fs, - io::{Read, Write}, path::{Path, PathBuf}, - time::Duration, }; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; +use ente_model_download::download; use ente_photos::ml::{ error::MlError, indexing::{ @@ -16,17 +15,14 @@ use ente_photos::ml::{ types::FaceResult as RustFaceResult, }; use flate2::read::GzDecoder; -use reqwest::{StatusCode, Url, blocking::Client}; use serde::Deserialize; -use sha2::{Digest, Sha256}; +use url::Url; const ASSET_LOCK_PATH: &str = "infra/ml/test/ml_indexing/assets.json"; const CLIP_EMBEDDING_DIM: usize = 512; const FACE_EMBEDDING_DIM: usize = 192; const FLOAT_TOLERANCE: f64 = 1e-8; const PRINT_STATS_ENV: &str = "ENTE_ML_INDEXING_PRINT_STATS"; -const DOWNLOAD_MAX_ATTEMPTS: usize = 4; -const DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 500; pub(crate) fn run_with_large_stack(name: &str, test: fn() -> Result<()>) { let result = std::thread::Builder::new() @@ -168,7 +164,6 @@ fn should_print_stats() -> bool { pub(crate) struct MlIndexingTestContext { asset_lock: AssetLock, cache_dir: PathBuf, - client: Client, golden_results: HashMap, manifest: FixtureManifest, runtime_config: MlRuntimeConfig, @@ -179,30 +174,21 @@ impl MlIndexingTestContext { let repo_root = repo_root()?; let asset_lock = load_asset_lock(&repo_root)?; let cache_dir = cache_dir(&repo_root); - let client = Client::builder() - .user_agent("ente-rust-ml-indexing-test") - .build() - .context("build HTTP client")?; - - let manifest_path = - resolve_document_asset(&client, &cache_dir, "manifest", &asset_lock.manifest)?; - let golden_path = resolve_document_asset( - &client, - &cache_dir, - "python-golden", - &asset_lock.python_golden, - )?; + + let manifest_path = resolve_document_asset(&cache_dir, "manifest", &asset_lock.manifest)?; + let golden_path = + resolve_document_asset(&cache_dir, "python-golden", &asset_lock.python_golden)?; let manifest = load_manifest(&manifest_path)?; let golden_results = load_golden_results(&golden_path)?; let onnx_runtime_library = - resolve_onnx_runtime_library(&client, &cache_dir, &asset_lock.onnx_runtime)?; + resolve_onnx_runtime_library(&cache_dir, &asset_lock.onnx_runtime)?; let _ = ort::init_from(&onnx_runtime_library) .context("load ONNX Runtime dynamic library")? .commit(); let runtime_config = MlRuntimeConfig { - model_paths: resolve_model_paths(&client, &cache_dir, &asset_lock.models)?, + model_paths: resolve_model_paths(&cache_dir, &asset_lock.models)?, provider_policy: ExecutionProviderPolicy { prefer_coreml: false, prefer_nnapi: false, @@ -214,7 +200,6 @@ impl MlIndexingTestContext { Ok(Self { asset_lock, cache_dir, - client, golden_results, manifest, runtime_config, @@ -353,12 +338,7 @@ impl MlIndexingTestContext { } fn resolve_fixture(&self, fixture: &FixtureFile) -> Result { - resolve_fixture_asset( - &self.client, - &self.cache_dir, - &self.asset_lock.fixture_base_url, - fixture, - ) + resolve_fixture_asset(&self.cache_dir, &self.asset_lock.fixture_base_url, fixture) } fn unsupported_decode_file_ids(&self) -> HashSet { @@ -539,18 +519,12 @@ fn file_id_for_manifest_path(path: &str) -> Result { .with_context(|| format!("derive file_id from manifest path '{path}'")) } -fn resolve_document_asset( - client: &Client, - cache_dir: &Path, - label: &str, - asset: &DocumentAsset, -) -> Result { +fn resolve_document_asset(cache_dir: &Path, label: &str, asset: &DocumentAsset) -> Result { let target = cache_path_for(cache_dir, "documents", &asset.path, &asset.sha256)?; - ensure_remote_asset(client, &asset.url, &asset.sha256, &target, label) + ensure_remote_asset(&asset.url, &asset.sha256, &target, label) } fn resolve_fixture_asset( - client: &Client, cache_dir: &Path, fixture_base_url: &str, fixture: &FixtureFile, @@ -562,14 +536,10 @@ fn resolve_fixture_asset( .with_context(|| format!("join fixture URL for {}", fixture.path))? .to_string(); let target = cache_path_for(cache_dir, "fixtures", &fixture.path, &fixture.sha256)?; - ensure_remote_asset(client, &url, &fixture.sha256, &target, &file_id) + ensure_remote_asset(&url, &fixture.sha256, &target, &file_id) } -fn resolve_onnx_runtime_library( - client: &Client, - cache_dir: &Path, - assets: &OnnxRuntimeAssets, -) -> Result { +fn resolve_onnx_runtime_library(cache_dir: &Path, assets: &OnnxRuntimeAssets) -> Result { let target_key = onnx_runtime_target_key()?; let archive = assets.archives.get(&target_key).with_context(|| { let supported = assets @@ -591,7 +561,6 @@ fn resolve_onnx_runtime_library( &archive.sha256, )?; let archive_path = ensure_remote_asset( - client, &archive.url, &archive.sha256, &archive_path, @@ -682,16 +651,10 @@ fn extract_tgz_member(archive_path: &Path, member_path: &str, target: &Path) -> ); } -fn resolve_model_paths( - client: &Client, - cache_dir: &Path, - models: &ModelAssets, -) -> Result { - let face_detection = - resolve_model_asset(client, cache_dir, "face-detection", &models.face_detection)?; - let face_embedding = - resolve_model_asset(client, cache_dir, "face-embedding", &models.face_embedding)?; - let clip_image = resolve_model_asset(client, cache_dir, "clip-image", &models.clip_image)?; +fn resolve_model_paths(cache_dir: &Path, models: &ModelAssets) -> Result { + let face_detection = resolve_model_asset(cache_dir, "face-detection", &models.face_detection)?; + let face_embedding = resolve_model_asset(cache_dir, "face-embedding", &models.face_embedding)?; + let clip_image = resolve_model_asset(cache_dir, "clip-image", &models.clip_image)?; Ok(ModelPaths { face_detection: face_detection.to_string_lossy().into_owned(), @@ -707,142 +670,32 @@ fn resolve_model_paths( }) } -fn resolve_model_asset( - client: &Client, - cache_dir: &Path, - label: &str, - asset: &ModelAsset, -) -> Result { +fn resolve_model_asset(cache_dir: &Path, label: &str, asset: &ModelAsset) -> Result { let target = cache_path_for(cache_dir, "models", &asset.file_name, &asset.sha256)?; - ensure_remote_asset(client, &asset.url, &asset.sha256, &target, label) -} - -enum DownloadAttemptError { - Fatal(anyhow::Error), - Retryable(anyhow::Error), + ensure_remote_asset(&asset.url, &asset.sha256, &target, label) } fn ensure_remote_asset( - client: &Client, url: &str, expected_sha256: &str, target: &Path, label: &str, ) -> Result { - if target.is_file() { - verify_file_sha256(target, expected_sha256, label)?; - return Ok(target.to_path_buf()); - } - - let parent = target - .parent() - .with_context(|| format!("resolve parent for {}", target.display()))?; - fs::create_dir_all(parent) - .with_context(|| format!("create cache directory {}", parent.display()))?; - - let mut last_error = None; - for attempt in 1..=DOWNLOAD_MAX_ATTEMPTS { - match download_asset_once(client, url, expected_sha256, target, parent, label) { - Ok(path) => return Ok(path), - Err(DownloadAttemptError::Fatal(error)) => return Err(error), - Err(DownloadAttemptError::Retryable(error)) => { - last_error = Some(error); - if attempt < DOWNLOAD_MAX_ATTEMPTS { - std::thread::sleep(download_retry_delay(attempt)); - } - } - } - } - - Err(last_error.expect("retry loop records an error before exhausting attempts")).with_context( - || format!("download {label} from {url} after {DOWNLOAD_MAX_ATTEMPTS} attempt(s)"), + download::fetch( + vec![download::Target { + label: label.to_string(), + url: url.to_string(), + destination_path: target.display().to_string(), + sha256: Some(normalize_sha256(expected_sha256)), + }], + |_, _| Ok(()), + |_| {}, + || false, ) -} - -fn download_asset_once( - client: &Client, - url: &str, - expected_sha256: &str, - target: &Path, - parent: &Path, - label: &str, -) -> Result { - let mut response = match client.get(url).send() { - Ok(response) if response.status().is_success() => response, - Ok(response) => { - let status = response.status(); - let error = anyhow!("download {label} from {url}: HTTP status {status}"); - return Err(if is_retryable_download_status(status) { - DownloadAttemptError::Retryable(error) - } else { - DownloadAttemptError::Fatal(error) - }); - } - Err(error) => { - return Err(DownloadAttemptError::Retryable( - anyhow::Error::new(error).context(format!("download {label} from {url}")), - )); - } - }; - - let mut temp_file = tempfile::NamedTempFile::new_in(parent) - .map_err(|error| fatal_io(error, format!("create temp file in {}", parent.display())))?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 1024 * 1024]; - loop { - let read = match response.read(&mut buffer) { - Ok(read) => read, - Err(error) => { - return Err(DownloadAttemptError::Retryable( - anyhow::Error::new(error) - .context(format!("read {label} response body from {url}")), - )); - } - }; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - temp_file - .write_all(&buffer[..read]) - .map_err(|error| fatal_io(error, format!("write cached {label}")))?; - } - - let actual_sha256 = hex_digest(&hasher.finalize()); - if actual_sha256 != normalize_sha256(expected_sha256) { - return Err(DownloadAttemptError::Retryable(anyhow!( - "{label} SHA-256 mismatch after download: expected {}, got {actual_sha256}", - normalize_sha256(expected_sha256) - ))); - } - - temp_file - .flush() - .map_err(|error| fatal_io(error, format!("flush cached {label}")))?; - temp_file.persist(target).map_err(|error| { - DownloadAttemptError::Fatal(anyhow!( - "persist cached {label} to {}: {}", - target.display(), - error.error - )) - })?; + .with_context(|| format!("download {label} from {url}"))?; Ok(target.to_path_buf()) } -fn fatal_io(error: std::io::Error, context: String) -> DownloadAttemptError { - DownloadAttemptError::Fatal(anyhow::Error::new(error).context(context)) -} - -fn is_retryable_download_status(status: StatusCode) -> bool { - status == StatusCode::TOO_MANY_REQUESTS - || status == StatusCode::REQUEST_TIMEOUT - || status.is_server_error() -} - -fn download_retry_delay(attempt: usize) -> Duration { - Duration::from_millis(DOWNLOAD_RETRY_BASE_DELAY_MS * 2_u64.pow((attempt - 1) as u32)) -} - fn cache_path_for( cache_dir: &Path, namespace: &str, @@ -861,7 +714,8 @@ fn cache_path_for( } fn verify_file_sha256(path: &Path, expected_sha256: &str, label: &str) -> Result<()> { - let actual_sha256 = sha256_file(path)?; + let actual_sha256 = + download::sha256_file(path).with_context(|| format!("hash {}", path.display()))?; let expected_sha256 = normalize_sha256(expected_sha256); if actual_sha256 != expected_sha256 { bail!( @@ -874,35 +728,10 @@ fn verify_file_sha256(path: &Path, expected_sha256: &str, label: &str) -> Result Ok(()) } -fn sha256_file(path: &Path) -> Result { - let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 1024 * 1024]; - loop { - let read = file - .read(&mut buffer) - .with_context(|| format!("read {}", path.display()))?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - let digest = hasher.finalize(); - Ok(hex_digest(&digest)) -} - fn normalize_sha256(value: &str) -> String { value.trim().to_ascii_lowercase() } -fn hex_digest(bytes: &[u8]) -> String { - let mut hex = String::with_capacity(bytes.len() * 2); - for byte in bytes { - hex.push_str(&format!("{byte:02x}")); - } - hex -} - fn expect_decode_error(file_id: &str, req: AnalyzeImageRequest, failures: &mut Vec) { match analyze_image(req) { Err(MlError::Decode(_)) => {} From 0a38ad61bd475cea0d7c0e79e11643de79d0c7a1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 17 Jul 2026 14:16:57 +0530 Subject: [PATCH 69/91] Fetch ML test fixtures as a single verified batch --- .../photos/tests/support/ml_indexing.rs | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/rust/crates/photos/tests/support/ml_indexing.rs b/rust/crates/photos/tests/support/ml_indexing.rs index 47ca04c51fd..6c14fbcc6e1 100644 --- a/rust/crates/photos/tests/support/ml_indexing.rs +++ b/rust/crates/photos/tests/support/ml_indexing.rs @@ -180,6 +180,7 @@ impl MlIndexingTestContext { resolve_document_asset(&cache_dir, "python-golden", &asset_lock.python_golden)?; let manifest = load_manifest(&manifest_path)?; let golden_results = load_golden_results(&golden_path)?; + fetch_fixtures(&cache_dir, &asset_lock.fixture_base_url, &manifest)?; let onnx_runtime_library = resolve_onnx_runtime_library(&cache_dir, &asset_lock.onnx_runtime)?; @@ -338,7 +339,7 @@ impl MlIndexingTestContext { } fn resolve_fixture(&self, fixture: &FixtureFile) -> Result { - resolve_fixture_asset(&self.cache_dir, &self.asset_lock.fixture_base_url, fixture) + cache_path_for(&self.cache_dir, "fixtures", &fixture.path, &fixture.sha256) } fn unsupported_decode_file_ids(&self) -> HashSet { @@ -524,19 +525,39 @@ fn resolve_document_asset(cache_dir: &Path, label: &str, asset: &DocumentAsset) ensure_remote_asset(&asset.url, &asset.sha256, &target, label) } -fn resolve_fixture_asset( +fn fixture_url(fixture_base_url: &str, path: &str) -> Result { + Ok(Url::parse(fixture_base_url) + .with_context(|| format!("parse fixture base URL '{fixture_base_url}'"))? + .join(path) + .with_context(|| format!("join fixture URL for {path}"))? + .to_string()) +} + +fn fetch_fixtures( cache_dir: &Path, fixture_base_url: &str, - fixture: &FixtureFile, -) -> Result { - let file_id = file_id_for_manifest_path(&fixture.path)?; - let url = Url::parse(fixture_base_url) - .with_context(|| format!("parse fixture base URL '{fixture_base_url}'"))? - .join(&fixture.path) - .with_context(|| format!("join fixture URL for {}", fixture.path))? - .to_string(); - let target = cache_path_for(cache_dir, "fixtures", &fixture.path, &fixture.sha256)?; - ensure_remote_asset(&url, &fixture.sha256, &target, &file_id) + manifest: &FixtureManifest, +) -> Result<()> { + let targets = manifest + .files + .iter() + .map(|fixture| { + Ok(download::Target { + label: file_id_for_manifest_path(&fixture.path)?, + url: fixture_url(fixture_base_url, &fixture.path)?, + destination_path: cache_path_for( + cache_dir, + "fixtures", + &fixture.path, + &fixture.sha256, + )? + .display() + .to_string(), + sha256: Some(normalize_sha256(&fixture.sha256)), + }) + }) + .collect::>>()?; + download::fetch(targets, |_, _| Ok(()), |_| {}, || false).context("fetch fixtures") } fn resolve_onnx_runtime_library(cache_dir: &Path, assets: &OnnxRuntimeAssets) -> Result { From f9a5052b02cef12cc543412579b3d518d61fb1f5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 17 Jul 2026 17:40:54 +0530 Subject: [PATCH 70/91] Adopt checksum-only validation across the model downloader and its consumers --- .../main/java/io/ente/ensu/AppViewModel.kt | 6 +- .../main/java/io/ente/ensu/llm/LlmModels.kt | 10 +- .../java/io/ente/ensu/llm/ModelDownloader.kt | 9 +- .../io/ente/ensu/llm/ModelSettingsActions.kt | 16 +- .../io/ente/ensu/llm/ModelSettingsScreen.kt | 58 +++- .../io/ente/ensu/llm/ModelSettingsState.kt | 2 + .../settings/AdvancedSettingsDataStore.kt | 8 + .../ModelSettings/ModelSettingsView.swift | 49 +++ .../apps/ensu/Ensu/Services/LlmProvider.swift | 4 +- .../ensu/Ensu/Services/ModelDownloader.swift | 21 +- .../Ensu/Services/ModelSettingsStore.swift | 46 ++- rust/apps/ensu/src-tauri/src/commands/llm.rs | 6 + rust/bindings/uniffi/ensu/src/config.rs | 4 + .../uniffi/ensu/src/model_downloader.rs | 33 +- rust/crates/ensu/src/config.rs | 24 ++ rust/crates/model-download/src/archive.rs | 10 - rust/crates/model-download/src/download.rs | 297 +++------------- rust/crates/model-download/src/downloader.rs | 322 ++++++++++++------ rust/crates/model-download/src/gguf.rs | 60 +--- rust/crates/model-download/src/lib.rs | 4 +- .../photos/tests/support/ml_indexing.rs | 7 +- .../ensu/src/components/chat/ChatDialogs.tsx | 91 ++++- web/apps/ensu/src/pages/chat.tsx | 37 ++ web/apps/ensu/src/services/llm/provider.ts | 22 +- web/apps/ensu/src/services/llm/types.ts | 4 + 25 files changed, 709 insertions(+), 441 deletions(-) diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt index c89e25c5b64..f22c1441ed7 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt @@ -91,13 +91,15 @@ class AppViewModel(application: Application) : AndroidViewModel(application) { private fun migrationTargets(settings: ModelSettingsState): List { val presets = listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets val targets = presets - .map { ModelDownloadTarget.Gguf(it.id, it.url, it.mmprojUrl) } + .map { ModelDownloadTarget.Gguf(it.id, it.url, it.sha256, it.mmprojUrl, it.mmprojSha256) } .toMutableList() if (settings.useCustomModel && settings.modelUrl.isNotBlank()) { targets += ModelDownloadTarget.Gguf( "custom:${settings.modelUrl}", settings.modelUrl, - settings.mmprojUrl.takeIf { it.isNotBlank() } + settings.modelSha256.trim().takeIf { it.isNotEmpty() }, + settings.mmprojUrl.takeIf { it.isNotBlank() }, + settings.mmprojSha256.trim().takeIf { it.isNotEmpty() } ) } return targets diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt index d5998dbabd3..46759b30fe4 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt @@ -5,12 +5,20 @@ import io.ente.ensu.bindings.ModelDownloadTarget data class LlmModelTarget( val id: String, val url: String, + val sha256: String? = null, val mmprojUrl: String? = null, + val mmprojSha256: String? = null, val contextLength: Int? = null, val maxTokens: Int? = null ) { val downloadTarget: ModelDownloadTarget - get() = ModelDownloadTarget.Gguf(id = id, url = url, mmprojUrl = mmprojUrl) + get() = ModelDownloadTarget.Gguf( + id = id, + url = url, + sha256 = sha256, + mmprojUrl = mmprojUrl, + mmprojSha256 = mmprojSha256 + ) } data class DownloadProgress( diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt index 576e13f65da..31ac02ed743 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt @@ -36,11 +36,14 @@ class ModelDownloader(context: Context) { val defaults = loadConfigDefaults() transcriptionModelTarget = ModelDownloadTarget.TarGz( defaults.transcriptionModel.id, - defaults.transcriptionModel.url + defaults.transcriptionModel.url, + requireNotNull(defaults.transcriptionModel.sha256) ) - voiceActivityModelTarget = ModelDownloadTarget.Onnx( + voiceActivityModelTarget = ModelDownloadTarget.File( defaults.voiceActivityModel.id, - defaults.voiceActivityModel.url + "model.onnx", + defaults.voiceActivityModel.url, + requireNotNull(defaults.voiceActivityModel.sha256) ) } diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt index c221183de85..ae13aa938cc 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt @@ -324,19 +324,25 @@ internal class ModelSettingsActions( } val contextLength = settings.contextLength.toIntOrNull() val maxTokens = settings.maxTokens.toIntOrNull()?.takeIf { it > 0 } - val id = if (useCustom) { + val preset = if (useCustom) { (listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets) .firstOrNull { it.url == url && it.mmprojUrl == mmproj } - ?.id - ?: "custom:$url" } else { - configDefaults.mobileDefaultModel.id + configDefaults.mobileDefaultModel } return LlmModelTarget( - id = id, + id = preset?.id ?: "custom:$url", url = url, + sha256 = preset?.sha256 + ?: settings.modelSha256.trim().takeIf { it.isNotEmpty() }, mmprojUrl = mmproj, + mmprojSha256 = if (mmproj == null) { + null + } else { + preset?.mmprojSha256 + ?: settings.mmprojSha256.trim().takeIf { it.isNotEmpty() } + }, contextLength = contextLength, maxTokens = maxTokens ) diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt index 33b065f8e85..96aea1c15d4 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt @@ -93,6 +93,24 @@ fun ModelSettingsScreen( } ) } + var customModelSha by remember(state) { + mutableStateOf( + if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { + state.modelSha256 + } else { + "" + } + ) + } + var customMmprojSha by remember(state) { + mutableStateOf( + if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { + state.mmprojSha256 + } else { + "" + } + ) + } var contextLength by remember(state) { mutableStateOf(state.contextLength) } var maxTokens by remember(state) { mutableStateOf(state.maxTokens) } var temperature by remember(state) { mutableStateOf(state.temperature) } @@ -107,7 +125,13 @@ fun ModelSettingsScreen( val selectedModel = modelChoices.firstOrNull { it.id == selectedModelId } ?: modelChoices.first() val isCustomSelected = selectedModel.isCustom - val canSave = !isCustomSelected || customModelUrl.isNotBlank() + val shaPattern = remember { Regex("[0-9a-fA-F]{64}") } + val canSave = !isCustomSelected || + ( + customModelUrl.isNotBlank() && + shaPattern.matches(customModelSha.trim()) && + (customMmprojUrl.isBlank() || shaPattern.matches(customMmprojSha.trim())) + ) Column( modifier = Modifier @@ -153,6 +177,8 @@ fun ModelSettingsScreen( if (choice.isCustom) { customModelUrl = "" customMmprojUrl = "" + customModelSha = "" + customMmprojSha = "" } } ) @@ -172,6 +198,16 @@ fun ModelSettingsScreen( modifier = Modifier.fillMaxWidth() ) + Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) + Text(text = "Model SHA-256", style = EnsuTypography.small, color = EnsuColor.textMuted()) + Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) + OutlinedTextField( + value = customModelSha, + onValueChange = { customModelSha = it }, + placeholder = { Text(text = "Checksum from the model page") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) Text(text = "mmproj .gguf URL", style = EnsuTypography.small, color = EnsuColor.textMuted()) Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) @@ -181,6 +217,20 @@ fun ModelSettingsScreen( placeholder = { Text(text = "(optional for multimodal)") }, modifier = Modifier.fillMaxWidth() ) + + AnimatedVisibility(customMmprojUrl.isNotBlank()) { + Column { + Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) + Text(text = "mmproj SHA-256", style = EnsuTypography.small, color = EnsuColor.textMuted()) + Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) + OutlinedTextField( + value = customMmprojSha, + onValueChange = { customMmprojSha = it }, + placeholder = { Text(text = "Checksum from the model page") }, + modifier = Modifier.fillMaxWidth() + ) + } + } } } @@ -252,7 +302,9 @@ fun ModelSettingsScreen( selectedModel.isDefault -> state.copy( useCustomModel = false, modelUrl = "", + modelSha256 = "", mmprojUrl = "", + mmprojSha256 = "", contextLength = contextLength, maxTokens = maxTokens, temperature = temperature @@ -260,7 +312,9 @@ fun ModelSettingsScreen( selectedModel.isCustom -> state.copy( useCustomModel = true, modelUrl = customModelUrl, + modelSha256 = customModelSha.trim(), mmprojUrl = customMmprojUrl, + mmprojSha256 = customMmprojSha.trim(), contextLength = contextLength, maxTokens = maxTokens, temperature = temperature @@ -268,7 +322,9 @@ fun ModelSettingsScreen( else -> state.copy( useCustomModel = true, modelUrl = selectedModel.url.orEmpty(), + modelSha256 = "", mmprojUrl = selectedModel.mmproj.orEmpty(), + mmprojSha256 = "", contextLength = contextLength, maxTokens = maxTokens, temperature = temperature diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt index c22bd01d6b1..1c8e1457066 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt @@ -3,7 +3,9 @@ package io.ente.ensu.llm data class ModelSettingsState( val useCustomModel: Boolean = false, val modelUrl: String = "", + val modelSha256: String = "", val mmprojUrl: String = "", + val mmprojSha256: String = "", val contextLength: String = "", val maxTokens: String = "", val temperature: String = "", diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt index 053f24e9f2e..9535430bbc2 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt @@ -33,7 +33,9 @@ class AdvancedSettingsDataStore(private val context: Context) { modelSettings = ModelSettingsState( useCustomModel = prefs[Keys.useCustomModel] ?: false, modelUrl = prefs[Keys.modelUrl].orEmpty(), + modelSha256 = prefs[Keys.modelSha256].orEmpty(), mmprojUrl = prefs[Keys.mmprojUrl].orEmpty(), + mmprojSha256 = prefs[Keys.mmprojSha256].orEmpty(), contextLength = prefs[Keys.contextLength].orEmpty(), maxTokens = prefs[Keys.maxTokens].orEmpty(), temperature = prefs[Keys.temperature].orEmpty() @@ -69,7 +71,9 @@ class AdvancedSettingsDataStore(private val context: Context) { context.advancedSettingsPreferences.edit { prefs -> prefs[Keys.useCustomModel] = settings.useCustomModel prefs[Keys.modelUrl] = settings.modelUrl + prefs[Keys.modelSha256] = settings.modelSha256 prefs[Keys.mmprojUrl] = settings.mmprojUrl + prefs[Keys.mmprojSha256] = settings.mmprojSha256 prefs[Keys.contextLength] = settings.contextLength prefs[Keys.maxTokens] = settings.maxTokens prefs[Keys.temperature] = settings.temperature @@ -86,7 +90,9 @@ class AdvancedSettingsDataStore(private val context: Context) { context.advancedSettingsPreferences.edit { prefs -> prefs[Keys.useCustomModel] = false prefs[Keys.modelUrl] = "" + prefs[Keys.modelSha256] = "" prefs[Keys.mmprojUrl] = "" + prefs[Keys.mmprojSha256] = "" prefs[Keys.contextLength] = "" prefs[Keys.maxTokens] = "" prefs[Keys.temperature] = "" @@ -105,7 +111,9 @@ class AdvancedSettingsDataStore(private val context: Context) { val systemPrompt = stringPreferencesKey("system_prompt") val useCustomModel = booleanPreferencesKey("use_custom_model") val modelUrl = stringPreferencesKey("model_url") + val modelSha256 = stringPreferencesKey("model_sha256") val mmprojUrl = stringPreferencesKey("mmproj_url") + val mmprojSha256 = stringPreferencesKey("mmproj_sha256") val contextLength = stringPreferencesKey("context_length") val maxTokens = stringPreferencesKey("max_tokens") val temperature = stringPreferencesKey("temperature") diff --git a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift index e9efee663b1..c0dbfe13e7a 100644 --- a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift +++ b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift @@ -9,12 +9,16 @@ struct ModelSettingsView: View { @State private var selectedModelId: String = defaultOptionId @State private var customModelUrl: String = "" @State private var customMmprojUrl: String = "" + @State private var customModelSha: String = "" + @State private var customMmprojSha: String = "" @State private var contextLength: String = "" @State private var maxTokens: String = "" @State private var temperature: String = "" @State private var urlError: String? @State private var mmprojError: String? + @State private var shaError: String? + @State private var mmprojShaError: String? @State private var contextError: String? @State private var maxTokensError: String? @State private var temperatureError: String? @@ -88,6 +92,8 @@ struct ModelSettingsView: View { selectedModelId = initialSelectionId() customModelUrl = isStoredCustomModel ? settings.modelUrl : "" customMmprojUrl = isStoredCustomModel ? settings.mmprojUrl : "" + customModelSha = isStoredCustomModel ? settings.modelSha256 : "" + customMmprojSha = isStoredCustomModel ? settings.mmprojSha256 : "" contextLength = settings.contextLength maxTokens = settings.maxTokens temperature = settings.temperature @@ -136,6 +142,8 @@ struct ModelSettingsView: View { if newValue == Self.customOptionId { customModelUrl = "" customMmprojUrl = "" + customModelSha = "" + customMmprojSha = "" } } @@ -149,6 +157,14 @@ struct ModelSettingsView: View { keyboardType: .URL ) + field( + label: "Model SHA-256", + hint: "Checksum from the model page", + text: $customModelSha, + error: shaError, + keyboardType: .default + ) + field( label: "mmproj .gguf URL", hint: "(optional for multimodal)", @@ -156,6 +172,16 @@ struct ModelSettingsView: View { error: mmprojError, keyboardType: .URL ) + + if !customMmprojUrl.isEmpty { + field( + label: "mmproj SHA-256", + hint: "Checksum from the model page", + text: $customMmprojSha, + error: mmprojShaError, + keyboardType: .default + ) + } } } @@ -292,6 +318,8 @@ struct ModelSettingsView: View { private func saveTapped() { urlError = nil mmprojError = nil + shaError = nil + mmprojShaError = nil contextError = nil maxTokensError = nil temperatureError = nil @@ -311,7 +339,9 @@ struct ModelSettingsView: View { } else if selectedModel.isCustom { settings.saveCustomModel( url: customModelUrl, + sha256: customModelSha.trimmingCharacters(in: .whitespaces), mmproj: customMmprojUrl, + mmprojSha256: customMmprojSha.trimmingCharacters(in: .whitespaces), contextLength: contextLength, maxTokens: maxTokens, temperature: temperature @@ -319,7 +349,9 @@ struct ModelSettingsView: View { } else { settings.saveCustomModel( url: selectedModel.url ?? "", + sha256: "", mmproj: selectedModel.mmproj ?? "", + mmprojSha256: "", contextLength: contextLength, maxTokens: maxTokens, temperature: temperature @@ -338,6 +370,8 @@ struct ModelSettingsView: View { selectedModelId = Self.defaultOptionId customModelUrl = "" customMmprojUrl = "" + customModelSha = "" + customMmprojSha = "" contextLength = "" maxTokens = "" temperature = "" @@ -365,6 +399,16 @@ struct ModelSettingsView: View { isValid = false } + if selectedModel.isCustom && !isValidSha256(customModelSha) { + shaError = "Enter the model's SHA-256 checksum" + isValid = false + } + + if selectedModel.isCustom && !customMmprojUrl.isEmpty && !isValidSha256(customMmprojSha) { + mmprojShaError = "Enter the mmproj's SHA-256 checksum" + isValid = false + } + if !contextLength.isEmpty, Int(contextLength) == nil { contextError = "Enter a valid integer" isValid = false @@ -388,6 +432,11 @@ struct ModelSettingsView: View { return isValid } + private func isValidSha256(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces) + return trimmed.count == 64 && trimmed.allSatisfy(\.isHexDigit) + } + private func isValidHuggingFaceUrl(_ urlString: String) -> Bool { guard let url = URL(string: urlString) else { return false } guard url.host?.contains("huggingface.co") == true else { return false } diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift b/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift index a09ad0e6cc8..14f8cbf5209 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift @@ -3,12 +3,14 @@ import Foundation struct LlmModelTarget: Equatable { let id: String let url: String + let sha256: String? let mmprojUrl: String? + let mmprojSha256: String? let contextLength: Int? let maxTokens: Int? var downloadTarget: ModelDownloadTarget { - .gguf(id: id, url: url, mmprojUrl: mmprojUrl) + .gguf(id: id, url: url, sha256: sha256, mmprojUrl: mmprojUrl, mmprojSha256: mmprojSha256) } } diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift index 7fdd6998f65..7925dfad418 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift @@ -19,11 +19,14 @@ final class ModelDownloader { let defaults = ConfigDefaults.shared transcriptionModelTarget = .tarGz( id: defaults.transcriptionModel.id, - url: defaults.transcriptionModel.url + url: defaults.transcriptionModel.url, + sha256: defaults.transcriptionModel.sha256! ) - voiceActivityModelTarget = .onnx( + voiceActivityModelTarget = .file( id: defaults.voiceActivityModel.id, - url: defaults.voiceActivityModel.url + name: "model.onnx", + url: defaults.voiceActivityModel.url, + sha256: defaults.voiceActivityModel.sha256! ) migrateEnsuLegacyModels( modelsDir: modelsDir.path, @@ -49,14 +52,22 @@ final class ModelDownloader { private static func migrationTargets() -> [ModelDownloadTarget] { let config = ConfigDefaults.shared var targets = ([config.mobileDefaultModel] + config.mobileModelPresets).map { - ModelDownloadTarget.gguf(id: $0.id, url: $0.url, mmprojUrl: $0.mmprojUrl) + ModelDownloadTarget.gguf( + id: $0.id, + url: $0.url, + sha256: $0.sha256, + mmprojUrl: $0.mmprojUrl, + mmprojSha256: $0.mmprojSha256 + ) } let settings = ModelSettingsStore.shared if settings.useCustomModel && !settings.modelUrl.isEmpty { targets.append(.gguf( id: "custom:\(settings.modelUrl)", url: settings.modelUrl, - mmprojUrl: settings.mmprojUrl.isEmpty ? nil : settings.mmprojUrl + sha256: nil, + mmprojUrl: settings.mmprojUrl.isEmpty ? nil : settings.mmprojUrl, + mmprojSha256: nil )) } return targets diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift index cceac09e9ba..a3e0e12da4d 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift @@ -62,6 +62,12 @@ final class ModelSettingsStore: ObservableObject { @Published var mmprojUrl: String { didSet { persist() } } + @Published var modelSha256: String { + didSet { persist() } + } + @Published var mmprojSha256: String { + didSet { persist() } + } @Published var contextLength: String { didSet { persist() } } @@ -81,16 +87,28 @@ final class ModelSettingsStore: ObservableObject { self.useCustomModel = defaults.bool(forKey: Keys.useCustomModel) self.modelUrl = defaults.string(forKey: Keys.modelUrl) ?? "" self.mmprojUrl = defaults.string(forKey: Keys.mmprojUrl) ?? "" + self.modelSha256 = defaults.string(forKey: Keys.modelSha256) ?? "" + self.mmprojSha256 = defaults.string(forKey: Keys.mmprojSha256) ?? "" self.contextLength = defaults.string(forKey: Keys.contextLength) ?? "" self.maxTokens = defaults.string(forKey: Keys.maxTokens) ?? "" self.temperature = defaults.string(forKey: Keys.temperature) ?? "" self.systemPromptBody = defaults.string(forKey: Keys.systemPromptBody) ?? "" } - func saveCustomModel(url: String, mmproj: String, contextLength: String, maxTokens: String, temperature: String) { + func saveCustomModel( + url: String, + sha256: String, + mmproj: String, + mmprojSha256: String, + contextLength: String, + maxTokens: String, + temperature: String + ) { useCustomModel = true modelUrl = url mmprojUrl = mmproj + self.modelSha256 = sha256 + self.mmprojSha256 = mmprojSha256 self.contextLength = contextLength self.maxTokens = maxTokens self.temperature = temperature @@ -100,6 +118,8 @@ final class ModelSettingsStore: ObservableObject { useCustomModel = false modelUrl = "" mmprojUrl = "" + modelSha256 = "" + mmprojSha256 = "" contextLength = "" maxTokens = "" temperature = "" @@ -113,14 +133,26 @@ final class ModelSettingsStore: ObservableObject { let mmproj = useCustom ? (mmprojUrl.isEmpty ? nil : mmprojUrl) : defaultModel.mmprojUrl let context = Int(contextLength) let maxOutput = Int(maxTokens).flatMap { $0 > 0 ? $0 : nil } - let id: String + let preset: ConfigModelPreset? if useCustom { let presets = [defaultModel] + defaults.mobileModelPresets - id = presets.first(where: { $0.url == url && $0.mmprojUrl == mmproj })?.id ?? "custom:\(url)" + preset = presets.first(where: { $0.url == url && $0.mmprojUrl == mmproj }) } else { - id = defaultModel.id + preset = defaultModel } - return LlmModelTarget(id: id, url: url, mmprojUrl: mmproj, contextLength: context, maxTokens: maxOutput) + let trimmedSha = modelSha256.trimmingCharacters(in: .whitespaces) + let trimmedMmprojSha = mmprojSha256.trimmingCharacters(in: .whitespaces) + return LlmModelTarget( + id: preset?.id ?? "custom:\(url)", + url: url, + sha256: preset?.sha256 ?? (trimmedSha.isEmpty ? nil : trimmedSha), + mmprojUrl: mmproj, + mmprojSha256: mmproj == nil + ? nil + : preset?.mmprojSha256 ?? (trimmedMmprojSha.isEmpty ? nil : trimmedMmprojSha), + contextLength: context, + maxTokens: maxOutput + ) } static var defaultModelName: String { platformDefaultModel.title } @@ -150,6 +182,8 @@ final class ModelSettingsStore: ObservableObject { defaults.set(useCustomModel, forKey: Keys.useCustomModel) defaults.set(modelUrl, forKey: Keys.modelUrl) defaults.set(mmprojUrl, forKey: Keys.mmprojUrl) + defaults.set(modelSha256, forKey: Keys.modelSha256) + defaults.set(mmprojSha256, forKey: Keys.mmprojSha256) defaults.set(contextLength, forKey: Keys.contextLength) defaults.set(maxTokens, forKey: Keys.maxTokens) defaults.set(temperature, forKey: Keys.temperature) @@ -160,6 +194,8 @@ final class ModelSettingsStore: ObservableObject { static let useCustomModel = "ensu.model.use_custom" static let modelUrl = "ensu.model.url" static let mmprojUrl = "ensu.model.mmproj" + static let modelSha256 = "ensu.model.sha256" + static let mmprojSha256 = "ensu.model.mmproj_sha256" static let contextLength = "ensu.model.context" static let maxTokens = "ensu.model.max_tokens" static let temperature = "ensu.model.temperature" diff --git a/rust/apps/ensu/src-tauri/src/commands/llm.rs b/rust/apps/ensu/src-tauri/src/commands/llm.rs index bb7ff2dffd5..09b154a2761 100644 --- a/rust/apps/ensu/src-tauri/src/commands/llm.rs +++ b/rust/apps/ensu/src-tauri/src/commands/llm.rs @@ -38,7 +38,9 @@ impl ModelDownloadState { pub struct ModelTarget { id: String, url: String, + sha256: Option, mmproj_url: Option, + mmproj_sha256: Option, } impl From for ModelDownloadTarget { @@ -46,7 +48,9 @@ impl From for ModelDownloadTarget { Self::Gguf { id: value.id, url: value.url, + sha256: value.sha256, mmproj_url: value.mmproj_url, + mmproj_sha256: value.mmproj_sha256, } } } @@ -281,7 +285,9 @@ pub async fn llm_migrate_models( .map(|preset| ModelDownloadTarget::Gguf { id: preset.id, url: preset.url, + sha256: preset.sha256, mmproj_url: preset.mmproj_url, + mmproj_sha256: preset.mmproj_sha256, }) .collect(); targets.extend(custom_targets.into_iter().map(Into::into)); diff --git a/rust/bindings/uniffi/ensu/src/config.rs b/rust/bindings/uniffi/ensu/src/config.rs index f8c32beea23..038af5f720c 100644 --- a/rust/bindings/uniffi/ensu/src/config.rs +++ b/rust/bindings/uniffi/ensu/src/config.rs @@ -5,7 +5,9 @@ pub struct ConfigModelPreset { pub id: String, pub title: String, pub url: String, + pub sha256: Option, pub mmproj_url: Option, + pub mmproj_sha256: Option, } #[derive(Debug, Clone, uniffi::Record)] @@ -28,7 +30,9 @@ impl From for ConfigModelPreset { id: value.id, title: value.title, url: value.url, + sha256: value.sha256, mmproj_url: value.mmproj_url, + mmproj_sha256: value.mmproj_sha256, } } } diff --git a/rust/bindings/uniffi/ensu/src/model_downloader.rs b/rust/bindings/uniffi/ensu/src/model_downloader.rs index 656df194bbb..532e7995285 100644 --- a/rust/bindings/uniffi/ensu/src/model_downloader.rs +++ b/rust/bindings/uniffi/ensu/src/model_downloader.rs @@ -9,15 +9,20 @@ pub enum ModelDownloadTarget { Gguf { id: String, url: String, + sha256: Option, mmproj_url: Option, + mmproj_sha256: Option, }, TarGz { id: String, url: String, + sha256: String, }, - Onnx { + File { id: String, + name: String, url: String, + sha256: String, }, } @@ -27,14 +32,23 @@ impl From for ente_model_download::ModelDownloadTarget { ModelDownloadTarget::Gguf { id, url, + sha256, mmproj_url, + mmproj_sha256, } => Self::Gguf { id, url, + sha256, mmproj_url, + mmproj_sha256, }, - ModelDownloadTarget::TarGz { id, url } => Self::TarGz { id, url }, - ModelDownloadTarget::Onnx { id, url } => Self::Onnx { id, url }, + ModelDownloadTarget::TarGz { id, url, sha256 } => Self::TarGz { id, url, sha256 }, + ModelDownloadTarget::File { + id, + name, + url, + sha256, + } => ente_model_download::ModelDownloadTarget::file(id, name, url, sha256), } } } @@ -104,7 +118,18 @@ impl ModelDownloadCore { } pub fn model_path(&self, target: ModelDownloadTarget) -> String { - self.inner.model_path(&target.into()).display().to_string() + let name = match &target { + ModelDownloadTarget::File { name, .. } => Some(name.clone()), + _ => None, + }; + let target = target.into(); + let path = match name { + Some(name) => self.inner.file_path(&target, &name), + None => None, + }; + path.unwrap_or_else(|| self.inner.model_path(&target)) + .display() + .to_string() } pub fn mmproj_path(&self, target: ModelDownloadTarget) -> Option { diff --git a/rust/crates/ensu/src/config.rs b/rust/crates/ensu/src/config.rs index e22d135f86b..ad3f277dad6 100644 --- a/rust/crates/ensu/src/config.rs +++ b/rust/crates/ensu/src/config.rs @@ -3,7 +3,9 @@ pub struct ModelPreset { pub id: String, pub title: String, pub url: String, + pub sha256: Option, pub mmproj_url: Option, + pub mmproj_sha256: Option, } #[derive(Debug, Clone)] @@ -30,10 +32,12 @@ fn lfm_vl_1_6b() -> ModelPreset { id: "lfm-vl-1.6b".to_string(), title: "LFM 2.5 VL 1.6B (Q4_0)".to_string(), url: "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/LFM2.5-VL-1.6B-Q4_0.gguf?download=true".to_string(), + sha256: Some("8186364a4e7c3ad30f6dd3d3b7a4e0074c77dd91eed6cad5d8be9090ce285804".to_string()), mmproj_url: Some( "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/mmproj-LFM2.5-VL-1.6b-Q8_0.gguf" .to_string(), ), + mmproj_sha256: Some("2ce89e610c56f3198ece2b86cf61743a08b9307279c89125eb2412ebb908689d".to_string()), } } @@ -42,7 +46,9 @@ fn lfm_1_2b() -> ModelPreset { id: "lfm-1.2b".to_string(), title: "LFM 2.5 1.2B Instruct (Q4_0)".to_string(), url: "https://huggingface.co/LiquidAI/LFM2.5-1.2B-GGUF/resolve/main/LFM2.5-1.2B-Q4_0.gguf?download=true".to_string(), + sha256: None, mmproj_url: None, + mmproj_sha256: None, } } @@ -51,10 +57,12 @@ fn qwen_0_8b() -> ModelPreset { id: "qwen-0.8b".to_string(), title: "Qwen 3.5 0.8B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/Qwen3.5-0.8B-Q4_K_M.gguf?download=true".to_string(), + sha256: Some("bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517".to_string()), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), ), + mmproj_sha256: Some("56e4c6cfe73b0c82e3e82bc518d7591997e61d81f723fc41a586f4fa69ea2453".to_string()), } } @@ -63,10 +71,12 @@ fn qwen_2b_q8() -> ModelPreset { id: "qwen-2b-q8".to_string(), title: "Qwen 3.5 2B (Q8_0)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q8_0.gguf?download=true".to_string(), + sha256: Some("1b04acba824817554f4ce23639bc8495ff70453b8fcb047900c731521021f2c1".to_string()), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), ), + mmproj_sha256: Some("7035e9cb8d7c6a9681d07eef9a364783e86ea4cd73faab2eabb4f43a101830c7".to_string()), } } @@ -75,10 +85,12 @@ fn qwen_4b_q4km() -> ModelPreset { id: "qwen-4b-q4km".to_string(), title: "Qwen 3.5 4B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf?download=true".to_string(), + sha256: Some("00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4".to_string()), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), ), + mmproj_sha256: Some("cd88edcf8d031894960bb0c9c5b9b7e1fea6ebee02b9f7ce925a00d12891f864".to_string()), } } @@ -87,10 +99,12 @@ fn gemma_4_e4b_q4km() -> ModelPreset { id: "gemma-4-e4b-q4km".to_string(), title: "Gemma 4 E4B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf?download=true".to_string(), + sha256: Some("519b9793ed6ce0ff530f1b7c96e848e08e49e7af4d57bb97f76215963a54146d".to_string()), mmproj_url: Some( "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), ), + mmproj_sha256: Some("ddf46c21d7078e95338cfc22306b19b276a29a5ad089023449dd54d4b6170a51".to_string()), } } @@ -99,10 +113,12 @@ fn gemma_4_e2b_q4km() -> ModelPreset { id: "gemma-4-e2b-q4km".to_string(), title: "Gemma 4 E2B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M.gguf?download=true".to_string(), + sha256: Some("9378bc471710229ef165709b62e34bfb62231420ddaf6d729e727305b5b8672d".to_string()), mmproj_url: Some( "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), ), + mmproj_sha256: Some("140be8d7849741f88c50757d529b84373ee8e27052cc2236855b537f4a8215fa".to_string()), } } @@ -111,7 +127,11 @@ fn parakeet_v3_int8() -> ModelPreset { id: "parakeet-v3-int8".to_string(), title: "Transcription model".to_string(), url: "https://models.ente.io/parakeet-v3-int8.tar.gz".to_string(), + sha256: Some( + "43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(), + ), mmproj_url: None, + mmproj_sha256: None, } } @@ -120,7 +140,11 @@ fn silero_vad_v4() -> ModelPreset { id: "silero-vad-v4".to_string(), title: "Voice activity model".to_string(), url: "https://models.ente.io/silero_vad_v4.onnx".to_string(), + sha256: Some( + "a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28".to_string(), + ), mmproj_url: None, + mmproj_sha256: None, } } diff --git a/rust/crates/model-download/src/archive.rs b/rust/crates/model-download/src/archive.rs index 5607dd7fa41..146ee9e06f1 100644 --- a/rust/crates/model-download/src/archive.rs +++ b/rust/crates/model-download/src/archive.rs @@ -1,6 +1,5 @@ use std::fs; use std::fs::File; -use std::io::Read; use std::path::Path; use flate2::read::GzDecoder; @@ -8,15 +7,6 @@ use tar::Archive; use crate::download; -pub(crate) fn looks_like_gzip(path: &Path) -> bool { - let mut file = match File::open(path) { - Ok(file) => file, - Err(_) => return false, - }; - let mut header = [0u8; 2]; - file.read_exact(&mut header).is_ok() && header == [0x1f, 0x8b] -} - pub(crate) fn extract_tar_gz( archive_path: &Path, staging_dir: &Path, diff --git a/rust/crates/model-download/src/download.rs b/rust/crates/model-download/src/download.rs index 2afd97a63bd..35f5f7da204 100644 --- a/rust/crates/model-download/src/download.rs +++ b/rust/crates/model-download/src/download.rs @@ -71,7 +71,7 @@ pub struct Target { pub label: String, pub url: String, pub destination_path: String, - pub sha256: Option, + pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -192,7 +192,6 @@ struct ContentRange { pub fn fetch( targets: Vec, - validate: impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: impl FnMut(Progress), is_cancelled: impl Fn() -> bool, ) -> Result<(), Error> { @@ -200,12 +199,11 @@ pub fn fetch( .enable_io() .enable_time() .build()?; - runtime.block_on(fetch_async(targets, validate, on_progress, is_cancelled)) + runtime.block_on(fetch_async(targets, on_progress, is_cancelled)) } async fn fetch_async( targets: Vec, - validate: impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: impl FnMut(Progress), is_cancelled: impl Fn() -> bool, ) -> Result<(), Error> { @@ -213,6 +211,15 @@ async fn fetch_async( return Ok(()); } + for target in &targets { + let sha256 = &target.sha256; + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(Error::InvalidTarget(format!( + "'{sha256}' is not a SHA-256 digest" + ))); + } + } + let client = Client::builder() .connect_timeout(CONNECT_TIMEOUT) .build() @@ -223,7 +230,7 @@ async fn fetch_async( for target in &targets { let destination = Path::new(&target.destination_path); - let is_cached = prepare_cached_download(target, destination, &validate); + let is_cached = prepare_cached_download(target, destination); cached.push(is_cached); if is_cached { download_probes.push(DownloadProbe { @@ -306,7 +313,6 @@ async fn fetch_async( let target_label = target.label.clone(); let client = &client; let is_cancelled = &is_cancelled; - let validate = &validate; downloads.push(async move { if is_cancelled() { @@ -318,7 +324,6 @@ async fn fetch_async( target, &destination, &download_probe, - validate, |file_progress| { { let mut states = progress_states.borrow_mut(); @@ -409,7 +414,6 @@ async fn download_file( target: &Target, destination: &Path, download_probe: &DownloadProbe, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, mut on_progress: impl FnMut(FileDownloadProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { @@ -427,7 +431,6 @@ async fn download_file( destination, total, download_probe.response_metadata.clone(), - validate, &mut on_progress, is_cancelled, ) @@ -452,7 +455,6 @@ async fn download_file( target, destination, download_probe.content_length, - validate, &mut on_progress, is_cancelled, ) @@ -473,7 +475,6 @@ async fn download_file_single( target: &Target, destination: &Path, expected_file_total: Option, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: &mut dyn FnMut(FileDownloadProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { @@ -662,15 +663,7 @@ async fn download_file_single( continue; } - if let Some(expected) = &target.sha256 - && let Err(err) = check_sha256(&tmp_path, expected, &target.label) - { - let _ = fs::remove_file(&tmp_path); - let _ = fs::remove_file(&partial_metadata_path); - return Err(err); - } - - if let Err(err) = validate(target, &tmp_path) { + if let Err(err) = check_sha256(&tmp_path, &target.sha256, &target.label) { let _ = fs::remove_file(&tmp_path); let _ = fs::remove_file(&partial_metadata_path); return Err(err); @@ -712,7 +705,6 @@ async fn download_file_ranged( destination: &Path, total: u64, response_metadata: Option, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: &mut dyn FnMut(FileDownloadProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { @@ -791,15 +783,7 @@ async fn download_file_ranged( emit_range_file_progress(total, file_started_at, &range_states, &on_progress); - if let Some(expected) = &target.sha256 - && let Err(err) = check_sha256(&tmp_path, expected, &target.label) - { - let _ = fs::remove_file(&tmp_path); - let _ = fs::remove_file(&range_metadata_path); - return Err(err); - } - - if let Err(err) = validate(target, &tmp_path) { + if let Err(err) = check_sha256(&tmp_path, &target.sha256, &target.label) { let _ = fs::remove_file(&tmp_path); let _ = fs::remove_file(&range_metadata_path); return Err(err); @@ -1528,32 +1512,12 @@ fn bytes_per_second(bytes: u64, elapsed: Duration) -> f64 { } } -fn prepare_cached_download( - target: &Target, - destination: &Path, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, -) -> bool { - if !destination.exists() || validate(target, destination).is_err() { +fn prepare_cached_download(target: &Target, destination: &Path) -> bool { + if !destination.exists() || check_sha256(destination, &target.sha256, &target.label).is_err() { return false; } - if let Some(expected) = &target.sha256 { - if check_sha256(destination, expected, &target.label).is_err() { - return false; - } - cleanup_range_download(destination); - return true; - } - let adopted = if !metadata_path_for(destination).exists() { - let size = file_size(destination).unwrap_or(0); - let _ = write_download_metadata(destination, target, size, None); - true - } else { - download_metadata_matches(destination, &target.url) - }; - if adopted { - cleanup_range_download(destination); - } - adopted + cleanup_range_download(destination); + true } fn read_download_metadata(path: &Path) -> Option { @@ -1561,16 +1525,6 @@ fn read_download_metadata(path: &Path) -> Option { serde_json::from_str(&text).ok() } -fn download_metadata_matches(path: &Path, url: &str) -> bool { - let Some(metadata) = read_download_metadata(path) else { - return false; - }; - let Some(size) = file_size(path) else { - return false; - }; - metadata.url == url && metadata.size_bytes == size -} - fn write_download_metadata( path: &Path, target: &Target, @@ -1946,9 +1900,8 @@ mod tests { label: "Model".to_string(), url, destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -1996,9 +1949,8 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2013,52 +1965,6 @@ mod tests { let _ = fs::remove_dir_all(test_dir); } - #[test] - fn rejected_validation_leaves_no_cached_file() { - let bytes = Arc::new(sample_bytes(MIN_RANGE_DOWNLOAD_BYTES as usize + 123)); - let get_count = Arc::new(AtomicUsize::new(0)); - - let server = { - let bytes = Arc::clone(&bytes); - let get_count = Arc::clone(&get_count); - TestServer::spawn(move |stream| { - handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); - }) - }; - - let test_dir = scratch_dir("reject-download"); - let destination = test_dir.join("model.bin"); - - let result = fetch( - vec![Target { - label: "Model".to_string(), - url: server.url("/model.bin"), - destination_path: destination.display().to_string(), - sha256: None, - }], - |_, _| Err(Error::Validation("rejected".to_string())), - |_| {}, - || false, - ); - - assert!(result.is_err(), "validation failure should fail the fetch"); - assert_eq!(get_count.load(Ordering::SeqCst), 1); - assert!( - !destination.exists(), - "rejected download must not be committed" - ); - assert!( - !metadata_path_for(&destination).exists(), - "rejected download must not be cached" - ); - assert!( - !tmp_path_for(&destination).exists(), - "temp file must be cleaned up" - ); - - let _ = fs::remove_dir_all(test_dir); - } - #[test] fn completed_download_is_served_from_cache() { let bytes = Arc::new(sample_bytes(MIN_RANGE_DOWNLOAD_BYTES as usize + 123)); @@ -2088,16 +1994,15 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; - fetch(vec![target.clone()], |_, _| Ok(()), |_| {}, || false) - .expect("first download succeeds"); + fetch(vec![target.clone()], |_| {}, || false).expect("first download succeeds"); let requests_after_first = head_count.load(Ordering::SeqCst) + range_get_count.load(Ordering::SeqCst); assert!(requests_after_first > 0); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("second download succeeds"); + fetch(vec![target], |_| {}, || false).expect("second download succeeds"); assert_eq!( head_count.load(Ordering::SeqCst) + range_get_count.load(Ordering::SeqCst), requests_after_first, @@ -2127,26 +2032,17 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, - }; - let require_zero_header = |_: &Target, path: &Path| match fs::read(path) - .ok() - .and_then(|data| data.first().copied()) - { - Some(0) => Ok(()), - _ => Err(Error::Validation("bad header".to_string())), + sha256: sha_hex(&bytes), }; - fetch(vec![target.clone()], require_zero_header, |_| {}, || false) - .expect("first download succeeds"); + fetch(vec![target.clone()], |_| {}, || false).expect("first download succeeds"); assert_eq!(get_count.load(Ordering::SeqCst), 1); let mut data = fs::read(&destination).expect("read cached file"); - data[0] = 0xFF; + data[0] = data[0].wrapping_add(1); fs::write(&destination, &data).expect("corrupt cached file"); - fetch(vec![target], require_zero_header, |_| {}, || false) - .expect("second download re-heals the corrupt cache"); + fetch(vec![target], |_| {}, || false).expect("second download re-heals the corrupt cache"); assert_eq!( get_count.load(Ordering::SeqCst), 2, @@ -2179,112 +2075,21 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch adopts existing file"); + fetch(vec![target], |_| {}, || false).expect("fetch adopts existing file"); assert_eq!( get_count.load(Ordering::SeqCst), 0, "an existing valid file must not be re-downloaded" ); - assert!( - metadata_path_for(&destination).exists(), - "adopting the file writes its metadata sidecar" - ); assert_eq!(fs::read(&destination).expect("read adopted file"), *bytes); let _ = fs::remove_dir_all(test_dir); } - #[test] - fn existing_file_with_corrupt_sidecar_is_redownloaded() { - let bytes = Arc::new(sample_bytes(512)); - let get_count = Arc::new(AtomicUsize::new(0)); - - let server = { - let bytes = Arc::clone(&bytes); - let get_count = Arc::clone(&get_count); - TestServer::spawn(move |stream| { - handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); - }) - }; - - let test_dir = scratch_dir("corrupt-sidecar-download"); - let destination = test_dir.join("model.bin"); - fs::write(&destination, bytes.as_slice()).expect("place existing file"); - fs::write(metadata_path_for(&destination), "{ truncated").expect("write corrupt sidecar"); - - let target = Target { - label: "Model".to_string(), - url: server.url("/model.bin"), - destination_path: destination.display().to_string(), - sha256: None, - }; - - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch redownloads"); - - assert_eq!( - get_count.load(Ordering::SeqCst), - 1, - "a corrupt sidecar must trigger a re-download, not adoption" - ); - assert_eq!( - fs::read(&destination).expect("read downloaded file"), - *bytes - ); - - let _ = fs::remove_dir_all(test_dir); - } - - #[test] - fn existing_file_with_stale_sidecar_is_redownloaded() { - let bytes = Arc::new(sample_bytes(512)); - let get_count = Arc::new(AtomicUsize::new(0)); - - let server = { - let bytes = Arc::clone(&bytes); - let get_count = Arc::clone(&get_count); - TestServer::spawn(move |stream| { - handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); - }) - }; - - let test_dir = scratch_dir("stale-sidecar-download"); - let destination = test_dir.join("model.bin"); - fs::write(&destination, bytes.as_slice()).expect("place existing file"); - let stale_target = Target { - label: "Model".to_string(), - url: "http://127.0.0.1:1/old-model.bin".to_string(), - destination_path: destination.display().to_string(), - sha256: None, - }; - write_download_metadata(&destination, &stale_target, bytes.len() as u64, None) - .expect("write stale sidecar"); - - let target = Target { - label: "Model".to_string(), - url: server.url("/model.bin"), - destination_path: destination.display().to_string(), - sha256: None, - }; - - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch redownloads"); - - assert_eq!( - get_count.load(Ordering::SeqCst), - 1, - "a stale sidecar must trigger a re-download, not adoption" - ); - assert_eq!( - fs::read(&destination).expect("read downloaded file"), - *bytes - ); - - let _ = fs::remove_dir_all(test_dir); - } - #[test] fn single_stream_download_resumes_from_partial_tmp() { let bytes = Arc::new(sample_bytes(512)); @@ -2314,7 +2119,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2327,7 +2132,7 @@ mod tests { ) .expect("write partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("resume succeeds"); + fetch(vec![target], |_| {}, || false).expect("resume succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2374,7 +2179,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), vec![0xAAu8; 256]).expect("place stale tmp"); write_partial_download_metadata( @@ -2387,7 +2192,7 @@ mod tests { ) .expect("write stale partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("restart succeeds"); + fetch(vec![target], |_| {}, || false).expect("restart succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2424,10 +2229,10 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fresh download succeeds"); + fetch(vec![target], |_| {}, || false).expect("fresh download succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2468,7 +2273,7 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2481,8 +2286,7 @@ mod tests { ) .expect("write partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false) - .expect("restart after bad 206 succeeds"); + fetch(vec![target], |_| {}, || false).expect("restart after bad 206 succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2562,9 +2366,8 @@ mod tests { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), destination_path: destination.display().to_string(), - sha256: None, + sha256: "0".repeat(64), }], - |_, _| Ok(()), |_| {}, || true, ); @@ -2588,9 +2391,8 @@ mod tests { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), destination_path: destination.display().to_string(), - sha256: None, + sha256: sha_hex(b"complete-model"), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2628,9 +2430,8 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: Some(expected), + sha256: expected, }], - |_, _| Ok(()), |_| {}, || false, ) @@ -2663,9 +2464,8 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: Some("0".repeat(64)), + sha256: "0".repeat(64), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2707,9 +2507,8 @@ mod tests { label: "Model".to_string(), url: server.url("/model.bin"), destination_path: destination.display().to_string(), - sha256: Some("0".repeat(64)), + sha256: "0".repeat(64), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2736,7 +2535,7 @@ mod tests { label: "Model".to_string(), url: "http://cache.example.org/old-location".to_string(), destination_path: destination.display().to_string(), - sha256: Some(sha.clone()), + sha256: sha.clone(), }; write_download_metadata(&destination, &old_target, 11, None).expect("write stale metadata"); @@ -2745,7 +2544,6 @@ mod tests { url: "http://127.0.0.1:9/new-location".to_string(), ..old_target.clone() }], - |_, _| Ok(()), |_| {}, || false, ) @@ -2757,7 +2555,6 @@ mod tests { url: "http://127.0.0.1:9/new-location".to_string(), ..old_target }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2770,6 +2567,14 @@ mod tests { (0..len).map(|index| (index % 251) as u8).collect() } + fn sha_hex(bytes: &[u8]) -> String { + let mut hex = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + hex.push_str(&format!("{byte:02x}")); + } + hex + } + fn scratch_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("ensu-{name}-test-{}", now_ms())); fs::create_dir_all(&dir).expect("create test dir"); diff --git a/rust/crates/model-download/src/downloader.rs b/rust/crates/model-download/src/downloader.rs index 695d19cb7c2..6e18c53b0c8 100644 --- a/rust/crates/model-download/src/downloader.rs +++ b/rust/crates/model-download/src/downloader.rs @@ -16,18 +16,37 @@ pub enum ModelDownloadTarget { Gguf { id: String, url: String, + sha256: Option, mmproj_url: Option, + mmproj_sha256: Option, }, TarGz { id: String, url: String, + sha256: String, }, - Onnx { + Files { id: String, - url: String, + files: Vec, }, } +impl ModelDownloadTarget { + pub fn file(id: String, name: String, url: String, sha256: String) -> Self { + Self::Files { + id, + files: vec![ModelFile { name, url, sha256 }], + } + } +} + +#[derive(Debug, Clone)] +pub struct ModelFile { + pub name: String, + pub url: String, + pub sha256: String, +} + #[derive(Debug, Clone)] pub struct ModelDownloadProgress { pub downloaded_bytes: u64, @@ -66,20 +85,29 @@ impl ModelDownloader { id, url, mmproj_url, + .. } => gguf::mmproj_path(&self.models_dir, id, url, mmproj_url.as_deref()), - ModelDownloadTarget::TarGz { .. } | ModelDownloadTarget::Onnx { .. } => None, + _ => None, } } - pub fn is_downloaded(&self, target: &ModelDownloadTarget) -> bool { + pub fn file_path(&self, target: &ModelDownloadTarget, name: &str) -> Option { match target { - ModelDownloadTarget::Gguf { .. } => self.fetch_targets(target).iter().all(|entry| { - let path = Path::new(&entry.destination_path); - path.exists() && gguf::looks_like_gguf(path) - }), - ModelDownloadTarget::TarGz { .. } => self.model_path(target).is_dir(), - ModelDownloadTarget::Onnx { .. } => is_non_empty_file(&self.model_path(target)), + ModelDownloadTarget::Files { files, .. } + if files.iter().any(|file| file.name == name) => + { + Some(storage_dir(&self.models_dir, target).join(name)) + } + _ => None, + } + } + + pub fn is_downloaded(&self, target: &ModelDownloadTarget) -> bool { + if let ModelDownloadTarget::TarGz { .. } = target { + return storage_dir(&self.models_dir, target).is_dir(); } + let paths = expected_paths(&self.models_dir, target); + !paths.is_empty() && paths.iter().all(|path| path.is_file()) } pub fn is_download_active(&self) -> bool { @@ -91,22 +119,11 @@ impl ModelDownloader { } pub fn remove_downloaded(&self, target: &ModelDownloadTarget) -> bool { - if let ModelDownloadTarget::TarGz { .. } = target { - let dir = self.model_path(target); - return dir.exists() && fs::remove_dir_all(dir).is_ok(); + if checked_file_name(&storage_key(target)).is_err() { + return false; } - let mut removed = false; - for entry in self.fetch_targets(target) { - let path = Path::new(&entry.destination_path); - if path.exists() { - let _ = fs::remove_file(path); - removed = true; - } - if let Some(parent) = path.parent() { - let _ = fs::remove_dir(parent); - } - } - removed + let dir = storage_dir(&self.models_dir, target); + dir.exists() && fs::remove_dir_all(dir).is_ok() } /// Returns whether a network download ran; false when everything was @@ -129,14 +146,15 @@ impl ModelDownloader { return Ok(false); } + let mut transfers = Vec::new(); + for target in &pending { + transfers.extend(download_targets(&self.models_dir, target)?); + } + self.cancelled.store(false, Ordering::SeqCst); self.active.store(true, Ordering::SeqCst); let result = download::fetch( - pending - .iter() - .flat_map(|target| self.fetch_targets(target)) - .collect(), - validate_target, + transfers, |progress| on_progress(display_progress(progress)), || self.cancelled.load(Ordering::SeqCst) || is_cancelled(), ) @@ -193,7 +211,7 @@ impl ModelDownloader { .ok()?; let mut total: i64 = 0; let mut any = false; - for entry in self.fetch_targets(target) { + for entry in download_targets(&self.models_dir, target).ok()? { let path = Path::new(&entry.destination_path); let size = if path.exists() { fs::metadata(path).ok().map(|m| m.len()).filter(|s| *s > 0) @@ -208,66 +226,147 @@ impl ModelDownloader { any.then_some(total) }) } +} - fn fetch_targets(&self, target: &ModelDownloadTarget) -> Vec { - match target { - ModelDownloadTarget::Gguf { - id, - url, - mmproj_url, - } => gguf::expected_targets(&self.models_dir, id, url, mmproj_url.as_deref()), - ModelDownloadTarget::TarGz { id, url } => vec![Target { - label: "Model".to_string(), - url: url.clone(), - destination_path: self - .staging_dir() - .join(format!("{id}.tar.gz")) - .display() - .to_string(), - sha256: None, - }], - ModelDownloadTarget::Onnx { url, .. } => vec![Target { - label: "Model".to_string(), - url: url.clone(), - destination_path: self.model_path(target).display().to_string(), - sha256: None, - }], +fn storage_key(target: &ModelDownloadTarget) -> String { + match target { + ModelDownloadTarget::Gguf { + id, + url, + mmproj_url, + .. + } => gguf::storage_key(id, url, mmproj_url.as_deref()), + ModelDownloadTarget::TarGz { id, .. } | ModelDownloadTarget::Files { id, .. } => id.clone(), + } +} + +fn storage_dir(models_dir: &Path, target: &ModelDownloadTarget) -> PathBuf { + models_dir.join(storage_key(target)) +} + +fn expected_paths(models_dir: &Path, target: &ModelDownloadTarget) -> Vec { + let dir = storage_dir(models_dir, target); + match target { + ModelDownloadTarget::Gguf { mmproj_url, .. } => { + let mut paths = vec![dir.join("model.gguf")]; + if gguf::trimmed(mmproj_url.as_deref()).is_some() { + paths.push(dir.join("mmproj.gguf")); + } + paths + } + ModelDownloadTarget::TarGz { .. } => vec![dir], + ModelDownloadTarget::Files { files, .. } => { + files.iter().map(|file| dir.join(&file.name)).collect() } } } -fn model_path(models_dir: &Path, target: &ModelDownloadTarget) -> PathBuf { +fn download_targets( + models_dir: &Path, + target: &ModelDownloadTarget, +) -> Result, download::Error> { + let dir = storage_dir(models_dir, target); match target { ModelDownloadTarget::Gguf { id, url, + sha256, mmproj_url, - } => gguf::model_path(models_dir, id, url, mmproj_url.as_deref()), - ModelDownloadTarget::TarGz { id, .. } => models_dir.join(id), - ModelDownloadTarget::Onnx { id, .. } => models_dir.join(id).join("model.onnx"), + mmproj_sha256, + } => { + checked_file_name(&storage_key(target))?; + let sha256 = sha256 + .as_deref() + .ok_or_else(|| checksum_required(id, url))?; + let mut targets = vec![Target { + label: "Model".to_string(), + url: url.clone(), + destination_path: dir.join("model.gguf").display().to_string(), + sha256: sha256.to_string(), + }]; + if let Some(mmproj_url) = gguf::trimmed(mmproj_url.as_deref()) { + let mmproj_sha256 = mmproj_sha256 + .as_deref() + .ok_or_else(|| checksum_required(id, mmproj_url))?; + targets.push(Target { + label: "Mmproj".to_string(), + url: mmproj_url.to_string(), + destination_path: dir.join("mmproj.gguf").display().to_string(), + sha256: mmproj_sha256.to_string(), + }); + } else if gguf::trimmed(mmproj_sha256.as_deref()).is_some() { + return Err(download::Error::InvalidTarget(format!( + "{id} has a mmproj checksum without a mmproj URL" + ))); + } + Ok(targets) + } + ModelDownloadTarget::TarGz { id, url, sha256 } => { + checked_file_name(id)?; + Ok(vec![Target { + label: "Model".to_string(), + url: url.clone(), + destination_path: models_dir + .join(".staging") + .join(format!("{id}.tar.gz")) + .display() + .to_string(), + sha256: sha256.clone(), + }]) + } + ModelDownloadTarget::Files { id, files } => { + checked_file_name(id)?; + checked_file_name(&storage_key(target))?; + if files.is_empty() { + return Err(download::Error::InvalidTarget(format!("{id} has no files"))); + } + let mut names = HashSet::new(); + files + .iter() + .map(|file| { + checked_file_name(&file.name)?; + if !names.insert(file.name.as_str()) { + return Err(download::Error::InvalidTarget(format!( + "{id} has a duplicate file name {}", + file.name + ))); + } + Ok(Target { + label: file.name.clone(), + url: file.url.clone(), + destination_path: dir.join(&file.name).display().to_string(), + sha256: file.sha256.clone(), + }) + }) + .collect() + } } } -fn validate_target(target: &Target, path: &Path) -> Result<(), download::Error> { - if target.destination_path.ends_with(".tar.gz") { - if !archive::looks_like_gzip(path) { - return Err(download::Error::Validation(format!( - "{} is not a gzip archive", - path.display() - ))); - } +fn checksum_required(id: &str, url: &str) -> download::Error { + download::Error::Validation(format!("{id} needs a checksum to download {url}")) +} + +fn checked_file_name(name: &str) -> Result<(), download::Error> { + if !name.is_empty() + && name != "." + && name != ".." + && !name.contains('/') + && !name.contains('\\') + { return Ok(()); } - if target.destination_path.ends_with(".onnx") { - if !is_non_empty_file(path) { - return Err(download::Error::Validation(format!( - "{} is empty", - path.display() - ))); - } - return Ok(()); + Err(download::Error::InvalidTarget(format!( + "'{name}' is not a safe file name" + ))) +} + +fn model_path(models_dir: &Path, target: &ModelDownloadTarget) -> PathBuf { + let dir = storage_dir(models_dir, target); + match target { + ModelDownloadTarget::Gguf { .. } => dir.join("model.gguf"), + ModelDownloadTarget::TarGz { .. } | ModelDownloadTarget::Files { .. } => dir, } - gguf::validate_gguf(path) } fn is_non_empty_file(path: &Path) -> bool { @@ -424,8 +523,11 @@ fn migrate_legacy_transcription_dir( } } - if matches!(vad, ModelDownloadTarget::Onnx { id, .. } if id == LEGACY_VAD_ID) { - let dest = model_path(models_dir, vad); + if let ModelDownloadTarget::Files { id, files } = vad + && id == LEGACY_VAD_ID + && let [file] = files.as_slice() + { + let dest = models_dir.join(id).join(&file.name); if !dest.exists() { let source = legacy_dir.join(LEGACY_VAD_FILE_NAME); if is_non_empty_file(&source) && !move_file(&source, &dest) { @@ -458,6 +560,7 @@ fn adopt_targets( id, url, mmproj_url, + .. } = target else { continue; @@ -570,11 +673,17 @@ mod tests { dir } + fn test_sha() -> String { + "0".repeat(64) + } + fn target(id: &str) -> ModelDownloadTarget { ModelDownloadTarget::Gguf { id: id.to_string(), url: "https://example.org/models/main.gguf?download=true".to_string(), + sha256: Some(test_sha()), mmproj_url: Some("https://example.org/models/mmproj.gguf".to_string()), + mmproj_sha256: Some(test_sha()), } } @@ -616,7 +725,9 @@ mod tests { let other_mmproj = ModelDownloadTarget::Gguf { id: "custom:1".to_string(), url: "https://example.org/models/main.gguf?download=true".to_string(), + sha256: Some(test_sha()), mmproj_url: Some("https://example.org/models/other.gguf".to_string()), + mmproj_sha256: Some(test_sha()), }; assert_ne!(downloader.model_path(&other_mmproj), custom); @@ -624,7 +735,7 @@ mod tests { } #[test] - fn is_downloaded_requires_gguf_magic_on_all_files() { + fn is_downloaded_requires_every_member_file() { let dir = scratch_dir("is-downloaded"); let downloader = ModelDownloader::new(&dir); let target = target("qwen-2b-q8"); @@ -636,9 +747,6 @@ mod tests { write_gguf(&model, b"GGUFdata"); assert!(!downloader.is_downloaded(&target)); - write_gguf(&mmproj, b"not-a-model"); - assert!(!downloader.is_downloaded(&target)); - write_gguf(&mmproj, b"GGUFdata"); assert!(downloader.is_downloaded(&target)); @@ -665,14 +773,17 @@ mod tests { ModelDownloadTarget::TarGz { id: id.to_string(), url: format!("https://models.example.org/{id}.tar.gz"), + sha256: test_sha(), } } - fn onnx_target(id: &str) -> ModelDownloadTarget { - ModelDownloadTarget::Onnx { - id: id.to_string(), - url: format!("https://models.example.org/{id}.onnx"), - } + fn file_target(id: &str) -> ModelDownloadTarget { + ModelDownloadTarget::file( + id.to_string(), + "model.onnx".to_string(), + format!("https://models.example.org/{id}.onnx"), + test_sha(), + ) } fn write_tar_gz(path: &Path, dir_name: &str, files: &[(&str, &[u8])]) { @@ -703,11 +814,13 @@ mod tests { ); assert_eq!(downloader.mmproj_path(&archive), None); - let onnx = onnx_target("silero-vad-v4"); + let onnx = file_target("silero-vad-v4"); + assert_eq!(downloader.model_path(&onnx), dir.join("silero-vad-v4")); assert_eq!( - downloader.model_path(&onnx), - dir.join("silero-vad-v4/model.onnx") + downloader.file_path(&onnx, "model.onnx"), + Some(dir.join("silero-vad-v4/model.onnx")) ); + assert_eq!(downloader.file_path(&onnx, "missing.onnx"), None); assert_eq!(downloader.mmproj_path(&onnx), None); let _ = fs::remove_dir_all(dir); @@ -749,10 +862,13 @@ mod tests { fn onnx_target_downloaded_and_removed_via_model_file() { let dir = scratch_dir("variant-onnx"); let downloader = ModelDownloader::new(&dir); - let target = onnx_target("silero-vad-v4"); + let target = file_target("silero-vad-v4"); assert!(!downloader.is_downloaded(&target)); - write_gguf(&downloader.model_path(&target), b"onnxbytes"); + write_gguf( + &downloader.file_path(&target, "model.onnx").unwrap(), + b"onnxbytes", + ); assert!(downloader.is_downloaded(&target)); assert!(downloader.remove_downloaded(&target)); @@ -777,10 +893,12 @@ mod tests { let models_dir = base.join("models"); let model = tar_gz_target("parakeet-v3-int8"); - let vad = ModelDownloadTarget::Onnx { - id: "silero-vad-v4".to_string(), - url: "https://models.example.org/silero_vad_v4.onnx".to_string(), - }; + let vad = ModelDownloadTarget::file( + "silero-vad-v4".to_string(), + "model.onnx".to_string(), + "https://models.example.org/silero_vad_v4.onnx".to_string(), + test_sha(), + ); migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); let downloader = ModelDownloader::new(&models_dir); @@ -815,7 +933,7 @@ mod tests { let models_dir = base.join("models"); let model = tar_gz_target("parakeet-v3-int8"); - let vad = onnx_target("silero-vad-v4"); + let vad = file_target("silero-vad-v4"); fs::create_dir_all(models_dir.join("parakeet-v3-int8")).unwrap(); fs::write(models_dir.join("parakeet-v3-int8/encoder.onnx"), b"new").unwrap(); @@ -844,7 +962,7 @@ mod tests { let models_dir = base.join("models"); let model = tar_gz_target("parakeet-v4-int8"); - let vad = onnx_target("silero-vad-v5"); + let vad = file_target("silero-vad-v5"); migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); let downloader = ModelDownloader::new(&models_dir); @@ -869,7 +987,9 @@ mod tests { let custom = ModelDownloadTarget::Gguf { id: "custom:1".to_string(), url: custom_url.to_string(), + sha256: Some(test_sha()), mmproj_url: None, + mmproj_sha256: Some(test_sha()), }; fs::write( legacy_models @@ -919,7 +1039,9 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), + sha256: Some(test_sha()), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), + mmproj_sha256: Some(test_sha()), }); let models_dir = base.join("models"); @@ -948,7 +1070,9 @@ mod tests { let custom = ModelDownloadTarget::Gguf { id: "custom:1".to_string(), url: custom_url.to_string(), + sha256: Some(test_sha()), mmproj_url: None, + mmproj_sha256: Some(test_sha()), }; fs::write( models_dir @@ -1032,7 +1156,9 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), + sha256: Some(test_sha()), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), + mmproj_sha256: Some(test_sha()), }); fs::write(models_dir.join("a.gguf"), b"GGUFa").unwrap(); @@ -1095,7 +1221,9 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), + sha256: Some(test_sha()), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), + mmproj_sha256: Some(test_sha()), }); migrate_flat_models_dir(&models_dir, &targets); diff --git a/rust/crates/model-download/src/gguf.rs b/rust/crates/model-download/src/gguf.rs index 93038996ca5..d8b21c151aa 100644 --- a/rust/crates/model-download/src/gguf.rs +++ b/rust/crates/model-download/src/gguf.rs @@ -4,33 +4,13 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; -use crate::download::{self, Target}; - -const MIN_MODEL_BYTES: u64 = 1024 * 1024; - -pub(crate) fn expected_targets( - models_dir: &Path, - id: &str, - url: &str, - mmproj_url: Option<&str>, -) -> Vec { - let mut targets = vec![Target { - label: "Model".to_string(), - url: url.to_string(), - destination_path: model_path(models_dir, id, url, mmproj_url) - .display() - .to_string(), - sha256: None, - }]; - if let Some(mmproj) = mmproj_path(models_dir, id, url, mmproj_url) { - targets.push(Target { - label: "Mmproj".to_string(), - url: trimmed(mmproj_url).unwrap().to_string(), - destination_path: mmproj.display().to_string(), - sha256: None, - }); +pub(crate) fn storage_key(id: &str, url: &str, mmproj_url: Option<&str>) -> String { + if id.starts_with("custom") { + let pair = format!("{url}\n{}", trimmed(mmproj_url).unwrap_or("")); + format!("custom-{}", &sha256_hex(&pair)[..16]) + } else { + id.to_string() } - targets } pub(crate) fn model_dir( @@ -39,13 +19,7 @@ pub(crate) fn model_dir( url: &str, mmproj_url: Option<&str>, ) -> PathBuf { - let key = if id.starts_with("custom") { - let pair = format!("{url}\n{}", trimmed(mmproj_url).unwrap_or("")); - format!("custom-{}", &sha256_hex(&pair)[..16]) - } else { - id.to_string() - }; - models_dir.join(key) + models_dir.join(storage_key(id, url, mmproj_url)) } pub(crate) fn model_path( @@ -76,22 +50,6 @@ pub(crate) fn looks_like_gguf(path: &Path) -> bool { file.read_exact(&mut header).is_ok() && &header == b"GGUF" } -pub(crate) fn validate_gguf(path: &Path) -> Result<(), download::Error> { - if file_size(path).is_none_or(|size| size < MIN_MODEL_BYTES) { - return Err(download::Error::Validation(format!( - "{} is too small to be a model file", - path.display() - ))); - } - if !looks_like_gguf(path) { - return Err(download::Error::Validation(format!( - "{} is not a valid GGUF file", - path.display() - ))); - } - Ok(()) -} - pub(crate) fn trimmed(url: Option<&str>) -> Option<&str> { url.map(str::trim).filter(|url| !url.is_empty()) } @@ -112,7 +70,3 @@ pub(crate) fn sha256_hex(value: &str) -> String { .map(|byte| format!("{byte:02x}")) .collect() } - -fn file_size(path: &Path) -> Option { - std::fs::metadata(path).ok().map(|metadata| metadata.len()) -} diff --git a/rust/crates/model-download/src/lib.rs b/rust/crates/model-download/src/lib.rs index 3dc27dd93ea..cf22c29c31b 100644 --- a/rust/crates/model-download/src/lib.rs +++ b/rust/crates/model-download/src/lib.rs @@ -4,6 +4,6 @@ mod downloader; mod gguf; pub use downloader::{ - ModelDownloadProgress, ModelDownloadTarget, ModelDownloader, migrate_ensu_legacy_models, - migrate_flat_models_dir, + ModelDownloadProgress, ModelDownloadTarget, ModelDownloader, ModelFile, + migrate_ensu_legacy_models, migrate_flat_models_dir, }; diff --git a/rust/crates/photos/tests/support/ml_indexing.rs b/rust/crates/photos/tests/support/ml_indexing.rs index 6c14fbcc6e1..ef9a24aeeb8 100644 --- a/rust/crates/photos/tests/support/ml_indexing.rs +++ b/rust/crates/photos/tests/support/ml_indexing.rs @@ -553,11 +553,11 @@ fn fetch_fixtures( )? .display() .to_string(), - sha256: Some(normalize_sha256(&fixture.sha256)), + sha256: normalize_sha256(&fixture.sha256), }) }) .collect::>>()?; - download::fetch(targets, |_, _| Ok(()), |_| {}, || false).context("fetch fixtures") + download::fetch(targets, |_| {}, || false).context("fetch fixtures") } fn resolve_onnx_runtime_library(cache_dir: &Path, assets: &OnnxRuntimeAssets) -> Result { @@ -707,9 +707,8 @@ fn ensure_remote_asset( label: label.to_string(), url: url.to_string(), destination_path: target.display().to_string(), - sha256: Some(normalize_sha256(expected_sha256)), + sha256: normalize_sha256(expected_sha256), }], - |_, _| Ok(()), |_| {}, || false, ) diff --git a/web/apps/ensu/src/components/chat/ChatDialogs.tsx b/web/apps/ensu/src/components/chat/ChatDialogs.tsx index c186a245a2d..69d46c1e3f7 100644 --- a/web/apps/ensu/src/components/chat/ChatDialogs.tsx +++ b/web/apps/ensu/src/components/chat/ChatDialogs.tsx @@ -57,6 +57,8 @@ export interface ModelSettingsDraft { useCustomModel: boolean; modelUrl: string; mmprojUrl: string; + modelSha256: string; + mmprojSha256: string; contextLength: string; maxTokens: string; } @@ -93,6 +95,8 @@ export interface ChatDialogsProps { isTauriRuntime: boolean; modelUrl: string; mmprojUrl: string; + modelSha256: string; + mmprojSha256: string; suggestedModels: SuggestedModel[]; contextLength: string; maxTokens: string; @@ -145,6 +149,8 @@ export const ChatDialogs = memo( isTauriRuntime, modelUrl, mmprojUrl, + modelSha256, + mmprojSha256, suggestedModels, contextLength, maxTokens, @@ -188,6 +194,14 @@ export const ChatDialogs = memo( React.useState(false); const [draftModelUrl, setDraftModelUrl] = React.useState(""); const [draftMmprojUrl, setDraftMmprojUrl] = React.useState(""); + const [draftModelSha, setDraftModelSha] = React.useState(""); + const [draftMmprojSha, setDraftMmprojSha] = React.useState(""); + const [draftModelShaError, setDraftModelShaError] = React.useState< + string | null + >(null); + const [draftMmprojShaError, setDraftMmprojShaError] = React.useState< + string | null + >(null); const [draftContextLength, setDraftContextLength] = React.useState(""); const [draftMaxTokens, setDraftMaxTokens] = React.useState(""); const [draftModelUrlError, setDraftModelUrlError] = React.useState< @@ -241,7 +255,10 @@ export const ChatDialogs = memo( ); const isCustomSelected = selectedModelId === "custom"; const canSaveModelSettings = - !isCustomSelected || draftModelUrl.trim().length > 0; + !isCustomSelected || + (draftModelUrl.trim().length > 0 && + draftModelSha.trim().length > 0 && + (!draftMmprojUrl.trim() || draftMmprojSha.trim().length > 0)); // Initialize model settings draft from parent state when dialog opens React.useEffect(() => { @@ -249,10 +266,14 @@ export const ChatDialogs = memo( setDraftUseCustomModel(useCustomModel); setDraftModelUrl(modelUrl); setDraftMmprojUrl(mmprojUrl); + setDraftModelSha(modelSha256); + setDraftMmprojSha(mmprojSha256); setDraftContextLength(contextLength); setDraftMaxTokens(maxTokens); setDraftModelUrlError(null); setDraftMmprojError(null); + setDraftModelShaError(null); + setDraftMmprojShaError(null); setDraftContextError(null); setDraftMaxTokensError(null); const matchedOption = useCustomModel @@ -268,6 +289,8 @@ export const ChatDialogs = memo( mmprojUrl, modelOptions, modelUrl, + modelSha256, + mmprojSha256, showModelSettings, useCustomModel, ]); @@ -311,6 +334,20 @@ export const ChatDialogs = memo( ? validateUrl(draftMmprojUrl) : undefined; + const validateSha = (value: string) => + /^[0-9a-fA-F]{64}$/.test(value.trim()) + ? undefined + : "Enter the file's SHA-256 checksum"; + const modelShaError = isCustomSelected + ? validateSha(draftModelSha) + : undefined; + const mmprojShaErr = + isCustomSelected && + isTauriRuntime && + draftMmprojUrl.trim().length > 0 + ? validateSha(draftMmprojSha) + : undefined; + const contextErrorValue = draftContextLength && !/^\d+$/.test(draftContextLength) ? "Enter a number" @@ -334,6 +371,8 @@ export const ChatDialogs = memo( setDraftModelUrlError(modelError ?? null); setDraftMmprojError(mmprojErr ?? null); + setDraftModelShaError(modelShaError ?? null); + setDraftMmprojShaError(mmprojShaErr ?? null); setDraftContextError(contextErrorValue ?? null); setDraftMaxTokensError( maxTokensErrorValue ?? maxTokensLimitError ?? null, @@ -342,6 +381,8 @@ export const ChatDialogs = memo( return !( modelError || mmprojErr || + modelShaError || + mmprojShaErr || contextErrorValue || maxTokensErrorValue || maxTokensLimitError @@ -351,7 +392,10 @@ export const ChatDialogs = memo( draftMaxTokens, draftMmprojUrl, draftModelUrl, + draftModelSha, + draftMmprojSha, draftUseCustomModel, + isCustomSelected, isTauriRuntime, ]); @@ -795,15 +839,21 @@ export const ChatDialogs = memo( setDraftUseCustomModel(false); setDraftModelUrl(""); setDraftMmprojUrl(""); + setDraftModelSha(""); + setDraftMmprojSha(""); return; } setDraftUseCustomModel(true); if (nextId === "custom") { setDraftModelUrl(""); setDraftMmprojUrl(""); + setDraftModelSha(""); + setDraftMmprojSha(""); return; } setDraftModelUrl(nextModel.url); + setDraftModelSha(""); + setDraftMmprojSha(""); setDraftMmprojUrl( allowMmproj ? (nextModel.mmproj ?? "") @@ -844,6 +894,21 @@ export const ChatDialogs = memo( draftModelUrlError ?? " " } /> + + setDraftModelSha( + event.target.value, + ) + } + error={!!draftModelShaError} + helperText={ + draftModelShaError ?? " " + } + /> {allowMmproj && ( )} + {allowMmproj && + draftMmprojUrl.trim().length > + 0 && ( + + setDraftMmprojSha( + event.target.value, + ) + } + error={ + !!draftMmprojShaError + } + helperText={ + draftMmprojShaError ?? + " " + } + /> + )} )} @@ -947,6 +1034,8 @@ export const ChatDialogs = memo( useCustomModel: draftUseCustomModel, modelUrl: draftModelUrl, mmprojUrl: draftMmprojUrl, + modelSha256: draftModelSha.trim(), + mmprojSha256: draftMmprojSha.trim(), contextLength: draftContextLength, maxTokens: draftMaxTokens, }); diff --git a/web/apps/ensu/src/pages/chat.tsx b/web/apps/ensu/src/pages/chat.tsx index c7e294a3a1a..70074b1aeb1 100644 --- a/web/apps/ensu/src/pages/chat.tsx +++ b/web/apps/ensu/src/pages/chat.tsx @@ -600,6 +600,8 @@ const Page: React.FC = () => { >(null); const [modelUrl, setModelUrl] = useState(""); const [mmprojUrl, setMmprojUrl] = useState(""); + const [modelSha256, setModelSha256] = useState(""); + const [mmprojSha256, setMmprojSha256] = useState(""); const [contextLength, setContextLength] = useState(""); const [maxTokens, setMaxTokens] = useState(""); const [systemPrompt, setSystemPrompt] = useState( @@ -980,6 +982,8 @@ const Page: React.FC = () => { useCustomModel?: boolean; modelUrl?: string; mmprojUrl?: string; + modelSha256?: string; + mmprojSha256?: string; contextLength?: string; maxTokens?: string; }; @@ -1005,6 +1009,10 @@ const Page: React.FC = () => { setUseCustomModel(!!parsed.useCustomModel); setModelUrl(parsed.modelUrl ?? ""); setMmprojUrl(canMmproj ? (parsed.mmprojUrl ?? "") : ""); + setModelSha256(parsed.modelSha256 ?? ""); + setMmprojSha256( + canMmproj ? (parsed.mmprojSha256 ?? "") : "", + ); setContextLength(clampedContextLength); setMaxTokens(parsed.maxTokens ?? ""); void removeKV(MODEL_SETTINGS_STORAGE_KEY); @@ -1024,6 +1032,8 @@ const Page: React.FC = () => { useCustomModel?: boolean; modelUrl?: string; mmprojUrl?: string; + modelSha256?: string; + mmprojSha256?: string; contextLength?: string; maxTokens?: string; }; @@ -1052,6 +1062,8 @@ const Page: React.FC = () => { setUseCustomModel(!!parsed.useCustomModel); setModelUrl(parsed.modelUrl ?? ""); setMmprojUrl(canMmproj ? (parsed.mmprojUrl ?? "") : ""); + setModelSha256(parsed.modelSha256 ?? ""); + setMmprojSha256(canMmproj ? (parsed.mmprojSha256 ?? "") : ""); setContextLength(clampedContextLength); setMaxTokens(parsed.maxTokens ?? ""); } catch (error) { @@ -1726,6 +1738,14 @@ const Page: React.FC = () => { customModelEnabled && allowMmproj && mmprojUrl.trim() ? mmprojUrl.trim() : undefined, + modelSha256: + customModelEnabled && modelSha256.trim() + ? modelSha256.trim() + : undefined, + mmprojSha256: + customModelEnabled && allowMmproj && mmprojSha256.trim() + ? mmprojSha256.trim() + : undefined, contextLength: contextLength ? Number(contextLength) : undefined, maxTokens: maxTokens && Number(maxTokens) > 0 @@ -1736,6 +1756,8 @@ const Page: React.FC = () => { useCustomModel, modelUrl, mmprojUrl, + modelSha256, + mmprojSha256, contextLength, maxTokens, allowMmproj, @@ -3203,6 +3225,8 @@ const Page: React.FC = () => { useCustomModel: boolean; modelUrl: string; mmprojUrl: string; + modelSha256: string; + mmprojSha256: string; contextLength: string; maxTokens: string; }) => { @@ -3214,6 +3238,11 @@ const Page: React.FC = () => { draft.useCustomModel && isTauriRuntime ? draft.mmprojUrl : "", + modelSha256: draft.useCustomModel ? draft.modelSha256 : "", + mmprojSha256: + draft.useCustomModel && isTauriRuntime + ? draft.mmprojSha256 + : "", contextLength: draft.contextLength, maxTokens: draft.maxTokens, }; @@ -3227,6 +3256,8 @@ const Page: React.FC = () => { setUseCustomModel(draft.useCustomModel); setModelUrl(draft.modelUrl); setMmprojUrl(draft.mmprojUrl); + setModelSha256(draft.modelSha256); + setMmprojSha256(draft.mmprojSha256); setContextLength(draft.contextLength); setMaxTokens(draft.maxTokens); setLoadedModelName(null); @@ -3244,6 +3275,8 @@ const Page: React.FC = () => { useCustomModel: false, modelUrl: "", mmprojUrl: "", + modelSha256: "", + mmprojSha256: "", contextLength: "", maxTokens: "", }), @@ -3253,6 +3286,8 @@ const Page: React.FC = () => { setUseCustomModel(false); setModelUrl(""); setMmprojUrl(""); + setModelSha256(""); + setMmprojSha256(""); setContextLength(""); setMaxTokens(""); setLoadedModelName(null); @@ -4250,6 +4285,8 @@ const Page: React.FC = () => { isTauriRuntime={isTauriRuntime} modelUrl={modelUrl} mmprojUrl={mmprojUrl} + modelSha256={modelSha256} + mmprojSha256={mmprojSha256} suggestedModels={suggestedModels} contextLength={contextLength} maxTokens={maxTokens} diff --git a/web/apps/ensu/src/services/llm/provider.ts b/web/apps/ensu/src/services/llm/provider.ts index 09a6b891b1c..2b7ab138def 100644 --- a/web/apps/ensu/src/services/llm/provider.ts +++ b/web/apps/ensu/src/services/llm/provider.ts @@ -22,8 +22,11 @@ export const DEFAULT_MODEL: ModelInfo = { id: "lfm-vl-1.6b", name: "LFM 2.5 VL 1.6B (Q4_0)", url: "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/LFM2.5-VL-1.6B-Q4_0.gguf?download=true", + sha256: "8186364a4e7c3ad30f6dd3d3b7a4e0074c77dd91eed6cad5d8be9090ce285804", mmprojUrl: "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/mmproj-LFM2.5-VL-1.6b-Q8_0.gguf", + mmprojSha256: + "2ce89e610c56f3198ece2b86cf61743a08b9307279c89125eb2412ebb908689d", sizeBytes: 695_752_160, mmprojSizeBytes: 583_109_888, sizeHuman: "~664 MB", @@ -33,8 +36,11 @@ const DESKTOP_DEFAULT_MODEL: ModelInfo = { id: "gemma-4-e4b-q4km", name: "Gemma 4 E4B (Q4_K_M)", url: "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf?download=true", + sha256: "519b9793ed6ce0ff530f1b7c96e848e08e49e7af4d57bb97f76215963a54146d", mmprojUrl: "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/mmproj-F16.gguf", + mmprojSha256: + "ddf46c21d7078e95338cfc22306b19b276a29a5ad089023449dd54d4b6170a51", sizeBytes: 4_977_169_088, mmprojSizeBytes: 990_372_800, sizeHuman: "5.97 GB", @@ -44,7 +50,9 @@ interface ConfigModelPreset { id: string; title: string; url: string; + sha256?: string | null; mmprojUrl?: string | null; + mmprojSha256?: string | null; } interface ConfigDefaults { @@ -68,7 +76,9 @@ interface TauriLlmModelDownloadProgress { interface ModelTarget { id: string; url: string; + sha256?: string | null; mmprojUrl?: string | null; + mmprojSha256?: string | null; } interface TauriModelStatus { @@ -504,7 +514,9 @@ export class LlmProvider { id: rustPreset.id, name: rustPreset.title, url: rustPreset.url, + sha256: rustPreset.sha256 ?? undefined, mmprojUrl: rustPreset.mmprojUrl ?? undefined, + mmprojSha256: rustPreset.mmprojSha256 ?? undefined, }; this.configDefaults = defaults; } catch (defaultsError) { @@ -530,7 +542,9 @@ export class LlmProvider { id: `custom:${settings.modelUrl}`, name: "Custom model", url: settings.modelUrl, + sha256: settings.modelSha256, mmprojUrl: settings.mmprojUrl, + mmprojSha256: settings.mmprojSha256, }; } return this.defaultModel; @@ -566,7 +580,13 @@ export class LlmProvider { preset.url == model.url && (preset.mmprojUrl ?? null) == mmprojUrl, ); - return { id: preset?.id ?? model.id, url: model.url, mmprojUrl }; + return { + id: preset?.id ?? model.id, + url: model.url, + sha256: preset?.sha256 ?? model.sha256 ?? null, + mmprojUrl, + mmprojSha256: preset?.mmprojSha256 ?? model.mmprojSha256 ?? null, + }; } private ensureMigrated(settings: ModelSettings): Promise { diff --git a/web/apps/ensu/src/services/llm/types.ts b/web/apps/ensu/src/services/llm/types.ts index 4e7249ebf30..3cd222b92e6 100644 --- a/web/apps/ensu/src/services/llm/types.ts +++ b/web/apps/ensu/src/services/llm/types.ts @@ -9,7 +9,9 @@ export interface ModelInfo { id: string; name: string; url: string; + sha256?: string; mmprojUrl?: string; + mmprojSha256?: string; sizeHuman?: string; sizeBytes?: number; mmprojSizeBytes?: number; @@ -21,6 +23,8 @@ export interface ModelSettings { useCustomModel: boolean; modelUrl?: string; mmprojUrl?: string; + modelSha256?: string; + mmprojSha256?: string; contextLength?: number; maxTokens?: number; } From 0bebfa51c537e8cec89867e26a61d294083cf1d8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 20 Jul 2026 17:31:56 +0530 Subject: [PATCH 71/91] Select models by preset id and drop custom model URLs --- .../main/java/io/ente/ensu/AppViewModel.kt | 26 +- .../main/java/io/ente/ensu/llm/LlmModels.kt | 2 +- .../java/io/ente/ensu/llm/ModelDownloader.kt | 14 +- .../io/ente/ensu/llm/ModelSettingsActions.kt | 32 +- .../io/ente/ensu/llm/ModelSettingsScreen.kt | 164 +--------- .../io/ente/ensu/llm/ModelSettingsState.kt | 6 +- .../settings/AdvancedSettingsDataStore.kt | 45 +-- .../apple/apps/ensu/Ensu/Chat/ChatView.swift | 2 +- .../apps/ensu/Ensu/Chat/ChatViewModel.swift | 4 +- .../ModelSettings/ModelSettingsView.swift | 196 +----------- .../apps/ensu/Ensu/Services/LlmProvider.swift | 2 +- .../ensu/Ensu/Services/ModelDownloader.swift | 32 +- .../Ensu/Services/ModelSettingsStore.swift | 88 +----- rust/apps/ensu/src-tauri/src/commands/llm.rs | 23 +- rust/bindings/uniffi/ensu/src/config.rs | 2 +- .../uniffi/ensu/src/model_downloader.rs | 12 +- rust/crates/ensu/src/config.rs | 78 +++-- rust/crates/model-download/src/downloader.rs | 180 +++-------- rust/crates/model-download/src/gguf.rs | 39 +-- .../ensu/src/components/chat/ChatDialogs.tsx | 295 ++--------------- web/apps/ensu/src/pages/chat.tsx | 296 ++++++------------ web/apps/ensu/src/services/llm/provider.ts | 162 ++++------ web/apps/ensu/src/services/llm/types.ts | 8 +- 23 files changed, 437 insertions(+), 1271 deletions(-) diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt index f22c1441ed7..110d80cfcd6 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/AppViewModel.kt @@ -63,12 +63,16 @@ class AppViewModel(application: Application) : AndroidViewModel(application) { logRepository.log(LogLevel.Info, launchMessage, tag = "App") viewModelScope.launch { + runCatching { + advancedSettingsDataStore.migrateLegacyModelSelection { url, mmproj -> + withContext(Dispatchers.IO) { + modelDownloader.migrate(migrationTargets(), url, mmproj) + } + } + } val initialSettings = runCatching { advancedSettingsDataStore.settingsFlow.first() }.getOrDefault(AdvancedSettingsSnapshot()) - withContext(Dispatchers.IO) { - modelDownloader.migrate(migrationTargets(initialSettings.modelSettings)) - } store.applyPersistedSettings( developerSettings = initialSettings.developerSettings, modelSettings = initialSettings.modelSettings @@ -88,21 +92,11 @@ class AppViewModel(application: Application) : AndroidViewModel(application) { } } - private fun migrationTargets(settings: ModelSettingsState): List { + private fun migrationTargets(): List { val presets = listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets - val targets = presets - .map { ModelDownloadTarget.Gguf(it.id, it.url, it.sha256, it.mmprojUrl, it.mmprojSha256) } - .toMutableList() - if (settings.useCustomModel && settings.modelUrl.isNotBlank()) { - targets += ModelDownloadTarget.Gguf( - "custom:${settings.modelUrl}", - settings.modelUrl, - settings.modelSha256.trim().takeIf { it.isNotEmpty() }, - settings.mmprojUrl.takeIf { it.isNotBlank() }, - settings.mmprojSha256.trim().takeIf { it.isNotEmpty() } - ) + return presets.map { + ModelDownloadTarget.Gguf(it.id, it.url, it.sha256, it.mmprojUrl, it.mmprojSha256) } - return targets } @Suppress("DEPRECATION") diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt index 46759b30fe4..4db55bcc479 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmModels.kt @@ -5,7 +5,7 @@ import io.ente.ensu.bindings.ModelDownloadTarget data class LlmModelTarget( val id: String, val url: String, - val sha256: String? = null, + val sha256: String, val mmprojUrl: String? = null, val mmprojSha256: String? = null, val contextLength: Int? = null, diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt index 31ac02ed743..7a341ded34f 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelDownloader.kt @@ -37,25 +37,31 @@ class ModelDownloader(context: Context) { transcriptionModelTarget = ModelDownloadTarget.TarGz( defaults.transcriptionModel.id, defaults.transcriptionModel.url, - requireNotNull(defaults.transcriptionModel.sha256) + defaults.transcriptionModel.sha256 ) voiceActivityModelTarget = ModelDownloadTarget.File( defaults.voiceActivityModel.id, "model.onnx", defaults.voiceActivityModel.url, - requireNotNull(defaults.voiceActivityModel.sha256) + defaults.voiceActivityModel.sha256 ) } fun needsMigration(): Boolean = legacyDir?.exists() == true || legacyTranscriptionDir.exists() - fun migrate(targets: List) { + fun migrate( + targets: List, + legacyModelUrl: String?, + legacyMmprojUrl: String? + ): String? { File(appContext.filesDir, "llm").deleteRecursively() - migrateEnsuLegacyModels( + return migrateEnsuLegacyModels( modelsDir.absolutePath, legacyDir?.absolutePath, legacyTranscriptionDir.absolutePath, + legacyModelUrl, + legacyMmprojUrl, targets, transcriptionModelTarget, voiceActivityModelTarget diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt index ae13aa938cc..b9d280995e4 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsActions.kt @@ -315,34 +315,18 @@ internal class ModelSettingsActions( } fun resolveTarget(settings: ModelSettingsState): LlmModelTarget { - val useCustom = settings.useCustomModel && settings.modelUrl.isNotBlank() - val url = if (useCustom) settings.modelUrl else configDefaults.mobileDefaultModel.url - val mmproj = if (useCustom) { - settings.mmprojUrl.takeIf { it.isNotBlank() } - } else { - configDefaults.mobileDefaultModel.mmprojUrl - } + val presets = listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets + val preset = presets.firstOrNull { it.id == settings.modelId } + ?: configDefaults.mobileDefaultModel val contextLength = settings.contextLength.toIntOrNull() val maxTokens = settings.maxTokens.toIntOrNull()?.takeIf { it > 0 } - val preset = if (useCustom) { - (listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets) - .firstOrNull { it.url == url && it.mmprojUrl == mmproj } - } else { - configDefaults.mobileDefaultModel - } return LlmModelTarget( - id = preset?.id ?: "custom:$url", - url = url, - sha256 = preset?.sha256 - ?: settings.modelSha256.trim().takeIf { it.isNotEmpty() }, - mmprojUrl = mmproj, - mmprojSha256 = if (mmproj == null) { - null - } else { - preset?.mmprojSha256 - ?: settings.mmprojSha256.trim().takeIf { it.isNotEmpty() } - }, + id = preset.id, + url = preset.url, + sha256 = preset.sha256, + mmprojUrl = preset.mmprojUrl, + mmprojSha256 = preset.mmprojSha256, contextLength = contextLength, maxTokens = maxTokens ) diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt index 96aea1c15d4..3cc161bc62b 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsScreen.kt @@ -52,65 +52,19 @@ fun ModelSettingsScreen( ModelChoice( id = DEFAULT_OPTION_ID, title = defaults.mobileDefaultModel.title, - url = defaults.mobileDefaultModel.url, - mmproj = defaults.mobileDefaultModel.mmprojUrl, isDefault = true ) ) + defaults.mobileModelPresets.map { preset -> ModelChoice( id = preset.id, - title = preset.title, - url = preset.url, - mmproj = preset.mmprojUrl + title = preset.title ) - } + listOf( - ModelChoice( - id = CUSTOM_OPTION_ID, - title = "Custom", - isCustom = true - ) - ) + } } var selectedModelId by remember(state) { mutableStateOf(initialSelectionId(state, modelChoices)) } - var customModelUrl by remember(state) { - mutableStateOf( - if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { - state.modelUrl - } else { - "" - } - ) - } - var customMmprojUrl by remember(state) { - mutableStateOf( - if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { - state.mmprojUrl - } else { - "" - } - ) - } - var customModelSha by remember(state) { - mutableStateOf( - if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { - state.modelSha256 - } else { - "" - } - ) - } - var customMmprojSha by remember(state) { - mutableStateOf( - if (state.useCustomModel && modelChoices.none { !it.isCustom && it.url == state.modelUrl }) { - state.mmprojSha256 - } else { - "" - } - ) - } var contextLength by remember(state) { mutableStateOf(state.contextLength) } var maxTokens by remember(state) { mutableStateOf(state.maxTokens) } var temperature by remember(state) { mutableStateOf(state.temperature) } @@ -124,14 +78,6 @@ fun ModelSettingsScreen( var isModelMenuExpanded by remember { mutableStateOf(false) } val selectedModel = modelChoices.firstOrNull { it.id == selectedModelId } ?: modelChoices.first() - val isCustomSelected = selectedModel.isCustom - val shaPattern = remember { Regex("[0-9a-fA-F]{64}") } - val canSave = !isCustomSelected || - ( - customModelUrl.isNotBlank() && - shaPattern.matches(customModelSha.trim()) && - (customMmprojUrl.isBlank() || shaPattern.matches(customMmprojSha.trim())) - ) Column( modifier = Modifier @@ -142,7 +88,7 @@ fun ModelSettingsScreen( SectionHeader("Select model") Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) Text( - text = "Choose a built-in model or switch to Custom.", + text = "Choose a built-in model.", style = EnsuTypography.small, color = EnsuColor.textMuted() ) @@ -174,66 +120,12 @@ fun ModelSettingsScreen( onClick = { selectedModelId = choice.id isModelMenuExpanded = false - if (choice.isCustom) { - customModelUrl = "" - customMmprojUrl = "" - customModelSha = "" - customMmprojSha = "" - } } ) } } } - AnimatedVisibility(isCustomSelected) { - Column { - Spacer(modifier = Modifier.height(EnsuSpacing.lg.dp)) - Text(text = "Model .gguf URL", style = EnsuTypography.small, color = EnsuColor.textMuted()) - Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) - OutlinedTextField( - value = customModelUrl, - onValueChange = { customModelUrl = it }, - placeholder = { Text(text = "https://huggingface.co/...") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) - Text(text = "Model SHA-256", style = EnsuTypography.small, color = EnsuColor.textMuted()) - Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) - OutlinedTextField( - value = customModelSha, - onValueChange = { customModelSha = it }, - placeholder = { Text(text = "Checksum from the model page") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) - Text(text = "mmproj .gguf URL", style = EnsuTypography.small, color = EnsuColor.textMuted()) - Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) - OutlinedTextField( - value = customMmprojUrl, - onValueChange = { customMmprojUrl = it }, - placeholder = { Text(text = "(optional for multimodal)") }, - modifier = Modifier.fillMaxWidth() - ) - - AnimatedVisibility(customMmprojUrl.isNotBlank()) { - Column { - Spacer(modifier = Modifier.height(EnsuSpacing.md.dp)) - Text(text = "mmproj SHA-256", style = EnsuTypography.small, color = EnsuColor.textMuted()) - Spacer(modifier = Modifier.height(EnsuSpacing.xs.dp)) - OutlinedTextField( - value = customMmprojSha, - onValueChange = { customMmprojSha = it }, - placeholder = { Text(text = "Checksum from the model page") }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - Spacer(modifier = Modifier.height(EnsuSpacing.lg.dp)) ExpandButton( title = "Advanced limits", @@ -298,43 +190,16 @@ fun ModelSettingsScreen( Button( onClick = { - val savedState = when { - selectedModel.isDefault -> state.copy( - useCustomModel = false, - modelUrl = "", - modelSha256 = "", - mmprojUrl = "", - mmprojSha256 = "", - contextLength = contextLength, - maxTokens = maxTokens, - temperature = temperature - ) - selectedModel.isCustom -> state.copy( - useCustomModel = true, - modelUrl = customModelUrl, - modelSha256 = customModelSha.trim(), - mmprojUrl = customMmprojUrl, - mmprojSha256 = customMmprojSha.trim(), - contextLength = contextLength, - maxTokens = maxTokens, - temperature = temperature - ) - else -> state.copy( - useCustomModel = true, - modelUrl = selectedModel.url.orEmpty(), - modelSha256 = "", - mmprojUrl = selectedModel.mmproj.orEmpty(), - mmprojSha256 = "", - contextLength = contextLength, - maxTokens = maxTokens, - temperature = temperature - ) - } + val savedState = state.copy( + modelId = selectedModel.id.takeUnless { selectedModel.isDefault }.orEmpty(), + contextLength = contextLength, + maxTokens = maxTokens, + temperature = temperature + ) onSave(savedState) Toast.makeText(context, "Model settings saved", Toast.LENGTH_SHORT).show() }, modifier = Modifier.fillMaxWidth(), - enabled = canSave, colors = ButtonDefaults.buttonColors(containerColor = EnsuColor.accent()) ) { Text(text = "Save Model Settings", style = EnsuTypography.body) @@ -345,8 +210,6 @@ fun ModelSettingsScreen( TextButton(onClick = { onReset() selectedModelId = DEFAULT_OPTION_ID - customModelUrl = "" - customMmprojUrl = "" contextLength = "" maxTokens = "" temperature = "" @@ -390,19 +253,14 @@ private fun ExpandButton( private data class ModelChoice( val id: String, val title: String, - val url: String? = null, - val mmproj: String? = null, - val isDefault: Boolean = false, - val isCustom: Boolean = false + val isDefault: Boolean = false ) private fun initialSelectionId( state: ModelSettingsState, choices: List ): String { - if (!state.useCustomModel || state.modelUrl.isBlank()) return DEFAULT_OPTION_ID - return choices.firstOrNull { !it.isCustom && it.url == state.modelUrl }?.id ?: CUSTOM_OPTION_ID + return choices.firstOrNull { it.id == state.modelId }?.id ?: DEFAULT_OPTION_ID } private const val DEFAULT_OPTION_ID = "default" -private const val CUSTOM_OPTION_ID = "custom" diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt index 1c8e1457066..8278e1b0e1f 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/ModelSettingsState.kt @@ -1,11 +1,7 @@ package io.ente.ensu.llm data class ModelSettingsState( - val useCustomModel: Boolean = false, - val modelUrl: String = "", - val modelSha256: String = "", - val mmprojUrl: String = "", - val mmprojSha256: String = "", + val modelId: String = "", val contextLength: String = "", val maxTokens: String = "", val temperature: String = "", diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt index 9535430bbc2..2bc79dfb76b 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/settings/AdvancedSettingsDataStore.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.preferencesDataStore import io.ente.ensu.settings.DeveloperSettingsState import io.ente.ensu.llm.ModelSettingsState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow @@ -31,11 +32,7 @@ class AdvancedSettingsDataStore(private val context: Context) { systemPrompt = prefs[Keys.systemPrompt].orEmpty() ), modelSettings = ModelSettingsState( - useCustomModel = prefs[Keys.useCustomModel] ?: false, - modelUrl = prefs[Keys.modelUrl].orEmpty(), - modelSha256 = prefs[Keys.modelSha256].orEmpty(), - mmprojUrl = prefs[Keys.mmprojUrl].orEmpty(), - mmprojSha256 = prefs[Keys.mmprojSha256].orEmpty(), + modelId = prefs[Keys.modelId].orEmpty(), contextLength = prefs[Keys.contextLength].orEmpty(), maxTokens = prefs[Keys.maxTokens].orEmpty(), temperature = prefs[Keys.temperature].orEmpty() @@ -43,6 +40,26 @@ class AdvancedSettingsDataStore(private val context: Context) { ) } + suspend fun migrateLegacyModelSelection( + migrate: suspend (legacyModelUrl: String?, legacyMmprojUrl: String?) -> String? + ) { + val useCustomModel = booleanPreferencesKey("use_custom_model") + val modelUrl = stringPreferencesKey("model_url") + val mmprojUrl = stringPreferencesKey("mmproj_url") + val prefs = context.advancedSettingsPreferences.data.first() + val pending = prefs[Keys.modelId] == null + val url = prefs[modelUrl].takeIf { pending && prefs[useCustomModel] == true } + val presetId = migrate(url, prefs[mmprojUrl]) + context.advancedSettingsPreferences.edit { prefs -> + if (pending && prefs[Keys.modelId] == null) { + prefs[Keys.modelId] = presetId.orEmpty() + } + prefs.remove(useCustomModel) + prefs.remove(modelUrl) + prefs.remove(mmprojUrl) + } + } + suspend fun unlockAdvancedSettings() { context.advancedSettingsPreferences.edit { prefs -> prefs[Keys.advancedUnlocked] = true @@ -69,11 +86,7 @@ class AdvancedSettingsDataStore(private val context: Context) { suspend fun saveModelSettings(settings: ModelSettingsState) { context.advancedSettingsPreferences.edit { prefs -> - prefs[Keys.useCustomModel] = settings.useCustomModel - prefs[Keys.modelUrl] = settings.modelUrl - prefs[Keys.modelSha256] = settings.modelSha256 - prefs[Keys.mmprojUrl] = settings.mmprojUrl - prefs[Keys.mmprojSha256] = settings.mmprojSha256 + prefs[Keys.modelId] = settings.modelId prefs[Keys.contextLength] = settings.contextLength prefs[Keys.maxTokens] = settings.maxTokens prefs[Keys.temperature] = settings.temperature @@ -88,11 +101,7 @@ class AdvancedSettingsDataStore(private val context: Context) { suspend fun resetModelSettings() { context.advancedSettingsPreferences.edit { prefs -> - prefs[Keys.useCustomModel] = false - prefs[Keys.modelUrl] = "" - prefs[Keys.modelSha256] = "" - prefs[Keys.mmprojUrl] = "" - prefs[Keys.mmprojSha256] = "" + prefs[Keys.modelId] = "" prefs[Keys.contextLength] = "" prefs[Keys.maxTokens] = "" prefs[Keys.temperature] = "" @@ -109,11 +118,7 @@ class AdvancedSettingsDataStore(private val context: Context) { private object Keys { val advancedUnlocked = booleanPreferencesKey("advanced_unlocked") val systemPrompt = stringPreferencesKey("system_prompt") - val useCustomModel = booleanPreferencesKey("use_custom_model") - val modelUrl = stringPreferencesKey("model_url") - val modelSha256 = stringPreferencesKey("model_sha256") - val mmprojUrl = stringPreferencesKey("mmproj_url") - val mmprojSha256 = stringPreferencesKey("mmproj_sha256") + val modelId = stringPreferencesKey("model_id") val contextLength = stringPreferencesKey("context_length") val maxTokens = stringPreferencesKey("max_tokens") val temperature = stringPreferencesKey("temperature") diff --git a/mobile/native/apple/apps/ensu/Ensu/Chat/ChatView.swift b/mobile/native/apple/apps/ensu/Ensu/Chat/ChatView.swift index 9c823b260b4..79f198a0f42 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Chat/ChatView.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Chat/ChatView.swift @@ -44,7 +44,7 @@ struct ChatView: View { } private var modelSettingsSignature: String { - "\(modelSettings.useCustomModel)|\(modelSettings.modelUrl)|\(modelSettings.mmprojUrl)" + modelSettings.modelId } private let drawerWidth: CGFloat = 320 diff --git a/mobile/native/apple/apps/ensu/Ensu/Chat/ChatViewModel.swift b/mobile/native/apple/apps/ensu/Ensu/Chat/ChatViewModel.swift index 1a7eb57a86a..6931acf967b 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Chat/ChatViewModel.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Chat/ChatViewModel.swift @@ -82,7 +82,7 @@ final class ChatViewModel: ObservableObject { private let attachmentsDir: URL private let chatDbPath: String private let chatDbKey: Data - private let modelSettings = ModelSettingsStore.shared + private let modelSettings: ModelSettingsStore private var messageStore: [UUID: [MessageNode]] = [:] private var branchSelections: [UUID: [String: UUID]] = [:] @@ -114,6 +114,8 @@ final class ChatViewModel: ObservableObject { ?? FileManager.default.temporaryDirectory let downloader = ModelDownloader() + // Must initialize after the downloader migrates the persisted selection. + self.modelSettings = ModelSettingsStore.shared let transcriber = Transcriber( modelDir: downloader.modelPath(target: downloader.transcriptionModelTarget).path, vadModelPath: downloader.modelPath(target: downloader.voiceActivityModelTarget).path diff --git a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift index c0dbfe13e7a..9f645ef79e4 100644 --- a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift +++ b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift @@ -7,18 +7,10 @@ struct ModelSettingsView: View { @ObservedObject private var settings = ModelSettingsStore.shared @State private var selectedModelId: String = defaultOptionId - @State private var customModelUrl: String = "" - @State private var customMmprojUrl: String = "" - @State private var customModelSha: String = "" - @State private var customMmprojSha: String = "" @State private var contextLength: String = "" @State private var maxTokens: String = "" @State private var temperature: String = "" - @State private var urlError: String? - @State private var mmprojError: String? - @State private var shaError: String? - @State private var mmprojShaError: String? @State private var contextError: String? @State private var maxTokensError: String? @State private var temperatureError: String? @@ -32,27 +24,10 @@ struct ModelSettingsView: View { let defaultModel = defaults.mobileDefaultModel let presets = defaults.mobileModelPresets return [ - ModelChoice( - id: Self.defaultOptionId, - name: defaultModel.title, - url: defaultModel.url, - mmproj: defaultModel.mmprojUrl, - isDefault: true - ) + ModelChoice(id: Self.defaultOptionId, name: defaultModel.title, isDefault: true) ] + presets.map { preset in - ModelChoice( - id: preset.id, - name: preset.title, - url: preset.url, - mmproj: preset.mmprojUrl - ) - } + [ - ModelChoice( - id: Self.customOptionId, - name: "Custom", - isCustom: true - ) - ] + ModelChoice(id: preset.id, name: preset.title) + } }() init(embeddedInNavigation: Bool = false) { @@ -90,10 +65,6 @@ struct ModelSettingsView: View { } .onAppear { selectedModelId = initialSelectionId() - customModelUrl = isStoredCustomModel ? settings.modelUrl : "" - customMmprojUrl = isStoredCustomModel ? settings.mmprojUrl : "" - customModelSha = isStoredCustomModel ? settings.modelSha256 : "" - customMmprojSha = isStoredCustomModel ? settings.mmprojSha256 : "" contextLength = settings.contextLength maxTokens = settings.maxTokens temperature = settings.temperature @@ -108,26 +79,16 @@ struct ModelSettingsView: View { } } - private var isStoredCustomModel: Bool { - settings.useCustomModel && - !settings.modelUrl.isEmpty && - !modelChoices.contains(where: { !$0.isCustom && $0.url == settings.modelUrl }) - } - private var selectedModel: ModelChoice { modelChoices.first(where: { $0.id == selectedModelId }) ?? modelChoices[0] } - private var canSave: Bool { - !selectedModel.isCustom || !customModelUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - private var content: some View { ScrollView { VStack(alignment: .leading, spacing: EnsuSpacing.xxl) { sectionHeader("Select model") - Text("Choose a built-in model or switch to Custom.") + Text("Choose a built-in model.") .font(EnsuTypography.small) .foregroundStyle(EnsuColor.textMuted) .frame(maxWidth: .infinity, alignment: .leading) @@ -138,52 +99,6 @@ struct ModelSettingsView: View { } } .pickerStyle(.menu) - .onChange(of: selectedModelId) { newValue in - if newValue == Self.customOptionId { - customModelUrl = "" - customMmprojUrl = "" - customModelSha = "" - customMmprojSha = "" - } - } - - if selectedModel.isCustom { - VStack(spacing: EnsuSpacing.md) { - field( - label: "Model .gguf URL", - hint: "https://huggingface.co/...", - text: $customModelUrl, - error: urlError, - keyboardType: .URL - ) - - field( - label: "Model SHA-256", - hint: "Checksum from the model page", - text: $customModelSha, - error: shaError, - keyboardType: .default - ) - - field( - label: "mmproj .gguf URL", - hint: "(optional for multimodal)", - text: $customMmprojUrl, - error: mmprojError, - keyboardType: .URL - ) - - if !customMmprojUrl.isEmpty { - field( - label: "mmproj SHA-256", - hint: "Checksum from the model page", - text: $customMmprojSha, - error: mmprojShaError, - keyboardType: .default - ) - } - } - } sectionToggle( title: "Advanced limits", @@ -231,7 +146,7 @@ struct ModelSettingsView: View { Divider().background(EnsuColor.border) VStack(spacing: EnsuSpacing.md) { - PrimaryButton(text: "Save Model Settings", isLoading: isSaving, isEnabled: !isSaving && canSave) { + PrimaryButton(text: "Save Model Settings", isLoading: isSaving, isEnabled: !isSaving) { saveTapped() } @@ -252,10 +167,7 @@ struct ModelSettingsView: View { } private func initialSelectionId() -> String { - if !settings.useCustomModel || settings.modelUrl.isEmpty { - return Self.defaultOptionId - } - return modelChoices.first(where: { !$0.isCustom && $0.url == settings.modelUrl })?.id ?? Self.customOptionId + modelChoices.first(where: { $0.id == settings.modelId })?.id ?? Self.defaultOptionId } private func sectionHeader(_ title: String) -> some View { @@ -316,10 +228,6 @@ struct ModelSettingsView: View { } private func saveTapped() { - urlError = nil - mmprojError = nil - shaError = nil - mmprojShaError = nil contextError = nil maxTokensError = nil temperatureError = nil @@ -329,34 +237,12 @@ struct ModelSettingsView: View { isSaving = true Task { @MainActor in try? await Task.sleep(nanoseconds: 200_000_000) - if selectedModel.isDefault { - settings.useCustomModel = false - settings.modelUrl = "" - settings.mmprojUrl = "" - settings.contextLength = contextLength - settings.maxTokens = maxTokens - settings.temperature = temperature - } else if selectedModel.isCustom { - settings.saveCustomModel( - url: customModelUrl, - sha256: customModelSha.trimmingCharacters(in: .whitespaces), - mmproj: customMmprojUrl, - mmprojSha256: customMmprojSha.trimmingCharacters(in: .whitespaces), - contextLength: contextLength, - maxTokens: maxTokens, - temperature: temperature - ) - } else { - settings.saveCustomModel( - url: selectedModel.url ?? "", - sha256: "", - mmproj: selectedModel.mmproj ?? "", - mmprojSha256: "", - contextLength: contextLength, - maxTokens: maxTokens, - temperature: temperature - ) - } + settings.saveModel( + id: selectedModel.isDefault ? "" : selectedModel.id, + contextLength: contextLength, + maxTokens: maxTokens, + temperature: temperature + ) isSaving = false toastTask?.cancel() toastTask = presentToast("Model settings saved") { message in @@ -368,10 +254,6 @@ struct ModelSettingsView: View { private func resetTapped() { settings.resetToDefault() selectedModelId = Self.defaultOptionId - customModelUrl = "" - customMmprojUrl = "" - customModelSha = "" - customMmprojSha = "" contextLength = "" maxTokens = "" temperature = "" @@ -384,31 +266,6 @@ struct ModelSettingsView: View { private func validate() -> Bool { var isValid = true - if selectedModel.isCustom && customModelUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - urlError = "Enter a Hugging Face .gguf URL" - isValid = false - } - - if selectedModel.isCustom && !customModelUrl.isEmpty && !isValidHuggingFaceUrl(customModelUrl) { - urlError = "Enter a valid Hugging Face .gguf URL" - isValid = false - } - - if selectedModel.isCustom && !customMmprojUrl.isEmpty && !isValidHuggingFaceUrl(customMmprojUrl) { - mmprojError = "Enter a valid Hugging Face .gguf URL" - isValid = false - } - - if selectedModel.isCustom && !isValidSha256(customModelSha) { - shaError = "Enter the model's SHA-256 checksum" - isValid = false - } - - if selectedModel.isCustom && !customMmprojUrl.isEmpty && !isValidSha256(customMmprojSha) { - mmprojShaError = "Enter the mmproj's SHA-256 checksum" - isValid = false - } - if !contextLength.isEmpty, Int(contextLength) == nil { contextError = "Enter a valid integer" isValid = false @@ -432,42 +289,17 @@ struct ModelSettingsView: View { return isValid } - private func isValidSha256(_ value: String) -> Bool { - let trimmed = value.trimmingCharacters(in: .whitespaces) - return trimmed.count == 64 && trimmed.allSatisfy(\.isHexDigit) - } - - private func isValidHuggingFaceUrl(_ urlString: String) -> Bool { - guard let url = URL(string: urlString) else { return false } - guard url.host?.contains("huggingface.co") == true else { return false } - return url.path.lowercased().hasSuffix(".gguf") - } - private static let defaultOptionId = "default" - private static let customOptionId = "custom" } private struct ModelChoice: Identifiable { let id: String let name: String - let url: String? - let mmproj: String? let isDefault: Bool - let isCustom: Bool - - init( - id: String, - name: String, - url: String? = nil, - mmproj: String? = nil, - isDefault: Bool = false, - isCustom: Bool = false - ) { + + init(id: String, name: String, isDefault: Bool = false) { self.id = id self.name = name - self.url = url - self.mmproj = mmproj self.isDefault = isDefault - self.isCustom = isCustom } } diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift b/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift index 14f8cbf5209..ece2eda881c 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift @@ -3,7 +3,7 @@ import Foundation struct LlmModelTarget: Equatable { let id: String let url: String - let sha256: String? + let sha256: String let mmprojUrl: String? let mmprojSha256: String? let contextLength: Int? diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift index 7925dfad418..891d49d8fa6 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift @@ -20,22 +20,35 @@ final class ModelDownloader { transcriptionModelTarget = .tarGz( id: defaults.transcriptionModel.id, url: defaults.transcriptionModel.url, - sha256: defaults.transcriptionModel.sha256! + sha256: defaults.transcriptionModel.sha256 ) voiceActivityModelTarget = .file( id: defaults.voiceActivityModel.id, name: "model.onnx", url: defaults.voiceActivityModel.url, - sha256: defaults.voiceActivityModel.sha256! + sha256: defaults.voiceActivityModel.sha256 ) - migrateEnsuLegacyModels( + let settings = UserDefaults.standard + let pendingSelection = settings.object(forKey: "ensu.model.id") == nil + let legacyModelUrl = pendingSelection && settings.bool(forKey: "ensu.model.use_custom") + ? settings.string(forKey: "ensu.model.url") + : nil + let presetId = migrateEnsuLegacyModels( modelsDir: modelsDir.path, llmLegacyDir: baseDir.appendingPathComponent("llm", isDirectory: true).path, transcriptionLegacyDir: baseDir.appendingPathComponent("transcription", isDirectory: true).path, + legacyModelUrl: legacyModelUrl, + legacyMmprojUrl: settings.string(forKey: "ensu.model.mmproj"), llmTargets: Self.migrationTargets(), transcriptionModel: transcriptionModelTarget, voiceActivityModel: voiceActivityModelTarget ) + if pendingSelection { + settings.set(presetId ?? "", forKey: "ensu.model.id") + } + settings.removeObject(forKey: "ensu.model.use_custom") + settings.removeObject(forKey: "ensu.model.url") + settings.removeObject(forKey: "ensu.model.mmproj") var excludedDir = modelsDir var values = URLResourceValues() values.isExcludedFromBackup = true @@ -51,7 +64,7 @@ final class ModelDownloader { @MainActor private static func migrationTargets() -> [ModelDownloadTarget] { let config = ConfigDefaults.shared - var targets = ([config.mobileDefaultModel] + config.mobileModelPresets).map { + return ([config.mobileDefaultModel] + config.mobileModelPresets).map { ModelDownloadTarget.gguf( id: $0.id, url: $0.url, @@ -60,17 +73,6 @@ final class ModelDownloader { mmprojSha256: $0.mmprojSha256 ) } - let settings = ModelSettingsStore.shared - if settings.useCustomModel && !settings.modelUrl.isEmpty { - targets.append(.gguf( - id: "custom:\(settings.modelUrl)", - url: settings.modelUrl, - sha256: nil, - mmprojUrl: settings.mmprojUrl.isEmpty ? nil : settings.mmprojUrl, - mmprojSha256: nil - )) - } - return targets } func modelPath(target: ModelDownloadTarget) -> URL { diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift index a3e0e12da4d..e58004921fd 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift @@ -53,19 +53,7 @@ final class ModelSettingsStore: ObservableObject { static let shared = ModelSettingsStore() static let highRAMThresholdBytes: UInt64 = 16 * 1024 * 1024 * 1024 - @Published var useCustomModel: Bool { - didSet { persist() } - } - @Published var modelUrl: String { - didSet { persist() } - } - @Published var mmprojUrl: String { - didSet { persist() } - } - @Published var modelSha256: String { - didSet { persist() } - } - @Published var mmprojSha256: String { + @Published var modelId: String { didSet { persist() } } @Published var contextLength: String { @@ -84,80 +72,44 @@ final class ModelSettingsStore: ObservableObject { private let defaults = UserDefaults.standard private init() { - self.useCustomModel = defaults.bool(forKey: Keys.useCustomModel) - self.modelUrl = defaults.string(forKey: Keys.modelUrl) ?? "" - self.mmprojUrl = defaults.string(forKey: Keys.mmprojUrl) ?? "" - self.modelSha256 = defaults.string(forKey: Keys.modelSha256) ?? "" - self.mmprojSha256 = defaults.string(forKey: Keys.mmprojSha256) ?? "" + self.modelId = defaults.string(forKey: Keys.modelId) ?? "" self.contextLength = defaults.string(forKey: Keys.contextLength) ?? "" self.maxTokens = defaults.string(forKey: Keys.maxTokens) ?? "" self.temperature = defaults.string(forKey: Keys.temperature) ?? "" self.systemPromptBody = defaults.string(forKey: Keys.systemPromptBody) ?? "" } - func saveCustomModel( - url: String, - sha256: String, - mmproj: String, - mmprojSha256: String, - contextLength: String, - maxTokens: String, - temperature: String - ) { - useCustomModel = true - modelUrl = url - mmprojUrl = mmproj - self.modelSha256 = sha256 - self.mmprojSha256 = mmprojSha256 + func saveModel(id: String, contextLength: String, maxTokens: String, temperature: String) { + modelId = id self.contextLength = contextLength self.maxTokens = maxTokens self.temperature = temperature } func resetToDefault() { - useCustomModel = false - modelUrl = "" - mmprojUrl = "" - modelSha256 = "" - mmprojSha256 = "" + modelId = "" contextLength = "" maxTokens = "" temperature = "" } func currentTarget() -> LlmModelTarget { - let useCustom = useCustomModel && !modelUrl.isEmpty let defaults = ConfigDefaults.shared let defaultModel = Self.platformDefaultModel - let url = useCustom ? modelUrl : defaultModel.url - let mmproj = useCustom ? (mmprojUrl.isEmpty ? nil : mmprojUrl) : defaultModel.mmprojUrl - let context = Int(contextLength) - let maxOutput = Int(maxTokens).flatMap { $0 > 0 ? $0 : nil } - let preset: ConfigModelPreset? - if useCustom { - let presets = [defaultModel] + defaults.mobileModelPresets - preset = presets.first(where: { $0.url == url && $0.mmprojUrl == mmproj }) - } else { - preset = defaultModel - } - let trimmedSha = modelSha256.trimmingCharacters(in: .whitespaces) - let trimmedMmprojSha = mmprojSha256.trimmingCharacters(in: .whitespaces) + let presets = [defaultModel] + defaults.mobileModelPresets + let preset = presets.first(where: { $0.id == modelId }) ?? defaultModel return LlmModelTarget( - id: preset?.id ?? "custom:\(url)", - url: url, - sha256: preset?.sha256 ?? (trimmedSha.isEmpty ? nil : trimmedSha), - mmprojUrl: mmproj, - mmprojSha256: mmproj == nil - ? nil - : preset?.mmprojSha256 ?? (trimmedMmprojSha.isEmpty ? nil : trimmedMmprojSha), - contextLength: context, - maxTokens: maxOutput + id: preset.id, + url: preset.url, + sha256: preset.sha256, + mmprojUrl: preset.mmprojUrl, + mmprojSha256: preset.mmprojSha256, + contextLength: Int(contextLength), + maxTokens: Int(maxTokens).flatMap { $0 > 0 ? $0 : nil } ) } static var defaultModelName: String { platformDefaultModel.title } - static var defaultModelUrl: String { platformDefaultModel.url } - static var defaultMmprojUrl: String? { platformDefaultModel.mmprojUrl } static var defaultSystemPromptBody: String { platformSystemPromptBody } static func currentSystemPromptBody() -> String { @@ -179,11 +131,7 @@ final class ModelSettingsStore: ObservableObject { } private func persist() { - defaults.set(useCustomModel, forKey: Keys.useCustomModel) - defaults.set(modelUrl, forKey: Keys.modelUrl) - defaults.set(mmprojUrl, forKey: Keys.mmprojUrl) - defaults.set(modelSha256, forKey: Keys.modelSha256) - defaults.set(mmprojSha256, forKey: Keys.mmprojSha256) + defaults.set(modelId, forKey: Keys.modelId) defaults.set(contextLength, forKey: Keys.contextLength) defaults.set(maxTokens, forKey: Keys.maxTokens) defaults.set(temperature, forKey: Keys.temperature) @@ -191,11 +139,7 @@ final class ModelSettingsStore: ObservableObject { } fileprivate enum Keys { - static let useCustomModel = "ensu.model.use_custom" - static let modelUrl = "ensu.model.url" - static let mmprojUrl = "ensu.model.mmproj" - static let modelSha256 = "ensu.model.sha256" - static let mmprojSha256 = "ensu.model.mmproj_sha256" + static let modelId = "ensu.model.id" static let contextLength = "ensu.model.context" static let maxTokens = "ensu.model.max_tokens" static let temperature = "ensu.model.temperature" diff --git a/rust/apps/ensu/src-tauri/src/commands/llm.rs b/rust/apps/ensu/src-tauri/src/commands/llm.rs index 09b154a2761..b3bcd8095f3 100644 --- a/rust/apps/ensu/src-tauri/src/commands/llm.rs +++ b/rust/apps/ensu/src-tauri/src/commands/llm.rs @@ -38,7 +38,7 @@ impl ModelDownloadState { pub struct ModelTarget { id: String, url: String, - sha256: Option, + sha256: String, mmproj_url: Option, mmproj_sha256: Option, } @@ -274,11 +274,22 @@ pub fn llm_model_status( #[tauri::command] pub async fn llm_migrate_models( state: TauriState<'_, ModelDownloadState>, - custom_targets: Vec, -) -> Result<(), ApiError> { + legacy_model_url: Option, + legacy_mmproj_url: Option, +) -> Result, ApiError> { let models_dir = state.models_dir.clone(); let defaults = ente_ensu::config::defaults(); - let mut targets: Vec = std::iter::once(defaults.mobile_default_model) + let selected = legacy_model_url.as_deref().and_then(|model_url| { + ente_ensu::config::legacy_selected_preset_id( + defaults + .mobile_model_presets + .iter() + .chain(defaults.desktop_model_presets.iter()), + model_url, + legacy_mmproj_url.as_deref(), + ) + }); + let targets: Vec = std::iter::once(defaults.mobile_default_model) .chain(defaults.mobile_model_presets) .chain(std::iter::once(defaults.desktop_default_model)) .chain(defaults.desktop_model_presets) @@ -290,11 +301,11 @@ pub async fn llm_migrate_models( mmproj_sha256: preset.mmproj_sha256, }) .collect(); - targets.extend(custom_targets.into_iter().map(Into::into)); async_runtime::spawn_blocking(move || migrate_flat_models_dir(&models_dir, &targets)) .await - .map_err(|_| fs_thread_error()) + .map_err(|_| fs_thread_error())?; + Ok(selected) } #[tauri::command] diff --git a/rust/bindings/uniffi/ensu/src/config.rs b/rust/bindings/uniffi/ensu/src/config.rs index 038af5f720c..fa6ae09049a 100644 --- a/rust/bindings/uniffi/ensu/src/config.rs +++ b/rust/bindings/uniffi/ensu/src/config.rs @@ -5,7 +5,7 @@ pub struct ConfigModelPreset { pub id: String, pub title: String, pub url: String, - pub sha256: Option, + pub sha256: String, pub mmproj_url: Option, pub mmproj_sha256: Option, } diff --git a/rust/bindings/uniffi/ensu/src/model_downloader.rs b/rust/bindings/uniffi/ensu/src/model_downloader.rs index 532e7995285..07524c2a758 100644 --- a/rust/bindings/uniffi/ensu/src/model_downloader.rs +++ b/rust/bindings/uniffi/ensu/src/model_downloader.rs @@ -9,7 +9,7 @@ pub enum ModelDownloadTarget { Gguf { id: String, url: String, - sha256: Option, + sha256: String, mmproj_url: Option, mmproj_sha256: Option, }, @@ -87,10 +87,12 @@ pub fn migrate_ensu_legacy_models( models_dir: String, llm_legacy_dir: Option, transcription_legacy_dir: String, + legacy_model_url: Option, + legacy_mmproj_url: Option, llm_targets: Vec, transcription_model: ModelDownloadTarget, voice_activity_model: ModelDownloadTarget, -) { +) -> Option { let llm_targets: Vec = llm_targets.into_iter().map(Into::into).collect(); ente_model_download::migrate_ensu_legacy_models( @@ -101,6 +103,12 @@ pub fn migrate_ensu_legacy_models( &transcription_model.into(), &voice_activity_model.into(), ); + let defaults = ente_ensu::config::defaults(); + ente_ensu::config::legacy_selected_preset_id( + &defaults.mobile_model_presets, + legacy_model_url.as_deref()?, + legacy_mmproj_url.as_deref(), + ) } #[derive(uniffi::Object)] diff --git a/rust/crates/ensu/src/config.rs b/rust/crates/ensu/src/config.rs index ad3f277dad6..147bb65358b 100644 --- a/rust/crates/ensu/src/config.rs +++ b/rust/crates/ensu/src/config.rs @@ -3,7 +3,7 @@ pub struct ModelPreset { pub id: String, pub title: String, pub url: String, - pub sha256: Option, + pub sha256: String, pub mmproj_url: Option, pub mmproj_sha256: Option, } @@ -27,12 +27,27 @@ const MOBILE_SYSTEM_PROMPT_BODY: &str = "You are Ensu, an AI assistant built by const DESKTOP_SYSTEM_PROMPT_BODY: &str = MOBILE_SYSTEM_PROMPT_BODY; const SESSION_SUMMARY_SYSTEM_PROMPT: &str = "You create concise chat titles. Given the provided message, summarize the user's goal in 5-7 words. Use plain words. Don't use markdown characters in the title. No quotes, no emojis, no trailing punctuation, and output only the title."; +pub fn legacy_selected_preset_id<'a>( + presets: impl IntoIterator, + model_url: &str, + mmproj_url: Option<&str>, +) -> Option { + fn trim(url: Option<&str>) -> Option<&str> { + url.map(str::trim).filter(|url| !url.is_empty()) + } + let mmproj_url = trim(mmproj_url); + presets + .into_iter() + .find(|preset| preset.url == model_url && trim(preset.mmproj_url.as_deref()) == mmproj_url) + .map(|preset| preset.id.clone()) +} + fn lfm_vl_1_6b() -> ModelPreset { ModelPreset { id: "lfm-vl-1.6b".to_string(), title: "LFM 2.5 VL 1.6B (Q4_0)".to_string(), url: "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/LFM2.5-VL-1.6B-Q4_0.gguf?download=true".to_string(), - sha256: Some("8186364a4e7c3ad30f6dd3d3b7a4e0074c77dd91eed6cad5d8be9090ce285804".to_string()), + sha256: "8186364a4e7c3ad30f6dd3d3b7a4e0074c77dd91eed6cad5d8be9090ce285804".to_string(), mmproj_url: Some( "https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF/resolve/main/mmproj-LFM2.5-VL-1.6b-Q8_0.gguf" .to_string(), @@ -41,23 +56,12 @@ fn lfm_vl_1_6b() -> ModelPreset { } } -fn lfm_1_2b() -> ModelPreset { - ModelPreset { - id: "lfm-1.2b".to_string(), - title: "LFM 2.5 1.2B Instruct (Q4_0)".to_string(), - url: "https://huggingface.co/LiquidAI/LFM2.5-1.2B-GGUF/resolve/main/LFM2.5-1.2B-Q4_0.gguf?download=true".to_string(), - sha256: None, - mmproj_url: None, - mmproj_sha256: None, - } -} - fn qwen_0_8b() -> ModelPreset { ModelPreset { id: "qwen-0.8b".to_string(), title: "Qwen 3.5 0.8B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/Qwen3.5-0.8B-Q4_K_M.gguf?download=true".to_string(), - sha256: Some("bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517".to_string()), + sha256: "bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517".to_string(), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), @@ -71,7 +75,7 @@ fn qwen_2b_q8() -> ModelPreset { id: "qwen-2b-q8".to_string(), title: "Qwen 3.5 2B (Q8_0)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q8_0.gguf?download=true".to_string(), - sha256: Some("1b04acba824817554f4ce23639bc8495ff70453b8fcb047900c731521021f2c1".to_string()), + sha256: "1b04acba824817554f4ce23639bc8495ff70453b8fcb047900c731521021f2c1".to_string(), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), @@ -85,7 +89,7 @@ fn qwen_4b_q4km() -> ModelPreset { id: "qwen-4b-q4km".to_string(), title: "Qwen 3.5 4B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf?download=true".to_string(), - sha256: Some("00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4".to_string()), + sha256: "00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4".to_string(), mmproj_url: Some( "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), @@ -99,7 +103,7 @@ fn gemma_4_e4b_q4km() -> ModelPreset { id: "gemma-4-e4b-q4km".to_string(), title: "Gemma 4 E4B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf?download=true".to_string(), - sha256: Some("519b9793ed6ce0ff530f1b7c96e848e08e49e7af4d57bb97f76215963a54146d".to_string()), + sha256: "519b9793ed6ce0ff530f1b7c96e848e08e49e7af4d57bb97f76215963a54146d".to_string(), mmproj_url: Some( "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), @@ -113,7 +117,7 @@ fn gemma_4_e2b_q4km() -> ModelPreset { id: "gemma-4-e2b-q4km".to_string(), title: "Gemma 4 E2B (Q4_K_M)".to_string(), url: "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M.gguf?download=true".to_string(), - sha256: Some("9378bc471710229ef165709b62e34bfb62231420ddaf6d729e727305b5b8672d".to_string()), + sha256: "9378bc471710229ef165709b62e34bfb62231420ddaf6d729e727305b5b8672d".to_string(), mmproj_url: Some( "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/mmproj-F16.gguf" .to_string(), @@ -127,9 +131,7 @@ fn parakeet_v3_int8() -> ModelPreset { id: "parakeet-v3-int8".to_string(), title: "Transcription model".to_string(), url: "https://models.ente.io/parakeet-v3-int8.tar.gz".to_string(), - sha256: Some( - "43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(), - ), + sha256: "43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(), mmproj_url: None, mmproj_sha256: None, } @@ -140,9 +142,7 @@ fn silero_vad_v4() -> ModelPreset { id: "silero-vad-v4".to_string(), title: "Voice activity model".to_string(), url: "https://models.ente.io/silero_vad_v4.onnx".to_string(), - sha256: Some( - "a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28".to_string(), - ), + sha256: "a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28".to_string(), mmproj_url: None, mmproj_sha256: None, } @@ -158,15 +158,9 @@ pub fn defaults() -> Defaults { system_prompt_date_placeholder: SYSTEM_PROMPT_DATE_PLACEHOLDER.to_string(), session_summary_system_prompt: SESSION_SUMMARY_SYSTEM_PROMPT.to_string(), mobile_default_model, - mobile_model_presets: vec![lfm_1_2b(), qwen_0_8b(), qwen_2b_q8(), gemma_4_e2b_q4km()], + mobile_model_presets: vec![qwen_0_8b(), qwen_2b_q8(), gemma_4_e2b_q4km()], desktop_default_model, - desktop_model_presets: vec![ - qwen_4b_q4km(), - lfm_vl_1_6b(), - lfm_1_2b(), - qwen_0_8b(), - qwen_2b_q8(), - ], + desktop_model_presets: vec![qwen_4b_q4km(), lfm_vl_1_6b(), qwen_0_8b(), qwen_2b_q8()], transcription_model: parakeet_v3_int8(), voice_activity_model: silero_vad_v4(), } @@ -200,4 +194,24 @@ mod tests { } } } + + #[test] + fn preset_artifacts_resolve_unambiguously() { + let defaults = defaults(); + let mut seen: HashMap<(&str, Option<&str>), &str> = HashMap::new(); + for preset in defaults + .mobile_model_presets + .iter() + .chain(defaults.desktop_model_presets.iter()) + { + let artifact = (preset.url.as_str(), preset.mmproj_url.as_deref()); + if let Some(existing) = seen.insert(artifact, preset.id.as_str()) { + assert_eq!( + existing, preset.id, + "presets {existing} and {} share an artifact", + preset.id + ); + } + } + } } diff --git a/rust/crates/model-download/src/downloader.rs b/rust/crates/model-download/src/downloader.rs index 6e18c53b0c8..9f7f3510b33 100644 --- a/rust/crates/model-download/src/downloader.rs +++ b/rust/crates/model-download/src/downloader.rs @@ -16,7 +16,7 @@ pub enum ModelDownloadTarget { Gguf { id: String, url: String, - sha256: Option, + sha256: String, mmproj_url: Option, mmproj_sha256: Option, }, @@ -81,12 +81,8 @@ impl ModelDownloader { pub fn mmproj_path(&self, target: &ModelDownloadTarget) -> Option { match target { - ModelDownloadTarget::Gguf { - id, - url, - mmproj_url, - .. - } => gguf::mmproj_path(&self.models_dir, id, url, mmproj_url.as_deref()), + ModelDownloadTarget::Gguf { mmproj_url, .. } => gguf::trimmed(mmproj_url.as_deref()) + .map(|_| storage_dir(&self.models_dir, target).join("mmproj.gguf")), _ => None, } } @@ -119,7 +115,7 @@ impl ModelDownloader { } pub fn remove_downloaded(&self, target: &ModelDownloadTarget) -> bool { - if checked_file_name(&storage_key(target)).is_err() { + if checked_file_name(storage_key(target)).is_err() { return false; } let dir = storage_dir(&self.models_dir, target); @@ -228,15 +224,11 @@ impl ModelDownloader { } } -fn storage_key(target: &ModelDownloadTarget) -> String { +fn storage_key(target: &ModelDownloadTarget) -> &str { match target { - ModelDownloadTarget::Gguf { - id, - url, - mmproj_url, - .. - } => gguf::storage_key(id, url, mmproj_url.as_deref()), - ModelDownloadTarget::TarGz { id, .. } | ModelDownloadTarget::Files { id, .. } => id.clone(), + ModelDownloadTarget::Gguf { id, .. } + | ModelDownloadTarget::TarGz { id, .. } + | ModelDownloadTarget::Files { id, .. } => id, } } @@ -274,20 +266,19 @@ fn download_targets( mmproj_url, mmproj_sha256, } => { - checked_file_name(&storage_key(target))?; - let sha256 = sha256 - .as_deref() - .ok_or_else(|| checksum_required(id, url))?; + checked_file_name(id)?; let mut targets = vec![Target { label: "Model".to_string(), url: url.clone(), destination_path: dir.join("model.gguf").display().to_string(), - sha256: sha256.to_string(), + sha256: sha256.clone(), }]; if let Some(mmproj_url) = gguf::trimmed(mmproj_url.as_deref()) { - let mmproj_sha256 = mmproj_sha256 - .as_deref() - .ok_or_else(|| checksum_required(id, mmproj_url))?; + let mmproj_sha256 = gguf::trimmed(mmproj_sha256.as_deref()).ok_or_else(|| { + download::Error::InvalidTarget(format!( + "{id} has a mmproj URL without a mmproj checksum" + )) + })?; targets.push(Target { label: "Mmproj".to_string(), url: mmproj_url.to_string(), @@ -316,7 +307,6 @@ fn download_targets( } ModelDownloadTarget::Files { id, files } => { checked_file_name(id)?; - checked_file_name(&storage_key(target))?; if files.is_empty() { return Err(download::Error::InvalidTarget(format!("{id} has no files"))); } @@ -343,10 +333,6 @@ fn download_targets( } } -fn checksum_required(id: &str, url: &str) -> download::Error { - download::Error::Validation(format!("{id} needs a checksum to download {url}")) -} - fn checked_file_name(name: &str) -> Result<(), download::Error> { if !name.is_empty() && name != "." @@ -448,9 +434,7 @@ fn migrate_legacy_dir(models_dir: &Path, legacy_dir: &Path, targets: &[ModelDown if !legacy_dir.exists() { return; } - let legacy_models = legacy_dir.join("models"); - let hashed_dirs = [legacy_models.join("custom")]; - if adopt_targets(models_dir, targets, &hashed_dirs, &legacy_models) { + if adopt_targets(models_dir, targets, &[], &legacy_dir.join("models")) { let _ = fs::remove_dir_all(legacy_dir); } } @@ -459,7 +443,7 @@ pub fn migrate_flat_models_dir(models_dir: &Path, targets: &[ModelDownloadTarget if !models_dir.exists() { return; } - let hashed_dirs = [models_dir.join("custom"), models_dir.to_path_buf()]; + let hashed_dirs = [models_dir.to_path_buf()]; if !adopt_targets(models_dir, targets, &hashed_dirs, models_dir) { return; } @@ -557,37 +541,36 @@ fn adopt_targets( let mut plans: Vec> = Vec::new(); for target in targets { let ModelDownloadTarget::Gguf { - id, - url, - mmproj_url, - .. + url, mmproj_url, .. } = target else { continue; }; - let mmproj_url = mmproj_url.as_deref(); - let mut plan = vec![( - url.clone(), - "model.gguf", - gguf::model_path(models_dir, id, url, mmproj_url), - )]; - if let Some(dest) = gguf::mmproj_path(models_dir, id, url, mmproj_url) { - plan.push(( - gguf::trimmed(mmproj_url).unwrap().to_string(), - "mmproj.gguf", - dest, - )); + let dir = storage_dir(models_dir, target); + let mut plan = vec![(url.clone(), "model.gguf", dir.join("model.gguf"))]; + if let Some(mmproj) = gguf::trimmed(mmproj_url.as_deref()) { + plan.push((mmproj.to_string(), "mmproj.gguf", dir.join("mmproj.gguf"))); } plans.push(plan); } let mut basename_urls: HashMap> = HashMap::new(); - for plan in &plans { - for (url, fallback, _) in plan { + for target in targets { + let ModelDownloadTarget::Gguf { + url, mmproj_url, .. + } = target + else { + continue; + }; + let files = [ + Some((url.as_str(), "model.gguf")), + gguf::trimmed(mmproj_url.as_deref()).map(|url| (url, "mmproj.gguf")), + ]; + for (url, fallback) in files.into_iter().flatten() { basename_urls .entry(gguf::filename_for_url(url, fallback)) .or_default() - .insert(url.as_str()); + .insert(url); } } @@ -681,19 +664,26 @@ mod tests { ModelDownloadTarget::Gguf { id: id.to_string(), url: "https://example.org/models/main.gguf?download=true".to_string(), - sha256: Some(test_sha()), + sha256: test_sha(), mmproj_url: Some("https://example.org/models/mmproj.gguf".to_string()), mmproj_sha256: Some(test_sha()), } } + fn target_id(target: &ModelDownloadTarget) -> Option<&str> { + match target { + ModelDownloadTarget::Gguf { id, .. } => Some(id), + _ => None, + } + } + fn write_gguf(path: &Path, content: &[u8]) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, content).unwrap(); } #[test] - fn paths_are_keyed_by_preset_id_or_custom_pair_hash() { + fn paths_are_keyed_by_preset_id() { let dir = scratch_dir("paths"); let downloader = ModelDownloader::new(&dir); @@ -706,31 +696,6 @@ mod tests { Some(dir.join("qwen-2b-q8/mmproj.gguf")) ); - let custom = downloader.model_path(&target("custom:1")); - let key = custom - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy() - .into_owned(); - assert!(key.starts_with("custom-")); - assert_eq!(key.len(), "custom-".len() + 16); - assert_eq!(custom.file_name().unwrap(), "model.gguf"); - assert_eq!( - downloader.mmproj_path(&target("custom:1")), - Some(custom.with_file_name("mmproj.gguf")) - ); - - let other_mmproj = ModelDownloadTarget::Gguf { - id: "custom:1".to_string(), - url: "https://example.org/models/main.gguf?download=true".to_string(), - sha256: Some(test_sha()), - mmproj_url: Some("https://example.org/models/other.gguf".to_string()), - mmproj_sha256: Some(test_sha()), - }; - assert_ne!(downloader.model_path(&other_mmproj), custom); - let _ = fs::remove_dir_all(dir); } @@ -978,34 +943,17 @@ mod tests { let base = scratch_dir("migrate"); let legacy = base.join("llm"); let legacy_models = legacy.join("models"); - fs::create_dir_all(legacy_models.join("custom")).unwrap(); + fs::create_dir_all(&legacy_models).unwrap(); fs::write(legacy_models.join("main.gguf"), b"GGUFmain").unwrap(); fs::write(legacy_models.join("mmproj.gguf"), b"GGUFmmproj").unwrap(); - - let custom_url = "https://example.org/custom/other-main.gguf"; - let custom = ModelDownloadTarget::Gguf { - id: "custom:1".to_string(), - url: custom_url.to_string(), - sha256: Some(test_sha()), - mmproj_url: None, - mmproj_sha256: Some(test_sha()), - }; - fs::write( - legacy_models - .join("custom") - .join(format!("{}_other-main.gguf", gguf::sha256_hex(custom_url))), - b"GGUFcustom", - ) - .unwrap(); - fs::write(legacy_models.join("main.gguf.tmp"), b"GGUFpartial").unwrap(); fs::write(legacy_models.join("main.gguf.metadata.json"), b"{}").unwrap(); fs::write(legacy_models.join("orphan.gguf"), b"GGUForphan").unwrap(); let models_dir = base.join("models"); let preset = target("qwen-2b-q8"); - migrate_legacy_dir(&models_dir, &legacy, &[preset.clone(), custom.clone()]); + migrate_legacy_dir(&models_dir, &legacy, &[preset.clone()]); let downloader = ModelDownloader::new(&models_dir); assert_eq!( @@ -1016,11 +964,7 @@ mod tests { fs::read(downloader.mmproj_path(&preset).unwrap()).unwrap(), b"GGUFmmproj" ); - assert_eq!( - fs::read(downloader.model_path(&custom)).unwrap(), - b"GGUFcustom" - ); - assert_eq!(fs::read_dir(&models_dir).unwrap().count(), 2); + assert_eq!(fs::read_dir(&models_dir).unwrap().count(), 1); assert!(!legacy.exists()); let _ = fs::remove_dir_all(base); @@ -1039,7 +983,7 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), - sha256: Some(test_sha()), + sha256: test_sha(), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), mmproj_sha256: Some(test_sha()), }); @@ -1066,22 +1010,6 @@ mod tests { ); fs::write(models_dir.join(mmproj_hashed), b"GGUFmm").unwrap(); - let custom_url = "https://example.org/custom/other-main.gguf"; - let custom = ModelDownloadTarget::Gguf { - id: "custom:1".to_string(), - url: custom_url.to_string(), - sha256: Some(test_sha()), - mmproj_url: None, - mmproj_sha256: Some(test_sha()), - }; - fs::write( - models_dir - .join("custom") - .join(format!("{}_other-main.gguf", gguf::sha256_hex(custom_url))), - b"GGUFcustom", - ) - .unwrap(); - fs::write(models_dir.join("orphan.gguf"), b"GGUForphan").unwrap(); fs::write(models_dir.join("main.gguf.tmp"), b"GGUFpartial").unwrap(); let downloader = ModelDownloader::new(&models_dir); @@ -1090,7 +1018,7 @@ mod tests { b"GGUFkeep", ); - migrate_flat_models_dir(&models_dir, &[preset.clone(), custom.clone()]); + migrate_flat_models_dir(&models_dir, &[preset.clone()]); assert_eq!( fs::read(downloader.model_path(&preset)).unwrap(), @@ -1100,10 +1028,6 @@ mod tests { fs::read(downloader.mmproj_path(&preset).unwrap()).unwrap(), b"GGUFmm" ); - assert_eq!( - fs::read(downloader.model_path(&custom)).unwrap(), - b"GGUFcustom" - ); assert_eq!( fs::read(models_dir.join("other-key/model.gguf")).unwrap(), b"GGUFkeep" @@ -1116,7 +1040,7 @@ mod tests { .count(); assert_eq!(root_files, 0); - migrate_flat_models_dir(&models_dir, &[preset.clone(), custom]); + migrate_flat_models_dir(&models_dir, &[preset.clone()]); assert_eq!( fs::read(downloader.model_path(&preset)).unwrap(), b"GGUFmain" @@ -1156,7 +1080,7 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), - sha256: Some(test_sha()), + sha256: test_sha(), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), mmproj_sha256: Some(test_sha()), }); @@ -1221,7 +1145,7 @@ mod tests { let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { id: format!("preset-{name}"), url: format!("https://example.org/{name}/{name}.gguf"), - sha256: Some(test_sha()), + sha256: test_sha(), mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), mmproj_sha256: Some(test_sha()), }); diff --git a/rust/crates/model-download/src/gguf.rs b/rust/crates/model-download/src/gguf.rs index d8b21c151aa..9a3a9a0ea3b 100644 --- a/rust/crates/model-download/src/gguf.rs +++ b/rust/crates/model-download/src/gguf.rs @@ -1,46 +1,9 @@ use std::fs::File; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; use sha2::{Digest, Sha256}; -pub(crate) fn storage_key(id: &str, url: &str, mmproj_url: Option<&str>) -> String { - if id.starts_with("custom") { - let pair = format!("{url}\n{}", trimmed(mmproj_url).unwrap_or("")); - format!("custom-{}", &sha256_hex(&pair)[..16]) - } else { - id.to_string() - } -} - -pub(crate) fn model_dir( - models_dir: &Path, - id: &str, - url: &str, - mmproj_url: Option<&str>, -) -> PathBuf { - models_dir.join(storage_key(id, url, mmproj_url)) -} - -pub(crate) fn model_path( - models_dir: &Path, - id: &str, - url: &str, - mmproj_url: Option<&str>, -) -> PathBuf { - model_dir(models_dir, id, url, mmproj_url).join("model.gguf") -} - -pub(crate) fn mmproj_path( - models_dir: &Path, - id: &str, - url: &str, - mmproj_url: Option<&str>, -) -> Option { - trimmed(mmproj_url)?; - Some(model_dir(models_dir, id, url, mmproj_url).join("mmproj.gguf")) -} - pub(crate) fn looks_like_gguf(path: &Path) -> bool { let mut file = match File::open(path) { Ok(file) => file, diff --git a/web/apps/ensu/src/components/chat/ChatDialogs.tsx b/web/apps/ensu/src/components/chat/ChatDialogs.tsx index 69d46c1e3f7..fb98c5fafdf 100644 --- a/web/apps/ensu/src/components/chat/ChatDialogs.tsx +++ b/web/apps/ensu/src/components/chat/ChatDialogs.tsx @@ -38,9 +38,8 @@ interface IconProps { } interface SuggestedModel { + id: string; name: string; - url: string; - mmproj?: string; } type ModelGateStatus = @@ -54,11 +53,7 @@ type ModelGateStatus = type SxEntry = Exclude, readonly unknown[]>; export interface ModelSettingsDraft { - useCustomModel: boolean; - modelUrl: string; - mmprojUrl: string; - modelSha256: string; - mmprojSha256: string; + modelId: string; contextLength: string; maxTokens: string; } @@ -86,17 +81,10 @@ export interface ChatDialogsProps { handleConfirmDeleteSession: () => void | Promise; showModelSettings: boolean; closeModelSettings: () => void; - useCustomModel: boolean; + selectedModelId: string; defaultModelName: string; - defaultModelUrl: string; - defaultModelMmproj?: string; loadedModelName: string | null; - allowMmproj: boolean; isTauriRuntime: boolean; - modelUrl: string; - mmprojUrl: string; - modelSha256: string; - mmprojSha256: string; suggestedModels: SuggestedModel[]; contextLength: string; maxTokens: string; @@ -140,17 +128,10 @@ export const ChatDialogs = memo( handleConfirmDeleteSession, showModelSettings, closeModelSettings, - useCustomModel, + selectedModelId, defaultModelName, - defaultModelUrl, - defaultModelMmproj, loadedModelName, - allowMmproj, isTauriRuntime, - modelUrl, - mmprojUrl, - modelSha256, - mmprojSha256, suggestedModels, contextLength, maxTokens, @@ -190,26 +171,8 @@ export const ChatDialogs = memo( }; // --- Model settings draft state --- - const [draftUseCustomModel, setDraftUseCustomModel] = - React.useState(false); - const [draftModelUrl, setDraftModelUrl] = React.useState(""); - const [draftMmprojUrl, setDraftMmprojUrl] = React.useState(""); - const [draftModelSha, setDraftModelSha] = React.useState(""); - const [draftMmprojSha, setDraftMmprojSha] = React.useState(""); - const [draftModelShaError, setDraftModelShaError] = React.useState< - string | null - >(null); - const [draftMmprojShaError, setDraftMmprojShaError] = React.useState< - string | null - >(null); const [draftContextLength, setDraftContextLength] = React.useState(""); const [draftMaxTokens, setDraftMaxTokens] = React.useState(""); - const [draftModelUrlError, setDraftModelUrlError] = React.useState< - string | null - >(null); - const [draftMmprojError, setDraftMmprojError] = React.useState< - string | null - >(null); const [draftContextError, setDraftContextError] = React.useState< string | null >(null); @@ -218,7 +181,7 @@ export const ChatDialogs = memo( >(null); const [showAdvancedLimits, setShowAdvancedLimits] = React.useState(false); - const [selectedModelId, setSelectedModelId] = React.useState("default"); + const [draftModelId, setDraftModelId] = React.useState("default"); const [showBackupComingSoon, setShowBackupComingSoon] = React.useState(false); @@ -227,72 +190,31 @@ export const ChatDialogs = memo( const modelOptions = React.useMemo( () => [ - { - id: "default", - name: `${defaultModelName} (Default)`, - url: defaultModelUrl, - mmproj: allowMmproj - ? (defaultModelMmproj ?? undefined) - : "", - }, - ...suggestedModels - .filter((model) => model.url !== defaultModelUrl) - .map((model) => ({ - id: model.url, - name: model.name, - url: model.url, - mmproj: model.mmproj, - })), - { id: "custom", name: "Custom", url: "", mmproj: "" }, - ], - [ - allowMmproj, - defaultModelMmproj, - defaultModelName, - defaultModelUrl, - suggestedModels, + { id: "default", name: `${defaultModelName} (Default)` }, + ...suggestedModels, ], + [defaultModelName, suggestedModels], ); - const isCustomSelected = selectedModelId === "custom"; - const canSaveModelSettings = - !isCustomSelected || - (draftModelUrl.trim().length > 0 && - draftModelSha.trim().length > 0 && - (!draftMmprojUrl.trim() || draftMmprojSha.trim().length > 0)); // Initialize model settings draft from parent state when dialog opens React.useEffect(() => { if (!showModelSettings) return; - setDraftUseCustomModel(useCustomModel); - setDraftModelUrl(modelUrl); - setDraftMmprojUrl(mmprojUrl); - setDraftModelSha(modelSha256); - setDraftMmprojSha(mmprojSha256); + setDraftModelId( + modelOptions.some((model) => model.id === selectedModelId) + ? selectedModelId + : "default", + ); setDraftContextLength(contextLength); setDraftMaxTokens(maxTokens); - setDraftModelUrlError(null); - setDraftMmprojError(null); - setDraftModelShaError(null); - setDraftMmprojShaError(null); setDraftContextError(null); setDraftMaxTokensError(null); - const matchedOption = useCustomModel - ? modelOptions.find((model) => model.url === modelUrl) - : undefined; - setSelectedModelId( - !useCustomModel ? "default" : (matchedOption?.id ?? "custom"), - ); setShowAdvancedLimits(!!contextLength || !!maxTokens); }, [ contextLength, maxTokens, - mmprojUrl, modelOptions, - modelUrl, - modelSha256, - mmprojSha256, + selectedModelId, showModelSettings, - useCustomModel, ]); // Initialize system prompt draft from parent state when dialog opens @@ -302,52 +224,6 @@ export const ChatDialogs = memo( }, [showSystemPromptSettings]); // eslint-disable-line react-hooks/exhaustive-deps const validateModelSettings = React.useCallback(() => { - const validateUrl = (value: string) => { - if (!value) return undefined; - try { - const url = new URL(value); - if ( - url.hostname !== "huggingface.co" && - !url.hostname.endsWith(".huggingface.co") - ) { - return "URL must be a huggingface.co link"; - } - if (url.pathname.includes("/blob/")) { - return "Use a direct file URL, not a /blob/ page"; - } - if (!url.pathname.endsWith(".gguf")) { - return "URL must end with .gguf"; - } - return undefined; - } catch { - return "Enter a valid URL"; - } - }; - - const modelError = draftUseCustomModel - ? draftModelUrl - ? validateUrl(draftModelUrl) - : "Required" - : undefined; - const mmprojErr = - draftUseCustomModel && isTauriRuntime - ? validateUrl(draftMmprojUrl) - : undefined; - - const validateSha = (value: string) => - /^[0-9a-fA-F]{64}$/.test(value.trim()) - ? undefined - : "Enter the file's SHA-256 checksum"; - const modelShaError = isCustomSelected - ? validateSha(draftModelSha) - : undefined; - const mmprojShaErr = - isCustomSelected && - isTauriRuntime && - draftMmprojUrl.trim().length > 0 - ? validateSha(draftMmprojSha) - : undefined; - const contextErrorValue = draftContextLength && !/^\d+$/.test(draftContextLength) ? "Enter a number" @@ -369,35 +245,17 @@ export const ChatDialogs = memo( ? "Must be <= context length" : undefined; - setDraftModelUrlError(modelError ?? null); - setDraftMmprojError(mmprojErr ?? null); - setDraftModelShaError(modelShaError ?? null); - setDraftMmprojShaError(mmprojShaErr ?? null); setDraftContextError(contextErrorValue ?? null); setDraftMaxTokensError( maxTokensErrorValue ?? maxTokensLimitError ?? null, ); return !( - modelError || - mmprojErr || - modelShaError || - mmprojShaErr || contextErrorValue || maxTokensErrorValue || maxTokensLimitError ); - }, [ - draftContextLength, - draftMaxTokens, - draftMmprojUrl, - draftModelUrl, - draftModelSha, - draftMmprojSha, - draftUseCustomModel, - isCustomSelected, - isTauriRuntime, - ]); + }, [draftContextLength, draftMaxTokens]); return ( <> @@ -827,43 +685,14 @@ export const ChatDialogs = memo( select fullWidth label="Model" - value={selectedModelId} - onChange={(event) => { - const nextId = event.target.value; - const nextModel = modelOptions.find( - (model) => model.id === nextId, - ); - setSelectedModelId(nextId); - if (!nextModel) return; - if (nextId === "default") { - setDraftUseCustomModel(false); - setDraftModelUrl(""); - setDraftMmprojUrl(""); - setDraftModelSha(""); - setDraftMmprojSha(""); - return; - } - setDraftUseCustomModel(true); - if (nextId === "custom") { - setDraftModelUrl(""); - setDraftMmprojUrl(""); - setDraftModelSha(""); - setDraftMmprojSha(""); - return; - } - setDraftModelUrl(nextModel.url); - setDraftModelSha(""); - setDraftMmprojSha(""); - setDraftMmprojUrl( - allowMmproj - ? (nextModel.mmproj ?? "") - : "", - ); - }} + value={draftModelId} + onChange={(event) => + setDraftModelId(event.target.value) + } helperText={ loadedModelName ? `Loaded: ${loadedModelName}` - : "Custom reveals direct Hugging Face URLs." + : " " } > {modelOptions.map((model) => ( @@ -877,80 +706,6 @@ export const ChatDialogs = memo( - {isCustomSelected && ( - - - setDraftModelUrl( - event.target.value, - ) - } - error={!!draftModelUrlError} - helperText={ - draftModelUrlError ?? " " - } - /> - - setDraftModelSha( - event.target.value, - ) - } - error={!!draftModelShaError} - helperText={ - draftModelShaError ?? " " - } - /> - {allowMmproj && ( - - setDraftMmprojUrl( - event.target.value, - ) - } - error={!!draftMmprojError} - helperText={ - draftMmprojError ?? " " - } - /> - )} - {allowMmproj && - draftMmprojUrl.trim().length > - 0 && ( - - setDraftMmprojSha( - event.target.value, - ) - } - error={ - !!draftMmprojShaError - } - helperText={ - draftMmprojShaError ?? - " " - } - /> - )} - - )} -