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 diff --git a/mobile/apps/auth/assets/custom-icons/icons/capcom.svg b/mobile/apps/auth/assets/custom-icons/icons/capcom.svg index 4f12bb4e01f..37743186d94 100644 --- a/mobile/apps/auth/assets/custom-icons/icons/capcom.svg +++ b/mobile/apps/auth/assets/custom-icons/icons/capcom.svg @@ -1,71 +1,5 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - + + + + diff --git a/mobile/apps/auth/assets/custom-icons/icons/warner_bros.svg b/mobile/apps/auth/assets/custom-icons/icons/warner_bros.svg index 29cca93ca95..9ffc7d958c2 100644 --- a/mobile/apps/auth/assets/custom-icons/icons/warner_bros.svg +++ b/mobile/apps/auth/assets/custom-icons/icons/warner_bros.svg @@ -1,33 +1,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + 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/lib/db/social_db.dart b/mobile/apps/photos/lib/db/social_db.dart index 28ff6085e82..7ae5d80ced7 100644 --- a/mobile/apps/photos/lib/db/social_db.dart +++ b/mobile/apps/photos/lib/db/social_db.dart @@ -153,12 +153,48 @@ class SocialDB { return rows.map(_rowToComment).toList(); } - Future getCommentCountForFile(int fileID) async { + /// 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 candidateCollectionIDs, + }) async { + if (candidateCollectionIDs.isEmpty) { + return null; + } + + 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, ...candidateCollectionIDs], + orderBy: 'created_at DESC', + limit: 1, + ); + return rows.isEmpty ? null : _rowToComment(rows.first); + } + + 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; } @@ -269,6 +305,30 @@ class SocialDB { return rows.map(_rowToReaction).toList(); } + 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, ...collectionIDs], + ); + return rows.isNotEmpty; + } + Future> getReactionsForFileInCollection( int fileID, int collectionID, diff --git a/mobile/apps/photos/lib/l10n/intl_en.arb b/mobile/apps/photos/lib/l10n/intl_en.arb index d3699425304..25de9bdb5af 100644 --- a/mobile/apps/photos/lib/l10n/intl_en.arb +++ b/mobile/apps/photos/lib/l10n/intl_en.arb @@ -344,7 +344,7 @@ "theyAlsoGetXGb": "They also get {storageAmountInGB} GB", "referralStorageForBoth": "{storageAmountInGB} GB for them + {storageAmountInGB} GB for you when they upgrade.", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB each time someone signs up for a paid plan and applies your code", - "shareTextReferralCode": "Ente Photos is a private and safe home for your photos\n* Download Ente Photos from https://ente.com/app\n* Create an account\n* Apply the referral code {referralCode} in Settings->Referral\n* Get {referralStorageInGB}GB free when you upgrade to a paid plan", + "shareTextReferralCode": "Ente Photos is a private and safe home for your photos\n* Download Ente Photos from https://ente.com/app\n* Create an account\n* Apply the referral code {referralCode} in Settings->Referral\n* Get extra {referralStorageInGB} GB free!", "claimFreeStorage": "Claim free storage", "inviteYourFriends": "Invite your friends", "earnFreeStorage": "Earn free storage", 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..dd2c41871d1 100644 --- a/mobile/apps/photos/lib/models/social/social_data_provider.dart +++ b/mobile/apps/photos/lib/models/social/social_data_provider.dart @@ -24,8 +24,23 @@ class SocialDataProvider { return _db.getCommentsForFile(fileID); } - Future getCommentCountForFile(int fileID) { - return _db.getCommentCountForFile(fileID); + /// [candidateCollectionIDs] narrows the indexed lookup to collections the + /// caller already knows are eligible. + Future getLatestCommentForFile( + int fileID, { + required List candidateCollectionIDs, + }) { + return _db.getLatestCommentForFile( + fileID, + candidateCollectionIDs: candidateCollectionIDs, + ); + } + + Future getCommentCountForFileInCollections( + int fileID, + List collectionIDs, + ) { + return _db.getCommentCountForFileInCollections(fileID, collectionIDs); } Future getCommentCountForFileInCollection(int fileID, int collectionID) { @@ -36,6 +51,14 @@ class SocialDataProvider { return _db.getReactionsForFile(fileID); } + Future hasUserReactedToFileInCollections( + int fileID, + int userID, + List collectionIDs, + ) { + return _db.hasUserReactedToFileInCollections(fileID, userID, collectionIDs); + } + 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 9da6a0f4117..4ad53d6be7a 100644 --- a/mobile/apps/photos/lib/services/collections_service.dart +++ b/mobile/apps/photos/lib/services/collections_service.dart @@ -295,48 +295,76 @@ class CollectionsService { return false; } - /// Returns the count of shared collections containing this file. - /// Uses early exit optimization - stops counting at 2. + /// Returns the shared collections containing [uploadedFileID]. /// - /// 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 { + /// A collection is considered shared if it has sharees, has a public link, + /// or is owned by someone else (incoming share). + Future> getSharedCollectionsForFile( + int uploadedFileID, { + bool includeHidden = false, + }) async { final Set collectionIDs = await _filesDB.getAllCollectionIDsOfFile( uploadedFileID, ); - - if (collectionIDs.isEmpty) { - return 0; - } - final int? currentUserID = _config.getUserID(); - if (currentUserID == null) { - return 0; + if (collectionIDs.isEmpty || currentUserID == null) { + return []; } - int sharedCount = 0; - for (final int collectionID in collectionIDs) { - final Collection? collection = _collectionIDToCollections[collectionID]; - - if (collection == null || collection.isDeleted) { - continue; + final sharedCollections = []; + for (final collectionID in collectionIDs) { + final collection = _collectionIDToCollections[collectionID]; + if (_isEligibleSharedCollection( + collection, + currentUserID, + includeHidden: includeHidden, + )) { + sharedCollections.add(collection!); } + } + return sharedCollections; + } - // 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; - } + /// Returns IDs of shared collections containing [uploadedFileID]. + /// + /// 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, + ); + final currentUserID = _config.getUserID(); + if (collectionIDs.isEmpty || currentUserID == null) { + return []; + } + + final sharedCollectionIDs = []; + for (final collectionID in collectionIDs) { + if (_isEligibleSharedCollection( + _collectionIDToCollections[collectionID], + currentUserID, + includeHidden: includeHidden, + )) { + sharedCollectionIDs.add(collectionID); } } + return sharedCollectionIDs; + } - return sharedCount; + 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 { diff --git a/mobile/apps/photos/lib/services/update_service.dart b/mobile/apps/photos/lib/services/update_service.dart index de1889f89a6..1d3921d1d40 100644 --- a/mobile/apps/photos/lib/services/update_service.dart +++ b/mobile/apps/photos/lib/services/update_service.dart @@ -18,7 +18,7 @@ enum ChangeLogAction { skip, consumeWithoutShowing, show } class UpdateService { static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key"; static const changeLogVersionKey = "update_change_log_key"; - static const currentChangeLogVersion = 56; + static const currentChangeLogVersion = 57; LatestVersionInfo? _latestVersion; final _logger = Logger("UpdateService"); @@ -47,7 +47,6 @@ class UpdateService { required Locale locale, required bool isLocalGallery, required bool isSignedIn, - required bool isAndroid, }) async { if (!await shouldShowChangeLog()) { return ChangeLogAction.skip; @@ -60,7 +59,6 @@ class UpdateService { return ChangeLogStrings.hasContentForLocale( locale, isLocalGallery: isLocalGallery, - isAndroid: isAndroid, ) ? ChangeLogAction.show : ChangeLogAction.consumeWithoutShowing; 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/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/lib/ui/family/edit_storage_limit_page.dart b/mobile/apps/photos/lib/ui/family/edit_storage_limit_page.dart index 0b6da470020..9ddeb911560 100644 --- a/mobile/apps/photos/lib/ui/family/edit_storage_limit_page.dart +++ b/mobile/apps/photos/lib/ui/family/edit_storage_limit_page.dart @@ -13,12 +13,14 @@ import 'package:photos/utils/dialog_util.dart'; class EditStorageLimitPage extends StatefulWidget { const EditStorageLimitPage({ required this.member, + required this.displayName, required this.totalStorageInBytes, required this.avatarColor, super.key, }); final FamilyMember member; + final String displayName; final int totalStorageInBytes; final Color avatarColor; @@ -97,7 +99,7 @@ class _EditStorageLimitPageState extends State { radius: 20, backgroundColor: widget.avatarColor, child: Text( - widget.member.email.substring(0, 1).toUpperCase(), + widget.displayName.substring(0, 1).toUpperCase(), style: textTheme.bodyBold.copyWith( color: Colors.white, ), @@ -108,7 +110,7 @@ class _EditStorageLimitPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.member.email, style: textTheme.body), + Text(widget.displayName, style: textTheme.body), const SizedBox(height: 2), Text( l10n.usingStorage( diff --git a/mobile/apps/photos/lib/ui/family/family_dashboard.dart b/mobile/apps/photos/lib/ui/family/family_dashboard.dart index 953e7810c30..e7ff591daba 100644 --- a/mobile/apps/photos/lib/ui/family/family_dashboard.dart +++ b/mobile/apps/photos/lib/ui/family/family_dashboard.dart @@ -19,17 +19,9 @@ enum FamilyMemberAction { revokeInvite, } -AvatarComponentColor familyMemberAvatarComponentColor( - FamilyMember member, { - required String currentUserEmail, -}) { - return avatarComponentColorForAvatarIdentity( - AvatarIdentity.account( - label: member.email, - email: member.email, - userID: member.userID, - currentUserEmail: currentUserEmail, - ), +AvatarComponentColor familyMemberAvatarComponentColor(FamilyMember member) { + return avatarComponentColorForIdentity( + avatarIdentityKey(email: member.email, userID: member.userID), ); } @@ -76,6 +68,7 @@ class FamilyDashboard extends StatelessWidget { required this.contactsByUserId, required this.profilePictureBytesByUserId, required this.linkedPersonIdsByUserId, + required this.linkedPersonNamesByUserId, required this.onMemberTap, required this.onAddMember, required this.remainingSlots, @@ -88,7 +81,8 @@ class FamilyDashboard extends StatelessWidget { final Map contactsByUserId; final Map profilePictureBytesByUserId; final Map linkedPersonIdsByUserId; - final ValueChanged onMemberTap; + final Map linkedPersonNamesByUserId; + final void Function(FamilyMember member, String displayName) onMemberTap; final VoidCallback onAddMember; final int remainingSlots; @@ -124,7 +118,7 @@ class FamilyDashboard extends StatelessWidget { linkedPersonId: linkedPersonIdsByUserId[member.userID], hasSavedContact: contactsByUserId[member.userID] != null, avatarColor: _avatarColorFor(member), - onTap: () => onMemberTap(member), + onTap: () => onMemberTap(member, _displayNameFor(member)), ), if (index < visibleMembers.length - 1) const SizedBox(height: Spacing.sm), @@ -166,21 +160,25 @@ class FamilyDashboard extends StatelessWidget { } String _displayNameFor(FamilyMember member) => - _savedNameFor(member) ?? member.email; + _savedNameFor(member) ?? _linkedPersonNameFor(member) ?? member.email; String _storageLabelFor(FamilyMember member) => - _savedNameFor(member) ?? member.email.split('@').first; + _savedNameFor(member) ?? + _linkedPersonNameFor(member) ?? + member.email.split('@').first; String? _savedNameFor(FamilyMember member) { final savedName = contactsByUserId[member.userID]?.data?.name.trim(); return savedName == null || savedName.isEmpty ? null : savedName; } + String? _linkedPersonNameFor(FamilyMember member) { + final personName = linkedPersonNamesByUserId[member.userID]?.trim(); + return personName == null || personName.isEmpty ? null : personName; + } + AvatarComponentColor _avatarColorFor(FamilyMember member) => - familyMemberAvatarComponentColor( - member, - currentUserEmail: userDetails.email, - ); + familyMemberAvatarComponentColor(member); } class _FamilyStorageCard extends StatelessWidget { diff --git a/mobile/apps/photos/lib/ui/family/family_plan_page.dart b/mobile/apps/photos/lib/ui/family/family_plan_page.dart index ee047d28752..ec557a5df7b 100644 --- a/mobile/apps/photos/lib/ui/family/family_plan_page.dart +++ b/mobile/apps/photos/lib/ui/family/family_plan_page.dart @@ -348,38 +348,48 @@ class _FamilyPlanPageState extends State { Widget _buildDashboard(BuildContext context, {required bool isAdminView}) { final members = _userDetails.familyData?.members ?? const []; + final linkedPersons = _linkedPersonsFor(members); return FamilyDashboard( userDetails: _userDetails, members: members, isAdmin: isAdminView, contactsByUserId: _contactsByUserId, profilePictureBytesByUserId: _profilePictureBytesByUserId, - linkedPersonIdsByUserId: _linkedPersonIdsFor(members), + linkedPersonIdsByUserId: linkedPersons.idsByUserId, + linkedPersonNamesByUserId: linkedPersons.namesByUserId, onMemberTap: _showMemberActions, onAddMember: () => unawaited(_openInvitePage()), remainingSlots: _remainingSlots, ); } - Map _linkedPersonIdsFor(List members) { + ({Map idsByUserId, Map namesByUserId}) + _linkedPersonsFor(List members) { if (!PersonService.isInitialized) { - return const {}; + return (idsByUserId: const {}, namesByUserId: const {}); } - final result = {}; + final idsByUserId = {}; + final namesByUserId = {}; for (final member in members) { final userID = member.userID; if (userID == null) { continue; } - final personID = PersonService.instance.getCachedPartialPersonData( + final personData = PersonService.instance.getCachedPartialPersonData( userID: userID, email: member.email, - )?[PersonService.kPersonIDKey]; - if (personID != null) { - result[userID] = personID; + ); + final personID = personData?[PersonService.kPersonIDKey]; + if (personID == null) { + continue; + } + idsByUserId[userID] = personID; + final personName = personData?[PersonService.kNameKey]?.trim(); + if (personName != null && personName.isNotEmpty) { + namesByUserId[userID] = personName; } } - return result; + return (idsByUserId: idsByUserId, namesByUserId: namesByUserId); } Widget _buildDashboardOverflow(BuildContext context) { @@ -469,7 +479,10 @@ class _FamilyPlanPageState extends State { } } - Future _showMemberActions(FamilyMember member) async { + Future _showMemberActions( + FamilyMember member, + String fallbackDisplayName, + ) async { final isCurrentUser = member.email.trim().toLowerCase() == _userDetails.email.trim().toLowerCase(); @@ -487,14 +500,15 @@ class _FamilyPlanPageState extends State { return; } - final displayName = savedContact?.data?.name.trim(); + final savedContactName = savedContact?.data?.name.trim(); + final displayName = savedContactName == null || savedContactName.isEmpty + ? fallbackDisplayName + : savedContactName; final l10n = AppLocalizations.of(context); await showBottomSheetComponent( context: context, builder: (sheetContext) => BottomSheetComponent( - title: displayName == null || displayName.isEmpty - ? member.email - : displayName, + title: displayName, content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -502,6 +516,7 @@ class _FamilyPlanPageState extends State { _buildMemberActionItem( sheetContext, member: member, + displayName: displayName, action: actions[index], l10n: l10n, ), @@ -517,6 +532,7 @@ class _FamilyPlanPageState extends State { Widget _buildMemberActionItem( BuildContext sheetContext, { required FamilyMember member, + required String displayName, required FamilyMemberAction action, required AppLocalizations l10n, }) { @@ -565,13 +581,11 @@ class _FamilyPlanPageState extends State { context, EditStorageLimitPage( member: member, + displayName: displayName, totalStorageInBytes: _userDetails.getTotalStorage(), avatarColor: avatarComponentColorValue( context, - familyMemberAvatarComponentColor( - member, - currentUserEmail: _userDetails.email, - ), + familyMemberAvatarComponentColor(member), ), ), ); 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..86ddf1b4d3c 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,57 @@ 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 = event.localPosition - _downPosition!; + final isSwipeUp = + widget.onSwipeUp != null && + (widget.canSwipeUp?.call() ?? true) && + !wasPartOfMultitouch && + displacement.dy <= -widget.swipeUpThreshold && + 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) { + if (isSwipeUp) { + widget.onSwipeUp?.call(); + } else if (_longPressFired) { // widget.onLongPressUp?.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 +192,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 9c9cd62053b..83fe9f1b061 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,13 +1,14 @@ 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_pure_utils/ente_pure_utils.dart"; -import "package:flutter/cupertino.dart"; +import "package:flutter/foundation.dart" show ValueListenable; import "package:flutter/material.dart"; import "package:flutter/services.dart"; +import "package:hugeicons/hugeicons.dart"; import "package:photos/core/configuration.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/details_sheet_event.dart"; @@ -16,22 +17,24 @@ import "package:photos/events/reset_zoom_of_photo_view_event.dart"; import "package:photos/events/resume_video_event.dart"; import "package:photos/events/retry_failed_image_load_event.dart"; import "package:photos/generated/l10n.dart"; +import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/file/file_type.dart"; import "package:photos/models/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/home/memories/custom_listener.dart"; import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/home/memories/memory_share_sheet.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"; import "package:photos/ui/viewer/file_details/favorite_widget.dart"; @@ -39,6 +42,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 = 38.0; + //There are two states of variables that FullScreenMemory depends on: //1. The list of memories //2. The current index of the page view @@ -333,12 +339,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. @@ -357,7 +359,11 @@ 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); /// Used to check if any pointer is on the screen. final hasPointerOnScreenNotifier = ValueNotifier(false); @@ -373,9 +379,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( @@ -401,13 +404,9 @@ class _FullScreenMemoryState extends State { @override void dispose() { - _showTitle.dispose(); - durationNotifier.dispose(); hasPointerOnScreenNotifier.removeListener(_hasPointerListener); _detailSheetEventSubscription.cancel(); - _progressAnimationController = null; - _zoomAnimationController = null; - _kenBurnsStartToken = null; + _socialControlsVisible.dispose(); super.dispose(); } @@ -437,16 +436,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(); } } } @@ -488,17 +495,17 @@ class _FullScreenMemoryState extends State { void onFinalFileLoad(int duration) { if (!mounted) return; + _isPlaybackPaused = false; hasFinalFileLoaded = true; isAtFirstOrLastFile = false; - 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(); @@ -518,12 +525,15 @@ class _FullScreenMemoryState extends State { }); } else { _kenBurnsStartToken = null; - _zoomAnimationController?.forward(); + if (!_isAnimationPaused) { + _zoomAnimationController?.forward(); + } } } void _goToNext(FullScreenMemoryData inheritedData) { if (inheritedData.memories.isEmpty) return; + _isMediaZoomed = false; hasFinalFileLoaded = false; final currentIndex = _clampedMemoryIndex( inheritedData.indexNotifier.value, @@ -531,9 +541,10 @@ 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 { isAtFirstOrLastFile = true; @@ -543,6 +554,7 @@ class _FullScreenMemoryState extends State { void _goToPrevious(FullScreenMemoryData inheritedData) { if (inheritedData.memories.isEmpty) return; + _isMediaZoomed = false; hasFinalFileLoaded = false; final currentIndex = _clampedMemoryIndex( inheritedData.indexNotifier.value, @@ -550,9 +562,10 @@ 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 { isAtFirstOrLastFile = true; @@ -563,6 +576,7 @@ class _FullScreenMemoryState extends State { void _onPageChange(FullScreenMemoryData inheritedData, int index) { if (!_isValidMemoryIndex(index, inheritedData.memories.length)) return; + _isMediaZoomed = false; isAtFirstOrLastFile = false; unawaited( memoriesCacheService.markMemoryAsSeen( @@ -574,307 +588,215 @@ 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(); + 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, + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + 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, + ); + } + 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); + } + }, + onSwipeUp: () { + if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; + _runWithViewerPaused( + () => showDetailsSheet(context, currentFile), ); - 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); - }, + }, + canSwipeUp: () => !_isMediaZoomed, + 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, + shouldDisableScroll: (isZoomed) { + _isMediaZoomed = isZoomed; + }, + playbackCallback: (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( + inheritedData.indexNotifier.value, + inheritedData.memories.length, + ); + if (activeIndex == null || + inheritedData.memories[activeIndex].file != + currentFile) { + return; + } + onFinalFileLoad(memoryDuration); }, ), - 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(), - ], - ), - ], - ); - }, - ), - 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, - ); - } - 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); - }, - ), - ), - ), - ), - ); - }, + ); + }, + ), + _MemoryViewerScrims(socialControlsVisible: _socialControlsVisible), + ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + final safeIndex = _clampedMemoryIndex( + index, + inheritedData.memories.length, + ); + if (safeIndex == null) return const SizedBox.shrink(); + 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, ), - 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(), - ], + ); + }, + ), + const BottomIcons(), + _MemoryTopOverlay( + title: widget.title, + onClose: () => Navigator.pop(context), + onDateTap: (file) { + _runWithViewerPaused( + () => routeToPage( + context, + JumpToDateGallery(fileToJumpTo: file), ), - ], - ), + ); + }, + animationController: _setProgressAnimationController, + onAnimationControllerDisposed: _clearProgressAnimationController, + onComplete: () { + _autoAdvanceTransition = true; + _goToNext(inheritedData); + }, ), - ), + ], ), ), ); @@ -896,95 +818,178 @@ 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 HugeIcon( + icon: HugeIcons.strokeRoundedAddSquare, + 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._setSocialControlsVisible(false); + 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 _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) { + 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: onPressed, + icon: icon, + ), ); } } -class MemoryCounter extends StatelessWidget { - const MemoryCounter({super.key}); +class _MemoryTopOverlay 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 _MemoryTopOverlay({ + required this.title, + required this.onClose, + required this.onDateTap, + required this.animationController, + required this.onAnimationControllerDisposed, + required this.onComplete, + }); @override Widget build(BuildContext context) { @@ -992,52 +997,233 @@ class MemoryCounter extends StatelessWidget { 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, - ); - }, + 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: max(safePadding.top, 40)), + 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(8), + 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 HugeIcon( + icon: HugeIcons.strokeRoundedCancel01, + color: Colors.white, + size: 32, + ), + ), + ), + 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), + ), + ), + 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(), + ), + ), + ], + ), + ], + ), + ), + ), + if (showFavorite) ...[ + const SizedBox(width: 8), + FavoriteWidget( + currentFile, + iconSize: 24, + tapTargetSize: 48, + key: ValueKey( + currentFile.uploadedFileID ?? currentFile.localID, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + }, + ), ); } } -class BottomGradient extends StatelessWidget { - final ValueNotifier showTitle; - const BottomGradient({super.key, required this.showTitle}); +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; + + const _MemoryViewerScrims({required this.socialControlsVisible}); @override Widget build(BuildContext context) { + final topHeight = MediaQuery.paddingOf(context).top + 104; 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], + child: Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: double.infinity, + height: topHeight, + 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], + ), + ), ), ), - ); - }, + ), + Align( + alignment: Alignment.bottomCenter, + 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 + ? kMemorySocialScrimHeight + : bottomInset + kMemoryBottomActionBarHeight, + 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], + ), + ), + ); + }, + ), + ), + ], ), ); } @@ -1052,11 +1238,11 @@ class _MemoryBlur extends StatelessWidget { if (inheritedData == null || inheritedData.memories.isEmpty) { return const SizedBox.shrink(); } - return ValueListenableBuilder( + return ValueListenableBuilder( valueListenable: inheritedData.indexNotifier, - builder: (context, value, _) { + builder: (context, index, _) { final safeIndex = _clampedMemoryIndex( - value, + index, inheritedData.memories.length, ); if (safeIndex == null) return const SizedBox.shrink(); @@ -1068,8 +1254,16 @@ class _MemoryBlur extends StatelessWidget { 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(safeIndex), + key: ValueKey( + "memory-blur-${currentFile.uploadedFileID ?? currentFile.localID ?? safeIndex}", + ), imageFilter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), child: ThumbnailWidget( currentFile, 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..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 @@ -1,6 +1,34 @@ import "package:flutter/material.dart"; -const int kMemoryProgressTickCutoff = 60; +const double kMemoryProgressGap = 10.0; +const double kMemoryProgressHeight = 4.0; +const double kMemoryProgressMinSegmentWidth = 8.0; + +double memoryProgressSegmentWidthForLayout({ + required int totalSteps, + required double availableWidth, + double gap = kMemoryProgressGap, +}) { + if (totalSteps <= 0) return 0; + + final availableSegmentWidth = availableWidth - ((totalSteps - 1) * gap); + if (availableSegmentWidth <= 0) return 0; + + return availableSegmentWidth / totalSteps; +} + +bool memoryProgressUsesContinuousTrack({ + required int totalSteps, + required double availableWidth, + double gap = kMemoryProgressGap, +}) => + totalSteps > 1 && + memoryProgressSegmentWidthForLayout( + totalSteps: totalSteps, + availableWidth: availableWidth, + gap: gap, + ) < + kMemoryProgressMinSegmentWidth; class MemoryProgressIndicator extends StatefulWidget { final int totalSteps; @@ -21,8 +49,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 +100,69 @@ 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( + Expanded( + key: ValueKey("memory-progress-segment-$index"), + child: segment, + ), + ); + } + return Row(children: segments); + }, ); } } 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/notification/update/change_log_page.dart b/mobile/apps/photos/lib/ui/notification/update/change_log_page.dart index 8ad4e6e1e42..548c81205d2 100644 --- a/mobile/apps/photos/lib/ui/notification/update/change_log_page.dart +++ b/mobile/apps/photos/lib/ui/notification/update/change_log_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import "package:ente_components/ente_components.dart"; import 'package:flutter/material.dart'; import 'package:hugeicons/hugeicons.dart'; @@ -77,31 +75,16 @@ class _ChangeLogPageState extends State { final strings = ChangeLogStrings.maybeForLocale( Localizations.localeOf(context), isLocalGallery: isLocalGalleryMode, - isAndroid: Platform.isAndroid, ); if (strings == null) { return const SizedBox.shrink(); } - final items = - [ - ChangeLogEntry( - strings.title1, - description: strings.desc1, - items: [strings.desc1Item1, strings.desc1Item2] - .where((item) => item.trim().isNotEmpty) - .toList(growable: false), - ), - ChangeLogEntry(strings.title2, description: strings.desc2), - ChangeLogEntry(strings.title3, description: strings.desc3), - ChangeLogEntry(strings.title4, description: strings.desc4), - ] - .where( - (entry) => - entry.title.trim().isNotEmpty || - (entry.description?.trim().isNotEmpty ?? false) || - entry.items.isNotEmpty, - ) - .toList(growable: false); + final items = strings.entries + .map( + (entry) => + ChangeLogEntry(entry.title, description: entry.description), + ) + .toList(growable: false); return Flexible( child: ScrollbarTheme( data: ScrollbarTheme.of(context).copyWith( diff --git a/mobile/apps/photos/lib/ui/notification/update/change_log_strings.dart b/mobile/apps/photos/lib/ui/notification/update/change_log_strings.dart index 343b6363ac7..9baed1b7650 100644 --- a/mobile/apps/photos/lib/ui/notification/update/change_log_strings.dart +++ b/mobile/apps/photos/lib/ui/notification/update/change_log_strings.dart @@ -1,370 +1,746 @@ import 'dart:ui'; class ChangeLogStrings { - final String title1; - final String desc1; - final String desc1Item1; - final String desc1Item2; - final String title2; - final String desc2; - final String title3; - final String desc3; - final String title4; - final String desc4; + final List entries; - const ChangeLogStrings({ - required this.title1, - required this.desc1, - this.desc1Item1 = '', - this.desc1Item2 = '', - this.title2 = '', - this.desc2 = '', - this.title3 = '', - this.desc3 = '', - this.title4 = '', - this.desc4 = '', - }); - - bool get hasVisibleEntries => - title1.trim().isNotEmpty || - desc1.trim().isNotEmpty || - desc1Item1.trim().isNotEmpty || - desc1Item2.trim().isNotEmpty || - title2.trim().isNotEmpty || - desc2.trim().isNotEmpty || - title3.trim().isNotEmpty || - desc3.trim().isNotEmpty || - title4.trim().isNotEmpty || - desc4.trim().isNotEmpty; + const ChangeLogStrings({required this.entries}); static ChangeLogStrings? maybeForLocale( Locale locale, { bool isLocalGallery = false, - required bool isAndroid, }) { final key = locale.countryCode != null && locale.countryCode!.isNotEmpty ? '${locale.languageCode}_${locale.countryCode}' : locale.languageCode; final strings = - _featureTranslations[key] ?? - _featureTranslations[locale.languageCode] ?? - _featureTranslations['en']; + _translations[key] ?? + _translations[locale.languageCode] ?? + _translations['en']; if (strings == null) { return null; } - final changeLog = _forFeatureStrings( - strings, - isLocalGallery: isLocalGallery, - isAndroid: isAndroid, - ); - if (!changeLog.hasVisibleEntries) { - return null; - } - return changeLog; + final entries = isLocalGallery + ? strings.entries + .where((entry) => !entry.isOnlineOnly) + .toList(growable: false) + : strings.entries; + return entries.isEmpty ? null : ChangeLogStrings(entries: entries); } static bool hasContentForLocale( Locale locale, { bool isLocalGallery = false, - required bool isAndroid, - }) { - return maybeForLocale( - locale, - isLocalGallery: isLocalGallery, - isAndroid: isAndroid, - ) != - null; - } - - static ChangeLogStrings _forFeatureStrings( - _ChangeLogFeatureStrings strings, { - required bool isLocalGallery, - required bool isAndroid, }) { - if (isLocalGallery) { - return isAndroid - ? ChangeLogStrings( - title1: strings.deleteTitle, - desc1: strings.localAndroidDeleteDesc, - title2: strings.storageTitle, - desc2: strings.storageDesc, - ) - : ChangeLogStrings( - title1: strings.storageTitle, - desc1: strings.storageDesc, - ); - } - - return isAndroid - ? ChangeLogStrings( - title1: strings.deleteTitle, - desc1: strings.onlineAndroidDeleteDesc, - title2: strings.castTitle, - desc2: strings.castDesc, - title3: strings.storageTitle, - desc3: strings.storageDesc, - ) - : ChangeLogStrings( - title1: strings.castTitle, - desc1: strings.castDesc, - title2: strings.storageTitle, - desc2: strings.storageDesc, - ); + return maybeForLocale(locale, isLocalGallery: isLocalGallery) != null; } - static const Map _featureTranslations = { - 'en': _ChangeLogFeatureStrings( - deleteTitle: 'Easier delete confirmations', - onlineAndroidDeleteDesc: - 'When deleting from your device, Ente can help you set up media management to avoid repeated system prompts. Ente can also remember your last delete choice.', - localAndroidDeleteDesc: - 'When deleting from your device, Ente can help you set up media management to avoid repeated system prompts.', - castTitle: 'Cast to multiple screens', - castDesc: - "You can now cast albums to more than one screen at a time, view active sessions, and stop a specific session when you're done.", - storageTitle: 'More reliable storage cleanup', - storageDesc: - 'Ente now clears temporary image and video files more reliably, keeping the app from holding on to extra device storage.', + static const Map _translations = { + 'en': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comments and reactions in memories', + description: + "Talk about shared memories with your loved ones, as you're reliving them.", + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Share memories, your way', + description: + 'Choose exactly which photos and videos go into a memory before you share.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Photo viewer, polished', + description: + 'The photo viewer and info sheet have been redesigned. Cleaner, nicer, easier to read.', + ), + ChangeLogEntryStrings( + title: 'Text in photos, sharper', + description: + 'Copying text from photos is now faster and more reliable. Long press to start selecting.', + ), + ChangeLogEntryStrings( + title: 'Smarter caching', + description: + 'Your photos stay quick to open while taking up less space on your device.', + ), + ChangeLogEntryStrings( + title: 'and more!', + description: + 'Faster and more reliable backups, smoother gallery browsing, better thumbnail loading and download reliability, improved text detection in photos, better avatar colors, fixes for crashes during background work, and lots of smaller polish across the app.', + isOnlineOnly: true, + ), + ], + ), + 'ca': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comentaris i reaccions als records', + description: + 'Parla dels records compartits amb les persones que estimes mentre els torneu a viure.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Comparteix els records a la teva manera', + description: + "Tria exactament quines fotos i vídeos formaran part d'un record abans de compartir-lo.", + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visualitzador de fotos, renovat', + description: + "El visualitzador de fotos i el full d'informació s'han redissenyat. Més nets, agradables i fàcils de llegir.", + ), + ChangeLogEntryStrings( + title: 'Text més nítid a les fotos', + description: + 'Copiar text de les fotos ara és més ràpid i fiable. Mantén premut per començar a seleccionar.', + ), + ChangeLogEntryStrings( + title: 'Memòria cau més intel·ligent', + description: + 'Les fotos es continuen obrint ràpidament i ocupen menys espai al dispositiu.', + ), + ChangeLogEntryStrings( + title: 'i molt més!', + description: + "Còpies de seguretat més ràpides i fiables, navegació més fluida per la galeria, millor càrrega de miniatures i baixades més fiables, detecció de text millorada a les fotos, millors colors d'avatar, correccions d'errors durant les tasques en segon pla i molts petits retocs a tota l'aplicació.", + isOnlineOnly: true, + ), + ], ), - 'cs': _ChangeLogFeatureStrings( - deleteTitle: 'Snazší potvrzení odstranění', - onlineAndroidDeleteDesc: - 'Při odstraňování ze zařízení vám Ente může pomoci nastavit správu médií, abyste se vyhnuli opakovaným systémovým výzvám. Ente si také může zapamatovat vaši poslední volbu odstranění.', - localAndroidDeleteDesc: - 'Při odstraňování ze zařízení vám Ente může pomoci nastavit správu médií, abyste se vyhnuli opakovaným systémovým výzvám.', - castTitle: 'Promítání na více obrazovek', - castDesc: - 'Alba teď můžete promítat na více obrazovek najednou, zobrazit aktivní relace a po skončení zastavit konkrétní relaci.', - storageTitle: 'Spolehlivější čištění úložiště', - storageDesc: - 'Ente teď spolehlivěji odstraňuje dočasné soubory obrázků a videí, takže aplikace nezabírá zbytečně další místo v úložišti zařízení.', + 'cs': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Komentáře a reakce ve vzpomínkách', + description: + 'Povídejte si o sdílených vzpomínkách se svými blízkými, zatímco je znovu prožíváte.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Sdílejte vzpomínky po svém', + description: + 'Před sdílením si přesně vyberte, které fotky a videa budou ve vzpomínce.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Vyladěný prohlížeč fotek', + description: + 'Prohlížeč fotek a informační panel jsme přepracovali. Jsou přehlednější, hezčí a lépe se čtou.', + ), + ChangeLogEntryStrings( + title: 'Ostřejší text ve fotkách', + description: + 'Kopírování textu z fotek je nyní rychlejší a spolehlivější. Dlouhým stisknutím zahájíte výběr.', + ), + ChangeLogEntryStrings( + title: 'Chytřejší ukládání do mezipaměti', + description: + 'Fotky se otevírají rychle a zabírají v zařízení méně místa.', + ), + ChangeLogEntryStrings( + title: 'a ještě víc!', + description: + 'Rychlejší a spolehlivější zálohování, plynulejší procházení galerie, lepší načítání náhledů a spolehlivější stahování, lepší rozpoznávání textu ve fotkách, lepší barvy avatarů, opravy pádů během práce na pozadí a spousta dalších drobných vylepšení v celé aplikaci.', + isOnlineOnly: true, + ), + ], ), - 'de': _ChangeLogFeatureStrings( - deleteTitle: 'Einfachere Löschbestätigungen', - onlineAndroidDeleteDesc: - 'Beim Löschen von deinem Gerät kann Ente dir helfen, die Medienverwaltung einzurichten, um wiederholte Systemabfragen zu vermeiden. Ente kann sich auch deine letzte Löschentscheidung merken.', - localAndroidDeleteDesc: - 'Beim Löschen von deinem Gerät kann Ente dir helfen, die Medienverwaltung einzurichten, um wiederholte Systemabfragen zu vermeiden.', - castTitle: 'Auf mehrere Bildschirme streamen', - castDesc: - 'Du kannst Alben jetzt auf mehr als einen Bildschirm gleichzeitig streamen, aktive Sitzungen anzeigen und eine bestimmte Sitzung beenden, wenn du fertig bist.', - storageTitle: 'Zuverlässigere Speicherbereinigung', - storageDesc: - 'Ente entfernt temporäre Bild- und Videodateien jetzt zuverlässiger, damit die App keinen zusätzlichen Gerätespeicher belegt.', + 'de': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Kommentare und Reaktionen in Erinnerungen', + description: + 'Unterhalte dich mit deinen Liebsten über geteilte Erinnerungen, während ihr sie gemeinsam noch einmal erlebt.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Erinnerungen teilen, wie du möchtest', + description: + 'Wähle vor dem Teilen genau aus, welche Fotos und Videos in einer Erinnerung enthalten sind.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Fotoanzeige, jetzt noch besser', + description: + 'Die Fotoanzeige und das Infoblatt wurden neu gestaltet. Aufgeräumter, schöner und leichter zu lesen.', + ), + ChangeLogEntryStrings( + title: 'Text in Fotos, klarer', + description: + 'Das Kopieren von Text aus Fotos ist jetzt schneller und zuverlässiger. Halte zum Auswählen länger gedrückt.', + ), + ChangeLogEntryStrings( + title: 'Intelligenteres Caching', + description: + 'Deine Fotos lassen sich weiterhin schnell öffnen und belegen dabei weniger Speicherplatz auf deinem Gerät.', + ), + ChangeLogEntryStrings( + title: 'und vieles mehr!', + description: + 'Schnellere und zuverlässigere Backups, flüssigeres Durchsuchen der Galerie, besseres Laden von Miniaturansichten und zuverlässigere Downloads, verbesserte Texterkennung in Fotos, bessere Avatarfarben, Korrekturen für Abstürze bei Hintergrundaufgaben und viele kleinere Verbesserungen in der gesamten App.', + isOnlineOnly: true, + ), + ], ), - 'es': _ChangeLogFeatureStrings( - deleteTitle: 'Confirmaciones de eliminación más sencillas', - onlineAndroidDeleteDesc: - 'Al eliminar desde tu dispositivo, Ente puede ayudarte a configurar la gestión de medios para evitar solicitudes repetidas del sistema. Ente también puede recordar tu última elección de eliminación.', - localAndroidDeleteDesc: - 'Al eliminar desde tu dispositivo, Ente puede ayudarte a configurar la gestión de medios para evitar solicitudes repetidas del sistema.', - castTitle: 'Transmitir a varias pantallas', - castDesc: - 'Ahora puedes transmitir álbumes a más de una pantalla a la vez, ver las sesiones activas y detener una sesión específica cuando termines.', - storageTitle: 'Limpieza de almacenamiento más fiable', - storageDesc: - 'Ente ahora elimina los archivos temporales de imágenes y videos con mayor fiabilidad, evitando que la app retenga almacenamiento adicional del dispositivo.', + 'es': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comentarios y reacciones en los recuerdos', + description: + 'Habla sobre los recuerdos compartidos con tus seres queridos mientras los revives.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Comparte recuerdos a tu manera', + description: + 'Elige exactamente qué fotos y vídeos incluir en un recuerdo antes de compartirlo.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visor de fotos, renovado', + description: + 'Se han rediseñado el visor de fotos y la hoja de información. Más limpios, agradables y fáciles de leer.', + ), + ChangeLogEntryStrings( + title: 'Texto más nítido en las fotos', + description: + 'Copiar texto de las fotos ahora es más rápido y fiable. Mantén pulsado para empezar a seleccionar.', + ), + ChangeLogEntryStrings( + title: 'Caché más inteligente', + description: + 'Tus fotos siguen abriéndose rápidamente y ocupan menos espacio en el dispositivo.', + ), + ChangeLogEntryStrings( + title: '¡y mucho más!', + description: + 'Copias de seguridad más rápidas y fiables, navegación más fluida por la galería, mejor carga de miniaturas y descargas más fiables, detección de texto mejorada en las fotos, mejores colores de avatar, correcciones de fallos durante tareas en segundo plano y muchos pequeños retoques en toda la app.', + isOnlineOnly: true, + ), + ], ), - 'fr': _ChangeLogFeatureStrings( - deleteTitle: 'Confirmations de suppression plus simples', - onlineAndroidDeleteDesc: - 'Lorsque vous supprimez des éléments de votre appareil, Ente peut vous aider à configurer la gestion des médias afin d’éviter les invites système répétées. Ente peut aussi mémoriser votre dernier choix de suppression.', - localAndroidDeleteDesc: - 'Lorsque vous supprimez des éléments de votre appareil, Ente peut vous aider à configurer la gestion des médias afin d’éviter les invites système répétées.', - castTitle: 'Diffuser sur plusieurs écrans', - castDesc: - 'Vous pouvez maintenant diffuser des albums sur plusieurs écrans à la fois, voir les sessions actives et arrêter une session précise lorsque vous avez terminé.', - storageTitle: 'Nettoyage du stockage plus fiable', - storageDesc: - 'Ente supprime désormais les fichiers temporaires d’images et de vidéos de manière plus fiable, afin que l’app n’occupe pas inutilement de l’espace sur votre appareil.', + 'fr': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Commentaires et réactions dans les souvenirs', + description: + 'Discutez des souvenirs partagés avec vos proches tout en les revivant.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Partagez vos souvenirs à votre façon', + description: + 'Choisissez précisément les photos et vidéos à inclure dans un souvenir avant de le partager.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visionneuse de photos peaufinée', + description: + 'La visionneuse de photos et la fiche d’informations ont été repensées. Plus claires, plus agréables et plus faciles à lire.', + ), + ChangeLogEntryStrings( + title: 'Texte plus net dans les photos', + description: + 'La copie de texte depuis les photos est désormais plus rapide et plus fiable. Appuyez longuement pour commencer la sélection.', + ), + ChangeLogEntryStrings( + title: 'Mise en cache plus intelligente', + description: + 'Vos photos restent rapides à ouvrir tout en occupant moins d’espace sur votre appareil.', + ), + ChangeLogEntryStrings( + title: 'et bien plus encore !', + description: + 'Des sauvegardes plus rapides et plus fiables, une navigation plus fluide dans la galerie, un meilleur chargement des miniatures et des téléchargements plus fiables, une meilleure détection du texte dans les photos, de meilleures couleurs d’avatar, des correctifs pour les plantages pendant les tâches en arrière-plan et de nombreuses petites améliorations dans toute l’app.', + isOnlineOnly: true, + ), + ], ), - 'it': _ChangeLogFeatureStrings( - deleteTitle: 'Conferme di eliminazione più semplici', - onlineAndroidDeleteDesc: - 'Quando elimini dal dispositivo, Ente può aiutarti a configurare la gestione dei contenuti multimediali per evitare richieste di sistema ripetute. Ente può anche ricordare la tua ultima scelta di eliminazione.', - localAndroidDeleteDesc: - 'Quando elimini dal dispositivo, Ente può aiutarti a configurare la gestione dei contenuti multimediali per evitare richieste di sistema ripetute.', - castTitle: 'Trasmetti su più schermi', - castDesc: - 'Ora puoi trasmettere gli album su più di uno schermo alla volta, vedere le sessioni attive e interrompere una sessione specifica quando hai finito.', - storageTitle: 'Pulizia dello spazio più affidabile', - storageDesc: - "Ente ora elimina i file temporanei di immagini e video in modo più affidabile, evitando che l'app trattenga spazio extra sul dispositivo.", + 'it': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Commenti e reazioni nei ricordi', + description: + 'Parla dei ricordi condivisi con le persone che ami mentre li rivivi.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Condividi i ricordi a modo tuo', + description: + 'Scegli esattamente quali foto e video inserire in un ricordo prima di condividerlo.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visualizzatore di foto perfezionato', + description: + 'Il visualizzatore di foto e la scheda delle informazioni sono stati riprogettati. Più ordinati, gradevoli e facili da leggere.', + ), + ChangeLogEntryStrings( + title: 'Testo nelle foto più nitido', + description: + 'Copiare il testo dalle foto ora è più veloce e affidabile. Tieni premuto per iniziare la selezione.', + ), + ChangeLogEntryStrings( + title: 'Cache più intelligente', + description: + 'Le tue foto restano rapide da aprire e occupano meno spazio sul dispositivo.', + ), + ChangeLogEntryStrings( + title: 'e molto altro!', + description: + 'Backup più veloci e affidabili, navigazione più fluida nella galleria, caricamento migliore delle miniature e download più affidabili, rilevamento del testo nelle foto migliorato, colori degli avatar migliori, correzioni per gli arresti anomali durante le attività in background e tanti piccoli miglioramenti in tutta l’app.', + isOnlineOnly: true, + ), + ], ), - 'ja': _ChangeLogFeatureStrings( - deleteTitle: '削除確認がより簡単に', - onlineAndroidDeleteDesc: - 'デバイスから削除するとき、Ente はメディア管理の設定を案内し、繰り返し表示されるシステム確認を避けられるようにします。Ente は最後に選んだ削除方法を記憶することもできます。', - localAndroidDeleteDesc: - 'デバイスから削除するとき、Ente はメディア管理の設定を案内し、繰り返し表示されるシステム確認を避けられるようにします。', - castTitle: '複数の画面にキャスト', - castDesc: - 'アルバムを複数の画面に同時にキャストし、アクティブなセッションを確認して、終了したい特定のセッションだけを停止できるようになりました。', - storageTitle: 'ストレージクリーンアップの信頼性向上', - storageDesc: - 'Ente は一時的な画像ファイルと動画ファイルをより確実に削除し、アプリが余分なデバイスストレージを使い続けないようにします。', + 'ja': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: '思い出でのコメントとリアクション', + description: '大切な人と共有した思い出を振り返りながら、会話を楽しめます。', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: '思い出を自分らしく共有', + description: '共有する前に、思い出に含める写真や動画を自由に選べます。', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: '写真ビューアーをさらに使いやすく', + description: '写真ビューアーと情報シートを再設計しました。よりすっきり、美しく、読みやすくなりました。', + ), + ChangeLogEntryStrings( + title: '写真内のテキストをより鮮明に', + description: '写真からのテキストコピーが、より高速で確実になりました。長押しして選択を開始できます。', + ), + ChangeLogEntryStrings( + title: 'よりスマートなキャッシュ', + description: '写真をすばやく開ける快適さはそのままに、デバイスの使用容量を抑えます。', + ), + ChangeLogEntryStrings( + title: 'さらに多くの改善!', + description: + 'バックアップの高速化と信頼性向上、ギャラリー閲覧のなめらかさ向上、サムネイル読み込みとダウンロードの信頼性向上、写真内のテキスト検出の改善、アバターの色の改善、バックグラウンド処理中のクラッシュ修正など、アプリ全体に多くの細かな改善を加えました。', + isOnlineOnly: true, + ), + ], ), - 'nl': _ChangeLogFeatureStrings( - deleteTitle: 'Eenvoudigere verwijderbevestigingen', - onlineAndroidDeleteDesc: - 'Wanneer je iets van je apparaat verwijdert, kan Ente je helpen mediabeheer in te stellen om herhaalde systeemmeldingen te vermijden. Ente kan ook je laatste verwijderkeuze onthouden.', - localAndroidDeleteDesc: - 'Wanneer je iets van je apparaat verwijdert, kan Ente je helpen mediabeheer in te stellen om herhaalde systeemmeldingen te vermijden.', - castTitle: 'Naar meerdere schermen casten', - castDesc: - 'Je kunt albums nu naar meer dan één scherm tegelijk casten, actieve sessies bekijken en een specifieke sessie stoppen wanneer je klaar bent.', - storageTitle: 'Betrouwbaardere opslagopruiming', - storageDesc: - 'Ente verwijdert tijdelijke afbeeldings- en videobestanden nu betrouwbaarder, zodat de app geen extra opslagruimte op je apparaat blijft innemen.', + 'nl': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Opmerkingen en reacties in herinneringen', + description: + 'Praat met je dierbaren over gedeelde herinneringen terwijl jullie ze opnieuw beleven.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Deel herinneringen op jouw manier', + description: + "Kies precies welke foto's en video's in een herinnering komen voordat je deze deelt.", + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Fotoviewer, verfijnd', + description: + 'De fotoviewer en het informatieblad zijn opnieuw ontworpen. Rustiger, mooier en makkelijker te lezen.', + ), + ChangeLogEntryStrings( + title: "Tekst in foto's, scherper", + description: + "Tekst uit foto's kopiëren is nu sneller en betrouwbaarder. Houd ingedrukt om te beginnen met selecteren.", + ), + ChangeLogEntryStrings( + title: 'Slimmere caching', + description: + "Je foto's blijven snel openen en nemen minder ruimte in op je apparaat.", + ), + ChangeLogEntryStrings( + title: 'en meer!', + description: + "Snellere en betrouwbaardere back-ups, soepeler bladeren door de galerij, beter laden van miniaturen en betrouwbaardere downloads, verbeterde tekstherkenning in foto's, betere avatarkleuren, oplossingen voor crashes tijdens achtergrondtaken en veel kleinere verbeteringen in de hele app.", + isOnlineOnly: true, + ), + ], ), - 'no': _ChangeLogFeatureStrings( - deleteTitle: 'Enklere bekreftelser ved sletting', - onlineAndroidDeleteDesc: - 'Når du sletter fra enheten, kan Ente hjelpe deg med å sette opp medieadministrasjon for å unngå gjentatte systemmeldinger. Ente kan også huske det siste slettevalget ditt.', - localAndroidDeleteDesc: - 'Når du sletter fra enheten, kan Ente hjelpe deg med å sette opp medieadministrasjon for å unngå gjentatte systemmeldinger.', - castTitle: 'Cast til flere skjermer', - castDesc: - 'Du kan nå caste album til mer enn én skjerm om gangen, se aktive økter og stoppe en bestemt økt når du er ferdig.', - storageTitle: 'Mer pålitelig lagringsopprydding', - storageDesc: - 'Ente fjerner nå midlertidige bilde- og videofiler mer pålitelig, slik at appen ikke bruker ekstra lagringsplass på enheten.', + 'no': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Kommentarer og reaksjoner i minner', + description: + 'Snakk om delte minner med dem du er glad i, mens dere opplever dem på nytt.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Del minner på din måte', + description: + 'Velg nøyaktig hvilke bilder og videoer som skal være med i et minne før du deler det.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'En mer polert bildevisning', + description: + 'Bildevisningen og informasjonsarket har fått ny design. Renere, finere og enklere å lese.', + ), + ChangeLogEntryStrings( + title: 'Skarpere tekst i bilder', + description: + 'Kopiering av tekst fra bilder er nå raskere og mer pålitelig. Trykk og hold for å begynne å velge.', + ), + ChangeLogEntryStrings( + title: 'Smartere hurtigbufring', + description: + 'Bildene dine åpnes fortsatt raskt, samtidig som de tar mindre plass på enheten.', + ), + ChangeLogEntryStrings( + title: 'og mer!', + description: + 'Raskere og mer pålitelige sikkerhetskopier, jevnere galleriblaing, bedre innlasting av miniatyrbilder og mer pålitelige nedlastinger, forbedret tekstgjenkjenning i bilder, bedre avatarfarger, rettelser for krasj under bakgrunnsarbeid og mange små forbedringer i hele appen.', + isOnlineOnly: true, + ), + ], ), - 'pl': _ChangeLogFeatureStrings( - deleteTitle: 'Łatwiejsze potwierdzanie usuwania', - onlineAndroidDeleteDesc: - 'Podczas usuwania z urządzenia Ente może pomóc skonfigurować zarządzanie multimediami, aby uniknąć powtarzających się monitów systemowych. Ente może też zapamiętać twój ostatni wybór usuwania.', - localAndroidDeleteDesc: - 'Podczas usuwania z urządzenia Ente może pomóc skonfigurować zarządzanie multimediami, aby uniknąć powtarzających się monitów systemowych.', - castTitle: 'Przesyłaj na wiele ekranów', - castDesc: - 'Możesz teraz przesyłać albumy na więcej niż jeden ekran jednocześnie, wyświetlać aktywne sesje i zatrzymać wybraną sesję, gdy skończysz.', - storageTitle: 'Bardziej niezawodne czyszczenie pamięci', - storageDesc: - 'Ente teraz bardziej niezawodnie usuwa tymczasowe pliki obrazów i wideo, dzięki czemu aplikacja nie zajmuje dodatkowej pamięci urządzenia.', + 'pl': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Komentarze i reakcje we wspomnieniach', + description: + 'Rozmawiaj z bliskimi o udostępnionych wspomnieniach, przeżywając je ponownie.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Udostępniaj wspomnienia po swojemu', + description: + 'Przed udostępnieniem wybierz dokładnie, które zdjęcia i filmy znajdą się we wspomnieniu.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Dopracowana przeglądarka zdjęć', + description: + 'Przeglądarka zdjęć i panel informacji zostały przeprojektowane. Są przejrzystsze, ładniejsze i łatwiejsze do odczytania.', + ), + ChangeLogEntryStrings( + title: 'Wyraźniejszy tekst na zdjęciach', + description: + 'Kopiowanie tekstu ze zdjęć jest teraz szybsze i bardziej niezawodne. Naciśnij i przytrzymaj, aby rozpocząć zaznaczanie.', + ), + ChangeLogEntryStrings( + title: 'Inteligentniejsze buforowanie', + description: + 'Zdjęcia nadal otwierają się szybko, zajmując mniej miejsca na urządzeniu.', + ), + ChangeLogEntryStrings( + title: 'i wiele więcej!', + description: + 'Szybsze i bardziej niezawodne kopie zapasowe, płynniejsze przeglądanie galerii, lepsze wczytywanie miniatur i bardziej niezawodne pobieranie, ulepszone wykrywanie tekstu na zdjęciach, lepsze kolory awatarów, poprawki awarii podczas pracy w tle oraz wiele drobnych ulepszeń w całej aplikacji.', + isOnlineOnly: true, + ), + ], ), - 'pt_BR': _ChangeLogFeatureStrings( - deleteTitle: 'Confirmações de exclusão mais fáceis', - onlineAndroidDeleteDesc: - 'Ao excluir do seu dispositivo, o Ente pode ajudar você a configurar o gerenciamento de mídia para evitar avisos repetidos do sistema. O Ente também pode lembrar sua última escolha de exclusão.', - localAndroidDeleteDesc: - 'Ao excluir do seu dispositivo, o Ente pode ajudar você a configurar o gerenciamento de mídia para evitar avisos repetidos do sistema.', - castTitle: 'Transmitir para várias telas', - castDesc: - 'Agora você pode transmitir álbuns para mais de uma tela ao mesmo tempo, ver sessões ativas e encerrar uma sessão específica quando terminar.', - storageTitle: 'Limpeza de armazenamento mais confiável', - storageDesc: - 'Agora o Ente limpa arquivos temporários de imagens e vídeos com mais confiabilidade, evitando que o app ocupe espaço extra no dispositivo.', + 'pt_BR': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comentários e reações nas memórias', + description: + 'Converse sobre memórias compartilhadas com quem você ama enquanto vocês as revivem.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Compartilhe memórias do seu jeito', + description: + 'Escolha exatamente quais fotos e vídeos entram em uma memória antes de compartilhá-la.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visualizador de fotos aprimorado', + description: + 'O visualizador de fotos e a tela de informações foram redesenhados. Mais limpos, bonitos e fáceis de ler.', + ), + ChangeLogEntryStrings( + title: 'Texto mais nítido nas fotos', + description: + 'Copiar texto das fotos agora está mais rápido e confiável. Toque e segure para começar a selecionar.', + ), + ChangeLogEntryStrings( + title: 'Cache mais inteligente', + description: + 'Suas fotos continuam abrindo rapidamente e ocupam menos espaço no dispositivo.', + ), + ChangeLogEntryStrings( + title: 'e muito mais!', + description: + 'Backups mais rápidos e confiáveis, navegação mais fluida na galeria, melhor carregamento de miniaturas e downloads mais confiáveis, melhor detecção de texto nas fotos, melhores cores de avatar, correções de falhas durante tarefas em segundo plano e muitos pequenos aprimoramentos em todo o app.', + isOnlineOnly: true, + ), + ], ), - 'pt_PT': _ChangeLogFeatureStrings( - deleteTitle: 'Confirmações de eliminação mais simples', - onlineAndroidDeleteDesc: - 'Ao eliminar do seu dispositivo, o Ente pode ajudá-lo a configurar a gestão de multimédia para evitar avisos repetidos do sistema. O Ente também pode memorizar a sua última escolha de eliminação.', - localAndroidDeleteDesc: - 'Ao eliminar do seu dispositivo, o Ente pode ajudá-lo a configurar a gestão de multimédia para evitar avisos repetidos do sistema.', - castTitle: 'Transmitir para vários ecrãs', - castDesc: - 'Agora pode transmitir álbuns para mais de um ecrã ao mesmo tempo, ver sessões ativas e parar uma sessão específica quando terminar.', - storageTitle: 'Limpeza de armazenamento mais fiável', - storageDesc: - 'O Ente limpa agora ficheiros temporários de imagens e vídeos de forma mais fiável, evitando que a app ocupe espaço extra no dispositivo.', + 'pt_PT': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comentários e reações nas memórias', + description: + 'Converse sobre memórias partilhadas com quem mais gosta enquanto as revive.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Partilhe memórias à sua maneira', + description: + 'Escolha exatamente quais fotografias e vídeos entram numa memória antes de a partilhar.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Visualizador de fotografias aperfeiçoado', + description: + 'O visualizador de fotografias e a folha de informações foram redesenhados. Mais simples, agradáveis e fáceis de ler.', + ), + ChangeLogEntryStrings( + title: 'Texto mais nítido nas fotografias', + description: + 'Copiar texto das fotografias é agora mais rápido e fiável. Toque sem soltar para começar a selecionar.', + ), + ChangeLogEntryStrings( + title: 'Armazenamento em cache mais inteligente', + description: + 'As suas fotografias continuam a abrir rapidamente e ocupam menos espaço no dispositivo.', + ), + ChangeLogEntryStrings( + title: 'e muito mais!', + description: + 'Cópias de segurança mais rápidas e fiáveis, navegação mais fluida na galeria, melhor carregamento de miniaturas e transferências mais fiáveis, melhor deteção de texto nas fotografias, melhores cores de avatar, correções de falhas durante tarefas em segundo plano e muitos pequenos aperfeiçoamentos em toda a aplicação.', + isOnlineOnly: true, + ), + ], ), - 'ro': _ChangeLogFeatureStrings( - deleteTitle: 'Confirmări de ștergere mai simple', - onlineAndroidDeleteDesc: - 'Când ștergi de pe dispozitiv, Ente te poate ajuta să configurezi gestionarea media pentru a evita solicitările repetate ale sistemului. Ente poate reține și ultima ta alegere de ștergere.', - localAndroidDeleteDesc: - 'Când ștergi de pe dispozitiv, Ente te poate ajuta să configurezi gestionarea media pentru a evita solicitările repetate ale sistemului.', - castTitle: 'Transmite pe mai multe ecrane', - castDesc: - 'Acum poți transmite albume pe mai multe ecrane în același timp, poți vedea sesiunile active și poți opri o anumită sesiune când ai terminat.', - storageTitle: 'Curățare mai fiabilă a stocării', - storageDesc: - 'Ente curăță acum mai fiabil fișierele temporare de imagini și video, împiedicând aplicația să păstreze spațiu suplimentar pe dispozitiv.', + 'ro': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Comentarii și reacții în amintiri', + description: + 'Vorbește despre amintirile partajate cu cei dragi, în timp ce le retrăiți.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Partajează amintirile în felul tău', + description: + 'Alege exact ce fotografii și videoclipuri intră într-o amintire înainte de a o partaja.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Vizualizator de fotografii îmbunătățit', + description: + 'Vizualizatorul de fotografii și panoul de informații au fost reproiectate. Mai clare, mai plăcute și mai ușor de citit.', + ), + ChangeLogEntryStrings( + title: 'Text mai clar în fotografii', + description: + 'Copierea textului din fotografii este acum mai rapidă și mai fiabilă. Apasă lung pentru a începe selectarea.', + ), + ChangeLogEntryStrings( + title: 'Stocare în cache mai inteligentă', + description: + 'Fotografiile se deschid în continuare rapid și ocupă mai puțin spațiu pe dispozitiv.', + ), + ChangeLogEntryStrings( + title: 'și multe altele!', + description: + 'Copii de siguranță mai rapide și mai fiabile, navigare mai fluidă în galerie, încărcare mai bună a miniaturilor și descărcări mai fiabile, detectare îmbunătățită a textului din fotografii, culori mai bune pentru avatare, remedieri pentru blocări în timpul activităților din fundal și multe mici îmbunătățiri în întreaga aplicație.', + isOnlineOnly: true, + ), + ], ), - 'ru': _ChangeLogFeatureStrings( - deleteTitle: 'Более простые подтверждения удаления', - onlineAndroidDeleteDesc: - 'При удалении с устройства Ente может помочь настроить управление медиа, чтобы избежать повторяющихся системных запросов. Ente также может запомнить ваш последний выбор удаления.', - localAndroidDeleteDesc: - 'При удалении с устройства Ente может помочь настроить управление медиа, чтобы избежать повторяющихся системных запросов.', - castTitle: 'Трансляция на несколько экранов', - castDesc: - 'Теперь вы можете транслировать альбомы сразу на несколько экранов, просматривать активные сеансы и останавливать нужный сеанс, когда закончите.', - storageTitle: 'Более надежная очистка хранилища', - storageDesc: - 'Теперь Ente надежнее удаляет временные файлы изображений и видео, чтобы приложение не занимало лишнее место в памяти устройства.', + 'ru': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Комментарии и реакции в воспоминаниях', + description: + 'Обсуждайте общие воспоминания с близкими, заново переживая их вместе.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Делитесь воспоминаниями по-своему', + description: + 'Перед публикацией выберите, какие именно фото и видео войдут в воспоминание.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Обновлённый просмотр фото', + description: + 'Мы переработали просмотр фото и панель сведений. Они стали чище, приятнее и удобнее для чтения.', + ), + ChangeLogEntryStrings( + title: 'Более чёткий текст на фото', + description: + 'Копировать текст с фотографий теперь быстрее и надёжнее. Нажмите и удерживайте, чтобы начать выделение.', + ), + ChangeLogEntryStrings( + title: 'Умнее кэширование', + description: + 'Фотографии по-прежнему быстро открываются, занимая меньше места на устройстве.', + ), + ChangeLogEntryStrings( + title: 'и не только!', + description: + 'Более быстрое и надёжное резервное копирование, плавный просмотр галереи, улучшенная загрузка миниатюр и надёжность скачивания, более точное распознавание текста на фото, улучшенные цвета аватаров, исправления сбоев во время фоновой работы и множество небольших улучшений во всём приложении.', + isOnlineOnly: true, + ), + ], ), - 'tr': _ChangeLogFeatureStrings( - deleteTitle: 'Daha kolay silme onayları', - onlineAndroidDeleteDesc: - 'Cihazınızdan silerken Ente, tekrarlanan sistem istemlerini önlemek için medya yönetimini ayarlamanıza yardımcı olabilir. Ente son silme seçiminizi de hatırlayabilir.', - localAndroidDeleteDesc: - 'Cihazınızdan silerken Ente, tekrarlanan sistem istemlerini önlemek için medya yönetimini ayarlamanıza yardımcı olabilir.', - castTitle: 'Birden fazla ekrana yayınla', - castDesc: - 'Artık albümleri aynı anda birden fazla ekrana yayınlayabilir, etkin oturumları görebilir ve işiniz bittiğinde belirli bir oturumu durdurabilirsiniz.', - storageTitle: 'Daha güvenilir depolama temizliği', - storageDesc: - 'Ente artık geçici görüntü ve video dosyalarını daha güvenilir şekilde temizleyerek uygulamanın cihazda fazladan depolama alanı tutmasını önler.', + 'tr': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Anılarda yorumlar ve tepkiler', + description: + 'Paylaşılan anıları yeniden yaşarken sevdiklerinizle onlar hakkında konuşun.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Anıları istediğiniz gibi paylaşın', + description: + 'Paylaşmadan önce bir anıya hangi fotoğraf ve videoların ekleneceğini tam olarak seçin.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Daha şık fotoğraf görüntüleyici', + description: + 'Fotoğraf görüntüleyici ve bilgi sayfası yeniden tasarlandı. Daha sade, güzel ve okunması kolay.', + ), + ChangeLogEntryStrings( + title: 'Fotoğraflardaki metin artık daha net', + description: + 'Fotoğraflardan metin kopyalamak artık daha hızlı ve güvenilir. Seçmeye başlamak için basılı tutun.', + ), + ChangeLogEntryStrings( + title: 'Daha akıllı önbellekleme', + description: + 'Fotoğraflarınız hızlı açılmaya devam ederken cihazınızda daha az yer kaplar.', + ), + ChangeLogEntryStrings( + title: 've daha fazlası!', + description: + 'Daha hızlı ve güvenilir yedeklemeler, daha akıcı galeri gezintisi, daha iyi küçük resim yükleme ve indirme güvenilirliği, fotoğraflarda geliştirilmiş metin algılama, daha iyi avatar renkleri, arka plan çalışmaları sırasında oluşan çökmeler için düzeltmeler ve uygulama genelinde birçok küçük iyileştirme.', + isOnlineOnly: true, + ), + ], ), - 'uk': _ChangeLogFeatureStrings( - deleteTitle: 'Простіші підтвердження видалення', - onlineAndroidDeleteDesc: - 'Під час видалення з пристрою Ente може допомогти налаштувати керування медіа, щоб уникнути повторних системних запитів. Ente також може запам’ятати ваш останній вибір видалення.', - localAndroidDeleteDesc: - 'Під час видалення з пристрою Ente може допомогти налаштувати керування медіа, щоб уникнути повторних системних запитів.', - castTitle: 'Трансляція на кілька екранів', - castDesc: - 'Тепер ви можете транслювати альбоми на кілька екранів одночасно, переглядати активні сеанси та зупиняти певний сеанс, коли завершите.', - storageTitle: 'Надійніше очищення сховища', - storageDesc: - 'Ente тепер надійніше очищує тимчасові файли зображень і відео, щоб застосунок не займав зайве місце у сховищі пристрою.', + 'uk': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Коментарі та реакції у спогадах', + description: + 'Обговорюйте спільні спогади з близькими, переживаючи їх знову.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Діліться спогадами по-своєму', + description: + 'Перед публікацією виберіть, які саме фото й відео увійдуть до спогаду.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Оновлений переглядач фото', + description: + 'Ми оновили переглядач фото та інформаційну панель. Вони стали охайнішими, приємнішими й легшими для читання.', + ), + ChangeLogEntryStrings( + title: 'Чіткіший текст на фото', + description: + 'Копіювати текст із фотографій тепер швидше й надійніше. Натисніть і утримуйте, щоб почати виділення.', + ), + ChangeLogEntryStrings( + title: 'Розумніше кешування', + description: + 'Фотографії, як і раніше, відкриваються швидко, займаючи менше місця на пристрої.', + ), + ChangeLogEntryStrings( + title: 'і не тільки!', + description: + 'Швидше та надійніше резервне копіювання, плавніший перегляд галереї, краще завантаження мініатюр і надійніше завантаження файлів, покращене розпізнавання тексту на фото, кращі кольори аватарів, виправлення збоїв під час фонової роботи та багато невеликих покращень у всьому застосунку.', + isOnlineOnly: true, + ), + ], ), - 'vi': _ChangeLogFeatureStrings( - deleteTitle: 'Xác nhận xóa dễ hơn', - onlineAndroidDeleteDesc: - 'Khi xóa khỏi thiết bị, Ente có thể giúp bạn thiết lập quản lý phương tiện để tránh các lời nhắc hệ thống lặp lại. Ente cũng có thể ghi nhớ lựa chọn xóa gần nhất của bạn.', - localAndroidDeleteDesc: - 'Khi xóa khỏi thiết bị, Ente có thể giúp bạn thiết lập quản lý phương tiện để tránh các lời nhắc hệ thống lặp lại.', - castTitle: 'Truyền lên nhiều màn hình', - castDesc: - 'Giờ bạn có thể truyền album lên nhiều màn hình cùng lúc, xem các phiên đang hoạt động và dừng một phiên cụ thể khi xong.', - storageTitle: 'Dọn dẹp bộ nhớ đáng tin cậy hơn', - storageDesc: - 'Ente giờ xóa các tệp ảnh và video tạm thời đáng tin cậy hơn, giúp ứng dụng không giữ thêm dung lượng lưu trữ trên thiết bị.', + 'vi': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: 'Bình luận và cảm xúc trong kỷ niệm', + description: + 'Trò chuyện về những kỷ niệm đã chia sẻ với người thân yêu khi cùng nhau sống lại những khoảnh khắc ấy.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Chia sẻ kỷ niệm theo cách của bạn', + description: + 'Chọn chính xác ảnh và video sẽ có trong một kỷ niệm trước khi chia sẻ.', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: 'Trình xem ảnh được trau chuốt', + description: + 'Trình xem ảnh và bảng thông tin đã được thiết kế lại. Gọn gàng, đẹp mắt và dễ đọc hơn.', + ), + ChangeLogEntryStrings( + title: 'Văn bản trong ảnh rõ nét hơn', + description: + 'Sao chép văn bản từ ảnh giờ nhanh hơn và đáng tin cậy hơn. Nhấn giữ để bắt đầu chọn.', + ), + ChangeLogEntryStrings( + title: 'Bộ nhớ đệm thông minh hơn', + description: + 'Ảnh vẫn mở nhanh trong khi chiếm ít dung lượng hơn trên thiết bị.', + ), + ChangeLogEntryStrings( + title: 'và nhiều hơn nữa!', + description: + 'Sao lưu nhanh và đáng tin cậy hơn, duyệt thư viện mượt mà hơn, tải hình thu nhỏ tốt hơn và tải xuống đáng tin cậy hơn, cải thiện khả năng phát hiện văn bản trong ảnh, màu hình đại diện đẹp hơn, sửa lỗi treo ứng dụng khi chạy tác vụ nền cùng nhiều cải tiến nhỏ khác trong toàn bộ ứng dụng.', + isOnlineOnly: true, + ), + ], ), - 'zh_CN': _ChangeLogFeatureStrings( - deleteTitle: '更轻松的删除确认', - onlineAndroidDeleteDesc: - '从设备删除时,Ente 可以帮助你设置媒体管理,避免重复的系统提示。Ente 还可以记住你上一次的删除选择。', - localAndroidDeleteDesc: '从设备删除时,Ente 可以帮助你设置媒体管理,避免重复的系统提示。', - castTitle: '投放到多个屏幕', - castDesc: '你现在可以将相册同时投放到多个屏幕,查看活跃会话,并在完成后停止指定会话。', - storageTitle: '更可靠的存储清理', - storageDesc: 'Ente 现在会更可靠地清理临时图片和视频文件,避免应用占用额外的设备存储空间。', + 'zh_CN': ChangeLogStrings( + entries: [ + ChangeLogEntryStrings( + title: '回忆中的评论和回应', + description: '与亲友一起重温共享回忆,边看边聊。', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: '按你的方式分享回忆', + description: '分享前,精确选择要加入回忆的照片和视频。', + isOnlineOnly: true, + ), + ChangeLogEntryStrings( + title: '更精致的照片查看器', + description: '照片查看器和信息面板已重新设计。界面更简洁、更美观,也更易读。', + ), + ChangeLogEntryStrings( + title: '照片中的文字更清晰', + description: '现在,从照片中复制文字更快、更可靠。长按即可开始选择。', + ), + ChangeLogEntryStrings( + title: '更智能的缓存', + description: '照片依然能快速打开,同时占用更少的设备空间。', + ), + ChangeLogEntryStrings( + title: '还有更多!', + description: + '备份更快、更可靠,浏览图库更流畅,缩略图加载更好,下载更可靠,照片文字检测更准确,头像颜色更协调,修复后台任务期间的崩溃问题,以及贯穿整个应用的众多细节优化。', + isOnlineOnly: true, + ), + ], ), }; } -class _ChangeLogFeatureStrings { - final String deleteTitle; - final String onlineAndroidDeleteDesc; - final String localAndroidDeleteDesc; - final String castTitle; - final String castDesc; - final String storageTitle; - final String storageDesc; +class ChangeLogEntryStrings { + final String title; + final String description; + final bool isOnlineOnly; - const _ChangeLogFeatureStrings({ - required this.deleteTitle, - required this.onlineAndroidDeleteDesc, - required this.localAndroidDeleteDesc, - required this.castTitle, - required this.castDesc, - required this.storageTitle, - required this.storageDesc, + const ChangeLogEntryStrings({ + required this.title, + required this.description, + this.isOnlineOnly = false, }); } diff --git a/mobile/apps/photos/lib/ui/social/comments_screen.dart b/mobile/apps/photos/lib/ui/social/comments_screen.dart index 8390c472a42..8a02f2dab38 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, @@ -205,29 +211,17 @@ class _FileCommentsBottomSheetState extends State { super.dispose(); } - Future _loadSharedCollections() async { - final collectionIDs = await FilesDB.instance.getAllCollectionIDsOfFile( - widget.fileID, - ); + bool _isOpenedFromHiddenCollection() => CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.collectionID); - // 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(); - } + Future _loadSharedCollections() async { + final sharedCollectionsList = + widget.sharedCollections ?? + await CollectionsService.instance.getSharedCollectionsForFile( + widget.fileID, + includeHidden: _isOpenedFromHiddenCollection(), + ); // Fetch data in parallel final sharedCollections = await Future.wait( 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..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,7 +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/services/collections_service.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"; @@ -32,11 +33,13 @@ 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, }) { return showModalBottomSheet( @@ -46,6 +49,7 @@ Future showLikeCollectionSelectorSheet( builder: (_) => LikeCollectionSelectorSheet( fileID: fileID, currentUserID: currentUserID, + collections: collections, file: file, ), ); @@ -54,11 +58,13 @@ Future showLikeCollectionSelectorSheet( class LikeCollectionSelectorSheet extends StatefulWidget { final int fileID; final int currentUserID; + final List collections; final EnteFile? file; const LikeCollectionSelectorSheet({ required this.fileID, required this.currentUserID, + required this.collections, this.file, super.key, }); @@ -89,30 +95,9 @@ 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), - ) - .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( @@ -154,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/likes_bottom_sheet.dart b/mobile/apps/photos/lib/ui/social/likes_bottom_sheet.dart index 325347eb95c..a1a0bc5ebde 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, }); @@ -76,31 +79,18 @@ class _LikesBottomSheetState extends State { _loadSharedCollections(); } + bool _isOpenedFromHiddenCollection() => CollectionsService.instance + .getHiddenCollectionIds() + .contains(widget.initialCollectionID); + 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: _isOpenedFromHiddenCollection(), + ); // 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 new file mode 100644 index 00000000000..ee56aea0ce8 --- /dev/null +++ b/mobile/apps/photos/lib/ui/social/widgets/file_social_overlay.dart @@ -0,0 +1,696 @@ +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/foundation.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.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/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"; +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); +const _socialControlsSize = 48.0; +// 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)]; +const _latestCommentShadows = [ + BoxShadow(color: Color(0x14000000), blurRadius: 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; + final int? currentUserID; + + /// 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.openingCollectionID, + this.onInteractionStart, + this.onInteractionEnd, + this.onVisibilityChanged, + super.key, + }); + + @override + State createState() => _FileSocialOverlayState(); +} + +class _FileSocialOverlayState extends State { + bool _hasEligibleSharedCollections = false; + bool _hasLiked = false; + int _commentCount = 0; + Comment? _latestComment; + User? _latestCommentAuthor; + int? _loadedFileID; + int _latestRefreshID = 0; + final _fileIDsWithReactionUpdateInProgress = {}; + + @override + void initState() { + super.initState(); + unawaited(_refreshSocialState()); + } + + @override + void didUpdateWidget(covariant FileSocialOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.uploadedFileID != widget.file.uploadedFileID) { + unawaited(_refreshSocialState()); + } + } + + void _clearSocialState() { + _loadedFileID = null; + _hasEligibleSharedCollections = false; + _hasLiked = false; + _commentCount = 0; + _latestComment = null; + _latestCommentAuthor = null; + } + + bool _isCurrentSocialRefresh(int refreshID) { + return mounted && refreshID == _latestRefreshID; + } + + bool _isOpenedFromHiddenCollection() { + final openingCollectionID = widget.openingCollectionID; + return openingCollectionID != null && + CollectionsService.instance.getHiddenCollectionIds().contains( + openingCollectionID, + ); + } + + Future _refreshSocialState() async { + // 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(); + widget.onVisibilityChanged?.call(false); + return; + } + + try { + final collectionIDs = await CollectionsService.instance + .getSharedCollectionIDsForFile( + fileID, + includeHidden: _isOpenedFromHiddenCollection(), + ); + if (!_isCurrentSocialRefresh(refreshID)) { + return; + } + + if (collectionIDs.isEmpty) { + setState(_clearSocialState); + widget.onVisibilityChanged?.call(false); + return; + } + + final provider = SocialDataProvider.instance; + final results = await Future.wait([ + provider.hasUserReactedToFileInCollections( + fileID, + currentUserID, + collectionIDs, + ), + provider.getCommentCountForFileInCollections(fileID, collectionIDs), + provider.getLatestCommentForFile( + fileID, + candidateCollectionIDs: collectionIDs, + ), + ]); + if (!_isCurrentSocialRefresh(refreshID)) { + return; + } + + final hasLiked = results[0] as bool; + 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 (!_isCurrentSocialRefresh(refreshID)) { + return; + } + + latestCommentAuthor = CommentAuthorResolver().resolve( + comment: latestComment, + anonDisplayNames: anonDisplayNames, + registeredUserResolver: (userID) => CollectionsService.instance + .getFileOwner(userID, latestComment.collectionID), + ); + } + + if (!_isCurrentSocialRefresh(refreshID)) { + return; + } + setState(() { + _hasEligibleSharedCollections = true; + _hasLiked = hasLiked; + _commentCount = commentCount; + _latestComment = latestComment; + _latestCommentAuthor = latestCommentAuthor; + _loadedFileID = fileID; + }); + widget.onVisibilityChanged?.call(true); + } catch (error, stackTrace) { + _logger.warning( + "Failed to refresh social overlay for file $fileID", + error, + stackTrace, + ); + if (_isCurrentSocialRefresh(refreshID) && _loadedFileID != fileID) { + setState(_clearSocialState); + widget.onVisibilityChanged?.call(false); + } + } + } + + Future _toggleReaction() async { + final file = widget.file; + final fileID = file.uploadedFileID; + final currentUserID = widget.currentUserID; + if (fileID == null || + currentUserID == null || + _fileIDsWithReactionUpdateInProgress.contains(fileID)) { + return; + } + + _fileIDsWithReactionUpdateInProgress.add(fileID); + try { + if (_hasLiked) { + await _unlikeFromAllCollections(fileID, currentUserID); + return; + } + + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile( + fileID, + includeHidden: _isOpenedFromHiddenCollection(), + ); + if (!mounted || + widget.file.uploadedFileID != fileID || + sharedCollections.isEmpty) { + 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.warning("Failed to like photo", error, stackTrace); + if (mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + _showFailedToUpdateLikeToast(); + } + } + return; + } + + await _runSheetAndRefresh( + () => showLikeCollectionSelectorSheet( + context, + fileID: fileID, + currentUserID: currentUserID, + collections: sharedCollections, + file: file, + ), + ); + } finally { + _fileIDsWithReactionUpdateInProgress.remove(fileID); + } + } + + 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, + ); + 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); + _showFailedToUpdateLikeToast(); + } + } catch (error, stackTrace) { + _logger.warning( + "Failed to unlike from all collections", + error, + stackTrace, + ); + if (mounted && widget.file.uploadedFileID == fileID) { + setState(() => _hasLiked = previousState); + _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; + await _runSheetAndRefresh(() async { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile( + fileID, + includeHidden: _isOpenedFromHiddenCollection(), + ); + if (!mounted || + widget.file.uploadedFileID != fileID || + sharedCollections.isEmpty) { + return; + } + final initialCollection = sharedCollections.firstWhereOrNull( + (collection) => collection.id == widget.openingCollectionID, + ); + await showLikesBottomSheet( + context, + fileID: fileID, + initialCollectionID: + initialCollection?.id ?? sharedCollections.first.id, + sharedCollections: sharedCollections, + ); + }); + } + + Future _openComments({Comment? comment}) async { + final fileID = widget.file.uploadedFileID; + if (fileID == null) return; + await _runSheetAndRefresh(() async { + final sharedCollections = await CollectionsService.instance + .getSharedCollectionsForFile( + fileID, + includeHidden: _isOpenedFromHiddenCollection(), + ); + if (!mounted || + widget.file.uploadedFileID != fileID || + sharedCollections.isEmpty) { + return; + } + + Collection? commentCollection; + Collection? openingCollection; + Collection? latestCommentCollection; + for (final collection in sharedCollections) { + if (collection.id == comment?.collectionID) { + commentCollection = collection; + } + if (collection.id == widget.openingCollectionID) { + openingCollection = collection; + } + if (collection.id == _latestComment?.collectionID) { + latestCommentCollection = collection; + } + } + final initialCollection = + commentCollection ?? + latestCommentCollection ?? + openingCollection ?? + sharedCollections.first; + + await showFileCommentsBottomSheet( + context, + collectionID: initialCollection.id, + fileID: fileID, + sharedCollections: sharedCollections, + ); + }); + } + + Future _runSheetAndRefresh(Future Function() showSheet) async { + widget.onInteractionStart?.call(); + try { + await showSheet(); + await _refreshSocialState(); + } finally { + widget.onInteractionEnd?.call(); + } + } + + @override + Widget build(BuildContext context) { + if (!_hasEligibleSharedCollections) { + return const SizedBox.shrink(); + } + + final latestComment = _latestComment; + final latestCommentAuthor = _latestCommentAuthor; + final latestCommentPill = + latestComment != null && latestCommentAuthor != null + ? Transform.translate( + key: const ValueKey("latest-comment"), + 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(); + return IgnorePointer( + ignoring: _loadedFileID != widget.file.uploadedFileID, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AnimatedSwitcher( + duration: _motionDuration(context, 220), + reverseDuration: Duration.zero, + switchInCurve: Curves.easeOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.94, end: 1).animate(animation), + alignment: Alignment.topRight, + child: child, + ), + ), + child: latestCommentPill, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: AppLocalizations.of(context).like, + child: GestureDetector( + onLongPress: _showLikes, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _likeIconPadding, + style: IconButton.styleFrom( + overlayColor: WidgetStateColor.transparent, + ), + onPressed: _toggleReaction, + icon: Icon( + _hasLiked ? EnteIcons.likeFilled : EnteIcons.likeStroke, + color: _hasLiked ? _likedColor : Colors.white, + size: _likeIconSize, + shadows: _socialIconShadows, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Tooltip( + message: AppLocalizations.of(context).comments, + child: SizedBox.square( + dimension: _socialControlsSize, + child: IconButton( + padding: _commentIconPadding, + onPressed: _openComments, + icon: _CommentBadgeIcon(count: _commentCount), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +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 width; + final int currentUserID; + final VoidCallback onTap; + + const _LatestCommentPill({ + required this.comment, + required this.author, + 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); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: _latestCommentShadows, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(6), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + 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( + widget.author, + type: AvatarType.small, + currentUserID: widget.currentUserID, + ), + ), + ), + ], + ), + ); + } +} + +class _CommentBadgeIcon extends StatelessWidget { + final int count; + + const _CommentBadgeIcon({required this.count}); + + @override + Widget build(BuildContext context) { + final displayCount = count > 99 ? "99+" : count.toString(); + return Stack( + clipBehavior: Clip.none, + children: [ + const Icon( + EnteIcons.commentBubbleStroke, + color: Colors.white, + size: _commentIconSize, + shadows: _socialIconShadows, + ), + Positioned( + right: -7, + top: -7, + 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, + boxShadow: _countBadgeShadows, + 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)), + ), + ), + ], + ); + } +} 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..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 @@ -63,12 +63,10 @@ class SharedEmptyState extends StatelessWidget { ButtonComponent( label: strings.shareAnAlbum, shouldSurfaceExecutionStates: false, - onTap: () async { - showCollectionActionSheet( - context, - actionType: CollectionActionType.shareCollection, - ); - }, + onTap: () => showCollectionActionSheet( + context, + actionType: CollectionActionType.shareCollection, + ), ), ], ), diff --git a/mobile/apps/photos/lib/ui/tabs/home_widget.dart b/mobile/apps/photos/lib/ui/tabs/home_widget.dart index e54bf722112..61599cc2f1d 100644 --- a/mobile/apps/photos/lib/ui/tabs/home_widget.dart +++ b/mobile/apps/photos/lib/ui/tabs/home_widget.dart @@ -1238,7 +1238,6 @@ class _HomeWidgetState extends State { locale: Localizations.localeOf(context), isLocalGallery: isLocalGalleryMode, isSignedIn: Configuration.instance.isLoggedIn(), - isAndroid: Platform.isAndroid, ); if (!mounted || action == ChangeLogAction.skip) { return; 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..c22c748bf72 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,7 +609,7 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - showCollectionActionSheet( + await showCollectionActionSheet( context, selectedFiles: widget.selectedFiles, actionType: CollectionActionType.moveFiles, @@ -617,7 +617,7 @@ class _FileSelectionActionsWidgetState } Future _moveFilesToHiddenAlbum() async { - showCollectionActionSheet( + await showCollectionActionSheet( context, selectedFiles: widget.selectedFiles, actionType: CollectionActionType.moveToHiddenCollection, @@ -625,11 +625,14 @@ class _FileSelectionActionsWidgetState } Future _addToAlbum() async { - showCollectionActionSheet(context, selectedFiles: widget.selectedFiles); + await showCollectionActionSheet( + context, + selectedFiles: widget.selectedFiles, + ); } Future _addToHiddenAlbum() async { - showCollectionActionSheet( + await showCollectionActionSheet( context, selectedFiles: widget.selectedFiles, actionType: CollectionActionType.addToHiddenAlbum, @@ -883,7 +886,7 @@ class _FileSelectionActionsWidgetState skipNotify: true, ); } - showCollectionActionSheet( + await showCollectionActionSheet( context, selectedFiles: widget.selectedFiles, actionType: CollectionActionType.unHide, @@ -1120,6 +1123,9 @@ class _FileSelectionActionsWidgetState if (actionResult?.action == ButtonAction.first) { widget.selectedFiles.clearAll(); + if (mounted) { + await showMediaManagementHintSheet(context); + } } } 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..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 @@ -352,12 +352,12 @@ class _PeopleSelectionActionWidgetState final selectedPersonIds = _getSelectedPersonIds(personMap); if (selectedPersonIds.isEmpty) return; if (!mounted) return; - showCollectionActionSheet( + widget.selectedPeople.clearAll(); + await showCollectionActionSheet( context, selectedPeople: selectedPersonIds, actionType: CollectionActionType.autoAddPeople, ); - widget.selectedPeople.clearAll(); } Future _updatePinState(bool shouldPin) async { 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 45d83e127b0..e745c8736a3 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 = 130.0; + enum DetailPageMode { minimalistic, full } class DetailPageConfiguration { @@ -302,6 +306,16 @@ class _BodyState extends State<_Body> { }, valueListenable: _selectedIndexNotifier, ), + ValueListenableBuilder( + valueListenable: _selectedIndexNotifier, + builder: (BuildContext context, int selectedIndex, _) { + return _GallerySocialOverlay( + file: _files![selectedIndex], + mode: widget.config.mode, + isGuestView: isGuestView, + ); + }, + ), if (_qrHelper != null) ValueListenableBuilder( valueListenable: _selectedIndexNotifier, @@ -634,3 +648,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, + ), + ), + ); + } +} 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..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); - showCollectionActionSheet( - context, - selectedFiles: selectedFiles, - actionType: CollectionActionType.addFiles, - ); } } @@ -615,7 +573,7 @@ class FileAppBarState extends State { Future _handleUnHideRequest(BuildContext context) async { final selectedFiles = SelectedFiles(); selectedFiles.files.add(widget.file); - showCollectionActionSheet( + await showCollectionActionSheet( context, selectedFiles: selectedFiles, actionType: CollectionActionType.unHide, 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..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 @@ -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, @@ -229,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, @@ -257,12 +194,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,149 +304,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 || - file.collectionID == null || - widget.userID == null) { - return; - } - - // If already liked, unlike from ALL shared collections - if (_hasLiked) { - await _unlikeFromAllCollections(); - return; - } - - // Check how many shared collections contain this file - final sharedCount = await CollectionsService.instance - .getSharedCollectionCountForFile(file.uploadedFileID!); - - if (sharedCount <= 1) { - // Single shared collection: like directly - final previousState = _hasLiked; - _hasLiked = true; - safeRefresh(); - - try { - await SocialDataProvider.instance.toggleReaction( - userID: widget.userID!, - collectionID: file.collectionID!, - 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!, - 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; @@ -531,76 +319,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/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), + ], + ), ), ], ), 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/lib/ui/viewer/search/result/contact_person_picker_page.dart b/mobile/apps/photos/lib/ui/viewer/search/result/contact_person_picker_page.dart index 480e1e5e493..79b92278667 100644 --- a/mobile/apps/photos/lib/ui/viewer/search/result/contact_person_picker_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/search/result/contact_person_picker_page.dart @@ -4,18 +4,61 @@ import "package:flutter/material.dart"; import "package:hugeicons/hugeicons.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/ml/face/person.dart"; +import "package:photos/models/search/search_constants.dart"; import "package:photos/ui/viewer/people/face_thumbnail_squircle.dart"; import "package:photos/ui/viewer/people/person_face_widget.dart"; import "package:photos/utils/person_contact_linking_util.dart"; +sealed class ContactPersonPickerCandidate { + const ContactPersonPickerCandidate(); +} + +class ContactPersonPickerPersonCandidate extends ContactPersonPickerCandidate { + const ContactPersonPickerPersonCandidate(this.person); + + final PersonEntity person; +} + +class ContactPersonPickerClusterCandidate extends ContactPersonPickerCandidate { + const ContactPersonPickerClusterCandidate(this.clusterID); + + final String clusterID; +} + +List buildContactPersonPickerCandidates({ + required Iterable> faceResultParams, + required Iterable persons, +}) { + final personsByID = {for (final person in persons) person.remoteID: person}; + final candidates = []; + + for (final params in faceResultParams) { + final personID = params[kPersonParamID] as String?; + if (personID != null) { + final person = personsByID[personID]; + if (person != null) { + candidates.add(ContactPersonPickerPersonCandidate(person)); + } + continue; + } + + final clusterID = params[kClusterParamId] as String?; + if (clusterID != null) { + candidates.add(ContactPersonPickerClusterCandidate(clusterID)); + } + } + + return candidates; +} + abstract class ContactPersonPickerResult { const ContactPersonPickerResult(); } class ContactPersonPickerSelected extends ContactPersonPickerResult { - const ContactPersonPickerSelected(this.person); + const ContactPersonPickerSelected(this.candidate); - final PersonEntity person; + final ContactPersonPickerCandidate candidate; } class ContactPersonPickerPickPhoto extends ContactPersonPickerResult { @@ -26,13 +69,13 @@ class ContactPersonPickerPage extends StatefulWidget { const ContactPersonPickerPage({ required this.contactUserId, required this.contactEmail, - required this.persons, + required this.candidates, super.key, }); final int contactUserId; final String contactEmail; - final List persons; + final List candidates; static const _horizontalPadding = 16.0; static const _gridGap = 10.0; @@ -49,26 +92,34 @@ class _ContactPersonPickerPageState extends State { Widget build(BuildContext context) { final colors = context.componentColors; final l10n = AppLocalizations.of(context); - final persons = - widget.persons + final personCandidates = + widget.candidates + .whereType() .where( - (person) => !isLinkedToDifferentContact( - person, + (candidate) => !isLinkedToDifferentContact( + candidate.person, contactUserId: widget.contactUserId, email: widget.contactEmail, ), ) .where( - (person) => person.data.name.toLowerCase().contains(_searchQuery), + (candidate) => candidate.person.data.name.toLowerCase().contains( + _searchQuery, + ), ) .toList() ..sort((first, second) { final comparison = compareAsciiLowerCaseNatural( - first.data.name, - second.data.name, + first.person.data.name, + second.person.data.name, ); return _sortAscending ? comparison : -comparison; }); + final candidates = [ + ...personCandidates, + if (_searchQuery.isEmpty) + ...widget.candidates.whereType(), + ]; return Scaffold( backgroundColor: colors.backgroundBase, @@ -96,7 +147,7 @@ class _ContactPersonPickerPageState extends State { ), sliver: SliverToBoxAdapter(child: _buildSearchRow(context)), ), - _buildGrid(persons), + _buildGrid(candidates), ], ), ); @@ -163,7 +214,7 @@ class _ContactPersonPickerPageState extends State { } } - Widget _buildGrid(List persons) { + Widget _buildGrid(List candidates) { return SliverPadding( padding: const EdgeInsets.fromLTRB( ContactPersonPickerPage._horizontalPadding, @@ -186,10 +237,15 @@ class _ContactPersonPickerPageState extends State { childAspectRatio: tileSize / (tileSize + 28), ), delegate: SliverChildBuilderDelegate( - childCount: persons.length, + childCount: candidates.length, (context, index) => _PersonTile( - key: ValueKey(persons[index].remoteID), - person: persons[index], + key: ValueKey(switch (candidates[index]) { + ContactPersonPickerPersonCandidate(:final person) => + "person-${person.remoteID}", + ContactPersonPickerClusterCandidate(:final clusterID) => + "cluster-$clusterID", + }), + candidate: candidates[index], size: tileSize, ), ), @@ -201,20 +257,28 @@ class _ContactPersonPickerPageState extends State { } class _PersonTile extends StatelessWidget { - const _PersonTile({super.key, required this.person, required this.size}); + const _PersonTile({super.key, required this.candidate, required this.size}); - final PersonEntity person; + final ContactPersonPickerCandidate candidate; final double size; @override Widget build(BuildContext context) { final colors = context.componentColors; final pixelWidth = (size * MediaQuery.devicePixelRatioOf(context)).round(); + final person = switch (candidate) { + ContactPersonPickerPersonCandidate(:final person) => person, + ContactPersonPickerClusterCandidate() => null, + }; + final clusterID = switch (candidate) { + ContactPersonPickerPersonCandidate() => null, + ContactPersonPickerClusterCandidate(:final clusterID) => clusterID, + }; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => - Navigator.of(context).pop(ContactPersonPickerSelected(person)), + Navigator.of(context).pop(ContactPersonPickerSelected(candidate)), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -226,7 +290,8 @@ class _PersonTile extends StatelessWidget { child: ColoredBox( color: colors.strokeFaint, child: PersonFaceWidget( - personId: person.remoteID, + personId: person?.remoteID, + clusterID: clusterID, keepAlive: true, cachedPixelWidth: pixelWidth, ), @@ -237,7 +302,7 @@ class _PersonTile extends StatelessWidget { SizedBox( width: double.infinity, child: Text( - person.data.name, + person?.data.name ?? " ", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyles.body.copyWith(color: colors.textBase), diff --git a/mobile/apps/photos/lib/ui/viewer/search/result/edit_contact_page.dart b/mobile/apps/photos/lib/ui/viewer/search/result/edit_contact_page.dart index 398ee53750d..f1fcaa42a00 100644 --- a/mobile/apps/photos/lib/ui/viewer/search/result/edit_contact_page.dart +++ b/mobile/apps/photos/lib/ui/viewer/search/result/edit_contact_page.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:typed_data"; import "package:ente_components/ente_components.dart"; @@ -11,9 +12,9 @@ import "package:photos/core/event_bus.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/ml/face/person.dart"; -import "package:photos/models/search/search_constants.dart"; import "package:photos/module/download/thumbnail.dart"; import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart"; +import "package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart"; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import "package:photos/services/photos_contacts_service.dart"; import "package:photos/services/search_service.dart"; @@ -62,6 +63,7 @@ class _EditContactPageState extends State { int _photoLoadGeneration = 0; PersonEntity? _initialLinkedPerson; PersonEntity? _draftLinkedPerson; + String? _draftUnassignedClusterID; bool _linkDirty = false; @override @@ -90,7 +92,8 @@ class _EditContactPageState extends State { String get _initialName => (widget.existingContact?.data?.name ?? "").trim(); bool get _hasUnsavedChanges => _nameController.text.trim() != _initialName || _photoDirty || _linkDirty; - bool get _hasLinkedPersonDraft => _draftLinkedPerson != null; + bool get _hasLinkedPersonDraft => + _draftLinkedPerson != null || _draftUnassignedClusterID != null; bool get _hasContactPhoto => _draftPhotoBytes != null || (!_photoDirty && @@ -318,19 +321,16 @@ class _EditContactPageState extends State { if (_isSaving) { return; } - List persons; + List candidates; try { final faceResults = await SearchService.instance.getAllFace( null, minClusterSize: kMinimumClusterSizeAllFaces, ); - final personIdsWithFiles = faceResults - .map((result) => result.params[kPersonParamID]) - .whereType() - .toSet(); - persons = (await PersonService.instance.getPersons()) - .where((person) => personIdsWithFiles.contains(person.remoteID)) - .toList(); + candidates = buildContactPersonPickerCandidates( + faceResultParams: faceResults.map((result) => result.params), + persons: await PersonService.instance.getPersons(), + ); } catch (e, s) { _logger.warning( "Failed to load people before editing contact photo", @@ -343,7 +343,7 @@ class _EditContactPageState extends State { if (!mounted) { return; } - if (persons.isEmpty) { + if (candidates.isEmpty) { await _pickContactPhoto(); return; } @@ -353,7 +353,7 @@ class _EditContactPageState extends State { ContactPersonPickerPage( contactUserId: widget.contactUserId, contactEmail: widget.email, - persons: persons, + candidates: candidates, ), ); if (!mounted || result == null) { @@ -364,13 +364,19 @@ class _EditContactPageState extends State { return; } if (result is ContactPersonPickerSelected) { - await _draftSelectedPerson(result.person); + switch (result.candidate) { + case ContactPersonPickerPersonCandidate(:final person): + await _draftSelectedPerson(person); + case ContactPersonPickerClusterCandidate(:final clusterID): + await _draftSelectedCluster(clusterID); + } } } void _draftUnlinkPerson() { setState(() { _draftLinkedPerson = null; + _draftUnassignedClusterID = null; _linkDirty = _initialLinkedPerson != null; }); } @@ -388,15 +394,41 @@ class _EditContactPageState extends State { _nameController.text = person.data.name; setState(() { _draftLinkedPerson = person; + _draftUnassignedClusterID = null; _linkDirty = _initialLinkedPerson?.remoteID != person.remoteID || _personNeedsContactLinkUpdate(person); }); } + Future _draftSelectedCluster(String clusterID) async { + setState(() { + _draftLinkedPerson = null; + _draftUnassignedClusterID = clusterID; + _linkDirty = true; + }); + await _loadClusterPhotoDraft(clusterID, showError: true); + } + Future _loadPersonPhotoDraft( PersonEntity person, { required bool showError, + }) => _loadFacePhotoDraft( + () => buildContactPhotoAttachmentBytesFromPerson(person), + showError: showError, + ); + + Future _loadClusterPhotoDraft( + String clusterID, { + required bool showError, + }) => _loadFacePhotoDraft( + () => buildContactPhotoAttachmentBytesFromCluster(clusterID), + showError: showError, + ); + + Future _loadFacePhotoDraft( + Future Function() loadPhotoBytes, { + required bool showError, }) async { final loadGeneration = ++_photoLoadGeneration; setState(() { @@ -404,9 +436,9 @@ class _EditContactPageState extends State { }); Uint8List? photoBytes; try { - photoBytes = await buildContactPhotoAttachmentBytesFromPerson(person); + photoBytes = await loadPhotoBytes(); } catch (e, s) { - _logger.warning("Failed to build contact photo from person", e, s); + _logger.warning("Failed to build contact photo from face", e, s); } if (!mounted || loadGeneration != _photoLoadGeneration) { return; @@ -487,6 +519,39 @@ class _EditContactPageState extends State { }); } + Future _createPersonForDraftCluster(String contactName) async { + final clusterID = _draftUnassignedClusterID; + if (clusterID == null) { + return; + } + + final person = await PersonService.instance.addPerson( + name: contactName, + clusterID: clusterID, + ); + unawaited( + ClusterFeedbackService.instance + .checkAndDoAutomaticMerges(person, personClusterID: clusterID) + .catchError((Object error, StackTrace stackTrace) { + _logger.warning( + "Failed to automatically merge clusters for new person", + error, + stackTrace, + ); + return false; + }), + ); + _draftLinkedPerson = person; + _draftUnassignedClusterID = null; + Bus.instance.fire( + PeopleChangedEvent( + type: PeopleEventType.saveOrEditPerson, + person: person, + source: "edit_contact_create_person", + ), + ); + } + Future _saveContact() async { if (!_canSave) { return; @@ -497,6 +562,7 @@ class _EditContactPageState extends State { try { final contactName = _nameController.text.trim(); final contactNameChanged = contactName != _initialName; + await _createPersonForDraftCluster(contactName); var saved = await PhotosContactsService.instance.createOrUpdateContact( contactUserId: widget.contactUserId, name: contactName, diff --git a/mobile/apps/photos/lib/utils/contact_photo_util.dart b/mobile/apps/photos/lib/utils/contact_photo_util.dart index 73462163d22..98362815fcf 100644 --- a/mobile/apps/photos/lib/utils/contact_photo_util.dart +++ b/mobile/apps/photos/lib/utils/contact_photo_util.dart @@ -30,14 +30,39 @@ Future buildContactPhotoAttachmentBytesFromFace({ Future buildContactPhotoAttachmentBytesFromPerson( PersonEntity person, ) async { - final hiddenFileIds = await SearchService.instance.getHiddenFiles().then( - (files) => files.map((file) => file.uploadedFileID).toSet(), - ); final faceIds = await MLDataDB.instance.getFaceIDsForPersonOrderedByScore( person.remoteID, ); + return _buildContactPhotoAttachmentBytesFromFaceGroup( + faceIds: faceIds, + avatarFaceId: person.data.avatarFaceID, + personID: person.remoteID, + ); +} + +Future buildContactPhotoAttachmentBytesFromCluster( + String clusterID, +) async { + final faceIds = await MLDataDB.instance.getFaceIDsForClusterOrderedByScore( + clusterID, + ); + return _buildContactPhotoAttachmentBytesFromFaceGroup( + faceIds: faceIds, + clusterID: clusterID, + ); +} + +Future _buildContactPhotoAttachmentBytesFromFaceGroup({ + required List faceIds, + String? avatarFaceId, + String? personID, + String? clusterID, +}) async { + final hiddenFileIds = await SearchService.instance.getHiddenFiles().then( + (files) => files.map((file) => file.uploadedFileID).toSet(), + ); EnteFile? sourceFile; - String? faceId = person.data.avatarFaceID; + String? faceId = avatarFaceId; if (faceId != null) { final fileId = getFileIdFromFaceId(faceId); @@ -67,7 +92,8 @@ Future buildContactPhotoAttachmentBytesFromPerson( final face = await MLDataDB.instance.getCoverFaceForPerson( recentFileID: sourceFile.uploadedFileID!, avatarFaceId: faceId, - personID: person.remoteID, + personID: personID, + clusterID: clusterID, ); if (face == null) { return null; diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 638d13f4bea..fc2075ed1b0 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..56c1347b59a --- /dev/null +++ b/mobile/apps/photos/test/db/social_db_test.dart @@ -0,0 +1,143 @@ +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 stays inside the requested collection scope", () async { + await SocialDB.instance.upsertReactions([ + _reaction(id: "hidden", collectionID: 20, userID: 1), + _reaction(id: "deleted", collectionID: 20, userID: 2, isDeleted: true), + _reaction( + id: "comment-reaction", + collectionID: 10, + userID: 2, + commentID: "comment", + ), + ]); + + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, [10]), + isFalse, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 1, [ + 10, + 20, + ]), + isTrue, + ); + expect( + await SocialDB.instance.hasUserReactedToFileInCollections(100, 2, [ + 10, + 20, + ]), + 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], + ); + expect(visibleLatest?.id, "visible"); + }); + + 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.getCommentCountForFileInCollections(100, [10]), + 1, + ); + expect( + await SocialDB.instance.getCommentCountForFileInCollections(100, [ + 10, + 20, + ]), + 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/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); + }); } diff --git a/mobile/apps/photos/test/ui/family/family_dashboard_test.dart b/mobile/apps/photos/test/ui/family/family_dashboard_test.dart index a1929df9180..be5f800bb7f 100644 --- a/mobile/apps/photos/test/ui/family/family_dashboard_test.dart +++ b/mobile/apps/photos/test/ui/family/family_dashboard_test.dart @@ -127,22 +127,21 @@ void main() { }); group('familyMemberAvatarComponentColor', () { - test('uses black for the current user and hashes other members', () { + test('hashes every member including the current user', () { final currentUser = _member(email: 'admin@example.com', userID: 1); final otherMember = _member(email: 'saved@example.com', userID: 42); expect( - familyMemberAvatarComponentColor( - currentUser, - currentUserEmail: 'ADMIN@example.com', + familyMemberAvatarComponentColor(currentUser), + avatarComponentColorForIdentity( + avatarIdentityKey( + email: currentUser.email, + userID: currentUser.userID, + ), ), - AvatarComponentColor.black, ); expect( - familyMemberAvatarComponentColor( - otherMember, - currentUserEmail: currentUser.email, - ), + familyMemberAvatarComponentColor(otherMember), avatarComponentColorForIdentity( avatarIdentityKey( email: otherMember.email, @@ -165,16 +164,13 @@ void main() { email: 'pending@example.com', status: FamilyMemberStatus.invited, ); - final members = [ - _member( - email: 'admin@example.com', - userID: 1, - isAdmin: true, - status: FamilyMemberStatus.self, - ), - savedMember, - pendingMember, - ]; + final currentUser = _member( + email: 'admin@example.com', + userID: 1, + isAdmin: true, + status: FamilyMemberStatus.self, + ); + final members = [currentUser, savedMember, pendingMember]; FamilyMember? selectedMember; await tester.pumpWidget( @@ -191,11 +187,16 @@ void main() { members: members, isAdmin: true, contactsByUserId: { + 1: _contact(currentUser, name: 'Current user'), 42: _contact(savedMember, name: 'Saved member'), }, profilePictureBytesByUserId: const {}, linkedPersonIdsByUserId: const {}, - onMemberTap: (member) => selectedMember = member, + linkedPersonNamesByUserId: const { + 1: 'Person current user', + 42: 'Person saved member', + }, + onMemberTap: (member, _) => selectedMember = member, onAddMember: () {}, remainingSlots: 2, ), @@ -206,8 +207,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('admin@example.com'), findsOneWidget); - expect(find.text('admin'), findsOneWidget); + expect(find.text('Current user'), findsWidgets); expect(find.text('Saved member'), findsNWidgets(2)); expect(find.text('pending@example.com'), findsOneWidget); expect( @@ -236,9 +236,9 @@ void main() { ); expect(avatars.map((avatar) => avatar.seed), everyElement(isNull)); final currentUserAvatar = avatars.singleWhere( - (avatar) => avatar.semanticLabel == 'admin@example.com', + (avatar) => avatar.semanticLabel == 'Current user', ); - expect(currentUserAvatar.color, AvatarComponentColor.black); + expect(currentUserAvatar.color, isIn(avatarComponentIdentityPalette)); final savedMemberAvatar = avatars.singleWhere( (avatar) => avatar.semanticLabel == 'Saved member', ); @@ -265,11 +265,12 @@ void main() { }, ); - testWidgets('uses a linked Person face when no contact photo is saved', ( + testWidgets('uses a linked Person name and face without a saved contact', ( tester, ) async { - final member = _member(email: 'me@example.com', userID: 42); + final member = _member(email: 'member@example.com', userID: 42); final members = [member]; + String? selectedDisplayName; await tester.pumpWidget( MaterialApp( @@ -284,7 +285,10 @@ void main() { contactsByUserId: const {}, profilePictureBytesByUserId: const {}, linkedPersonIdsByUserId: const {42: 'person-42'}, - onMemberTap: (_) {}, + linkedPersonNamesByUserId: const {42: 'Current person'}, + onMemberTap: (_, displayName) { + selectedDisplayName = displayName; + }, onAddMember: () {}, remainingSlots: 0, ), @@ -292,11 +296,15 @@ void main() { ), ); + expect(find.text('Current person'), findsWidgets); expect(find.byType(PersonFaceWidget), findsOneWidget); final personAvatar = tester.widget( find.byType(PersonFaceWidget), ); expect(personAvatar.personId, 'person-42'); + + await tester.tap(find.byType(MenuComponent)); + expect(selectedDisplayName, 'Current person'); }); testWidgets('sorts other members by their displayed name or email', ( @@ -330,7 +338,8 @@ void main() { }, profilePictureBytesByUserId: const {}, linkedPersonIdsByUserId: const {}, - onMemberTap: (_) {}, + linkedPersonNamesByUserId: const {4: 'Aaron'}, + onMemberTap: (_, _) {}, onAddMember: () {}, remainingSlots: 0, ), @@ -343,7 +352,7 @@ void main() { tester .widgetList(find.byType(MenuComponent)) .map((item) => item.title), - ['admin@example.com', 'Amy', 'bob@example.com', 'Zoe'], + ['admin@example.com', 'Aaron', 'Amy', 'Zoe'], ); }); } 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..c9f9823ff1f --- /dev/null +++ b/mobile/apps/photos/test/ui/home/memories/custom_listener_test.dart @@ -0,0 +1,95 @@ +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("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( + 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); + }); +} 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..d82499aaf2f --- /dev/null +++ b/mobile/apps/photos/test/ui/home/memories/memory_progress_indicator_test.dart @@ -0,0 +1,59 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:photos/ui/home/memories/memory_progress_indicator.dart"; + +void main() { + testWidgets("fills the available width with equal chunks", (tester) async { + const totalSteps = 6; + const availableWidth = 343.0; + const currentIndex = 3; + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: availableWidth, + child: MemoryProgressIndicator( + totalSteps: totalSteps, + currentIndex: currentIndex, + ), + ), + ), + ), + ); + + final widths = [ + for (var index = 0; index < totalSteps; index++) + tester + .getSize(find.byKey(ValueKey("memory-progress-segment-$index"))) + .width, + ]; + + 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, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox( + width: 343, + child: MemoryProgressIndicator(totalSteps: 20, currentIndex: 7), + ), + ), + ), + ); + + expect(find.byType(Row), findsNothing); + expect(tester.getSize(find.byType(LinearProgressIndicator)).width, 343); + }); +} diff --git a/mobile/apps/photos/test/ui/viewer/search/result/contact_person_picker_page_test.dart b/mobile/apps/photos/test/ui/viewer/search/result/contact_person_picker_page_test.dart new file mode 100644 index 00000000000..ddf1c488a84 --- /dev/null +++ b/mobile/apps/photos/test/ui/viewer/search/result/contact_person_picker_page_test.dart @@ -0,0 +1,28 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:photos/models/ml/face/person.dart"; +import "package:photos/models/search/search_constants.dart"; +import "package:photos/ui/viewer/search/result/contact_person_picker_page.dart"; + +void main() { + test("includes unassigned face clusters alongside existing people", () { + final person = PersonEntity("person-1", PersonData(name: "Alex")); + + final candidates = buildContactPersonPickerCandidates( + faceResultParams: const [ + {kPersonParamID: "person-1"}, + {kClusterParamId: "cluster-1"}, + ], + persons: [person], + ); + + expect(candidates, hasLength(2)); + expect( + (candidates.first as ContactPersonPickerPersonCandidate).person, + same(person), + ); + expect( + (candidates.last as ContactPersonPickerClusterCandidate).clusterID, + "cluster-1", + ); + }); +} 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/app/src/main/AndroidManifest.xml b/mobile/native/android/apps/ensu/app/src/main/AndroidManifest.xml index cf009beba64..1f4f1118703 100644 --- a/mobile/native/android/apps/ensu/app/src/main/AndroidManifest.xml +++ b/mobile/native/android/apps/ensu/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + 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..a6f66e0d861 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 @@ -11,7 +11,6 @@ import io.ente.ensu.device.AndroidDeviceCapabilityProvider import io.ente.ensu.settings.SessionPreferencesDataStore import io.ente.ensu.chat.ChatRepository import io.ente.ensu.config.loadConfigDefaults -import io.ente.ensu.bindings.ModelDownloadTarget import io.ente.ensu.llm.LlmProvider import io.ente.ensu.llm.ModelDownloader import io.ente.ensu.llm.ModelSettingsState @@ -63,12 +62,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(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 +91,6 @@ 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) } - .toMutableList() - if (settings.useCustomModel && settings.modelUrl.isNotBlank()) { - targets += ModelDownloadTarget.Gguf( - "custom:${settings.modelUrl}", - settings.modelUrl, - settings.mmprojUrl.takeIf { it.isNotBlank() } - ) - } - return targets - } - @Suppress("DEPRECATION") private fun getAppVersion(application: Application): String { val packageManager = application.packageManager diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/EnsuApplication.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/EnsuApplication.kt index 8c0b7201cc8..7bc5256cea3 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/EnsuApplication.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/EnsuApplication.kt @@ -8,8 +8,8 @@ class EnsuApplication : Application() { val modelDownloader by lazy { ModelDownloader(this) } val transcriber by lazy { Transcriber( - modelDownloader.modelPath(modelDownloader.transcriptionModelTarget).absolutePath, - modelDownloader.modelPath(modelDownloader.voiceActivityModelTarget).absolutePath + modelDownloader.modelDir(modelDownloader.transcriptionTarget).absolutePath, + modelDownloader.voiceActivityModelPath().absolutePath ) } } diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/ChatStoreActions.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/ChatStoreActions.kt index f57c0fcb1b8..1d130ae6902 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/ChatStoreActions.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/ChatStoreActions.kt @@ -8,7 +8,7 @@ import io.ente.ensu.logging.FileLogRepository import io.ente.ensu.llm.DownloadPhase import io.ente.ensu.llm.LlmMessage import io.ente.ensu.llm.LlmMessageRole -import io.ente.ensu.llm.LlmModelTarget +import io.ente.ensu.llm.LlmModelSelection import io.ente.ensu.llm.ModelDownloader import io.ente.ensu.llm.LlmProvider import io.ente.ensu.chat.Attachment @@ -438,7 +438,7 @@ internal class ChatStoreActions( stopRequested = false val settings = state.value.modelSettings - val target = modelSettingsActions.resolveTarget(settings) + val selection = modelSettingsActions.resolveSelection(settings) val prompt = buildPrompt(userMessage.text, userMessage.attachments) val generationToken = nextGenerationToken() @@ -463,7 +463,7 @@ internal class ChatStoreActions( val isActive = { isGenerationActive(generationToken, sessionId) } val progressTracker = DownloadProgressTracker() try { - llmProvider.ensureModelReady(target) { progress -> + llmProvider.ensureModelReady(selection) { progress -> if (!isActive()) return@ensureModelReady val resolvedProgress = progressTracker.resolve(progress) state.update { appState -> @@ -515,7 +515,7 @@ internal class ChatStoreActions( appState.copy(chat = appState.chat.copy(isModelDownloaded = true)) } - val generationLimits = resolveGenerationLimits(target) + val generationLimits = resolveGenerationLimits(selection) val historySelection = buildHistorySelection( sessionId = sessionId, promptText = prompt.text, @@ -567,7 +567,7 @@ internal class ChatStoreActions( try { val summary = llmProvider.generateChat( - target = target, + selection = selection, messages = llmMessages, imageFiles = prompt.imageFiles, temperature = modelSettingsActions.resolveTemperature(settings), @@ -737,13 +737,13 @@ internal class ChatStoreActions( sessionSummaryJob?.cancel() if (sessionSummaries.containsKey(sessionKey(sessionId))) return val summaryInput = buildSessionSummaryInput(sessionId) ?: return - val target = modelSettingsActions.resolveTarget(state.value.modelSettings) + val selection = modelSettingsActions.resolveSelection(state.value.modelSettings) sessionSummaryJob = scope.launch(Dispatchers.Default) { val summary = generateSessionSummary( input = summaryInput.text, fallback = summaryInput.fallback, - target = target + selection = selection ) ?: return@launch if (!isActive) return@launch withContext(Dispatchers.Main) { @@ -774,12 +774,12 @@ internal class ChatStoreActions( private suspend fun generateSessionSummary( input: String, fallback: String, - target: LlmModelTarget + selection: LlmModelSelection ): String? { if (!state.value.chat.deviceCapability.isChatSupported()) { return sessionTitleFromText(fallback, fallback = fallback) } - if (!modelDownloader.isDownloaded(target.downloadTarget)) { + if (!modelDownloader.isDownloaded(selection.modelTarget)) { return sessionTitleFromText(fallback, fallback = fallback) } val cleanedInput = sanitizeTitleText(input) @@ -793,7 +793,7 @@ internal class ChatStoreActions( val buffer = StringBuilder() try { llmProvider.generateChat( - target = target, + selection = selection, messages = messages, imageFiles = emptyList(), temperature = 0.2f, @@ -1132,11 +1132,11 @@ internal class ChatStoreActions( return HistorySelection(selected, inputTokens, inputBudget, inputTokens > inputBudget) } - private fun resolveGenerationLimits(target: LlmModelTarget): GenerationLimits { - val contextLength = llmProvider.loadedContextLength(target) - ?: target.contextLength + private fun resolveGenerationLimits(selection: LlmModelSelection): GenerationLimits { + val contextLength = llmProvider.loadedContextLength(selection) + ?: selection.contextLength ?: DEFAULT_CONTEXT_LENGTH - val maxOutput = resolveMaxOutputTokens(target.maxTokens, contextLength) + val maxOutput = resolveMaxOutputTokens(selection.maxTokens, contextLength) return GenerationLimits(contextLength = contextLength, maxOutput = maxOutput) } diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/VoiceInput.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/VoiceInput.kt index cdd933503a2..f6efd855ce7 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/VoiceInput.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/chat/VoiceInput.kt @@ -98,8 +98,8 @@ internal class VoiceTranscriptionController( private val onTranscript: (String) -> Unit ) { private val modelTargets = listOf( - modelDownloader.transcriptionModelTarget, - modelDownloader.voiceActivityModelTarget + modelDownloader.transcriptionTarget, + modelDownloader.voiceActivityTarget ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) 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..0b76ba6cf6d 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 @@ -1,17 +1,13 @@ package io.ente.ensu.llm -import io.ente.ensu.bindings.ModelDownloadTarget +import io.ente.ensu.bindings.ModelTarget -data class LlmModelTarget( +data class LlmModelSelection( val id: String, - val url: String, - val mmprojUrl: String? = null, + val modelTarget: ModelTarget, val contextLength: Int? = null, val maxTokens: Int? = null -) { - val downloadTarget: ModelDownloadTarget - get() = ModelDownloadTarget.Gguf(id = id, url = url, mmprojUrl = mmprojUrl) -} +) data class DownloadProgress( val percent: Int?, diff --git a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmProvider.kt b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmProvider.kt index b5bd22e65e3..7717d48cfde 100644 --- a/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmProvider.kt +++ b/mobile/native/android/apps/ensu/app/src/main/java/io/ente/ensu/llm/LlmProvider.kt @@ -42,18 +42,18 @@ class LlmProvider( private val modelLoadMutex = Mutex() suspend fun ensureModelReady( - target: LlmModelTarget, + selection: LlmModelSelection, onProgress: (DownloadProgress) -> Unit ) { withContext(ioDispatcher) { modelLoadMutex.withLock { - ensureModelReadyLocked(target, onProgress) + ensureModelReadyLocked(selection, onProgress) } } } suspend fun generateChat( - target: LlmModelTarget, + selection: LlmModelSelection, messages: List, imageFiles: List, temperature: Float, @@ -66,7 +66,7 @@ class LlmProvider( val mmprojPath = if (imageFiles.isEmpty()) { null } else { - downloader.mmprojPath(target.downloadTarget) + downloader.llmMmprojPath(selection.modelTarget)?.absolutePath } val clampedTemperature = temperature.coerceIn(0.35f, 0.7f) @@ -96,15 +96,16 @@ class LlmProvider( GenerationSummary(summary.jobId, summary.generatedTokens ?: 0, summary.totalTimeMs) } - suspend fun prewarmImageInference(target: LlmModelTarget) { + suspend fun prewarmImageInference(selection: LlmModelSelection) { withContext(ioDispatcher) { runCatching { modelLoadMutex.withLock { - if (!downloader.isDownloaded(target.downloadTarget)) return@withLock - val mmprojPath = downloader.mmprojPath(target.downloadTarget) + if (!downloader.isDownloaded(selection.modelTarget)) return@withLock + val mmprojPath = + downloader.llmMmprojPath(selection.modelTarget)?.absolutePath ?.takeIf { File(it).exists() } ?: return@withLock - ensureModelReadyLocked(target, onProgress = {}) + ensureModelReadyLocked(selection, onProgress = {}) val context = loadedContext ?: return@withLock unloadTranscriptionModelIfLoaded() context.prewarmMultimodal(mmprojPath, null) @@ -115,8 +116,8 @@ class LlmProvider( } } - fun loadedContextLength(target: LlmModelTarget): Int? { - val modelKey = LoadedModelKey(target.id, target.contextLength) + fun loadedContextLength(selection: LlmModelSelection): Int? { + val modelKey = LoadedModelKey(selection.id, selection.contextLength) return if (currentModelKey == modelKey && loadedContext != null && loadedModel != null) { currentContextLength } else { @@ -170,12 +171,12 @@ class LlmProvider( } private suspend fun ensureModelReadyLocked( - target: LlmModelTarget, + selection: LlmModelSelection, onProgress: (DownloadProgress) -> Unit, allowRecovery: Boolean = true ) { deviceCapabilityProvider.chatCapability().requireChatSupported() - val modelKey = LoadedModelKey(target.id, target.contextLength) + val modelKey = LoadedModelKey(selection.id, selection.contextLength) if (!backendInitialized) { llmInitBackend() backendInitialized = true @@ -187,15 +188,19 @@ class LlmProvider( unloadModel() - val downloaded = downloader.download(listOf(target.downloadTarget), onProgress) + val wasAlreadyDownloaded = downloader.isDownloaded(selection.modelTarget) + downloader.download(listOf(selection.modelTarget), onProgress) onProgress(DownloadProgress(100, "Loading model...", phase = DownloadPhase.Loading)) try { - loadWithFallbacks(target, downloader.modelPath(target.downloadTarget)) + loadWithFallbacks( + selection, + requireNotNull(downloader.llmModelPath(selection.modelTarget)) + ) } catch (error: Throwable) { - if (allowRecovery && !downloaded && downloader.removeDownloaded(target.downloadTarget)) { + if (allowRecovery && wasAlreadyDownloaded && downloader.removeDownloaded(selection.modelTarget)) { onProgress(DownloadProgress(0, "Starting download...", phase = DownloadPhase.Downloading)) - ensureModelReadyLocked(target, onProgress, allowRecovery = false) + ensureModelReadyLocked(selection, onProgress, allowRecovery = false) return } throw error @@ -203,8 +208,8 @@ class LlmProvider( onProgress(DownloadProgress(100, "Ready", phase = DownloadPhase.Ready)) } - private fun loadWithFallbacks(target: LlmModelTarget, modelFile: File) { - val desiredCtx = target.contextLength ?: 12000 + private fun loadWithFallbacks(selection: LlmModelSelection, modelFile: File) { + val desiredCtx = selection.contextLength ?: 12000 val contexts = listOf(desiredCtx, 12000, 8192, 4096, 2048, 1024).distinct().filter { it > 0 } val threads = max(1, Runtime.getRuntime().availableProcessors() - 1) val batch = 512 @@ -228,7 +233,7 @@ class LlmProvider( nBatch = batch ) loadedContext = model.newContext(contextParams) - currentModelKey = LoadedModelKey(target.id, target.contextLength) + currentModelKey = LoadedModelKey(selection.id, selection.contextLength) currentContextLength = ctx return } catch (err: Throwable) { 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..1d0a6db5532 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 @@ -3,18 +3,22 @@ package io.ente.ensu.llm import android.content.Context import android.os.Environment import android.util.Log +import io.ente.ensu.bindings.CancellationToken import io.ente.ensu.bindings.ModelDownloadCallback import io.ente.ensu.bindings.ModelDownloadCore import io.ente.ensu.bindings.ModelDownloadProgress import io.ente.ensu.bindings.LlmException -import io.ente.ensu.bindings.ModelDownloadTarget -import io.ente.ensu.bindings.migrateEnsuLegacyModels +import io.ente.ensu.bindings.ModelTarget +import io.ente.ensu.bindings.LegacyModels +import io.ente.ensu.bindings.migrateMobileModels +import io.ente.ensu.bindings.transcriptionModelTarget +import io.ente.ensu.bindings.voiceActivityModelTarget import io.ente.ensu.bindings.uniffiEnsureInitialized -import io.ente.ensu.config.loadConfigDefaults import java.io.File -import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext @@ -26,59 +30,57 @@ class ModelDownloader(context: Context) { private val legacyTranscriptionDir = File(appContext.dataDir, "app_ensu_transcription_models") private val downloadMutex = Mutex() private val core: ModelDownloadCore - val transcriptionModelTarget: ModelDownloadTarget - val voiceActivityModelTarget: ModelDownloadTarget + val transcriptionTarget: ModelTarget + val voiceActivityTarget: ModelTarget init { uniffiEnsureInitialized() ModelDownloadJobService.attach(appContext) core = ModelDownloadCore(modelsDir.absolutePath) - val defaults = loadConfigDefaults() - transcriptionModelTarget = ModelDownloadTarget.TarGz( - defaults.transcriptionModel.id, - defaults.transcriptionModel.url - ) - voiceActivityModelTarget = ModelDownloadTarget.Onnx( - defaults.voiceActivityModel.id, - defaults.voiceActivityModel.url - ) + transcriptionTarget = transcriptionModelTarget() + voiceActivityTarget = voiceActivityModelTarget() } fun needsMigration(): Boolean = legacyDir?.exists() == true || legacyTranscriptionDir.exists() - fun migrate(targets: List) { + fun migrate(legacyModelUrl: String?, legacyMmprojUrl: String?): String? { File(appContext.filesDir, "llm").deleteRecursively() - migrateEnsuLegacyModels( + return migrateMobileModels( modelsDir.absolutePath, - legacyDir?.absolutePath, - legacyTranscriptionDir.absolutePath, - targets, - transcriptionModelTarget, - voiceActivityModelTarget + LegacyModels( + legacyDir?.absolutePath, + legacyTranscriptionDir.absolutePath, + legacyModelUrl, + legacyMmprojUrl + ) ) } val isDownloadActive: Boolean get() = core.isDownloadActive() - fun modelPath(target: ModelDownloadTarget): File = File(core.modelPath(target)) + fun modelDir(target: ModelTarget): File = File(core.modelDir(target)) + + fun llmModelPath(target: ModelTarget): File? = + core.llmModelPath(target)?.let(::File) - fun mmprojPath(target: ModelDownloadTarget): String? = core.mmprojPath(target) + fun llmMmprojPath(target: ModelTarget): File? = + core.llmMmprojPath(target)?.let(::File) - fun isDownloaded(target: ModelDownloadTarget): Boolean = core.isDownloaded(target) + fun voiceActivityModelPath(): File = File(core.voiceActivityModelPath()) - fun removeDownloaded(target: ModelDownloadTarget): Boolean = core.removeDownloaded(target) + fun isDownloaded(target: ModelTarget): Boolean = core.isDownloaded(target) - fun cancel() = core.cancel() + fun removeDownloaded(target: ModelTarget): Boolean = core.removeDownloaded(target) - suspend fun estimateDownloadSize(target: ModelDownloadTarget): Long? = withContext(Dispatchers.IO) { + suspend fun estimateDownloadSize(target: ModelTarget): Long? = withContext(Dispatchers.IO) { core.estimatedDownloadSize(target) } suspend fun download( - targets: List, + targets: List, onProgress: (DownloadProgress) -> Unit - ): Boolean = withContext(Dispatchers.IO) { + ): Unit = withContext(Dispatchers.IO) { if (!downloadMutex.tryLock()) throw LlmException.Cancelled() try { downloadLocked(targets, onProgress) @@ -88,26 +90,35 @@ class ModelDownloader(context: Context) { } private suspend fun downloadLocked( - targets: List, + targets: List, onProgress: (DownloadProgress) -> Unit - ): Boolean { - val downloadJob = coroutineContext[Job] - if (targets.all { core.isDownloaded(it) }) return false - - ModelDownloadJobService.begin { core.cancel() } - return try { - core.download( - targets, - object : ModelDownloadCallback { - override fun onProgress(progress: ModelDownloadProgress) { - progress.logLine?.let { Log.i("ModelDownloader", it) } - ModelDownloadJobService.update(progress.percent, progress.totalBytes == null) - onProgress(DownloadProgress(progress.percent, progress.status)) - } - - override fun isCancelled(): Boolean = downloadJob?.isCancelled == true + ) { + if (targets.all { core.isDownloaded(it) }) return + + val token = CancellationToken() + ModelDownloadJobService.begin { token.cancel() } + try { + coroutineScope { + val download = async { + core.download( + targets, + object : ModelDownloadCallback { + override fun onProgress(progress: ModelDownloadProgress) { + progress.logLine?.let { Log.i("ModelDownloader", it) } + ModelDownloadJobService.update(progress.percent, progress.totalBytes == null) + onProgress(DownloadProgress(progress.percent, progress.status)) + } + }, + token + ) } - ) + try { + download.await() + } catch (e: CancellationException) { + token.cancel() + throw e + } + } } finally { ModelDownloadJobService.end() } 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..368c76fbd78 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 @@ -6,6 +6,7 @@ import io.ente.ensu.AppState import io.ente.ensu.bindings.ConfigDefaults import io.ente.ensu.bindings.DownloadError import io.ente.ensu.bindings.LlmException +import io.ente.ensu.bindings.mobileLlmTarget import io.ente.ensu.device.isChatSupported import io.ente.ensu.logging.FileLogRepository import io.ente.ensu.logging.LogLevel @@ -33,15 +34,14 @@ internal class ModelSettingsActions( } fun updateModelSettings(settings: ModelSettingsState) { - val oldTarget = resolveTarget(state.value.modelSettings) - val newTarget = resolveTarget(settings) + val oldSelection = resolveSelection(state.value.modelSettings) + val newSelection = resolveSelection(settings) state.update { appState -> appState.copy(modelSettings = settings) } - if (downloadIdentityChanged(oldTarget, newTarget)) { + if (downloadIdentityChanged(oldSelection, newSelection)) { modelDownloadJob?.cancel() modelDownloadJob = null - modelDownloader.cancel() } refreshModelDownloadInfo() } @@ -63,7 +63,6 @@ internal class ModelSettingsActions( fun refreshModelDownloadInfo() { if (!state.value.chat.deviceCapability.isChatSupported()) { modelDownloadJob?.cancel() - modelDownloader.cancel() persistModelDownloadRequested(false) state.update { appState -> appState.copy( @@ -79,8 +78,8 @@ internal class ModelSettingsActions( } return } - val target = resolveTarget(state.value.modelSettings) - val isDownloaded = modelDownloader.isDownloaded(target.downloadTarget) + val selection = resolveSelection(state.value.modelSettings) + val isDownloaded = modelDownloader.isDownloaded(selection.modelTarget) if (isDownloaded) { persistModelDownloadRequested(false) } @@ -117,7 +116,7 @@ internal class ModelSettingsActions( } } - val size = modelDownloader.estimateDownloadSize(target.downloadTarget) + val size = modelDownloader.estimateDownloadSize(selection.modelTarget) state.update { appState -> appState.copy( chat = appState.chat.copy( @@ -136,8 +135,8 @@ internal class ModelSettingsActions( if (currentState.chat.isDownloading || currentState.chat.isGenerating) return if (!userInitiated && !currentState.chat.hasRequestedModelDownload) return - val target = resolveTarget(currentState.modelSettings) - val isDownloaded = modelDownloader.isDownloaded(target.downloadTarget) + val selection = resolveSelection(currentState.modelSettings) + val isDownloaded = modelDownloader.isDownloaded(selection.modelTarget) if (isDownloaded) { state.update { appState -> appState.copy( @@ -156,7 +155,7 @@ internal class ModelSettingsActions( logRepository.log( LogLevel.Info, "Model download started", - details = "model=${target.id}", + details = "model=${selection.id}", tag = "Model" ) state.update { appState -> @@ -182,14 +181,14 @@ internal class ModelSettingsActions( var retryCount = 0 while (true) { try { - llmProvider.ensureModelReady(target) { progress -> + llmProvider.ensureModelReady(selection) { progress -> val resolvedProgress = progressTracker.resolve(progress) if (!isDownloaded && resolvedProgress.isFinished && !loggedComplete) { loggedComplete = true logRepository.log( LogLevel.Info, "Model download complete", - details = "model=${target.id}", + details = "model=${selection.id}", tag = "Model" ) } @@ -262,12 +261,12 @@ internal class ModelSettingsActions( if (currentState.chat.isGenerating || currentState.chat.isDownloading) return if (!currentState.chat.deviceCapability.isChatSupported()) return - val target = resolveTarget(currentState.modelSettings) - if (!modelDownloader.isDownloaded(target.downloadTarget)) return + val selection = resolveSelection(currentState.modelSettings) + if (!modelDownloader.isDownloaded(selection.modelTarget)) return scope.launch { try { - llmProvider.prewarmImageInference(target) + llmProvider.prewarmImageInference(selection) } catch (err: Throwable) { logRepository.log( LogLevel.Warning, @@ -282,7 +281,6 @@ internal class ModelSettingsActions( fun cancelModelDownload() { modelDownloadJob?.cancel() modelDownloadJob = null - modelDownloader.cancel() persistModelDownloadRequested(false) state.update { appState -> appState.copy( @@ -314,29 +312,16 @@ 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 - } + fun resolveSelection(settings: ModelSettingsState): LlmModelSelection { + 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 id = if (useCustom) { - (listOf(configDefaults.mobileDefaultModel) + configDefaults.mobileModelPresets) - .firstOrNull { it.url == url && it.mmprojUrl == mmproj } - ?.id - ?: "custom:$url" - } else { - configDefaults.mobileDefaultModel.id - } - return LlmModelTarget( - id = id, - url = url, - mmprojUrl = mmproj, + return LlmModelSelection( + id = preset.id, + modelTarget = mobileLlmTarget(preset.id), contextLength = contextLength, maxTokens = maxTokens ) @@ -349,12 +334,10 @@ internal class ModelSettingsActions( } private fun downloadIdentityChanged( - oldTarget: LlmModelTarget, - newTarget: LlmModelTarget + oldSelection: LlmModelSelection, + newSelection: LlmModelSelection ): Boolean { - return oldTarget.id != newTarget.id || - oldTarget.url != newTarget.url || - oldTarget.mmprojUrl != newTarget.mmprojUrl + return oldSelection.id != newSelection.id } companion object { 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..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,47 +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 contextLength by remember(state) { mutableStateOf(state.contextLength) } var maxTokens by remember(state) { mutableStateOf(state.maxTokens) } var temperature by remember(state) { mutableStateOf(state.temperature) } @@ -106,8 +78,6 @@ fun ModelSettingsScreen( var isModelMenuExpanded by remember { mutableStateOf(false) } val selectedModel = modelChoices.firstOrNull { it.id == selectedModelId } ?: modelChoices.first() - val isCustomSelected = selectedModel.isCustom - val canSave = !isCustomSelected || customModelUrl.isNotBlank() Column( modifier = Modifier @@ -118,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() ) @@ -150,40 +120,12 @@ fun ModelSettingsScreen( onClick = { selectedModelId = choice.id isModelMenuExpanded = false - if (choice.isCustom) { - customModelUrl = "" - customMmprojUrl = "" - } } ) } } } - 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 = "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() - ) - } - } - Spacer(modifier = Modifier.height(EnsuSpacing.lg.dp)) ExpandButton( title = "Advanced limits", @@ -248,37 +190,16 @@ fun ModelSettingsScreen( Button( onClick = { - val savedState = when { - selectedModel.isDefault -> state.copy( - useCustomModel = false, - modelUrl = "", - mmprojUrl = "", - contextLength = contextLength, - maxTokens = maxTokens, - temperature = temperature - ) - selectedModel.isCustom -> state.copy( - useCustomModel = true, - modelUrl = customModelUrl, - mmprojUrl = customMmprojUrl, - contextLength = contextLength, - maxTokens = maxTokens, - temperature = temperature - ) - else -> state.copy( - useCustomModel = true, - modelUrl = selectedModel.url.orEmpty(), - mmprojUrl = selectedModel.mmproj.orEmpty(), - 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) @@ -289,8 +210,6 @@ fun ModelSettingsScreen( TextButton(onClick = { onReset() selectedModelId = DEFAULT_OPTION_ID - customModelUrl = "" - customMmprojUrl = "" contextLength = "" maxTokens = "" temperature = "" @@ -334,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 c22bd01d6b1..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,9 +1,7 @@ package io.ente.ensu.llm data class ModelSettingsState( - val useCustomModel: Boolean = false, - val modelUrl: String = "", - val mmprojUrl: 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 053f24e9f2e..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,9 +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(), - mmprojUrl = prefs[Keys.mmprojUrl].orEmpty(), + modelId = prefs[Keys.modelId].orEmpty(), contextLength = prefs[Keys.contextLength].orEmpty(), maxTokens = prefs[Keys.maxTokens].orEmpty(), temperature = prefs[Keys.temperature].orEmpty() @@ -41,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 @@ -67,9 +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.mmprojUrl] = settings.mmprojUrl + prefs[Keys.modelId] = settings.modelId prefs[Keys.contextLength] = settings.contextLength prefs[Keys.maxTokens] = settings.maxTokens prefs[Keys.temperature] = settings.temperature @@ -84,9 +101,7 @@ class AdvancedSettingsDataStore(private val context: Context) { suspend fun resetModelSettings() { context.advancedSettingsPreferences.edit { prefs -> - prefs[Keys.useCustomModel] = false - prefs[Keys.modelUrl] = "" - prefs[Keys.mmprojUrl] = "" + prefs[Keys.modelId] = "" prefs[Keys.contextLength] = "" prefs[Keys.maxTokens] = "" prefs[Keys.temperature] = "" @@ -103,9 +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 mmprojUrl = stringPreferencesKey("mmproj_url") + val modelId = stringPreferencesKey("model_id") val contextLength = stringPreferencesKey("context_length") val maxTokens = stringPreferencesKey("max_tokens") val temperature = stringPreferencesKey("temperature") 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 61285a659d1..1b33c55baab 100644 Binary files a/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.jar and b/mobile/native/android/apps/ensu/gradle/wrapper/gradle-wrapper.jar differ 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. 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..1a316a53584 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,9 +114,11 @@ 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 + modelDir: downloader.modelDir(downloader.transcriptionTarget).path, + vadModelPath: downloader.voiceActivityModelPath().path ) let provider = LlmProvider(downloader: downloader, transcriber: transcriber) let voiceTranscriber = VoiceTranscriptionService(transcriber: transcriber, downloader: downloader) @@ -459,12 +461,12 @@ final class ChatViewModel: ObservableObject { private func prewarmImageInferenceIfDownloaded() { guard !isGenerating && !isDownloading else { return } guard !isChatUnsupported else { return } - let target = modelSettings.currentTarget() - guard downloader.isDownloaded(target: target.downloadTarget) else { return } + let selection = modelSettings.currentSelection() + guard downloader.isDownloaded(selection.modelTarget) else { return } Task { [weak self] in guard let self else { return } - await self.provider.prewarmImageInference(target: target) + await self.provider.prewarmImageInference(selection) } } @@ -560,11 +562,11 @@ final class ChatViewModel: ObservableObject { guard !trimmed.isEmpty || !attachments.isEmpty else { return } Task { @MainActor in - let target = self.modelSettings.currentTarget() + let selection = self.modelSettings.currentSelection() self.hasRequestedModelDownload = true do { - try await self.ensureModelReadyShared(target: target) + try await self.ensureModelReadyShared(selection) self.isModelDownloaded = true } catch { if self.isCancellation(error) { @@ -680,8 +682,8 @@ final class ChatViewModel: ObservableObject { hasRequestedModelDownload = false return } - let target = modelSettings.currentTarget() - isModelDownloaded = downloader.isDownloaded(target: target.downloadTarget) + let selection = modelSettings.currentSelection() + isModelDownloaded = downloader.isDownloaded(selection.modelTarget) if isModelDownloaded { clearDownloadProgressMemory() modelDownloadSizeBytes = nil @@ -690,9 +692,9 @@ final class ChatViewModel: ObservableObject { Task { [weak self] in guard let self else { return } - let size = await downloader.estimateDownloadSize(target: target.downloadTarget) + let size = await downloader.estimateDownloadSize(selection.modelTarget) await MainActor.run { - guard self.modelReadyKey(for: self.modelSettings.currentTarget()) == self.modelReadyKey(for: target) else { + guard self.modelReadyKey(for: self.modelSettings.currentSelection()) == self.modelReadyKey(for: selection) else { return } if self.sharedModelReadyTask == nil && @@ -712,15 +714,15 @@ final class ChatViewModel: ObservableObject { sharedModelReadyKey = nil } - private func modelReadyKey(for target: LlmModelTarget) -> ModelReadyKey { - ModelReadyKey(id: target.id, requestedContextLength: target.contextLength) + private func modelReadyKey(for selection: LlmModelSelection) -> ModelReadyKey { + ModelReadyKey(id: selection.id, requestedContextLength: selection.contextLength) } - private func ensureModelReadyShared(target: LlmModelTarget) async throws { + private func ensureModelReadyShared(_ selection: LlmModelSelection) async throws { if isChatUnsupported { throw UnsupportedDeviceMemoryError(capability: deviceCapability) } - let modelKey = modelReadyKey(for: target) + let modelKey = modelReadyKey(for: selection) if let existingTask = sharedModelReadyTask, sharedModelReadyKey == modelKey { try await existingTask.value return @@ -737,7 +739,7 @@ final class ChatViewModel: ObservableObject { var retryCount = 0 while true { do { - try await provider.ensureModelReady(target: target) { progress in + try await provider.ensureModelReady(selection) { progress in Task { @MainActor in self.handleProgress(progress) } @@ -784,8 +786,8 @@ final class ChatViewModel: ObservableObject { hasRequestedModelDownload = true } - let target = modelSettings.currentTarget() - let isDownloaded = downloader.isDownloaded(target: target.downloadTarget) + let selection = modelSettings.currentSelection() + let isDownloaded = downloader.isDownloaded(selection.modelTarget) if isDownloaded { isModelDownloaded = true modelDownloadSizeBytes = nil @@ -795,12 +797,12 @@ final class ChatViewModel: ObservableObject { isDownloading = true seedDownloadProgressMemory() modelDownloadLoggedStart = true - logger.info("Model download started", details: "model=\(target.id)") + logger.info("Model download started", details: "model=\(selection.id)") modelDownloadTask?.cancel() modelDownloadTask = Task { do { - try await self.ensureModelReadyShared(target: target) + try await self.ensureModelReadyShared(selection) } catch { if isCancellation(error) { return @@ -832,8 +834,8 @@ final class ChatViewModel: ObservableObject { downloadToast = nil return } - let target = modelSettings.currentTarget() - let isDownloaded = downloader.isDownloaded(target: target.downloadTarget) + let selection = modelSettings.currentSelection() + let isDownloaded = downloader.isDownloaded(selection.modelTarget) isModelDownloaded = isDownloaded if !isDownloaded { return @@ -843,7 +845,7 @@ final class ChatViewModel: ObservableObject { modelDownloadTask = Task { [weak self] in guard let self else { return } do { - try await self.ensureModelReadyShared(target: target) + try await self.ensureModelReadyShared(selection) } catch { if self.isCancellation(error) { return @@ -951,7 +953,7 @@ final class ChatViewModel: ObservableObject { sessionSummaryTask?.cancel() stopRequested = false - let target = modelSettings.currentTarget() + let selection = modelSettings.currentSelection() let prompt = buildPrompt(text: userNode.text, attachments: userNode.attachments) let generationId = UUID() @@ -968,7 +970,7 @@ final class ChatViewModel: ObservableObject { generationTask = Task { do { - try await self.ensureModelReadyShared(target: target) + try await self.ensureModelReadyShared(selection) } catch { if isCancellation(error) { self.sharedModelReadyTask?.cancel() @@ -992,7 +994,7 @@ final class ChatViewModel: ObservableObject { downloadToast = DownloadToastState( phase: .errorDownload, percent: nil, - status: self.userFacingModelReadyError(error, wasDownloaded: self.downloader.isDownloaded(target: target.downloadTarget)), + status: self.userFacingModelReadyError(error, wasDownloaded: self.downloader.isDownloaded(selection.modelTarget)), offerRetryDownload: true ) activeGenerationId = nil @@ -1001,7 +1003,7 @@ final class ChatViewModel: ObservableObject { return } - let generationLimits = resolveGenerationLimits(target: target) + let generationLimits = resolveGenerationLimits(selection) let historySelection = buildHistorySelection( sessionId: userNode.sessionId, promptText: prompt.text, @@ -1072,7 +1074,7 @@ final class ChatViewModel: ObservableObject { do { let summary = try await provider.generateChat( - target: target, + selection, messages: messages, imageFiles: prompt.imageFiles, temperature: resolveTemperature(), @@ -1555,7 +1557,7 @@ final class ChatViewModel: ObservableObject { guard sessionSummaries[summaryKey] == nil else { return } guard let summaryInput = buildSessionSummaryInput(sessionId: sessionId) else { return } let existingSummary = sessionSummaries[summaryKey] - let target = modelSettings.currentTarget() + let selection = modelSettings.currentSelection() let provider = provider sessionSummaryTask = Task.detached(priority: .utility) { [weak self] in @@ -1572,7 +1574,7 @@ final class ChatViewModel: ObservableObject { fallback: summaryInput.fallback, existingSummary: existingSummary, provider: provider, - target: target + selection: selection ) guard let summary else { return } @@ -1658,7 +1660,7 @@ final class ChatViewModel: ObservableObject { fallback: String, existingSummary: String?, provider: LlmProvider, - target: LlmModelTarget + selection: LlmModelSelection ) async -> String? { if let existingSummary, !existingSummary.isEmpty { return nil } let cleanedInput = sanitizeTitleText(input) @@ -1674,7 +1676,7 @@ final class ChatViewModel: ObservableObject { do { _ = try await provider.generateChat( - target: target, + selection, messages: messages, imageFiles: [], temperature: 0.2, @@ -1877,9 +1879,9 @@ final class ChatViewModel: ObservableObject { return HistorySelection(messages: selected, inputTokens: inputTokens, inputBudget: inputBudget, wasTrimmed: inputTokens > inputBudget) } - private func resolveGenerationLimits(target: LlmModelTarget) -> GenerationLimits { - let contextLength = provider.loadedContextLength(target: target) ?? target.contextLength ?? 12000 - let maxOutput = resolveMaxOutputTokens(configuredMaxTokens: target.maxTokens, contextLength: contextLength) + private func resolveGenerationLimits(_ selection: LlmModelSelection) -> GenerationLimits { + let contextLength = provider.loadedContextLength(selection) ?? selection.contextLength ?? 12000 + let maxOutput = resolveMaxOutputTokens(configuredMaxTokens: selection.maxTokens, contextLength: contextLength) return GenerationLimits(contextLength: contextLength, maxOutput: maxOutput) } diff --git a/mobile/native/apple/apps/ensu/Ensu/Chat/VoiceTranscriptionService.swift b/mobile/native/apple/apps/ensu/Ensu/Chat/VoiceTranscriptionService.swift index c99cf240278..9ba888a0c5d 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Chat/VoiceTranscriptionService.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Chat/VoiceTranscriptionService.swift @@ -144,7 +144,7 @@ final class VoiceTranscriptionService { let downloadId = beginDownload(taskId: taskId) let transcriber = transcriber let downloader = downloader - let targets = [downloader.transcriptionModelTarget, downloader.voiceActivityModelTarget] + let targets = [downloader.transcriptionTarget, downloader.voiceActivityTarget] let sampleRate = recording.sampleRate let pcm = recording.pcm @@ -254,9 +254,9 @@ final class VoiceTranscriptionService { downloadId: UUID, onState: @escaping StateHandler ) async throws { - let targets = await [downloader.transcriptionModelTarget, downloader.voiceActivityModelTarget] + let targets = await [downloader.transcriptionTarget, downloader.voiceActivityTarget] let downloader = self.downloader - if targets.allSatisfy({ downloader.isDownloaded(target: $0) }) { + if targets.allSatisfy({ downloader.isDownloaded($0) }) { return } await MainActor.run { [weak self] in diff --git a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift index e9efee663b1..9f645ef79e4 100644 --- a/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift +++ b/mobile/native/apple/apps/ensu/Ensu/ModelSettings/ModelSettingsView.swift @@ -7,14 +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 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 contextError: String? @State private var maxTokensError: String? @State private var temperatureError: String? @@ -28,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) { @@ -86,8 +65,6 @@ struct ModelSettingsView: View { } .onAppear { selectedModelId = initialSelectionId() - customModelUrl = isStoredCustomModel ? settings.modelUrl : "" - customMmprojUrl = isStoredCustomModel ? settings.mmprojUrl : "" contextLength = settings.contextLength maxTokens = settings.maxTokens temperature = settings.temperature @@ -102,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) @@ -132,32 +99,6 @@ struct ModelSettingsView: View { } } .pickerStyle(.menu) - .onChange(of: selectedModelId) { newValue in - if newValue == Self.customOptionId { - customModelUrl = "" - customMmprojUrl = "" - } - } - - if selectedModel.isCustom { - VStack(spacing: EnsuSpacing.md) { - field( - label: "Model .gguf URL", - hint: "https://huggingface.co/...", - text: $customModelUrl, - error: urlError, - keyboardType: .URL - ) - - field( - label: "mmproj .gguf URL", - hint: "(optional for multimodal)", - text: $customMmprojUrl, - error: mmprojError, - keyboardType: .URL - ) - } - } sectionToggle( title: "Advanced limits", @@ -205,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() } @@ -226,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 { @@ -290,8 +228,6 @@ struct ModelSettingsView: View { } private func saveTapped() { - urlError = nil - mmprojError = nil contextError = nil maxTokensError = nil temperatureError = nil @@ -301,30 +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, - mmproj: customMmprojUrl, - contextLength: contextLength, - maxTokens: maxTokens, - temperature: temperature - ) - } else { - settings.saveCustomModel( - url: selectedModel.url ?? "", - mmproj: selectedModel.mmproj ?? "", - 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 @@ -336,8 +254,6 @@ struct ModelSettingsView: View { private func resetTapped() { settings.resetToDefault() selectedModelId = Self.defaultOptionId - customModelUrl = "" - customMmprojUrl = "" contextLength = "" maxTokens = "" temperature = "" @@ -350,21 +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 !contextLength.isEmpty, Int(contextLength) == nil { contextError = "Enter a valid integer" isValid = false @@ -388,37 +289,17 @@ struct ModelSettingsView: View { return isValid } - 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 a09ad0e6cc8..820dedda9c5 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/LlmProvider.swift @@ -1,15 +1,10 @@ import Foundation -struct LlmModelTarget: Equatable { +struct LlmModelSelection: Equatable { let id: String - let url: String - let mmprojUrl: String? + let modelTarget: ModelTarget let contextLength: Int? let maxTokens: Int? - - var downloadTarget: ModelDownloadTarget { - .gguf(id: id, url: url, mmprojUrl: mmprojUrl) - } } struct DownloadProgress: Equatable { @@ -124,16 +119,16 @@ final class LlmProvider { } func ensureModelReady( - target: LlmModelTarget, + _ selection: LlmModelSelection, onProgress: @escaping (DownloadProgress) -> Void ) async throws { try await modelLoadGate.withLock { - try await ensureModelReadyLocked(target: target, onProgress: onProgress, allowRecovery: true) + try await ensureModelReadyLocked(selection, onProgress: onProgress, allowRecovery: true) } } private func ensureModelReadyLocked( - target: LlmModelTarget, + _ selection: LlmModelSelection, onProgress: @escaping (DownloadProgress) -> Void, allowRecovery: Bool ) async throws { @@ -141,7 +136,7 @@ final class LlmProvider { if !capability.isChatSupported { throw UnsupportedDeviceMemoryError(capability: capability) } - let modelKey = LoadedModelKey(id: target.id, requestedContextLength: target.contextLength) + let modelKey = LoadedModelKey(id: selection.id, requestedContextLength: selection.contextLength) if currentModelKey == modelKey, loadedModel != nil, loadedContext != nil { return } @@ -153,15 +148,19 @@ final class LlmProvider { backendInitialized = true } - let downloaded = try await downloader.download(targets: [target.downloadTarget], onProgress: onProgress) + let wasAlreadyDownloaded = downloader.isDownloaded(selection.modelTarget) + try await downloader.download(targets: [selection.modelTarget], onProgress: onProgress) onProgress(DownloadProgress(percent: 100, status: "Loading model...", phase: .loading)) do { - try loadModel(target: target, modelPath: downloader.modelPath(target: target.downloadTarget)) + try loadModel( + selection, + modelPath: downloader.llmModelPath(selection.modelTarget)! + ) } catch { - if allowRecovery, !downloaded, downloader.removeDownloaded(target: target.downloadTarget) { + if allowRecovery, wasAlreadyDownloaded, downloader.removeDownloaded(selection.modelTarget) { onProgress(DownloadProgress(percent: 0, status: "Starting download...")) - try await ensureModelReadyLocked(target: target, onProgress: onProgress, allowRecovery: false) + try await ensureModelReadyLocked(selection, onProgress: onProgress, allowRecovery: false) return } throw error @@ -170,7 +169,7 @@ final class LlmProvider { } func generateChat( - target: LlmModelTarget, + _ selection: LlmModelSelection, messages: [LlmMessage], imageFiles: [URL], temperature: Float, @@ -190,7 +189,9 @@ final class LlmProvider { LlmChatMessage(role: $0.role.roleString, content: $0.text) } - let mmprojPath = imageFiles.isEmpty ? nil : downloader.mmprojPath(target: target.downloadTarget) + let mmprojPath = imageFiles.isEmpty + ? nil + : downloader.llmMmprojPath(selection.modelTarget)?.path let clampedTemperature = min(max(temperature, 0.35), 0.7) let request = LlmChatRequest( @@ -250,27 +251,27 @@ final class LlmProvider { } } - func prewarmImageInference(target: LlmModelTarget) async { - guard downloader.isDownloaded(target: target.downloadTarget) else { return } + func prewarmImageInference(_ selection: LlmModelSelection) async { + guard downloader.isDownloaded(selection.modelTarget) else { return } do { try await Task.detached(priority: .utility) { [weak self] in guard let self else { return } try await self.modelLoadGate.withLock { - guard self.downloader.isDownloaded(target: target.downloadTarget) else { return } - guard let mmprojPath = self.downloader.mmprojPath(target: target.downloadTarget), - FileManager.default.fileExists(atPath: mmprojPath) else { + guard self.downloader.isDownloaded(selection.modelTarget) else { return } + guard let mmprojPath = self.downloader.llmMmprojPath(selection.modelTarget), + FileManager.default.fileExists(atPath: mmprojPath.path) else { return } - try await self.ensureModelReadyLocked(target: target, onProgress: { _ in }, allowRecovery: true) + try await self.ensureModelReadyLocked(selection, onProgress: { _ in }, allowRecovery: true) guard let context = self.loadedContext else { return } self.unloadTranscriptionModelIfLoaded() try context.prewarmMultimodal( - mmprojPath: mmprojPath, + mmprojPath: mmprojPath.path, mediaMarker: nil ) } @@ -287,8 +288,8 @@ final class LlmProvider { loadedContext = try? model.newContext(params: contextParams) } - func loadedContextLength(target: LlmModelTarget) -> Int? { - let modelKey = LoadedModelKey(id: target.id, requestedContextLength: target.contextLength) + func loadedContextLength(_ selection: LlmModelSelection) -> Int? { + let modelKey = LoadedModelKey(id: selection.id, requestedContextLength: selection.contextLength) guard currentModelKey == modelKey, loadedModel != nil, loadedContext != nil else { return nil } @@ -306,12 +307,12 @@ final class LlmProvider { transcriber.unloadModel() } - private func loadModel(target: LlmModelTarget, modelPath: URL) throws { + private func loadModel(_ selection: LlmModelSelection, modelPath: URL) throws { let params = LlmModelLoadParams(modelPath: modelPath.path, nGpuLayers: 0, useMmap: true, useMlock: false) let model = try LlmModel.load(params: params) loadedModel = model - let desiredContext = target.contextLength ?? 12000 + let desiredContext = selection.contextLength ?? 12000 let candidates = [desiredContext, 12000, 8192, 4096, 2048, 1024] .filter { $0 > 0 } .reduce(into: [Int]()) { if !$0.contains($1) { $0.append($1) } } @@ -321,7 +322,7 @@ final class LlmProvider { do { let contextParams = LlmContextParams(contextSize: Int32(contextSize), nThreads: Int32(threadCount), nBatch: Int32(512)) loadedContext = try model.newContext(params: contextParams) - currentModelKey = LoadedModelKey(id: target.id, requestedContextLength: target.contextLength) + currentModelKey = LoadedModelKey(id: selection.id, requestedContextLength: selection.contextLength) currentContextLength = contextSize return } catch { diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift index 7fdd6998f65..13f050e605b 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelDownloader.swift @@ -6,37 +6,42 @@ private let logger = EnsuLogging.shared.logger("ModelDownloader") final class ModelDownloader { private let core: ModelDownloadCore private let activeLock = NSLock() - private var downloadActive = false - let transcriptionModelTarget: ModelDownloadTarget - let voiceActivityModelTarget: ModelDownloadTarget + private var activeToken: CancellationToken? + let transcriptionTarget: ModelTarget + let voiceActivityTarget: ModelTarget @MainActor init() { let baseDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory - let modelsDir = baseDir.appendingPathComponent("models", isDirectory: true) + var modelsDir = baseDir.appendingPathComponent("models", isDirectory: true) + try? FileManager.default.createDirectory(at: modelsDir, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? modelsDir.setResourceValues(values) core = ModelDownloadCore(modelsDir: modelsDir.path) - let defaults = ConfigDefaults.shared - transcriptionModelTarget = .tarGz( - id: defaults.transcriptionModel.id, - url: defaults.transcriptionModel.url - ) - voiceActivityModelTarget = .onnx( - id: defaults.voiceActivityModel.id, - url: defaults.voiceActivityModel.url - ) - migrateEnsuLegacyModels( + transcriptionTarget = transcriptionModelTarget() + voiceActivityTarget = voiceActivityModelTarget() + 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 = migrateMobileModels( modelsDir: modelsDir.path, - llmLegacyDir: baseDir.appendingPathComponent("llm", isDirectory: true).path, - transcriptionLegacyDir: baseDir.appendingPathComponent("transcription", isDirectory: true).path, - llmTargets: Self.migrationTargets(), - transcriptionModel: transcriptionModelTarget, - voiceActivityModel: voiceActivityModelTarget + legacy: LegacyModels( + llmDir: baseDir.appendingPathComponent("llm", isDirectory: true).path, + transcriptionDir: baseDir.appendingPathComponent("transcription", isDirectory: true).path, + modelUrl: legacyModelUrl, + mmprojUrl: settings.string(forKey: "ensu.model.mmproj") + ) ) - var excludedDir = modelsDir - var values = URLResourceValues() - values.isExcludedFromBackup = true - try? excludedDir.setResourceValues(values) + 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") } static func registerBackgroundTask() { @@ -45,71 +50,62 @@ final class ModelDownloader { } } - @MainActor - 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) - } - 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 - )) - } - return targets + func modelDir(_ target: ModelTarget) -> URL { + URL(fileURLWithPath: core.modelDir(target: target)) } - func modelPath(target: ModelDownloadTarget) -> URL { - URL(fileURLWithPath: core.modelPath(target: target)) + func llmModelPath(_ target: ModelTarget) -> URL? { + core.llmModelPath(target: target).map { URL(fileURLWithPath: $0) } } - func mmprojPath(target: ModelDownloadTarget) -> String? { - core.mmprojPath(target: target) + func llmMmprojPath(_ target: ModelTarget) -> URL? { + core.llmMmprojPath(target: target).map { URL(fileURLWithPath: $0) } } - func isDownloaded(target: ModelDownloadTarget) -> Bool { + func voiceActivityModelPath() -> URL { + URL(fileURLWithPath: core.voiceActivityModelPath()) + } + + func isDownloaded(_ target: ModelTarget) -> Bool { core.isDownloaded(target: target) } - func removeDownloaded(target: ModelDownloadTarget) -> Bool { + func removeDownloaded(_ target: ModelTarget) -> Bool { core.removeDownloaded(target: target) } func cancel() { - core.cancel() + activeLock.withLock { activeToken }?.cancel() } - func estimateDownloadSize(target: ModelDownloadTarget) async -> Int64? { + func estimateDownloadSize(_ target: ModelTarget) async -> Int64? { await Task.detached(priority: .utility) { [core] in core.estimatedDownloadSize(target: target) }.value } - @discardableResult func download( - targets: [ModelDownloadTarget], + targets: [ModelTarget], onProgress: @escaping (DownloadProgress) -> Void - ) async throws -> Bool { + ) async throws { + let token = CancellationToken() let started = activeLock.withLock { () -> Bool in - if downloadActive { return false } - downloadActive = true + if activeToken != nil { return false } + activeToken = token return true } guard started else { throw DownloadAlreadyActiveError() } - defer { activeLock.withLock { downloadActive = false } } + defer { activeLock.withLock { activeToken = nil } } if targets.allSatisfy({ self.core.isDownloaded(target: $0) }) { - return false + return } onProgress(DownloadProgress(percent: 0, status: "Starting download...")) if #available(iOS 26.0, *) { - ModelDownloadBackgroundTask.begin { [core] in - core.cancel() + ModelDownloadBackgroundTask.begin { + token.cancel() } } var succeeded = false @@ -121,32 +117,29 @@ final class ModelDownloader { let core = core let downloadTask = Task.detached(priority: .utility) { - let callback = ModelDownloadCallbackSink( - onProgress: { progress in - if let line = progress.logLine { - logger.info(line) - } - if #available(iOS 26.0, *) { - ModelDownloadBackgroundTask.update( - downloadedBytes: progress.downloadedBytes, - totalBytes: progress.totalBytes - ) - } - onProgress(DownloadProgress(percent: Int(progress.percent), status: progress.status)) - }, - isCancelled: { Task.isCancelled } - ) - _ = try core.download(targets: targets, callback: callback) + try Task.checkCancellation() + let callback = ModelDownloadCallbackSink { progress in + if let line = progress.logLine { + logger.info(line) + } + if #available(iOS 26.0, *) { + ModelDownloadBackgroundTask.update( + downloadedBytes: progress.downloadedBytes, + totalBytes: progress.totalBytes + ) + } + onProgress(DownloadProgress(percent: Int(progress.percent), status: progress.status)) + } + try core.download(targets: targets, callback: callback, cancellation: token) } try await withTaskCancellationHandler { try await downloadTask.value } onCancel: { - core.cancel() downloadTask.cancel() + token.cancel() } succeeded = true - return true } } @@ -154,23 +147,14 @@ private struct DownloadAlreadyActiveError: Error {} private final class ModelDownloadCallbackSink: ModelDownloadCallback, @unchecked Sendable { private let onProgressHandler: (ModelDownloadProgress) -> Void - private let isCancelledHandler: () -> Bool - init( - onProgress: @escaping (ModelDownloadProgress) -> Void, - isCancelled: @escaping () -> Bool - ) { + init(onProgress: @escaping (ModelDownloadProgress) -> Void) { self.onProgressHandler = onProgress - self.isCancelledHandler = isCancelled } func onProgress(progress: ModelDownloadProgress) { onProgressHandler(progress) } - - func isCancelled() -> Bool { - isCancelledHandler() - } } @available(iOS 26.0, *) diff --git a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift index cceac09e9ba..776ead4909d 100644 --- a/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift +++ b/mobile/native/apple/apps/ensu/Ensu/Services/ModelSettingsStore.swift @@ -53,13 +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 { + @Published var modelId: String { didSet { persist() } } @Published var contextLength: String { @@ -78,54 +72,41 @@ 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.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, mmproj: String, contextLength: String, maxTokens: String, temperature: String) { - useCustomModel = true - modelUrl = url - mmprojUrl = mmproj + 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 = "" + modelId = "" contextLength = "" maxTokens = "" temperature = "" } - func currentTarget() -> LlmModelTarget { - let useCustom = useCustomModel && !modelUrl.isEmpty + func currentSelection() -> LlmModelSelection { 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 id: String - if useCustom { - let presets = [defaultModel] + defaults.mobileModelPresets - id = presets.first(where: { $0.url == url && $0.mmprojUrl == mmproj })?.id ?? "custom:\(url)" - } else { - id = defaultModel.id - } - return LlmModelTarget(id: id, url: url, mmprojUrl: mmproj, contextLength: context, maxTokens: maxOutput) + let presets = [defaultModel] + defaults.mobileModelPresets + let preset = presets.first(where: { $0.id == modelId }) ?? defaultModel + return LlmModelSelection( + id: preset.id, + modelTarget: try! mobileLlmTarget(modelId: preset.id), + 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 { @@ -147,9 +128,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(modelId, forKey: Keys.modelId) defaults.set(contextLength, forKey: Keys.contextLength) defaults.set(maxTokens, forKey: Keys.maxTokens) defaults.set(temperature, forKey: Keys.temperature) @@ -157,9 +136,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 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/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 e1189f014a0..b4bdefb0259 100644 Binary files a/mobile/packages/ente_icons/fonts/EnteIcons.ttf and b/mobile/packages/ente_icons/fonts/EnteIcons.ttf differ 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, ); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c689e59641d..2ca8fc17474 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2388,6 +2388,7 @@ dependencies = [ "self_cell", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "tokio", "transcribe-rs", @@ -2454,6 +2455,7 @@ version = "0.0.0" dependencies = [ "anyhow", "ente-image", + "ente-model-download", "fast_image_resize", "flate2", "half", @@ -2466,13 +2468,13 @@ dependencies = [ "plsfix", "quick-xml", "regex", - "reqwest 0.12.28", "serde", "serde_json", - "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", + "tokio", + "url", "usearch", ] @@ -7134,7 +7136,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", 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..683be9918be 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,8 +80,9 @@ async fn load_album_metadata( // Check if the actual file exists for filename in &disk_metadata.info.file_names { + let filename = validate_stored_file_name(filename)?; let file_path = export_path.join(album_name).join(filename); - if file_path.exists() { + if file_path.try_exists()? { existing_files.insert( disk_metadata.info.id, ExistingFile { @@ -1027,31 +1028,53 @@ 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() + } +} + +fn validate_stored_file_name(name: &str) -> Result<&str> { + let path = Path::new(name); + if path.file_name() == Some(path.as_os_str()) { + Ok(name) + } else { + Err(crate::Error::Generic(format!( + "Unsafe filename in export metadata: {name:?}" + ))) + } +} + /// 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 +1132,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, @@ -1270,3 +1220,24 @@ async fn write_file_metadata( Ok(meta_path) } + +#[cfg(test)] +mod tests { + use super::validate_stored_file_name; + + #[test] + fn validates_stored_file_names_without_rewriting() { + assert_eq!(validate_stored_file_name("a*b.jpg").unwrap(), "a*b.jpg"); + + for name in [ + "", + ".", + "..", + "../photo.jpg", + "album/photo.jpg", + "/photo.jpg", + ] { + assert!(validate_stored_file_name(name).is_err()); + } + } +} diff --git a/rust/apps/cli/src/commands/paste.rs b/rust/apps/cli/src/commands/paste.rs index 15a3f5fc078..c386c093912 100644 --- a/rust/apps/cli/src/commands/paste.rs +++ b/rust/apps/cli/src/commands/paste.rs @@ -85,35 +85,41 @@ 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( - paste_key, - payload, - password, - prompt_raw_paste_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( - paste_key: &PasteKey, - payload: &PastePayload, +fn decrypt_password_protected_paste_with( 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 +294,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}"; @@ -329,14 +342,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, + 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, ) .unwrap(); @@ -346,12 +356,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( PastePasswordAttempt::Env("wrong horse".to_string()), + || panic!("environment passwords must not be retried"), + test_decrypt_password_protected_paste, ) .unwrap_err(); 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(()) - } -} 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. diff --git a/rust/apps/ensu/src-tauri/src/commands/llm.rs b/rust/apps/ensu/src-tauri/src/commands/llm.rs index bb7ff2dffd5..7c3e7e1f5aa 100644 --- a/rust/apps/ensu/src-tauri/src/commands/llm.rs +++ b/rust/apps/ensu/src-tauri/src/commands/llm.rs @@ -1,12 +1,12 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use ente_ensu::llm; use ente_model_download::download; -use ente_model_download::{ModelDownloadTarget, ModelDownloader, migrate_flat_models_dir}; -use serde::{Deserialize, Serialize}; +use ente_model_download::{ModelDownloader, ModelTarget}; +use serde::Serialize; use tauri::async_runtime; use tauri::{AppHandle, Emitter, Manager, State as TauriState, WebviewWindow}; @@ -22,6 +22,7 @@ pub struct State { pub struct ModelDownloadState { downloader: Arc, models_dir: PathBuf, + active_token: Mutex>, } impl ModelDownloadState { @@ -29,24 +30,7 @@ impl ModelDownloadState { Self { downloader: Arc::new(ModelDownloader::new(&models_dir)), models_dir, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelTarget { - id: String, - url: String, - mmproj_url: Option, -} - -impl From for ModelDownloadTarget { - fn from(value: ModelTarget) -> Self { - Self::Gguf { - id: value.id, - url: value.url, - mmproj_url: value.mmproj_url, + active_token: Mutex::new(None), } } } @@ -110,6 +94,11 @@ fn fs_thread_error() -> ApiError { ApiError::new("io_thread", "FS task failed") } +fn desktop_target(model_id: &str) -> Result { + ente_ensu::model::desktop_llm_target(model_id) + .map_err(|err| ApiError::new("invalid_target", err.to_string())) +} + pub(crate) fn replace_state( state: &State, model: Option, @@ -254,52 +243,61 @@ impl llm::EventSink for EventSink { #[tauri::command] pub fn llm_model_status( state: TauriState<'_, ModelDownloadState>, - target: ModelTarget, -) -> ModelStatus { - let target = ModelDownloadTarget::from(target); - ModelStatus { - model_path: state.downloader.model_path(&target).display().to_string(), - mmproj_path: state - .downloader - .mmproj_path(&target) + model_id: String, +) -> Result { + let target = desktop_target(&model_id)?; + Ok(ModelStatus { + model_path: ente_ensu::model::llm_model_path(&state.downloader, &target) + .map(|path| path.display().to_string()) + .unwrap_or_default(), + mmproj_path: ente_ensu::model::llm_mmproj_path(&state.downloader, &target) .map(|path| path.display().to_string()), downloaded: state.downloader.is_downloaded(&target), - } + }) } #[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) - .chain(defaults.mobile_model_presets) - .chain(std::iter::once(defaults.desktop_default_model)) - .chain(defaults.desktop_model_presets) - .map(|preset| ModelDownloadTarget::Gguf { - id: preset.id, - url: preset.url, - mmproj_url: preset.mmproj_url, - }) - .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()) + async_runtime::spawn_blocking(move || { + ente_ensu::model::migrations::migrate_desktop_models( + &models_dir, + legacy_model_url.as_deref(), + legacy_mmproj_url.as_deref(), + ) + }) + .await + .map_err(|_| fs_thread_error()) } #[tauri::command] pub async fn llm_download_model( window: WebviewWindow, state: TauriState<'_, ModelDownloadState>, - target: ModelTarget, + model_id: String, ) -> Result<(), ApiError> { let downloader = Arc::clone(&state.downloader); - let target = ModelDownloadTarget::from(target); - async_runtime::spawn_blocking(move || { + let target = desktop_target(&model_id)?; + let token = { + let mut slot = state + .active_token + .lock() + .unwrap_or_else(PoisonError::into_inner); + if slot.is_some() { + return Err(ApiError::new( + "download_active", + "A model download is already in progress", + )); + } + let token = download::CancellationToken::new(); + *slot = Some(token.clone()); + token + }; + let result = async_runtime::spawn_blocking(move || { downloader .download( std::slice::from_ref(&target), @@ -317,18 +315,29 @@ pub async fn llm_download_model( }, ); }, - || false, + &token, ) - .map(|_| ()) .map_err(|err| ApiError::new(download_code(&err), err.to_string())) }) .await - .map_err(|_| fs_thread_error())? + .map_err(|_| fs_thread_error()); + *state + .active_token + .lock() + .unwrap_or_else(PoisonError::into_inner) = None; + result? } #[tauri::command] pub fn llm_cancel_model_download(state: TauriState<'_, ModelDownloadState>) { - state.downloader.cancel(); + if let Some(token) = state + .active_token + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + { + token.cancel(); + } } #[tauri::command] diff --git a/rust/bindings/uniffi/ensu/src/config.rs b/rust/bindings/uniffi/ensu/src/config.rs index f8c32beea23..fa6ae09049a 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: String, 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/lib.rs b/rust/bindings/uniffi/ensu/src/lib.rs index 86bd14b028b..c435d8bfafc 100644 --- a/rust/bindings/uniffi/ensu/src/lib.rs +++ b/rust/bindings/uniffi/ensu/src/lib.rs @@ -5,5 +5,5 @@ pub mod db; pub mod download; pub mod image; pub mod llm; -pub mod model_downloader; +pub mod model; pub mod transcription; diff --git a/rust/bindings/uniffi/ensu/src/model.rs b/rust/bindings/uniffi/ensu/src/model.rs new file mode 100644 index 00000000000..e9d1fb7452c --- /dev/null +++ b/rust/bindings/uniffi/ensu/src/model.rs @@ -0,0 +1,229 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::download::DownloadError; +use crate::llm::LlmError; + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum ModelTarget { + Files { + id: String, + files: Vec, + }, + TarGz { + id: String, + url: String, + sha256: String, + }, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct ModelFile { + pub name: String, + pub url: String, + pub sha256: String, +} + +impl From for ente_model_download::ModelTarget { + fn from(value: ModelTarget) -> Self { + match value { + ModelTarget::Files { id, files } => Self::Files { + id, + files: files + .into_iter() + .map(|file| ente_model_download::ModelFile { + name: file.name, + url: file.url, + sha256: file.sha256, + }) + .collect(), + }, + ModelTarget::TarGz { id, url, sha256 } => Self::TarGz { id, url, sha256 }, + } + } +} + +impl From for ModelTarget { + fn from(value: ente_model_download::ModelTarget) -> Self { + match value { + ente_model_download::ModelTarget::Files { id, files } => Self::Files { + id, + files: files + .into_iter() + .map(|file| ModelFile { + name: file.name, + url: file.url, + sha256: file.sha256, + }) + .collect(), + }, + ente_model_download::ModelTarget::TarGz { id, url, sha256 } => { + Self::TarGz { id, url, sha256 } + } + } + } +} + +#[uniffi::export] +pub fn mobile_llm_target(model_id: String) -> Result { + ente_ensu::model::mobile_llm_target(&model_id) + .map(Into::into) + .map_err(|err| LlmError::Download { + error: DownloadError::InvalidTarget { + message: err.to_string(), + }, + }) +} + +#[uniffi::export] +pub fn transcription_model_target() -> ModelTarget { + ente_ensu::model::transcription_target().into() +} + +#[uniffi::export] +pub fn voice_activity_model_target() -> ModelTarget { + ente_ensu::model::voice_activity_target().into() +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct ModelDownloadProgress { + pub downloaded_bytes: i64, + pub total_bytes: Option, + pub percent: i32, + pub status: String, + pub log_line: Option, +} + +impl From for ModelDownloadProgress { + fn from(value: ente_model_download::ModelDownloadProgress) -> Self { + Self { + downloaded_bytes: i64::try_from(value.downloaded_bytes).unwrap_or(i64::MAX), + total_bytes: value + .total_bytes + .map(|total| i64::try_from(total).unwrap_or(i64::MAX)), + percent: value.percent, + status: value.status, + log_line: value.log_line, + } + } +} + +#[uniffi::export(callback_interface)] +pub trait ModelDownloadCallback: Send + Sync { + fn on_progress(&self, progress: ModelDownloadProgress); +} + +#[derive(uniffi::Record)] +pub struct LegacyModels { + pub llm_dir: Option, + pub transcription_dir: String, + pub model_url: Option, + pub mmproj_url: Option, +} + +#[uniffi::export] +pub fn migrate_mobile_models(models_dir: String, legacy: LegacyModels) -> Option { + ente_ensu::model::migrations::migrate_mobile_models( + Path::new(&models_dir), + ente_ensu::model::migrations::LegacyModels { + llm_dir: legacy.llm_dir.map(PathBuf::from), + transcription_dir: PathBuf::from(legacy.transcription_dir), + model_url: legacy.model_url, + mmproj_url: legacy.mmproj_url, + }, + ) +} + +#[derive(uniffi::Object)] +pub struct ModelDownloadCore { + inner: ente_model_download::ModelDownloader, +} + +#[uniffi::export] +impl ModelDownloadCore { + #[uniffi::constructor] + pub fn new(models_dir: String) -> Arc { + Arc::new(Self { + inner: ente_model_download::ModelDownloader::new(models_dir), + }) + } + + pub fn model_dir(&self, target: ModelTarget) -> String { + self.inner.model_dir(&target.into()).display().to_string() + } + + pub fn llm_model_path(&self, target: ModelTarget) -> Option { + ente_ensu::model::llm_model_path(&self.inner, &target.into()) + .map(|path| path.display().to_string()) + } + + pub fn llm_mmproj_path(&self, target: ModelTarget) -> Option { + ente_ensu::model::llm_mmproj_path(&self.inner, &target.into()) + .map(|path| path.display().to_string()) + } + + pub fn voice_activity_model_path(&self) -> String { + ente_ensu::model::voice_activity_model_path(&self.inner) + .display() + .to_string() + } + + pub fn is_downloaded(&self, target: ModelTarget) -> bool { + self.inner.is_downloaded(&target.into()) + } + + pub fn is_download_active(&self) -> bool { + self.inner.is_download_active() + } + + pub fn estimated_download_size(&self, target: ModelTarget) -> Option { + self.inner + .estimated_download_size(&target.into()) + .map(|size| i64::try_from(size).unwrap_or(i64::MAX)) + } + + pub fn remove_downloaded(&self, target: ModelTarget) -> bool { + self.inner.remove_downloaded(&target.into()) + } + + pub fn download( + &self, + targets: Vec, + callback: Box, + cancellation: Arc, + ) -> Result<(), LlmError> { + let targets: Vec = + targets.into_iter().map(Into::into).collect(); + self.inner + .download( + &targets, + move |progress| callback.on_progress(progress.into()), + &cancellation.inner, + ) + .map_err(map_download_error) + } +} + +#[derive(Default, uniffi::Object)] +pub struct CancellationToken { + inner: ente_model_download::download::CancellationToken, +} + +#[uniffi::export] +impl CancellationToken { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn cancel(&self) { + self.inner.cancel(); + } +} + +fn map_download_error(err: ente_model_download::download::Error) -> LlmError { + match DownloadError::from(err) { + DownloadError::Cancelled => LlmError::Cancelled, + error => LlmError::Download { error }, + } +} diff --git a/rust/bindings/uniffi/ensu/src/model_downloader.rs b/rust/bindings/uniffi/ensu/src/model_downloader.rs deleted file mode 100644 index 656df194bbb..00000000000 --- a/rust/bindings/uniffi/ensu/src/model_downloader.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -use crate::download::DownloadError; -use crate::llm::LlmError; - -#[derive(Debug, Clone, uniffi::Enum)] -pub enum ModelDownloadTarget { - Gguf { - id: String, - url: String, - mmproj_url: Option, - }, - TarGz { - id: String, - url: String, - }, - Onnx { - id: String, - url: String, - }, -} - -impl From for ente_model_download::ModelDownloadTarget { - fn from(value: ModelDownloadTarget) -> Self { - match value { - ModelDownloadTarget::Gguf { - id, - url, - mmproj_url, - } => Self::Gguf { - id, - url, - mmproj_url, - }, - ModelDownloadTarget::TarGz { id, url } => Self::TarGz { id, url }, - ModelDownloadTarget::Onnx { id, url } => Self::Onnx { id, url }, - } - } -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ModelDownloadProgress { - pub downloaded_bytes: i64, - pub total_bytes: Option, - pub percent: i32, - pub status: String, - pub log_line: Option, -} - -impl From for ModelDownloadProgress { - fn from(value: ente_model_download::ModelDownloadProgress) -> Self { - Self { - downloaded_bytes: i64::try_from(value.downloaded_bytes).unwrap_or(i64::MAX), - total_bytes: value - .total_bytes - .map(|total| i64::try_from(total).unwrap_or(i64::MAX)), - percent: value.percent, - status: value.status, - log_line: value.log_line, - } - } -} - -#[uniffi::export(callback_interface)] -pub trait ModelDownloadCallback: Send + Sync { - fn on_progress(&self, progress: ModelDownloadProgress); - fn is_cancelled(&self) -> bool; -} - -#[uniffi::export] -pub fn migrate_ensu_legacy_models( - models_dir: String, - llm_legacy_dir: Option, - transcription_legacy_dir: String, - llm_targets: Vec, - transcription_model: ModelDownloadTarget, - voice_activity_model: ModelDownloadTarget, -) { - let llm_targets: Vec = - llm_targets.into_iter().map(Into::into).collect(); - ente_model_download::migrate_ensu_legacy_models( - Path::new(&models_dir), - llm_legacy_dir.as_deref().map(Path::new), - Path::new(&transcription_legacy_dir), - &llm_targets, - &transcription_model.into(), - &voice_activity_model.into(), - ); -} - -#[derive(uniffi::Object)] -pub struct ModelDownloadCore { - inner: ente_model_download::ModelDownloader, -} - -#[uniffi::export] -impl ModelDownloadCore { - #[uniffi::constructor] - pub fn new(models_dir: String) -> Arc { - Arc::new(Self { - inner: ente_model_download::ModelDownloader::new(models_dir), - }) - } - - pub fn model_path(&self, target: ModelDownloadTarget) -> String { - self.inner.model_path(&target.into()).display().to_string() - } - - pub fn mmproj_path(&self, target: ModelDownloadTarget) -> Option { - self.inner - .mmproj_path(&target.into()) - .map(|path| path.display().to_string()) - } - - pub fn is_downloaded(&self, target: ModelDownloadTarget) -> bool { - self.inner.is_downloaded(&target.into()) - } - - pub fn is_download_active(&self) -> bool { - self.inner.is_download_active() - } - - pub fn estimated_download_size(&self, target: ModelDownloadTarget) -> Option { - self.inner.estimated_download_size(&target.into()) - } - - pub fn cancel(&self) { - self.inner.cancel(); - } - - pub fn remove_downloaded(&self, target: ModelDownloadTarget) -> bool { - self.inner.remove_downloaded(&target.into()) - } - - pub fn download( - &self, - targets: Vec, - callback: Box, - ) -> Result { - let callback: Arc = Arc::from(callback); - let progress_callback = Arc::clone(&callback); - let cancel_callback = Arc::clone(&callback); - let targets: Vec = - targets.into_iter().map(Into::into).collect(); - self.inner - .download( - &targets, - move |progress| progress_callback.on_progress(progress.into()), - move || cancel_callback.is_cancelled(), - ) - .map_err(|err| match DownloadError::from(err) { - DownloadError::Cancelled => LlmError::Cancelled, - error => LlmError::Download { error }, - }) - } -} diff --git a/rust/crates/accounts/src/flow.rs b/rust/crates/accounts/src/flow.rs index 3171c74bab3..fea61340460 100644 --- a/rust/crates/accounts/src/flow.rs +++ b/rust/crates/accounts/src/flow.rs @@ -10,8 +10,8 @@ use base64::{ use ente_core::{ auth::{ self, DecryptedSecrets, GeneratedSrpSetup, KeyAttributes as CoreKeyAttributes, - KeyDerivationStrength, derive_kek, generate_keys_with_strength, generate_srp_setup, - get_recovery_key, + KeyDerivationStrength, derive_kek, generate_keys_with_strength, + generate_srp_setup_with_login_key, get_recovery_key, }, crypto::{self, SecretVec, secretbox}, }; @@ -411,32 +411,35 @@ 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, - )?; + 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_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); 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 update = self - .complete_srp_update( - &srp_user_id, - &srp_setup, - &updated_key_attr, - params.log_out_other_devices, - ) - .await?; + let srp_setup = generate_srp_setup_with_login_key(&login_key, &srp_user_id.to_string())?; + 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?; @@ -464,8 +467,6 @@ where ))); } - let _ = update; - Ok(ChangePasswordResult { key_attributes: updated_key_attributes, srp_attributes, @@ -697,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 @@ -2165,13 +2166,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(); diff --git a/rust/crates/core/src/auth/api.rs b/rust/crates/core/src/auth/api.rs index ef1b1bebcea..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. @@ -420,16 +419,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 { @@ -464,8 +453,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); diff --git a/rust/crates/ensu/Cargo.toml b/rust/crates/ensu/Cargo.toml index 73fed80a4de..42127552163 100644 --- a/rust/crates/ensu/Cargo.toml +++ b/rust/crates/ensu/Cargo.toml @@ -13,6 +13,7 @@ rusqlite = { version = "0.38", features = ["bundled"], optional = true } self_cell = "1" serde.workspace = true serde_json.workspace = true +sha2 = "0.11.0" thiserror.workspace = true tokio = { version = "1", features = ["rt", "time"] } transcribe-rs = { version = "0.3.11", default-features = false, features = ["onnx", "vad-silero"], optional = true } diff --git a/rust/crates/ensu/src/config.rs b/rust/crates/ensu/src/config.rs index e22d135f86b..b27399803d8 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: String, pub mmproj_url: Option, + pub mmproj_sha256: Option, } #[derive(Debug, Clone)] @@ -30,19 +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: "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(), ), - } -} - -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(), - mmproj_url: None, + mmproj_sha256: Some("2ce89e610c56f3198ece2b86cf61743a08b9307279c89125eb2412ebb908689d".to_string()), } } @@ -51,10 +46,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: "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 +60,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: "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 +74,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: "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 +88,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: "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 +102,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: "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()), } } @@ -110,8 +115,10 @@ fn parakeet_v3_int8() -> ModelPreset { ModelPreset { id: "parakeet-v3-int8".to_string(), title: "Transcription model".to_string(), - url: "https://models.ente.io/parakeet-v3-int8.tar.gz".to_string(), + url: "https://models.ente.com/parakeet-v3-int8.tar.gz".to_string(), + sha256: "43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(), mmproj_url: None, + mmproj_sha256: None, } } @@ -119,29 +126,50 @@ fn silero_vad_v4() -> ModelPreset { ModelPreset { id: "silero-vad-v4".to_string(), title: "Voice activity model".to_string(), - url: "https://models.ente.io/silero_vad_v4.onnx".to_string(), + url: "https://models.ente.com/silero_vad_v4.onnx".to_string(), + sha256: "a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28".to_string(), mmproj_url: None, + mmproj_sha256: None, } } -pub fn defaults() -> Defaults { - let mobile_default_model = lfm_vl_1_6b(); - let desktop_default_model = gemma_4_e4b_q4km(); +pub(crate) fn llm_catalog() -> Vec { + vec![ + lfm_vl_1_6b(), + qwen_0_8b(), + qwen_2b_q8(), + qwen_4b_q4km(), + gemma_4_e4b_q4km(), + gemma_4_e2b_q4km(), + ] +} +pub fn defaults() -> Defaults { + let catalog = llm_catalog(); + let preset = |id: &str| -> ModelPreset { + catalog + .iter() + .find(|preset| preset.id == id) + .expect("preset id is in the catalog") + .clone() + }; Defaults { mobile_system_prompt_body: MOBILE_SYSTEM_PROMPT_BODY.to_string(), desktop_system_prompt_body: DESKTOP_SYSTEM_PROMPT_BODY.to_string(), 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()], - desktop_default_model, + mobile_default_model: preset("lfm-vl-1.6b"), + mobile_model_presets: vec![ + preset("qwen-0.8b"), + preset("qwen-2b-q8"), + preset("gemma-4-e2b-q4km"), + ], + desktop_default_model: preset("gemma-4-e4b-q4km"), desktop_model_presets: vec![ - qwen_4b_q4km(), - lfm_vl_1_6b(), - lfm_1_2b(), - qwen_0_8b(), - qwen_2b_q8(), + preset("qwen-4b-q4km"), + preset("lfm-vl-1.6b"), + preset("qwen-0.8b"), + preset("qwen-2b-q8"), ], transcription_model: parakeet_v3_int8(), voice_activity_model: silero_vad_v4(), @@ -151,29 +179,60 @@ pub fn defaults() -> Defaults { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; + + #[test] + fn model_ids_are_unique() { + let mut seen = HashSet::new(); + for preset in llm_catalog() + .into_iter() + .chain([parakeet_v3_int8(), silero_vad_v4()]) + { + assert!( + seen.insert(preset.id.clone()), + "duplicate model id {}", + preset.id + ); + } + } + + #[test] + fn catalog_presets_pair_mmproj_url_with_checksum() { + for preset in llm_catalog() { + let has_url = preset + .mmproj_url + .as_deref() + .is_some_and(|u| !u.trim().is_empty()); + let has_sha = preset + .mmproj_sha256 + .as_deref() + .is_some_and(|s| !s.trim().is_empty()); + assert_eq!( + has_url, has_sha, + "preset {} must pair mmproj URL with its checksum", + preset.id + ); + } + } #[test] - fn preset_ids_key_unique_artifacts() { - let defaults = defaults(); - let all = std::iter::once(&defaults.mobile_default_model) - .chain(defaults.mobile_model_presets.iter()) - .chain(std::iter::once(&defaults.desktop_default_model)) - .chain(defaults.desktop_model_presets.iter()) - .chain([ - &defaults.transcription_model, - &defaults.voice_activity_model, - ]); - let mut seen: HashMap<&str, (&str, Option<&str>)> = HashMap::new(); - for preset in all { + fn catalog_artifacts_resolve_unambiguously() { + let catalog = llm_catalog(); + let mut seen: HashMap<(&str, Option<&str>), &str> = HashMap::new(); + for preset in &catalog { let artifact = (preset.url.as_str(), preset.mmproj_url.as_deref()); - if let Some(existing) = seen.insert(preset.id.as_str(), artifact) { + if let Some(existing) = seen.insert(artifact, preset.id.as_str()) { assert_eq!( - existing, artifact, - "preset id {} aliases different artifacts", + existing, preset.id, + "presets {existing} and {} share an artifact", preset.id ); } } } + + #[test] + fn defaults_views_select_from_the_catalog() { + defaults(); + } } diff --git a/rust/crates/ensu/src/lib.rs b/rust/crates/ensu/src/lib.rs index f2dca486b75..959af5920a2 100644 --- a/rust/crates/ensu/src/lib.rs +++ b/rust/crates/ensu/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod db; pub mod image; pub mod llm; +pub mod model; #[cfg(feature = "transcription")] pub mod transcription; diff --git a/rust/crates/ensu/src/model/migrations.rs b/rust/crates/ensu/src/model/migrations.rs new file mode 100644 index 00000000000..425005a7ee8 --- /dev/null +++ b/rust/crates/ensu/src/model/migrations.rs @@ -0,0 +1,762 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use ente_model_download::{ModelDownloader, ModelTarget}; +use sha2::{Digest, Sha256}; + +use crate::config; +use crate::model::{self, InvalidPreset, trimmed}; + +pub struct LegacyModels { + pub llm_dir: Option, + pub transcription_dir: PathBuf, + pub model_url: Option, + pub mmproj_url: Option, +} + +pub fn migrate_mobile_models(models_dir: &Path, legacy: LegacyModels) -> Option { + let store = ModelDownloader::new(models_dir); + if let Some(llm_dir) = &legacy.llm_dir + && let Ok(targets) = llm_targets() + { + migrate_legacy_dir(&store, llm_dir, &targets); + } + migrate_legacy_transcription_dir( + &store, + &legacy.transcription_dir, + &model::transcription_target(), + &model::voice_activity_target(), + ); + legacy_selected_preset_id(legacy.model_url.as_deref()?, legacy.mmproj_url.as_deref()) +} + +pub fn migrate_desktop_models( + models_dir: &Path, + model_url: Option<&str>, + mmproj_url: Option<&str>, +) -> Option { + let selected = model_url.and_then(|model_url| legacy_selected_preset_id(model_url, mmproj_url)); + if let Ok(targets) = llm_targets() { + migrate_flat_models_dir(models_dir, &targets); + } + selected +} + +fn llm_targets() -> Result, InvalidPreset> { + config::llm_catalog() + .iter() + .map(model::llm_target) + .collect() +} + +fn legacy_selected_preset_id(model_url: &str, mmproj_url: Option<&str>) -> Option { + let mmproj_url = trimmed(mmproj_url); + config::llm_catalog() + .into_iter() + .find(|preset| { + preset.url == model_url && trimmed(preset.mmproj_url.as_deref()) == mmproj_url + }) + .map(|preset| preset.id) +} + +fn migrate_legacy_dir(store: &ModelDownloader, legacy_dir: &Path, targets: &[ModelTarget]) { + if !legacy_dir.exists() { + return; + } + if adopt_targets(store, targets, &[], &legacy_dir.join("models")) { + let _ = fs::remove_dir_all(legacy_dir); + } +} + +fn migrate_flat_models_dir(models_dir: &Path, targets: &[ModelTarget]) { + if !models_dir.exists() { + return; + } + let store = ModelDownloader::new(models_dir); + let hashed_dirs = [models_dir.to_path_buf()]; + if !adopt_targets(&store, targets, &hashed_dirs, models_dir) { + return; + } + let Ok(entries) = fs::read_dir(models_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let _ = fs::remove_file(&path); + } else if path.file_name().is_some_and(|name| name == "custom") { + let _ = fs::remove_dir_all(&path); + } + } +} + +fn migrate_legacy_transcription_dir( + store: &ModelDownloader, + legacy_dir: &Path, + model: &ModelTarget, + vad: &ModelTarget, +) { + const LEGACY_MODEL_DIR_NAME: &str = "parakeet-tdt-0.6b-v3-int8"; + const LEGACY_MODEL_ID: &str = "parakeet-v3-int8"; + const LEGACY_VAD_FILE_NAME: &str = "silero_vad_v4.onnx"; + const LEGACY_VAD_ID: &str = "silero-vad-v4"; + if !legacy_dir.exists() { + return; + } + let mut all_moved = true; + + if matches!(model, ModelTarget::TarGz { id, .. } if id == LEGACY_MODEL_ID) { + let dest = store.model_dir(model); + if !dest.exists() { + let source = legacy_dir.join(LEGACY_MODEL_DIR_NAME); + if source.is_dir() { + if let Some(parent) = dest.parent() { + let _ = fs::create_dir_all(parent); + } + if fs::rename(&source, &dest).is_err() { + all_moved = false; + } + } + } + } + + if let ModelTarget::Files { id, files } = vad + && id == LEGACY_VAD_ID + && let [file] = files.as_slice() + { + let dest = store.model_dir(vad).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) { + all_moved = false; + } + } + } + + if all_moved { + let _ = fs::remove_dir_all(legacy_dir); + } +} + +fn sidecar_url(path: &Path) -> Option { + let sidecar = PathBuf::from(format!("{}.metadata.json", path.display())); + let text = fs::read_to_string(sidecar).ok()?; + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + value["url"].as_str().map(String::from) +} + +fn adopt_targets( + store: &ModelDownloader, + targets: &[ModelTarget], + hashed_dirs: &[PathBuf], + flat_dir: &Path, +) -> bool { + let mut plans: Vec> = Vec::new(); + for target in targets { + let ModelTarget::Files { files, .. } = target else { + continue; + }; + let dir = store.model_dir(target); + plans.push( + files + .iter() + .map(|file| (file.url.as_str(), file.name.as_str(), dir.join(&file.name))) + .collect(), + ); + } + + let mut basename_urls: HashMap> = HashMap::new(); + for target in targets { + let ModelTarget::Files { files, .. } = target else { + continue; + }; + for file in files { + basename_urls + .entry(filename_for_url(&file.url, &file.name)) + .or_default() + .insert(file.url.as_str()); + } + } + + let mut all_moved = true; + for plan in &plans { + let sources: Vec>> = plan + .iter() + .map(|(url, fallback, dest)| { + if dest.exists() { + return Some(None); + } + let basename = filename_for_url(url, fallback); + let url = *url; + for dir in hashed_dirs { + let hashed = dir.join(format!("{}_{basename}", sha256_hex(url))); + if looks_like_gguf(&hashed) { + return Some(Some(hashed)); + } + } + let flat = flat_dir.join(&basename); + if looks_like_gguf(&flat) { + match sidecar_url(&flat) { + Some(sidecar) => { + if sidecar == url { + return Some(Some(flat)); + } + } + None => { + if basename_urls[&basename].len() == 1 { + return Some(Some(flat)); + } + } + } + } + None + }) + .collect(); + if sources.iter().any(Option::is_none) { + continue; + } + for ((_, _, dest), source) in plan.iter().zip(sources) { + let Some(Some(source)) = source else { + continue; + }; + if !move_file(&source, dest) { + all_moved = false; + } + } + } + all_moved +} + +fn move_file(source: &Path, dest: &Path) -> bool { + if let Some(parent) = dest.parent() { + let _ = fs::create_dir_all(parent); + } + if fs::rename(source, dest).is_ok() { + return true; + } + let staged = PathBuf::from(format!("{}.migrating", dest.display())); + if fs::copy(source, &staged) + .and_then(|_| fs::rename(&staged, dest)) + .is_err() + { + let _ = fs::remove_file(&staged); + return false; + } + true +} + +fn is_non_empty_file(path: &Path) -> bool { + path.metadata() + .map(|metadata| metadata.is_file() && metadata.len() > 0) + .unwrap_or(false) +} + +fn looks_like_gguf(path: &Path) -> bool { + let mut file = match File::open(path) { + Ok(file) => file, + Err(_) => return false, + }; + let mut header = [0u8; 4]; + file.read_exact(&mut header).is_ok() && &header == b"GGUF" +} + +fn filename_for_url(url: &str, fallback: &str) -> String { + let without_query = url.split(['?', '#']).next().unwrap_or(url); + let name = without_query.rsplit('/').next().unwrap_or(""); + if name.trim().is_empty() { + fallback.to_string() + } else { + name.to_string() + } +} + +fn sha256_hex(value: &str) -> String { + Sha256::digest(value.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use ente_model_download::ModelFile; + + use super::*; + use crate::config::ModelPreset; + use crate::model; + + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ente-ensu-migrations-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).expect("create test dir"); + dir + } + + fn test_sha() -> String { + "0".repeat(64) + } + + fn gguf_target(id: &str, url: String, mmproj_url: String) -> ModelTarget { + model::llm_target(&ModelPreset { + id: id.to_string(), + title: String::new(), + url, + sha256: test_sha(), + mmproj_url: Some(mmproj_url), + mmproj_sha256: Some(test_sha()), + }) + .unwrap() + } + + fn target(id: &str) -> ModelTarget { + gguf_target( + id, + "https://example.org/models/main.gguf?download=true".to_string(), + "https://example.org/models/mmproj.gguf".to_string(), + ) + } + + fn tar_gz_target(id: &str) -> ModelTarget { + ModelTarget::TarGz { + id: id.to_string(), + url: format!("https://models.example.org/{id}.tar.gz"), + sha256: test_sha(), + } + } + + fn vad_target(id: &str, url: String) -> ModelTarget { + ModelTarget::Files { + id: id.to_string(), + files: vec![ModelFile { + name: "model.onnx".to_string(), + url, + sha256: test_sha(), + }], + } + } + + fn file_target(id: &str) -> ModelTarget { + vad_target(id, format!("https://models.example.org/{id}.onnx")) + } + + fn write_gguf(path: &Path, content: &[u8]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn mobile_migration_resolves_preset_selection() { + let base = scratch_dir("mobile-selection"); + let defaults = config::defaults(); + let preset = &defaults.mobile_model_presets[0]; + let selected = migrate_mobile_models( + &base.join("models"), + LegacyModels { + llm_dir: None, + transcription_dir: base.join("transcription"), + model_url: Some(preset.url.clone()), + mmproj_url: preset.mmproj_url.clone(), + }, + ); + assert_eq!(selected.as_deref(), Some(preset.id.as_str())); + + let unknown = migrate_mobile_models( + &base.join("models"), + LegacyModels { + llm_dir: None, + transcription_dir: base.join("transcription"), + model_url: Some("https://example.org/custom.gguf".to_string()), + mmproj_url: None, + }, + ); + assert_eq!(unknown, None); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn desktop_migration_resolves_preset_selection() { + let base = scratch_dir("desktop-selection"); + let defaults = config::defaults(); + let preset = &defaults.desktop_model_presets[0]; + let selected = migrate_desktop_models( + &base.join("models"), + Some(&preset.url), + preset.mmproj_url.as_deref(), + ); + assert_eq!(selected.as_deref(), Some(preset.id.as_str())); + + assert_eq!( + migrate_desktop_models(&base.join("models"), None, None), + None + ); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_transcription_moves_model_dir_and_vad_file() { + let base = scratch_dir("migrate-transcription"); + let legacy = base.join("transcription"); + fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); + fs::write( + legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), + b"enc", + ) + .unwrap(); + fs::write(legacy.join("silero_vad_v4.onnx"), b"vad").unwrap(); + fs::write(legacy.join("stale.partial"), b"junk").unwrap(); + + let models_dir = base.join("models"); + let model = tar_gz_target("parakeet-v3-int8"); + let vad = vad_target( + "silero-vad-v4", + "https://models.example.org/silero_vad_v4.onnx".to_string(), + ); + migrate_legacy_transcription_dir(&ModelDownloader::new(&models_dir), &legacy, &model, &vad); + + let downloader = ModelDownloader::new(&models_dir); + assert!(downloader.is_downloaded(&model)); + assert!(downloader.is_downloaded(&vad)); + assert_eq!( + fs::read(models_dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), + b"enc" + ); + assert_eq!( + fs::read(models_dir.join("silero-vad-v4/model.onnx")).unwrap(), + b"vad" + ); + assert!(!legacy.exists()); + + migrate_legacy_transcription_dir(&ModelDownloader::new(&models_dir), &legacy, &model, &vad); + assert!(downloader.is_downloaded(&model)); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_transcription_keeps_existing_destinations() { + let base = scratch_dir("migrate-transcription-existing"); + let legacy = base.join("transcription"); + fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); + fs::write( + legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), + b"old", + ) + .unwrap(); + + let models_dir = base.join("models"); + let model = tar_gz_target("parakeet-v3-int8"); + 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(); + + migrate_legacy_transcription_dir(&ModelDownloader::new(&models_dir), &legacy, &model, &vad); + + assert_eq!( + fs::read(models_dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), + b"new" + ); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_transcription_adopts_nothing_when_target_ids_differ() { + let base = scratch_dir("migrate-transcription-mismatch"); + let legacy = base.join("transcription"); + fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); + fs::write( + legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), + b"enc", + ) + .unwrap(); + fs::write(legacy.join("silero_vad_v4.onnx"), b"vad").unwrap(); + + let models_dir = base.join("models"); + let model = tar_gz_target("parakeet-v4-int8"); + let vad = file_target("silero-vad-v5"); + migrate_legacy_transcription_dir(&ModelDownloader::new(&models_dir), &legacy, &model, &vad); + + let downloader = ModelDownloader::new(&models_dir); + assert!(!downloader.is_downloaded(&model)); + assert!(!downloader.is_downloaded(&vad)); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_adopts_known_files_and_discards_legacy_dir() { + let base = scratch_dir("migrate"); + let legacy = base.join("llm"); + let legacy_models = legacy.join("models"); + 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(); + 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( + &ModelDownloader::new(&models_dir), + &legacy, + std::slice::from_ref(&preset), + ); + + let downloader = ModelDownloader::new(&models_dir); + assert_eq!( + fs::read(model::llm_model_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmain" + ); + assert_eq!( + fs::read(model::llm_mmproj_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmmproj" + ); + assert_eq!(fs::read_dir(&models_dir).unwrap().count(), 1); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_adopts_targets_only_when_every_file_resolves() { + let base = scratch_dir("migrate-ambiguous"); + let legacy = base.join("llm"); + let legacy_models = legacy.join("models"); + fs::create_dir_all(&legacy_models).unwrap(); + fs::write(legacy_models.join("a.gguf"), b"GGUFa").unwrap(); + fs::write(legacy_models.join("b.gguf"), b"GGUFb").unwrap(); + fs::write(legacy_models.join("mmproj-F16.gguf"), b"GGUFshared").unwrap(); + + let targets = ["a", "b"].map(|name| { + gguf_target( + &format!("preset-{name}"), + format!("https://example.org/{name}/{name}.gguf"), + format!("https://example.org/{name}/mmproj-F16.gguf"), + ) + }); + + let models_dir = base.join("models"); + migrate_legacy_dir(&ModelDownloader::new(&models_dir), &legacy, &targets); + + assert!(!models_dir.exists() || fs::read_dir(&models_dir).unwrap().count() == 0); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn migrate_flat_models_dir_adopts_known_files_and_cleans_root() { + let models_dir = scratch_dir("flat-migrate"); + fs::create_dir_all(models_dir.join("custom")).unwrap(); + + let preset = target("qwen-2b-q8"); + fs::write(models_dir.join("main.gguf"), b"GGUFmain").unwrap(); + let mmproj_hashed = format!( + "{}_mmproj.gguf", + sha256_hex("https://example.org/models/mmproj.gguf") + ); + fs::write(models_dir.join(mmproj_hashed), b"GGUFmm").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); + write_gguf( + &models_dir.join("other-key").join("model.gguf"), + b"GGUFkeep", + ); + + migrate_flat_models_dir(&models_dir, std::slice::from_ref(&preset)); + + assert_eq!( + fs::read(model::llm_model_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmain" + ); + assert_eq!( + fs::read(model::llm_mmproj_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmm" + ); + assert_eq!( + fs::read(models_dir.join("other-key/model.gguf")).unwrap(), + b"GGUFkeep" + ); + assert!(!models_dir.join("custom").exists()); + let root_files = fs::read_dir(&models_dir) + .unwrap() + .flatten() + .filter(|entry| entry.path().is_file()) + .count(); + assert_eq!(root_files, 0); + + migrate_flat_models_dir(&models_dir, std::slice::from_ref(&preset)); + assert_eq!( + fs::read(model::llm_model_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmain" + ); + + let _ = fs::remove_dir_all(models_dir); + } + + #[test] + fn migrate_flat_models_dir_adopts_sidecarless_files_for_duplicated_targets() { + let models_dir = scratch_dir("flat-duplicate-targets"); + fs::create_dir_all(&models_dir).unwrap(); + fs::write(models_dir.join("main.gguf"), b"GGUFmain").unwrap(); + fs::write(models_dir.join("mmproj.gguf"), b"GGUFmm").unwrap(); + + let preset = target("lfm-vl-1.6b"); + migrate_flat_models_dir(&models_dir, &[preset.clone(), preset.clone()]); + + let downloader = ModelDownloader::new(&models_dir); + assert_eq!( + fs::read(model::llm_model_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmain" + ); + assert_eq!( + fs::read(model::llm_mmproj_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmm" + ); + + let _ = fs::remove_dir_all(models_dir); + } + + #[test] + fn migrate_flat_models_dir_resolves_ambiguity_via_metadata_sidecars() { + let models_dir = scratch_dir("flat-sidecar"); + fs::create_dir_all(&models_dir).unwrap(); + + let targets = ["a", "b"].map(|name| { + gguf_target( + &format!("preset-{name}"), + format!("https://example.org/{name}/{name}.gguf"), + format!("https://example.org/{name}/mmproj-F16.gguf"), + ) + }); + + fs::write(models_dir.join("a.gguf"), b"GGUFa").unwrap(); + fs::write( + models_dir.join("a.gguf.metadata.json"), + br#"{"url": "https://example.org/a/a.gguf"}"#, + ) + .unwrap(); + fs::write(models_dir.join("mmproj-F16.gguf"), b"GGUFmma").unwrap(); + fs::write( + models_dir.join("mmproj-F16.gguf.metadata.json"), + br#"{"url": "https://example.org/a/mmproj-F16.gguf"}"#, + ) + .unwrap(); + + migrate_flat_models_dir(&models_dir, &targets); + + let downloader = ModelDownloader::new(&models_dir); + assert_eq!( + fs::read(model::llm_model_path(&downloader, &targets[0]).unwrap()).unwrap(), + b"GGUFa" + ); + assert_eq!( + fs::read(model::llm_mmproj_path(&downloader, &targets[0]).unwrap()).unwrap(), + b"GGUFmma" + ); + assert!( + !model::llm_model_path(&downloader, &targets[1]) + .unwrap() + .exists() + ); + + let _ = fs::remove_dir_all(models_dir); + } + + #[test] + fn migrate_flat_models_dir_rejects_files_whose_sidecar_disagrees() { + let models_dir = scratch_dir("flat-sidecar-mismatch"); + fs::create_dir_all(&models_dir).unwrap(); + + let preset = target("qwen-2b-q8"); + fs::write(models_dir.join("main.gguf"), b"GGUFstale").unwrap(); + fs::write( + models_dir.join("main.gguf.metadata.json"), + br#"{"url": "https://example.org/elsewhere/main.gguf"}"#, + ) + .unwrap(); + + migrate_flat_models_dir(&models_dir, std::slice::from_ref(&preset)); + + let downloader = ModelDownloader::new(&models_dir); + assert!( + !model::llm_model_path(&downloader, &preset) + .unwrap() + .exists() + ); + + let _ = fs::remove_dir_all(models_dir); + } + + #[test] + fn migrate_flat_models_dir_drops_targets_with_ambiguous_basenames() { + let models_dir = scratch_dir("flat-ambiguous"); + fs::create_dir_all(&models_dir).unwrap(); + fs::write(models_dir.join("a.gguf"), b"GGUFa").unwrap(); + fs::write(models_dir.join("mmproj-F16.gguf"), b"GGUFshared").unwrap(); + + let targets = ["a", "b"].map(|name| { + gguf_target( + &format!("preset-{name}"), + format!("https://example.org/{name}/{name}.gguf"), + format!("https://example.org/{name}/mmproj-F16.gguf"), + ) + }); + + migrate_flat_models_dir(&models_dir, &targets); + + assert_eq!(fs::read_dir(&models_dir).unwrap().count(), 0); + + let _ = fs::remove_dir_all(models_dir); + } + + #[test] + fn migrate_keeps_existing_destination_files() { + let base = scratch_dir("migrate-existing"); + let legacy = base.join("llm"); + fs::create_dir_all(legacy.join("models")).unwrap(); + fs::write(legacy.join("models/main.gguf"), b"GGUFold").unwrap(); + fs::write(legacy.join("models/mmproj.gguf"), b"GGUFmm").unwrap(); + + let models_dir = base.join("models"); + let preset = target("qwen-2b-q8"); + let downloader = ModelDownloader::new(&models_dir); + write_gguf( + &model::llm_model_path(&downloader, &preset).unwrap(), + b"GGUFnew", + ); + + migrate_legacy_dir( + &ModelDownloader::new(&models_dir), + &legacy, + std::slice::from_ref(&preset), + ); + + assert_eq!( + fs::read(model::llm_model_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFnew" + ); + assert_eq!( + fs::read(model::llm_mmproj_path(&downloader, &preset).unwrap()).unwrap(), + b"GGUFmm" + ); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(base); + } +} diff --git a/rust/crates/ensu/src/model/mod.rs b/rust/crates/ensu/src/model/mod.rs new file mode 100644 index 00000000000..c970fa94120 --- /dev/null +++ b/rust/crates/ensu/src/model/mod.rs @@ -0,0 +1,184 @@ +pub mod migrations; + +use std::path::PathBuf; + +use ente_model_download::{ModelDownloader, ModelFile, ModelTarget}; + +use crate::config::{self, ModelPreset}; + +const LLM_MODEL_FILE: &str = "model.gguf"; +const LLM_MMPROJ_FILE: &str = "mmproj.gguf"; +const VOICE_ACTIVITY_MODEL_FILE: &str = "model.onnx"; + +#[derive(Debug, thiserror::Error)] +#[error("preset {id} pairs only half of the mmproj URL and checksum")] +pub struct InvalidPreset { + pub id: String, +} + +pub(crate) fn llm_target(preset: &ModelPreset) -> Result { + let mut files = vec![ModelFile { + name: LLM_MODEL_FILE.to_string(), + url: preset.url.clone(), + sha256: preset.sha256.clone(), + }]; + match ( + trimmed(preset.mmproj_url.as_deref()), + trimmed(preset.mmproj_sha256.as_deref()), + ) { + (Some(url), Some(sha256)) => files.push(ModelFile { + name: LLM_MMPROJ_FILE.to_string(), + url: url.to_string(), + sha256: sha256.to_string(), + }), + (None, None) => {} + _ => { + return Err(InvalidPreset { + id: preset.id.clone(), + }); + } + } + Ok(ModelTarget::Files { + id: preset.id.clone(), + files, + }) +} + +pub fn mobile_llm_target(model_id: &str) -> Result { + let preset = config::llm_catalog() + .into_iter() + .find(|preset| preset.id == model_id) + .unwrap_or_else(|| config::defaults().mobile_default_model); + llm_target(&preset) +} + +pub fn desktop_llm_target(model_id: &str) -> Result { + let preset = config::llm_catalog() + .into_iter() + .find(|preset| preset.id == model_id) + .unwrap_or_else(|| config::defaults().desktop_default_model); + llm_target(&preset) +} + +pub fn transcription_target() -> ModelTarget { + let preset = config::defaults().transcription_model; + ModelTarget::TarGz { + id: preset.id, + url: preset.url, + sha256: preset.sha256, + } +} + +pub fn voice_activity_target() -> ModelTarget { + let preset = config::defaults().voice_activity_model; + ModelTarget::Files { + id: preset.id, + files: vec![ModelFile { + name: VOICE_ACTIVITY_MODEL_FILE.to_string(), + url: preset.url, + sha256: preset.sha256, + }], + } +} + +pub fn llm_model_path(downloader: &ModelDownloader, target: &ModelTarget) -> Option { + downloader.file_path(target, LLM_MODEL_FILE) +} + +pub fn llm_mmproj_path(downloader: &ModelDownloader, target: &ModelTarget) -> Option { + downloader.file_path(target, LLM_MMPROJ_FILE) +} + +pub fn voice_activity_model_path(downloader: &ModelDownloader) -> PathBuf { + downloader + .model_dir(&voice_activity_target()) + .join(VOICE_ACTIVITY_MODEL_FILE) +} + +pub(crate) fn trimmed(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn preset(mmproj_url: Option<&str>, mmproj_sha256: Option<&str>) -> ModelPreset { + ModelPreset { + id: "qwen-2b-q8".to_string(), + title: "Qwen".to_string(), + url: "https://example.org/main.gguf".to_string(), + sha256: "0".repeat(64), + mmproj_url: mmproj_url.map(Into::into), + mmproj_sha256: mmproj_sha256.map(Into::into), + } + } + + #[test] + fn llm_target_names_model_and_mmproj_files() { + let target = llm_target(&preset( + Some("https://example.org/mmproj.gguf"), + Some("abc"), + )) + .expect("paired preset"); + let downloader = ModelDownloader::new(std::env::temp_dir().join("ensu-models-test")); + assert_eq!( + llm_model_path(&downloader, &target).unwrap(), + downloader.model_dir(&target).join("model.gguf") + ); + assert_eq!( + llm_mmproj_path(&downloader, &target).unwrap(), + downloader.model_dir(&target).join("mmproj.gguf") + ); + } + + #[test] + fn llm_target_without_mmproj_has_no_mmproj_path() { + let target = llm_target(&preset(None, None)).expect("model-only preset"); + let downloader = ModelDownloader::new(std::env::temp_dir().join("ensu-models-test")); + assert!(llm_model_path(&downloader, &target).is_some()); + assert!(llm_mmproj_path(&downloader, &target).is_none()); + } + + #[test] + fn llm_target_rejects_half_specified_mmproj() { + assert!(llm_target(&preset(Some("https://example.org/mmproj.gguf"), None)).is_err()); + assert!(llm_target(&preset(None, Some("abc"))).is_err()); + assert!(llm_target(&preset(Some(" "), Some("abc"))).is_err()); + } + + #[test] + fn llm_targets_resolve_catalog_ids_or_fall_back_to_defaults() { + let defaults = crate::config::defaults(); + for preset in crate::config::llm_catalog() { + assert_eq!( + mobile_llm_target(&preset.id).unwrap(), + llm_target(&preset).unwrap(), + "{}", + preset.id + ); + assert_eq!( + desktop_llm_target(&preset.id).unwrap(), + llm_target(&preset).unwrap(), + "{}", + preset.id + ); + } + let mobile_default = llm_target(&defaults.mobile_default_model).unwrap(); + assert_eq!(mobile_llm_target("").unwrap(), mobile_default); + assert_eq!(mobile_llm_target("no-such-model").unwrap(), mobile_default); + let desktop_default = llm_target(&defaults.desktop_default_model).unwrap(); + assert_eq!(desktop_llm_target("").unwrap(), desktop_default); + assert_eq!( + desktop_llm_target("no-such-model").unwrap(), + desktop_default + ); + } + + #[test] + fn all_catalog_presets_produce_valid_targets() { + for preset in crate::config::llm_catalog() { + llm_target(&preset).expect("catalog preset"); + } + } +} 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 be8984fa8a6..1a9a6328a18 100644 --- a/rust/crates/model-download/src/download.rs +++ b/rust/crates/model-download/src/download.rs @@ -3,12 +3,15 @@ use std::fs::{self, File, OpenOptions}; use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; 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; @@ -65,14 +68,15 @@ impl From for Error { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct Target { pub label: String, pub url: String, - pub destination_path: String, + pub sha256: String, + pub destination: PathBuf, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct Progress { pub label: String, pub downloaded_bytes: u64, @@ -90,25 +94,23 @@ pub struct Progress { pub complete: bool, } -#[derive(Debug, Clone, Copy, Default)] -struct DownloadProgressMetrics { - elapsed_ms: u64, - bytes_per_second: f64, - file_elapsed_ms: u64, - file_bytes_per_second: f64, - retry_count: u32, - file_retry_count: u32, - file_complete: bool, - complete: bool, +#[derive(Clone, Default)] +pub struct CancellationToken { + cancelled: Arc, } -#[derive(Debug, Clone, Copy)] -struct FileDownloadProgress { - downloaded_bytes: u64, - total_bytes: Option, - network_downloaded_bytes: u64, - elapsed: Duration, - retry_count: u32, +impl CancellationToken { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } } #[derive(Debug, Clone, Copy)] @@ -119,16 +121,6 @@ struct FileDownloadReport { retry_count: u32, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DownloadMetadata { - url: String, - label: String, - size_bytes: u64, - etag: Option, - last_modified: Option, - downloaded_at_ms: u64, -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct PartialDownloadMetadata { url: String, @@ -150,7 +142,7 @@ struct DownloadProbe { } #[derive(Debug, Clone, Copy)] -struct FileDownloadState { +struct FileProgress { downloaded_bytes: u64, total_bytes: Option, network_downloaded_bytes: u64, @@ -188,9 +180,45 @@ struct ContentRange { total: Option, } -pub fn fetch( +#[derive(Clone)] +pub struct Downloader { + client: Client, +} + +impl Downloader { + pub fn new() -> Result { + Ok(Self { + client: build_client()?, + }) + } + + pub(crate) fn client(&self) -> &Client { + &self.client + } + + pub async fn download( + &self, + targets: Vec, + on_progress: impl FnMut(Progress) + Send, + cancellation: CancellationToken, + ) -> Result<(), Error> { + fetch_async(&self.client, targets, on_progress, move || { + cancellation.is_cancelled() + }) + .await + } +} + +fn build_client() -> Result { + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|err| Error::Network(err.to_string())) +} + +pub(crate) fn fetch( + downloader: &Downloader, targets: Vec, - validate: impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: impl FnMut(Progress), is_cancelled: impl Fn() -> bool, ) -> Result<(), Error> { @@ -198,12 +226,17 @@ pub fn fetch( .enable_io() .enable_time() .build()?; - runtime.block_on(fetch_async(targets, validate, on_progress, is_cancelled)) + runtime.block_on(fetch_async( + &downloader.client, + targets, + on_progress, + is_cancelled, + )) } async fn fetch_async( + client: &Client, targets: Vec, - validate: impl Fn(&Target, &Path) -> Result<(), Error>, on_progress: impl FnMut(Progress), is_cancelled: impl Fn() -> bool, ) -> Result<(), Error> { @@ -211,31 +244,35 @@ async fn fetch_async( return Ok(()); } - let client = Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .build() - .map_err(|err| Error::Network(err.to_string()))?; + 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" + ))); + } + } + + if is_cancelled() { + return Err(Error::Cancelled); + } + let download_started_at = Instant::now(); let mut download_probes = Vec::with_capacity(targets.len()); let mut cached = Vec::with_capacity(targets.len()); for target in &targets { - let destination = Path::new(&target.destination_path); - let is_cached = prepare_cached_download(target, destination, &validate); + let destination = &target.destination; + let is_cached = prepare_cached_download(target, destination); cached.push(is_cached); if is_cached { download_probes.push(DownloadProbe { content_length: file_size(destination), supports_ranges: false, - response_metadata: read_download_metadata(destination).map(|metadata| { - ResponseMetadata { - etag: metadata.etag, - last_modified: metadata.last_modified, - } - }), + response_metadata: None, }); } else { - download_probes.push(fetch_download_probe(&client, &target.url).await); + download_probes.push(fetch_download_probe(client, &target.url).await); } } @@ -257,13 +294,8 @@ async fn fetch_async( .zip(&download_probes) .zip(&cached) .map(|((target, probe), cached)| { - let existing = existing_download_bytes( - target, - Path::new(&target.destination_path), - probe, - *cached, - ); - FileDownloadState { + let existing = existing_download_bytes(target, &target.destination, probe, *cached); + FileProgress { downloaded_bytes: probe .content_length .map_or(existing, |value| existing.min(value)), @@ -277,124 +309,90 @@ async fn fetch_async( let file_states = Rc::new(RefCell::new(file_states)); let on_progress = Rc::new(RefCell::new(on_progress)); - emit_progress_from_states( - "Preparing downloads", + emit_progress( + ProgressPhase::Preparing, + Duration::ZERO, total_bytes, - DownloadProgressMetrics::default(), - None, &file_states, &on_progress, ); - let mut downloads = Vec::new(); - for (index, target) in targets.iter().enumerate() { if cached[index] { continue; } - let destination = PathBuf::from(&target.destination_path); - + if is_cancelled() { + return Err(Error::Cancelled); + } + let destination = target.destination.clone(); let download_probe = download_probes .get(index) .cloned() .unwrap_or_else(DownloadProbe::default); let expected_file_total = download_probe.content_length; - let progress_states = Rc::clone(&file_states); - let progress_callback = Rc::clone(&on_progress); let target_label = target.label.clone(); - let client = &client; - let is_cancelled = &is_cancelled; - let validate = &validate; - - downloads.push(async move { - if is_cancelled() { - return Err(Error::Cancelled); - } - - let file_report = download_file( - client, - target, - &destination, - &download_probe, - validate, - |file_progress| { - { - let mut states = progress_states.borrow_mut(); - if let Some(state) = states.get_mut(index) { - state.downloaded_bytes = file_progress.downloaded_bytes; - state.total_bytes = file_progress.total_bytes; - state.network_downloaded_bytes = file_progress.network_downloaded_bytes; - state.elapsed = file_progress.elapsed; - state.retry_count = file_progress.retry_count; - } + let file_report = download_file( + client, + target, + &destination, + &download_probe, + |file_progress| { + { + let mut states = file_states.borrow_mut(); + if let Some(state) = states.get_mut(index) { + *state = file_progress; } - - let metrics = aggregate_progress_metrics( - download_started_at.elapsed(), - &progress_states, + } + emit_progress( + ProgressPhase::File { + label: &target_label, index, - false, - false, - ); - emit_progress_from_states( - &target_label, - total_bytes, - metrics, - Some(index), - &progress_states, - &progress_callback, - ); - }, - is_cancelled, - ) - .await - .map_err(|err| match err { - Error::Cancelled => Error::Cancelled, - err => Error::Target { - label: target.label.clone(), - source: Box::new(err), - }, - })?; + complete: false, + }, + download_started_at.elapsed(), + total_bytes, + &file_states, + &on_progress, + ); + }, + &is_cancelled, + ) + .await + .map_err(|err| match err { + Error::Cancelled => Error::Cancelled, + err => Error::Target { + label: target.label.clone(), + source: Box::new(err), + }, + })?; - { - let mut states = progress_states.borrow_mut(); - if let Some(state) = states.get_mut(index) { - state.downloaded_bytes = file_report.final_size; - state.total_bytes = expected_file_total.or(Some(file_report.final_size)); - state.network_downloaded_bytes = file_report.network_downloaded_bytes; - state.elapsed = file_report.elapsed; - state.retry_count = file_report.retry_count; - } + { + let mut states = file_states.borrow_mut(); + if let Some(state) = states.get_mut(index) { + state.downloaded_bytes = file_report.final_size; + state.total_bytes = expected_file_total.or(Some(file_report.final_size)); + state.network_downloaded_bytes = file_report.network_downloaded_bytes; + state.elapsed = file_report.elapsed; + state.retry_count = file_report.retry_count; } + } - let metrics = aggregate_progress_metrics( - download_started_at.elapsed(), - &progress_states, + emit_progress( + ProgressPhase::File { + label: &target_label, index, - true, - false, - ); - emit_progress_from_states( - &target_label, - total_bytes, - metrics, - Some(index), - &progress_states, - &progress_callback, - ); - - Ok(file_report) - }); + complete: true, + }, + download_started_at.elapsed(), + total_bytes, + &file_states, + &on_progress, + ); } - - let _reports = try_join_all(downloads).await?; - let complete_metrics = aggregate_complete_metrics(download_started_at.elapsed(), &file_states); - - emit_progress_from_states( - "Complete", - total_bytes.or_else(|| Some(downloaded_bytes_from_states(&file_states))), - complete_metrics, - None, + emit_progress( + ProgressPhase::Complete, + download_started_at.elapsed(), + total_bytes, &file_states, &on_progress, ); @@ -407,8 +405,7 @@ async fn download_file( target: &Target, destination: &Path, download_probe: &DownloadProbe, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, - mut on_progress: impl FnMut(FileDownloadProgress), + mut on_progress: impl FnMut(FileProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { let parent = destination @@ -425,14 +422,15 @@ async fn download_file( destination, total, download_probe.response_metadata.clone(), - validate, &mut on_progress, is_cancelled, ) .await { Ok(report) => return Ok(report), - Err(Error::Cancelled) => return Err(Error::Cancelled), + Err(err @ (Error::Cancelled | Error::Validation(_) | Error::StorageFull)) => { + return Err(err); + } Err(err) => { range_error = Some(err); cleanup_range_download(destination); @@ -450,7 +448,6 @@ async fn download_file( target, destination, download_probe.content_length, - validate, &mut on_progress, is_cancelled, ) @@ -471,8 +468,7 @@ 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), + on_progress: &mut dyn FnMut(FileProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { let tmp_path = tmp_path_for(destination); @@ -569,7 +565,7 @@ async fn download_file_single( let mut last_progress = Instant::now(); let mut retry_attempt = false; - on_progress(FileDownloadProgress { + on_progress(FileProgress { downloaded_bytes: downloaded, total_bytes: file_total, network_downloaded_bytes, @@ -616,7 +612,7 @@ async fn download_file_single( network_downloaded_bytes = network_downloaded_bytes.saturating_add(chunk.len() as u64); if last_progress.elapsed() >= PROGRESS_INTERVAL { - on_progress(FileDownloadProgress { + on_progress(FileProgress { downloaded_bytes: downloaded, total_bytes: file_total, network_downloaded_bytes, @@ -635,7 +631,7 @@ async fn download_file_single( file.flush()?; drop(file); - on_progress(FileDownloadProgress { + on_progress(FileProgress { downloaded_bytes: downloaded, total_bytes: file_total, network_downloaded_bytes, @@ -660,7 +656,7 @@ async fn download_file_single( continue; } - 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); @@ -682,8 +678,6 @@ async fn download_file_single( }); } - let _ = write_download_metadata(destination, target, final_size, Some(response_metadata)); - return Ok(FileDownloadReport { final_size, network_downloaded_bytes, @@ -695,15 +689,13 @@ async fn download_file_single( unreachable!("the final attempt returns") } -#[allow(clippy::too_many_arguments)] async fn download_file_ranged( client: &Client, target: &Target, destination: &Path, total: u64, response_metadata: Option, - validate: &impl Fn(&Target, &Path) -> Result<(), Error>, - on_progress: &mut dyn FnMut(FileDownloadProgress), + on_progress: &mut dyn FnMut(FileProgress), is_cancelled: &impl Fn() -> bool, ) -> Result { let tmp_path = tmp_path_for(destination); @@ -781,7 +773,7 @@ async fn download_file_ranged( emit_range_file_progress(total, file_started_at, &range_states, &on_progress); - 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); @@ -814,8 +806,6 @@ async fn download_file_ranged( .map(|state| state.retry_count) .fold(0u32, u32::saturating_add); let elapsed = file_started_at.elapsed(); - let _ = write_download_metadata(destination, target, final_size, response_metadata); - Ok(FileDownloadReport { final_size, network_downloaded_bytes, @@ -837,7 +827,7 @@ async fn download_range_part( range_metadata: Rc>, range_metadata_path: PathBuf, file_started_at: Instant, - on_progress: Rc>, + on_progress: Rc>, is_cancelled: &impl Fn() -> bool, ) -> Result<(), Error> { let range_len = range_download_len(range); @@ -1182,7 +1172,7 @@ fn emit_range_file_progress( total_bytes: u64, file_started_at: Instant, range_states: &Rc>>, - on_progress: &Rc>, + on_progress: &Rc>, ) { let states = range_states.borrow(); let downloaded_bytes = states @@ -1201,7 +1191,7 @@ fn emit_range_file_progress( drop(states); let mut callback = on_progress.borrow_mut(); - (**callback)(FileDownloadProgress { + (**callback)(FileProgress { downloaded_bytes, total_bytes: Some(total_bytes), network_downloaded_bytes, @@ -1326,12 +1316,21 @@ fn parse_content_range(value: &str) -> Option { Some(ContentRange { start, end, total }) } -fn emit_progress_from_states( - label: &str, +enum ProgressPhase<'a> { + Preparing, + File { + label: &'a str, + index: usize, + complete: bool, + }, + Complete, +} + +fn emit_progress( + phase: ProgressPhase, + elapsed: Duration, total_bytes: Option, - metrics: DownloadProgressMetrics, - file_index: Option, - file_states: &Rc>>, + file_states: &Rc>>, on_progress: &Rc>, ) { let states = file_states.borrow(); @@ -1339,67 +1338,6 @@ fn emit_progress_from_states( .iter() .map(|state| state.downloaded_bytes) .sum::(); - let resolved_total_bytes = total_bytes.or_else(|| partial_total_from_states(&states)); - let (file_downloaded_bytes, file_total_bytes) = file_index - .and_then(|index| states.get(index)) - .map(|state| (state.downloaded_bytes, state.total_bytes)) - .unwrap_or((0, None)); - drop(states); - - emit_combined_progress( - label, - downloaded_bytes, - resolved_total_bytes, - file_downloaded_bytes, - file_total_bytes, - metrics, - &mut *on_progress.borrow_mut(), - ); -} - -fn aggregate_progress_metrics( - elapsed: Duration, - file_states: &Rc>>, - file_index: usize, - file_complete: bool, - complete: bool, -) -> DownloadProgressMetrics { - let states = file_states.borrow(); - let network_downloaded_bytes = states - .iter() - .map(|state| state.network_downloaded_bytes) - .sum::(); - let retry_count = states - .iter() - .map(|state| state.retry_count) - .fold(0u32, u32::saturating_add); - let file_state = states - .get(file_index) - .copied() - .unwrap_or(FileDownloadState { - downloaded_bytes: 0, - total_bytes: None, - network_downloaded_bytes: 0, - elapsed: Duration::ZERO, - retry_count: 0, - }); - drop(states); - - progress_metrics( - elapsed, - network_downloaded_bytes, - file_state, - retry_count, - file_complete, - complete, - ) -} - -fn aggregate_complete_metrics( - elapsed: Duration, - file_states: &Rc>>, -) -> DownloadProgressMetrics { - let states = file_states.borrow(); let network_downloaded_bytes = states .iter() .map(|state| state.network_downloaded_bytes) @@ -1408,93 +1346,57 @@ fn aggregate_complete_metrics( .iter() .map(|state| state.retry_count) .fold(0u32, u32::saturating_add); + let file_state = match &phase { + ProgressPhase::File { index, .. } => states.get(*index).copied(), + _ => None, + }; + let total_bytes = match phase { + ProgressPhase::Complete => total_bytes.or(Some(downloaded_bytes)), + _ => total_bytes.or_else(|| { + let total = states + .iter() + .filter_map(|state| state.total_bytes) + .sum::(); + (total > 0).then_some(total) + }), + }; drop(states); - progress_metrics( - elapsed, - network_downloaded_bytes, - FileDownloadState { - downloaded_bytes: 0, - total_bytes: None, - network_downloaded_bytes: 0, - elapsed: Duration::ZERO, - retry_count: 0, - }, - retry_count, - false, - true, - ) -} - -fn downloaded_bytes_from_states(file_states: &Rc>>) -> u64 { - file_states - .borrow() - .iter() - .map(|state| state.downloaded_bytes) - .sum() -} - -fn partial_total_from_states(states: &[FileDownloadState]) -> Option { - let total = states - .iter() - .filter_map(|state| state.total_bytes) - .sum::(); - (total > 0).then_some(total) -} - -fn emit_combined_progress( - label: &str, - downloaded_bytes: u64, - total_bytes: Option, - file_downloaded_bytes: u64, - file_total_bytes: Option, - metrics: DownloadProgressMetrics, - on_progress: &mut impl FnMut(Progress), -) { + let (label, file_complete, complete) = match phase { + ProgressPhase::Preparing => ("Preparing downloads", false, false), + ProgressPhase::File { + label, complete, .. + } => (label, complete, false), + ProgressPhase::Complete => ("Complete", false, true), + }; let percentage = total_bytes .filter(|value| *value > 0) .map(|total| ((downloaded_bytes as f64 / total as f64) * 100.0).clamp(0.0, 100.0)) .unwrap_or(0.0); + let (file_downloaded_bytes, file_total_bytes) = file_state + .map(|state| (state.downloaded_bytes, state.total_bytes)) + .unwrap_or((0, None)); - on_progress(Progress { + on_progress.borrow_mut()(Progress { label: label.to_string(), downloaded_bytes, total_bytes, file_downloaded_bytes, file_total_bytes, percentage, - elapsed_ms: metrics.elapsed_ms, - bytes_per_second: metrics.bytes_per_second, - file_elapsed_ms: metrics.file_elapsed_ms, - file_bytes_per_second: metrics.file_bytes_per_second, - retry_count: metrics.retry_count, - file_retry_count: metrics.file_retry_count, - file_complete: metrics.file_complete, - complete: metrics.complete, - }); -} - -fn progress_metrics( - elapsed: Duration, - downloaded_bytes: u64, - file_state: FileDownloadState, - retry_count: u32, - file_complete: bool, - complete: bool, -) -> DownloadProgressMetrics { - DownloadProgressMetrics { elapsed_ms: duration_ms(elapsed), - bytes_per_second: bytes_per_second(downloaded_bytes, elapsed), - file_elapsed_ms: duration_ms(file_state.elapsed), - file_bytes_per_second: bytes_per_second( - file_state.network_downloaded_bytes, - file_state.elapsed, - ), + bytes_per_second: bytes_per_second(network_downloaded_bytes, elapsed), + file_elapsed_ms: file_state + .map(|state| duration_ms(state.elapsed)) + .unwrap_or(0), + file_bytes_per_second: file_state + .map(|state| bytes_per_second(state.network_downloaded_bytes, state.elapsed)) + .unwrap_or(0.0), retry_count, - file_retry_count: file_state.retry_count, + file_retry_count: file_state.map(|state| state.retry_count).unwrap_or(0), file_complete, complete, - } + }); } fn duration_ms(duration: Duration) -> u64 { @@ -1510,65 +1412,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; } - 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 -} - -fn read_download_metadata(path: &Path) -> Option { - let text = fs::read_to_string(metadata_path_for(path)).ok()?; - 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, - size_bytes: u64, - response_metadata: Option, -) -> Result<(), Error> { - let (etag, last_modified) = response_metadata - .map(|metadata| (metadata.etag, metadata.last_modified)) - .unwrap_or((None, None)); - let metadata = DownloadMetadata { - url: target.url.clone(), - label: target.label.clone(), - size_bytes, - etag, - last_modified, - downloaded_at_ms: now_ms(), - }; - let text = serde_json::to_string_pretty(&metadata)?; - let metadata_path = metadata_path_for(path); - let tmp_path = PathBuf::from(format!("{}.tmp", metadata_path.display())); - fs::write(&tmp_path, text)?; - fs::rename(&tmp_path, &metadata_path)?; - Ok(()) + cleanup_range_download(destination); + true } fn response_metadata(response: &Response) -> ResponseMetadata { @@ -1600,8 +1449,33 @@ fn write_range_download_metadata( Ok(()) } -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 = vec![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 { @@ -1642,7 +1516,9 @@ fn cleanup_range_download(destination: &Path) { let _ = fs::remove_file(partial_metadata_path_for(destination)); } +#[cfg(test)] fn now_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) @@ -1888,12 +1764,13 @@ mod tests { assert!(probe.supports_ranges); let result = fetch( + &Downloader::new().unwrap(), vec![Target { label: "Model".to_string(), url, - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -1937,12 +1814,13 @@ mod tests { let destination = test_dir.join("model.bin"); let result = fetch( + &Downloader::new().unwrap(), vec![Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -1958,46 +1836,44 @@ mod tests { } #[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)); + fn downloads_files_sequentially() { + let bytes = Arc::new(sample_bytes(512)); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); let server = { let bytes = Arc::clone(&bytes); - let get_count = Arc::clone(&get_count); + let active = Arc::clone(&active); + let max_active = Arc::clone(&max_active); TestServer::spawn(move |stream| { - handle_no_range_test_request(stream, Arc::clone(&bytes), Arc::clone(&get_count)); + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(current, Ordering::SeqCst); + thread::sleep(Duration::from_millis(50)); + handle_no_range_test_request( + stream, + Arc::clone(&bytes), + Arc::new(AtomicUsize::new(0)), + ); + active.fetch_sub(1, Ordering::SeqCst); }) }; - let test_dir = scratch_dir("reject-download"); - let destination = test_dir.join("model.bin"); + let test_dir = scratch_dir("sequential-downloads"); + let targets = ["model", "projector"].map(|label| Target { + label: label.to_string(), + url: server.url(&format!("/{label}.bin")), + destination: test_dir.join(format!("{label}.bin")), + sha256: sha_hex(&bytes), + }); - let result = fetch( - vec![Target { - label: "Model".to_string(), - url: server.url("/model.bin"), - destination_path: destination.display().to_string(), - }], - |_, _| Err(Error::Validation("rejected".to_string())), + fetch( + &Downloader::new().unwrap(), + targets.into(), |_| {}, || 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" - ); + ) + .expect("downloads succeed"); + assert_eq!(max_active.load(Ordering::SeqCst), 1); let _ = fs::remove_dir_all(test_dir); } @@ -2030,16 +1906,23 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; - fetch(vec![target.clone()], |_, _| Ok(()), |_| {}, || false) - .expect("first download succeeds"); + fetch( + &Downloader::new().unwrap(), + 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(&Downloader::new().unwrap(), vec![target], |_| {}, || false) + .expect("second download succeeds"); assert_eq!( head_count.load(Ordering::SeqCst) + range_get_count.load(Ordering::SeqCst), requests_after_first, @@ -2068,25 +1951,24 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), - }; - 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())), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; - fetch(vec![target.clone()], require_zero_header, |_| {}, || false) - .expect("first download succeeds"); + fetch( + &Downloader::new().unwrap(), + 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) + fetch(&Downloader::new().unwrap(), vec![target], |_| {}, || false) .expect("second download re-heals the corrupt cache"); assert_eq!( get_count.load(Ordering::SeqCst), @@ -2113,115 +1995,28 @@ mod tests { let test_dir = scratch_dir("adopt-download"); let destination = test_dir.join("model.bin"); - fs::write(&destination, bytes.as_slice()).expect("place existing file without sidecar"); - assert!(!metadata_path_for(&destination).exists()); + fs::write(&destination, bytes.as_slice()).expect("place existing file"); let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fetch adopts existing file"); + fetch(&Downloader::new().unwrap(), 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(), - }; - - 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(), - }; - 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(), - }; - - 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)); @@ -2250,7 +2045,8 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2263,7 +2059,8 @@ mod tests { ) .expect("write partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("resume succeeds"); + fetch(&Downloader::new().unwrap(), vec![target], |_| {}, || false) + .expect("resume succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2309,7 +2106,8 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), vec![0xAAu8; 256]).expect("place stale tmp"); write_partial_download_metadata( @@ -2322,7 +2120,8 @@ mod tests { ) .expect("write stale partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("restart succeeds"); + fetch(&Downloader::new().unwrap(), vec![target], |_| {}, || false) + .expect("restart succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2358,10 +2157,12 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; - fetch(vec![target], |_, _| Ok(()), |_| {}, || false).expect("fresh download succeeds"); + fetch(&Downloader::new().unwrap(), vec![target], |_| {}, || false) + .expect("fresh download succeeds"); assert_eq!( fs::read(&destination).expect("read downloaded file"), @@ -2401,7 +2202,8 @@ mod tests { let target = Target { label: "Model".to_string(), url: server.url("/model.bin"), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(&bytes), }; fs::write(tmp_path_for(&destination), &bytes[..256]).expect("place partial tmp"); write_partial_download_metadata( @@ -2414,7 +2216,7 @@ mod tests { ) .expect("write partial sidecar"); - fetch(vec![target], |_, _| Ok(()), |_| {}, || false) + fetch(&Downloader::new().unwrap(), vec![target], |_| {}, || false) .expect("restart after bad 206 succeeds"); assert_eq!( @@ -2491,12 +2293,13 @@ mod tests { let destination = test_dir.join("model.bin"); let result = fetch( + &Downloader::new().unwrap(), vec![Target { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: "0".repeat(64), }], - |_, _| Ok(()), |_| {}, || true, ); @@ -2516,12 +2319,13 @@ mod tests { fs::write(partial_metadata_path_for(&destination), "{}").expect("write partial sidecar"); let result = fetch( + &Downloader::new().unwrap(), vec![Target { label: "Model".to_string(), url: "http://127.0.0.1:1/model.bin".to_string(), - destination_path: destination.display().to_string(), + destination: destination.clone(), + sha256: sha_hex(b"complete-model"), }], - |_, _| Ok(()), |_| {}, || false, ); @@ -2535,10 +2339,179 @@ 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( + &Downloader::new().unwrap(), + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination: destination.clone(), + sha256: expected, + }], + |_| {}, + || 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( + &Downloader::new().unwrap(), + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination: destination.clone(), + sha256: "0".repeat(64), + }], + |_| {}, + || 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( + &Downloader::new().unwrap(), + vec![Target { + label: "Model".to_string(), + url: server.url("/model.bin"), + destination: destination.clone(), + sha256: "0".repeat(64), + }], + |_| {}, + || 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: destination.clone(), + sha256: sha.clone(), + }; + + fetch( + &Downloader::new().unwrap(), + vec![Target { + url: "http://127.0.0.1:9/new-location".to_string(), + ..old_target.clone() + }], + |_| {}, + || false, + ) + .expect("cached file adopted by checksum despite changed URL"); + + fs::write(&destination, b"corrupted").expect("corrupt cached file"); + let result = fetch( + &Downloader::new().unwrap(), + vec![Target { + url: "http://127.0.0.1:9/new-location".to_string(), + ..old_target + }], + |_| {}, + || 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() } + 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 deleted file mode 100644 index 8b010c5fcfb..00000000000 --- a/rust/crates/model-download/src/downloader.rs +++ /dev/null @@ -1,1198 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, PoisonError}; -use std::time::Duration; - -use tokio::runtime::Builder; - -use crate::archive; -use crate::download::{self, Progress, Target}; -use crate::gguf; - -#[derive(Debug, Clone)] -pub enum ModelDownloadTarget { - Gguf { - id: String, - url: String, - mmproj_url: Option, - }, - TarGz { - id: String, - url: String, - }, - Onnx { - id: String, - url: String, - }, -} - -#[derive(Debug, Clone)] -pub struct ModelDownloadProgress { - pub downloaded_bytes: u64, - pub total_bytes: Option, - pub percent: i32, - pub status: String, - pub log_line: Option, -} - -pub struct ModelDownloader { - models_dir: PathBuf, - cancelled: AtomicBool, - active: AtomicBool, - download_lock: Mutex<()>, -} - -impl ModelDownloader { - pub fn new(models_dir: impl Into) -> Self { - let models_dir = models_dir.into(); - let _ = fs::create_dir_all(&models_dir); - Self { - models_dir, - cancelled: AtomicBool::new(false), - active: AtomicBool::new(false), - download_lock: Mutex::new(()), - } - } - - pub fn model_path(&self, target: &ModelDownloadTarget) -> PathBuf { - model_path(&self.models_dir, target) - } - - 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::TarGz { .. } | ModelDownloadTarget::Onnx { .. } => None, - } - } - - pub fn is_downloaded(&self, target: &ModelDownloadTarget) -> bool { - 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)), - } - } - - pub fn is_download_active(&self) -> bool { - self.active.load(Ordering::SeqCst) - } - - pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); - } - - 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(); - } - 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 - } - - /// Returns whether a network download ran; false when everything was - /// already present. - pub fn download( - &self, - targets: &[ModelDownloadTarget], - mut on_progress: impl FnMut(ModelDownloadProgress), - is_cancelled: impl Fn() -> bool, - ) -> Result { - let _guard = self - .download_lock - .lock() - .unwrap_or_else(PoisonError::into_inner); - let pending = targets - .iter() - .filter(|target| !self.is_downloaded(target)) - .collect::>(); - if pending.is_empty() { - return Ok(false); - } - - 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, - |progress| on_progress(display_progress(progress)), - || self.cancelled.load(Ordering::SeqCst) || is_cancelled(), - ) - .and_then(|()| { - for target in &pending { - self.extract_if_archive(target, &mut on_progress)?; - } - Ok(()) - }); - self.active.store(false, Ordering::SeqCst); - result.map(|()| true) - } - - fn extract_if_archive( - &self, - target: &ModelDownloadTarget, - on_progress: &mut impl FnMut(ModelDownloadProgress), - ) -> Result<(), download::Error> { - let ModelDownloadTarget::TarGz { id, .. } = target else { - return Ok(()); - }; - on_progress(ModelDownloadProgress { - downloaded_bytes: 0, - total_bytes: None, - percent: 99, - status: "Extracting...".to_string(), - log_line: Some(format!("Extracting model archive id={id}")), - }); - let archive_path = self.staging_dir().join(format!("{id}.tar.gz")); - let result = archive::extract_tar_gz( - &archive_path, - &self.staging_dir().join(id), - &self.model_path(target), - ); - let _ = fs::remove_file(download::metadata_path_for(&archive_path)); - let _ = fs::remove_file(&archive_path); - result - } - - fn staging_dir(&self) -> PathBuf { - self.models_dir.join(".staging") - } - - pub fn estimated_download_size(&self, target: &ModelDownloadTarget) -> Option { - let runtime = Builder::new_current_thread() - .enable_io() - .enable_time() - .build() - .ok()?; - runtime.block_on(async { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(30)) - .build() - .ok()?; - let mut total: i64 = 0; - let mut any = false; - for entry in self.fetch_targets(target) { - let path = Path::new(&entry.destination_path); - let size = if path.exists() { - fs::metadata(path).ok().map(|m| m.len()).filter(|s| *s > 0) - } else { - download::probe_content_length(&client, &entry.url).await - }; - if let Some(size) = size { - any = true; - total = total.saturating_add(size as i64); - } - } - 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(), - }], - ModelDownloadTarget::Onnx { url, .. } => vec![Target { - label: "Model".to_string(), - url: url.clone(), - destination_path: self.model_path(target).display().to_string(), - }], - } - } -} - -fn model_path(models_dir: &Path, target: &ModelDownloadTarget) -> PathBuf { - match target { - ModelDownloadTarget::Gguf { - id, - url, - 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"), - } -} - -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() - ))); - } - 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(()); - } - gguf::validate_gguf(path) -} - -fn is_non_empty_file(path: &Path) -> bool { - path.metadata() - .map(|metadata| metadata.is_file() && metadata.len() > 0) - .unwrap_or(false) -} - -fn display_progress(progress: Progress) -> ModelDownloadProgress { - let total = progress.total_bytes.filter(|total| *total > 0); - let percent = total - .map(|total| ((progress.downloaded_bytes * 100 / total) as i32).clamp(0, 99)) - .unwrap_or(0); - let status = if let Some(total) = total { - format!( - "Downloading... {} / {}", - format_bytes(progress.downloaded_bytes), - format_bytes(total) - ) - } else if progress.file_downloaded_bytes > 0 { - format!( - "Downloading {}... {}", - progress.label.to_lowercase(), - format_bytes(progress.file_downloaded_bytes) - ) - } else { - format!("Downloading {}...", progress.label.to_lowercase()) - }; - let log_line = if progress.file_complete { - Some(format!( - "Model download file complete label={} bytes={} elapsedMs={} rate={}/s retries={}", - progress.label, - progress.file_downloaded_bytes, - progress.file_elapsed_ms, - format_bytes(rate_bytes(progress.file_bytes_per_second)), - progress.file_retry_count - )) - } else if progress.complete { - Some(format!( - "Model download complete bytes={} elapsedMs={} rate={}/s retries={}", - progress.downloaded_bytes, - progress.elapsed_ms, - format_bytes(rate_bytes(progress.bytes_per_second)), - progress.retry_count - )) - } else { - None - }; - - ModelDownloadProgress { - downloaded_bytes: progress.downloaded_bytes, - total_bytes: progress.total_bytes, - percent, - status, - log_line, - } -} - -fn rate_bytes(bytes_per_second: f64) -> u64 { - if bytes_per_second.is_finite() && bytes_per_second > 0.0 { - bytes_per_second as u64 - } else { - 0 - } -} - -pub fn format_bytes(bytes: u64) -> String { - const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; - let mut size = bytes as f64; - let mut unit = 0; - while size >= 1024.0 && unit < UNITS.len() - 1 { - size /= 1024.0; - unit += 1; - } - format!("{size:.1} {}", UNITS[unit]) -} - -fn migrate_legacy_dir(models_dir: &Path, legacy_dir: &Path, targets: &[ModelDownloadTarget]) { - 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) { - let _ = fs::remove_dir_all(legacy_dir); - } -} - -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()]; - if !adopt_targets(models_dir, targets, &hashed_dirs, models_dir) { - return; - } - let Ok(entries) = fs::read_dir(models_dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - let _ = fs::remove_file(&path); - } else if path.file_name().is_some_and(|name| name == "custom") { - let _ = fs::remove_dir_all(&path); - } - } -} - -pub fn migrate_ensu_legacy_models( - models_dir: &Path, - llm_legacy_dir: Option<&Path>, - transcription_legacy_dir: &Path, - llm_targets: &[ModelDownloadTarget], - transcription_model: &ModelDownloadTarget, - voice_activity_model: &ModelDownloadTarget, -) { - if let Some(llm_legacy_dir) = llm_legacy_dir { - migrate_legacy_dir(models_dir, llm_legacy_dir, llm_targets); - } - migrate_legacy_transcription_dir( - models_dir, - transcription_legacy_dir, - transcription_model, - voice_activity_model, - ); -} - -fn migrate_legacy_transcription_dir( - models_dir: &Path, - legacy_dir: &Path, - model: &ModelDownloadTarget, - vad: &ModelDownloadTarget, -) { - const LEGACY_MODEL_DIR_NAME: &str = "parakeet-tdt-0.6b-v3-int8"; - const LEGACY_MODEL_ID: &str = "parakeet-v3-int8"; - const LEGACY_VAD_FILE_NAME: &str = "silero_vad_v4.onnx"; - const LEGACY_VAD_ID: &str = "silero-vad-v4"; - if !legacy_dir.exists() { - return; - } - let mut all_moved = true; - - if matches!(model, ModelDownloadTarget::TarGz { id, .. } if id == LEGACY_MODEL_ID) { - let dest = model_path(models_dir, model); - if !dest.exists() { - let source = legacy_dir.join(LEGACY_MODEL_DIR_NAME); - if source.is_dir() { - let _ = fs::create_dir_all(models_dir); - if fs::rename(&source, &dest).is_err() { - all_moved = false; - } - } - } - } - - if matches!(vad, ModelDownloadTarget::Onnx { id, .. } if id == LEGACY_VAD_ID) { - let dest = model_path(models_dir, vad); - if !dest.exists() { - let source = legacy_dir.join(LEGACY_VAD_FILE_NAME); - if is_non_empty_file(&source) && !move_file(&source, &dest) { - all_moved = false; - } - } - } - - if all_moved { - let _ = fs::remove_dir_all(legacy_dir); - } -} - -fn sidecar_url(path: &Path) -> Option { - let sidecar = PathBuf::from(format!("{}.metadata.json", path.display())); - let text = fs::read_to_string(sidecar).ok()?; - let value: serde_json::Value = serde_json::from_str(&text).ok()?; - value["url"].as_str().map(String::from) -} - -fn adopt_targets( - models_dir: &Path, - targets: &[ModelDownloadTarget], - hashed_dirs: &[PathBuf], - flat_dir: &Path, -) -> bool { - let mut plans: Vec> = Vec::new(); - for target in targets { - let ModelDownloadTarget::Gguf { - id, - 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, - )); - } - plans.push(plan); - } - - let mut basename_urls: HashMap> = HashMap::new(); - for plan in &plans { - for (url, fallback, _) in plan { - basename_urls - .entry(gguf::filename_for_url(url, fallback)) - .or_default() - .insert(url.as_str()); - } - } - - let mut all_moved = true; - for plan in &plans { - let sources: Vec>> = plan - .iter() - .map(|(url, fallback, dest)| { - if dest.exists() { - return Some(None); - } - let basename = gguf::filename_for_url(url, fallback); - for dir in hashed_dirs { - let hashed = dir.join(format!("{}_{basename}", gguf::sha256_hex(url))); - if gguf::looks_like_gguf(&hashed) { - return Some(Some(hashed)); - } - } - let flat = flat_dir.join(&basename); - if gguf::looks_like_gguf(&flat) { - match sidecar_url(&flat) { - Some(sidecar) => { - if sidecar == *url { - return Some(Some(flat)); - } - } - None => { - if basename_urls[&basename].len() == 1 { - return Some(Some(flat)); - } - } - } - } - None - }) - .collect(); - if sources.iter().any(Option::is_none) { - continue; - } - for ((_, _, dest), source) in plan.iter().zip(sources) { - let Some(Some(source)) = source else { - continue; - }; - if !move_file(&source, dest) { - all_moved = false; - } - } - } - all_moved -} - -fn move_file(source: &Path, dest: &Path) -> bool { - if let Some(parent) = dest.parent() { - let _ = fs::create_dir_all(parent); - } - if fs::rename(source, dest).is_ok() { - return true; - } - let staged = PathBuf::from(format!("{}.migrating", dest.display())); - if fs::copy(source, &staged) - .and_then(|_| fs::rename(&staged, dest)) - .is_err() - { - let _ = fs::remove_file(&staged); - return false; - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - - fn scratch_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "ente-model-download-{name}-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&dir).expect("create test dir"); - dir - } - - fn target(id: &str) -> ModelDownloadTarget { - ModelDownloadTarget::Gguf { - id: id.to_string(), - url: "https://example.org/models/main.gguf?download=true".to_string(), - mmproj_url: Some("https://example.org/models/mmproj.gguf".to_string()), - } - } - - 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() { - let dir = scratch_dir("paths"); - let downloader = ModelDownloader::new(&dir); - - assert_eq!( - downloader.model_path(&target("qwen-2b-q8")), - dir.join("qwen-2b-q8/model.gguf") - ); - assert_eq!( - downloader.mmproj_path(&target("qwen-2b-q8")), - 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(), - mmproj_url: Some("https://example.org/models/other.gguf".to_string()), - }; - assert_ne!(downloader.model_path(&other_mmproj), custom); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn is_downloaded_requires_gguf_magic_on_all_files() { - let dir = scratch_dir("is-downloaded"); - let downloader = ModelDownloader::new(&dir); - let target = target("qwen-2b-q8"); - let model = downloader.model_path(&target); - let mmproj = downloader.mmproj_path(&target).unwrap(); - - assert!(!downloader.is_downloaded(&target)); - - 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)); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn download_skips_when_already_present() { - let dir = scratch_dir("skip"); - let downloader = ModelDownloader::new(&dir); - let target = target("qwen-2b-q8"); - write_gguf(&downloader.model_path(&target), b"GGUFdata"); - write_gguf(&downloader.mmproj_path(&target).unwrap(), b"GGUFdata"); - - let downloaded = downloader - .download(std::slice::from_ref(&target), |_| {}, || false) - .expect("skip without network"); - assert!(!downloaded); - - let _ = fs::remove_dir_all(dir); - } - - fn tar_gz_target(id: &str) -> ModelDownloadTarget { - ModelDownloadTarget::TarGz { - id: id.to_string(), - url: format!("https://models.example.org/{id}.tar.gz"), - } - } - - fn onnx_target(id: &str) -> ModelDownloadTarget { - ModelDownloadTarget::Onnx { - id: id.to_string(), - url: format!("https://models.example.org/{id}.onnx"), - } - } - - fn write_tar_gz(path: &Path, dir_name: &str, files: &[(&str, &[u8])]) { - let file = fs::File::create(path).unwrap(); - let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast()); - let mut builder = tar::Builder::new(encoder); - for (name, content) in files { - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, format!("{dir_name}/{name}"), *content) - .unwrap(); - } - builder.into_inner().unwrap().finish().unwrap(); - } - - #[test] - fn archive_and_onnx_targets_use_per_model_dirs() { - let dir = scratch_dir("variant-paths"); - let downloader = ModelDownloader::new(&dir); - - let archive = tar_gz_target("parakeet-v3-int8"); - assert_eq!( - downloader.model_path(&archive), - dir.join("parakeet-v3-int8") - ); - assert_eq!(downloader.mmproj_path(&archive), None); - - let onnx = onnx_target("silero-vad-v4"); - assert_eq!( - downloader.model_path(&onnx), - dir.join("silero-vad-v4/model.onnx") - ); - assert_eq!(downloader.mmproj_path(&onnx), None); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn archive_download_extracts_into_model_dir() { - let dir = scratch_dir("variant-extract"); - let downloader = ModelDownloader::new(&dir); - let target = tar_gz_target("parakeet-v3-int8"); - assert!(!downloader.is_downloaded(&target)); - - fs::create_dir_all(dir.join(".staging")).unwrap(); - write_tar_gz( - &dir.join(".staging/parakeet-v3-int8.tar.gz"), - "parakeet-tdt-0.6b-v3-int8", - &[("encoder.onnx", b"enc"), ("tokens.txt", b"tok")], - ); - let mut statuses = Vec::new(); - downloader - .extract_if_archive(&target, &mut |progress| statuses.push(progress.status)) - .expect("extract"); - - assert!(downloader.is_downloaded(&target)); - assert_eq!( - fs::read(dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), - b"enc" - ); - assert_eq!(statuses, ["Extracting..."]); - assert!(!dir.join(".staging/parakeet-v3-int8.tar.gz").exists()); - - assert!(downloader.remove_downloaded(&target)); - assert!(!downloader.is_downloaded(&target)); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - 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"); - assert!(!downloader.is_downloaded(&target)); - - write_gguf(&downloader.model_path(&target), b"onnxbytes"); - assert!(downloader.is_downloaded(&target)); - - assert!(downloader.remove_downloaded(&target)); - assert!(!downloader.is_downloaded(&target)); - assert!(!dir.join("silero-vad-v4").exists()); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn migrate_transcription_moves_model_dir_and_vad_file() { - let base = scratch_dir("migrate-transcription"); - let legacy = base.join("transcription"); - fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); - fs::write( - legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), - b"enc", - ) - .unwrap(); - fs::write(legacy.join("silero_vad_v4.onnx"), b"vad").unwrap(); - fs::write(legacy.join("stale.partial"), b"junk").unwrap(); - - 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(), - }; - migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); - - let downloader = ModelDownloader::new(&models_dir); - assert!(downloader.is_downloaded(&model)); - assert!(downloader.is_downloaded(&vad)); - assert_eq!( - fs::read(models_dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), - b"enc" - ); - assert_eq!( - fs::read(models_dir.join("silero-vad-v4/model.onnx")).unwrap(), - b"vad" - ); - assert!(!legacy.exists()); - - migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); - assert!(downloader.is_downloaded(&model)); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn migrate_transcription_keeps_existing_destinations() { - let base = scratch_dir("migrate-transcription-existing"); - let legacy = base.join("transcription"); - fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); - fs::write( - legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), - b"old", - ) - .unwrap(); - - let models_dir = base.join("models"); - let model = tar_gz_target("parakeet-v3-int8"); - let vad = onnx_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(); - - migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); - - assert_eq!( - fs::read(models_dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), - b"new" - ); - assert!(!legacy.exists()); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn migrate_transcription_adopts_nothing_when_target_ids_differ() { - let base = scratch_dir("migrate-transcription-mismatch"); - let legacy = base.join("transcription"); - fs::create_dir_all(legacy.join("parakeet-tdt-0.6b-v3-int8")).unwrap(); - fs::write( - legacy.join("parakeet-tdt-0.6b-v3-int8/encoder.onnx"), - b"enc", - ) - .unwrap(); - fs::write(legacy.join("silero_vad_v4.onnx"), b"vad").unwrap(); - - let models_dir = base.join("models"); - let model = tar_gz_target("parakeet-v4-int8"); - let vad = onnx_target("silero-vad-v5"); - migrate_legacy_transcription_dir(&models_dir, &legacy, &model, &vad); - - let downloader = ModelDownloader::new(&models_dir); - assert!(!downloader.is_downloaded(&model)); - assert!(!downloader.is_downloaded(&vad)); - assert!(!legacy.exists()); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn migrate_adopts_known_files_and_discards_legacy_dir() { - 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::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(), - mmproj_url: None, - }; - 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()]); - - let downloader = ModelDownloader::new(&models_dir); - assert_eq!( - fs::read(downloader.model_path(&preset)).unwrap(), - b"GGUFmain" - ); - assert_eq!( - 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!(!legacy.exists()); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn migrate_adopts_targets_only_when_every_file_resolves() { - let base = scratch_dir("migrate-ambiguous"); - let legacy = base.join("llm"); - let legacy_models = legacy.join("models"); - fs::create_dir_all(&legacy_models).unwrap(); - fs::write(legacy_models.join("a.gguf"), b"GGUFa").unwrap(); - fs::write(legacy_models.join("b.gguf"), b"GGUFb").unwrap(); - fs::write(legacy_models.join("mmproj-F16.gguf"), b"GGUFshared").unwrap(); - - let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { - id: format!("preset-{name}"), - url: format!("https://example.org/{name}/{name}.gguf"), - mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), - }); - - let models_dir = base.join("models"); - migrate_legacy_dir(&models_dir, &legacy, &targets); - - assert!(!models_dir.exists() || fs::read_dir(&models_dir).unwrap().count() == 0); - assert!(!legacy.exists()); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn migrate_flat_models_dir_adopts_known_files_and_cleans_root() { - let models_dir = scratch_dir("flat-migrate"); - fs::create_dir_all(models_dir.join("custom")).unwrap(); - - let preset = target("qwen-2b-q8"); - fs::write(models_dir.join("main.gguf"), b"GGUFmain").unwrap(); - let mmproj_hashed = format!( - "{}_mmproj.gguf", - gguf::sha256_hex("https://example.org/models/mmproj.gguf") - ); - 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(), - mmproj_url: None, - }; - 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); - write_gguf( - &models_dir.join("other-key").join("model.gguf"), - b"GGUFkeep", - ); - - migrate_flat_models_dir(&models_dir, &[preset.clone(), custom.clone()]); - - assert_eq!( - fs::read(downloader.model_path(&preset)).unwrap(), - b"GGUFmain" - ); - assert_eq!( - 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" - ); - assert!(!models_dir.join("custom").exists()); - let root_files = fs::read_dir(&models_dir) - .unwrap() - .flatten() - .filter(|entry| entry.path().is_file()) - .count(); - assert_eq!(root_files, 0); - - migrate_flat_models_dir(&models_dir, &[preset.clone(), custom]); - assert_eq!( - fs::read(downloader.model_path(&preset)).unwrap(), - b"GGUFmain" - ); - - let _ = fs::remove_dir_all(models_dir); - } - - #[test] - fn migrate_flat_models_dir_adopts_sidecarless_files_for_duplicated_targets() { - let models_dir = scratch_dir("flat-duplicate-targets"); - fs::create_dir_all(&models_dir).unwrap(); - fs::write(models_dir.join("main.gguf"), b"GGUFmain").unwrap(); - fs::write(models_dir.join("mmproj.gguf"), b"GGUFmm").unwrap(); - - let preset = target("lfm-vl-1.6b"); - migrate_flat_models_dir(&models_dir, &[preset.clone(), preset.clone()]); - - let downloader = ModelDownloader::new(&models_dir); - assert_eq!( - fs::read(downloader.model_path(&preset)).unwrap(), - b"GGUFmain" - ); - assert_eq!( - fs::read(downloader.mmproj_path(&preset).unwrap()).unwrap(), - b"GGUFmm" - ); - - let _ = fs::remove_dir_all(models_dir); - } - - #[test] - fn migrate_flat_models_dir_resolves_ambiguity_via_metadata_sidecars() { - let models_dir = scratch_dir("flat-sidecar"); - fs::create_dir_all(&models_dir).unwrap(); - - let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { - id: format!("preset-{name}"), - url: format!("https://example.org/{name}/{name}.gguf"), - mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), - }); - - fs::write(models_dir.join("a.gguf"), b"GGUFa").unwrap(); - fs::write( - models_dir.join("a.gguf.metadata.json"), - br#"{"url": "https://example.org/a/a.gguf"}"#, - ) - .unwrap(); - fs::write(models_dir.join("mmproj-F16.gguf"), b"GGUFmma").unwrap(); - fs::write( - models_dir.join("mmproj-F16.gguf.metadata.json"), - br#"{"url": "https://example.org/a/mmproj-F16.gguf"}"#, - ) - .unwrap(); - - migrate_flat_models_dir(&models_dir, &targets); - - let downloader = ModelDownloader::new(&models_dir); - assert_eq!( - fs::read(downloader.model_path(&targets[0])).unwrap(), - b"GGUFa" - ); - assert_eq!( - fs::read(downloader.mmproj_path(&targets[0]).unwrap()).unwrap(), - b"GGUFmma" - ); - assert!(!downloader.model_path(&targets[1]).exists()); - - let _ = fs::remove_dir_all(models_dir); - } - - #[test] - fn migrate_flat_models_dir_rejects_files_whose_sidecar_disagrees() { - let models_dir = scratch_dir("flat-sidecar-mismatch"); - fs::create_dir_all(&models_dir).unwrap(); - - let preset = target("qwen-2b-q8"); - fs::write(models_dir.join("main.gguf"), b"GGUFstale").unwrap(); - fs::write( - models_dir.join("main.gguf.metadata.json"), - br#"{"url": "https://example.org/elsewhere/main.gguf"}"#, - ) - .unwrap(); - - migrate_flat_models_dir(&models_dir, std::slice::from_ref(&preset)); - - let downloader = ModelDownloader::new(&models_dir); - assert!(!downloader.model_path(&preset).exists()); - - let _ = fs::remove_dir_all(models_dir); - } - - #[test] - fn migrate_flat_models_dir_drops_targets_with_ambiguous_basenames() { - let models_dir = scratch_dir("flat-ambiguous"); - fs::create_dir_all(&models_dir).unwrap(); - fs::write(models_dir.join("a.gguf"), b"GGUFa").unwrap(); - fs::write(models_dir.join("mmproj-F16.gguf"), b"GGUFshared").unwrap(); - - let targets = ["a", "b"].map(|name| ModelDownloadTarget::Gguf { - id: format!("preset-{name}"), - url: format!("https://example.org/{name}/{name}.gguf"), - mmproj_url: Some(format!("https://example.org/{name}/mmproj-F16.gguf")), - }); - - migrate_flat_models_dir(&models_dir, &targets); - - assert_eq!(fs::read_dir(&models_dir).unwrap().count(), 0); - - let _ = fs::remove_dir_all(models_dir); - } - - #[test] - fn migrate_keeps_existing_destination_files() { - let base = scratch_dir("migrate-existing"); - let legacy = base.join("llm"); - fs::create_dir_all(legacy.join("models")).unwrap(); - fs::write(legacy.join("models/main.gguf"), b"GGUFold").unwrap(); - fs::write(legacy.join("models/mmproj.gguf"), b"GGUFmm").unwrap(); - - let models_dir = base.join("models"); - let preset = target("qwen-2b-q8"); - let downloader = ModelDownloader::new(&models_dir); - write_gguf(&downloader.model_path(&preset), b"GGUFnew"); - - migrate_legacy_dir(&models_dir, &legacy, std::slice::from_ref(&preset)); - - assert_eq!( - fs::read(downloader.model_path(&preset)).unwrap(), - b"GGUFnew" - ); - assert_eq!( - fs::read(downloader.mmproj_path(&preset).unwrap()).unwrap(), - b"GGUFmm" - ); - assert!(!legacy.exists()); - - let _ = fs::remove_dir_all(base); - } - - #[test] - fn display_progress_composes_percent_and_status() { - let progress = Progress { - label: "Model".to_string(), - downloaded_bytes: 512 * 1024 * 1024, - total_bytes: Some(2 * 1024 * 1024 * 1024), - file_downloaded_bytes: 512 * 1024 * 1024, - file_total_bytes: Some(2 * 1024 * 1024 * 1024), - percentage: 25.0, - elapsed_ms: 0, - bytes_per_second: 0.0, - file_elapsed_ms: 0, - file_bytes_per_second: 0.0, - retry_count: 0, - file_retry_count: 0, - file_complete: false, - complete: false, - }; - - let display = display_progress(progress); - assert_eq!(display.percent, 25); - assert_eq!(display.status, "Downloading... 512.0 MB / 2.0 GB"); - assert_eq!(display.log_line, None); - - let unknown_total = Progress { - total_bytes: None, - ..display_to_progress_stub() - }; - let display = display_progress(unknown_total); - assert_eq!(display.percent, 0); - assert_eq!(display.status, "Downloading model... 100.0 B"); - - let file_complete = Progress { - file_complete: true, - file_bytes_per_second: 2048.0, - ..display_to_progress_stub() - }; - let display = display_progress(file_complete); - assert_eq!( - display.log_line.as_deref(), - Some( - "Model download file complete label=Model bytes=100 elapsedMs=0 rate=2.0 KB/s retries=0" - ) - ); - } - - fn display_to_progress_stub() -> Progress { - Progress { - label: "Model".to_string(), - downloaded_bytes: 100, - total_bytes: None, - file_downloaded_bytes: 100, - file_total_bytes: None, - percentage: 0.0, - elapsed_ms: 0, - bytes_per_second: 0.0, - file_elapsed_ms: 0, - file_bytes_per_second: 0.0, - retry_count: 0, - file_retry_count: 0, - file_complete: false, - complete: false, - } - } -} diff --git a/rust/crates/model-download/src/gguf.rs b/rust/crates/model-download/src/gguf.rs deleted file mode 100644 index ad7558fe796..00000000000 --- a/rust/crates/model-download/src/gguf.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::fs::File; -use std::io::Read; -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(), - }]; - 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(), - }); - } - targets -} - -pub(crate) fn model_dir( - models_dir: &Path, - id: &str, - 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) -} - -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, - Err(_) => return false, - }; - let mut header = [0u8; 4]; - 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()) -} - -pub(crate) fn filename_for_url(url: &str, fallback: &str) -> String { - let without_query = url.split(['?', '#']).next().unwrap_or(url); - let name = without_query.rsplit('/').next().unwrap_or(""); - if name.trim().is_empty() { - fallback.to_string() - } else { - name.to_string() - } -} - -pub(crate) fn sha256_hex(value: &str) -> String { - Sha256::digest(value.as_bytes()) - .iter() - .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..6af18cdd836 100644 --- a/rust/crates/model-download/src/lib.rs +++ b/rust/crates/model-download/src/lib.rs @@ -1,9 +1,5 @@ mod archive; pub mod download; -mod downloader; -mod gguf; +mod model; -pub use downloader::{ - ModelDownloadProgress, ModelDownloadTarget, ModelDownloader, migrate_ensu_legacy_models, - migrate_flat_models_dir, -}; +pub use model::{ModelDownloadProgress, ModelDownloader, ModelFile, ModelTarget}; diff --git a/rust/crates/model-download/src/model.rs b/rust/crates/model-download/src/model.rs new file mode 100644 index 00000000000..9b96045788a --- /dev/null +++ b/rust/crates/model-download/src/model.rs @@ -0,0 +1,648 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock, PoisonError}; + +use tokio::runtime::Builder; + +use crate::archive; +use crate::download::{self, Progress, Target}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelTarget { + Files { + id: String, + files: Vec, + }, + TarGz { + id: String, + url: String, + sha256: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelFile { + pub name: String, + pub url: String, + pub sha256: String, +} + +#[derive(Debug, Clone)] +pub struct ModelDownloadProgress { + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub percent: i32, + pub status: String, + pub log_line: Option, +} + +pub struct ModelDownloader { + downloader: OnceLock, + models_dir: PathBuf, + active: AtomicBool, + download_lock: Mutex<()>, +} + +impl ModelDownloader { + pub fn new(models_dir: impl Into) -> Self { + Self { + downloader: OnceLock::new(), + models_dir: models_dir.into(), + active: AtomicBool::new(false), + download_lock: Mutex::new(()), + } + } + + pub fn downloader(&self) -> Result<&download::Downloader, download::Error> { + if let Some(downloader) = self.downloader.get() { + return Ok(downloader); + } + let downloader = download::Downloader::new()?; + Ok(self.downloader.get_or_init(|| downloader)) + } + + pub fn model_dir(&self, target: &ModelTarget) -> PathBuf { + storage_dir(&self.models_dir, target) + } + + pub fn file_path(&self, target: &ModelTarget, name: &str) -> Option { + match target { + ModelTarget::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: &ModelTarget) -> bool { + if let ModelTarget::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 { + self.active.load(Ordering::SeqCst) + } + + pub fn remove_downloaded(&self, target: &ModelTarget) -> bool { + let Ok(_guard) = self.download_lock.try_lock() else { + return false; + }; + if checked_file_name(storage_key(target)).is_err() { + return false; + } + let dir = storage_dir(&self.models_dir, target); + dir.exists() && fs::remove_dir_all(dir).is_ok() + } + + pub fn download( + &self, + targets: &[ModelTarget], + mut on_progress: impl FnMut(ModelDownloadProgress), + cancellation: &download::CancellationToken, + ) -> Result<(), download::Error> { + let _guard = self + .download_lock + .lock() + .unwrap_or_else(PoisonError::into_inner); + let pending = targets + .iter() + .filter(|target| !self.is_downloaded(target)) + .collect::>(); + if pending.is_empty() { + return Ok(()); + } + + let mut transfers = Vec::new(); + for target in &pending { + transfers.extend(download_targets(&self.models_dir, target)?); + } + + if cancellation.is_cancelled() { + return Err(download::Error::Cancelled); + } + let downloader = self.downloader()?; + self.active.store(true, Ordering::SeqCst); + let result = download::fetch( + downloader, + transfers, + |progress| on_progress(display_progress(progress)), + || cancellation.is_cancelled(), + ) + .and_then(|()| { + for target in &pending { + if cancellation.is_cancelled() { + return Err(download::Error::Cancelled); + } + self.extract_if_archive(target, &mut on_progress)?; + } + Ok(()) + }); + self.active.store(false, Ordering::SeqCst); + result + } + + fn extract_if_archive( + &self, + target: &ModelTarget, + on_progress: &mut impl FnMut(ModelDownloadProgress), + ) -> Result<(), download::Error> { + let ModelTarget::TarGz { id, .. } = target else { + return Ok(()); + }; + on_progress(ModelDownloadProgress { + downloaded_bytes: 0, + total_bytes: None, + percent: 99, + status: "Extracting...".to_string(), + log_line: Some(format!("Extracting model archive id={id}")), + }); + let archive_path = self.staging_dir().join(format!("{id}.tar.gz")); + let extraction_dir = self.staging_dir().join(id); + let result = + archive::extract_tar_gz(&archive_path, &extraction_dir, &self.model_dir(target)); + if result.is_err() { + let _ = fs::remove_dir_all(extraction_dir); + } else { + let _ = fs::remove_file(&archive_path); + } + result + } + + fn staging_dir(&self) -> PathBuf { + self.models_dir.join(".staging") + } + + pub fn estimated_download_size(&self, target: &ModelTarget) -> Option { + let runtime = Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .ok()?; + let downloader = self.downloader().ok()?; + runtime.block_on(async { + let mut total: u64 = 0; + let mut any = false; + for entry in download_targets(&self.models_dir, target).ok()? { + let path = &entry.destination; + let size = if path.exists() { + fs::metadata(path).ok().map(|m| m.len()).filter(|s| *s > 0) + } else { + download::probe_content_length(downloader.client(), &entry.url).await + }; + if let Some(size) = size { + any = true; + total = total.saturating_add(size); + } + } + any.then_some(total) + }) + } +} + +fn storage_key(target: &ModelTarget) -> &str { + match target { + ModelTarget::Files { id, .. } | ModelTarget::TarGz { id, .. } => id, + } +} + +fn storage_dir(models_dir: &Path, target: &ModelTarget) -> PathBuf { + models_dir.join(storage_key(target)) +} + +fn expected_paths(models_dir: &Path, target: &ModelTarget) -> Vec { + let dir = storage_dir(models_dir, target); + match target { + ModelTarget::TarGz { .. } => vec![dir], + ModelTarget::Files { files, .. } => files.iter().map(|file| dir.join(&file.name)).collect(), + } +} + +fn download_targets( + models_dir: &Path, + target: &ModelTarget, +) -> Result, download::Error> { + let dir = storage_dir(models_dir, target); + match target { + ModelTarget::TarGz { id, url, sha256 } => { + checked_file_name(id)?; + Ok(vec![Target { + label: "Model".to_string(), + url: url.clone(), + sha256: sha256.clone(), + destination: models_dir.join(".staging").join(format!("{id}.tar.gz")), + }]) + } + ModelTarget::Files { id, files } => { + checked_file_name(id)?; + 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(), + sha256: file.sha256.clone(), + destination: dir.join(&file.name), + }) + }) + .collect() + } + } +} + +fn checked_file_name(name: &str) -> Result<(), download::Error> { + if !name.is_empty() + && name != "." + && name != ".." + && !name.contains('/') + && !name.contains('\\') + { + return Ok(()); + } + Err(download::Error::InvalidTarget(format!( + "'{name}' is not a safe file name" + ))) +} + +fn display_progress(progress: Progress) -> ModelDownloadProgress { + let total = progress.total_bytes.filter(|total| *total > 0); + let percent = total + .map(|total| ((progress.downloaded_bytes * 100 / total) as i32).clamp(0, 99)) + .unwrap_or(0); + let status = if let Some(total) = total { + format!( + "Downloading... {} / {}", + format_bytes(progress.downloaded_bytes), + format_bytes(total) + ) + } else if progress.file_downloaded_bytes > 0 { + format!( + "Downloading {}... {}", + progress.label.to_lowercase(), + format_bytes(progress.file_downloaded_bytes) + ) + } else { + format!("Downloading {}...", progress.label.to_lowercase()) + }; + let log_line = if progress.file_complete { + Some(format!( + "Model download file complete label={} bytes={} elapsedMs={} rate={}/s retries={}", + progress.label, + progress.file_downloaded_bytes, + progress.file_elapsed_ms, + format_bytes(rate_bytes(progress.file_bytes_per_second)), + progress.file_retry_count + )) + } else if progress.complete { + Some(format!( + "Model download complete bytes={} elapsedMs={} rate={}/s retries={}", + progress.downloaded_bytes, + progress.elapsed_ms, + format_bytes(rate_bytes(progress.bytes_per_second)), + progress.retry_count + )) + } else { + None + }; + + ModelDownloadProgress { + downloaded_bytes: progress.downloaded_bytes, + total_bytes: progress.total_bytes, + percent, + status, + log_line, + } +} + +fn rate_bytes(bytes_per_second: f64) -> u64 { + if bytes_per_second.is_finite() && bytes_per_second > 0.0 { + bytes_per_second as u64 + } else { + 0 + } +} + +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + format!("{size:.1} {}", UNITS[unit]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ente-model-download-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).expect("create test dir"); + dir + } + + fn test_sha() -> String { + "0".repeat(64) + } + + fn target(id: &str) -> ModelTarget { + ModelTarget::Files { + id: id.to_string(), + files: vec![ + ModelFile { + name: "model.gguf".to_string(), + url: "https://example.org/models/main.gguf?download=true".to_string(), + sha256: test_sha(), + }, + ModelFile { + name: "mmproj.gguf".to_string(), + url: "https://example.org/models/mmproj.gguf".to_string(), + sha256: test_sha(), + }, + ], + } + } + + 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() { + let dir = scratch_dir("paths"); + let downloader = ModelDownloader::new(&dir); + + assert_eq!( + downloader + .file_path(&target("qwen-2b-q8"), "model.gguf") + .unwrap(), + dir.join("qwen-2b-q8/model.gguf") + ); + assert_eq!( + downloader.file_path(&target("qwen-2b-q8"), "mmproj.gguf"), + Some(dir.join("qwen-2b-q8/mmproj.gguf")) + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn is_downloaded_requires_every_member_file() { + let dir = scratch_dir("is-downloaded"); + let downloader = ModelDownloader::new(&dir); + let target = target("qwen-2b-q8"); + let model = downloader.file_path(&target, "model.gguf").unwrap(); + let mmproj = downloader.file_path(&target, "mmproj.gguf").unwrap(); + + assert!(!downloader.is_downloaded(&target)); + + write_gguf(&model, b"GGUFdata"); + assert!(!downloader.is_downloaded(&target)); + + write_gguf(&mmproj, b"GGUFdata"); + assert!(downloader.is_downloaded(&target)); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn download_skips_when_already_present() { + let dir = scratch_dir("skip"); + let downloader = ModelDownloader::new(&dir); + let target = target("qwen-2b-q8"); + write_gguf( + &downloader.file_path(&target, "model.gguf").unwrap(), + b"GGUFdata", + ); + write_gguf( + &downloader.file_path(&target, "mmproj.gguf").unwrap(), + b"GGUFdata", + ); + + downloader + .download( + std::slice::from_ref(&target), + |_| {}, + &download::CancellationToken::new(), + ) + .expect("skip without network"); + + let _ = fs::remove_dir_all(dir); + } + + fn tar_gz_target(id: &str) -> ModelTarget { + ModelTarget::TarGz { + id: id.to_string(), + url: format!("https://models.example.org/{id}.tar.gz"), + sha256: test_sha(), + } + } + + fn file_target(id: &str) -> ModelTarget { + ModelTarget::Files { + id: id.to_string(), + files: vec![ModelFile { + name: "model.onnx".to_string(), + url: format!("https://models.example.org/{id}.onnx"), + sha256: test_sha(), + }], + } + } + + fn write_tar_gz(path: &Path, dir_name: &str, files: &[(&str, &[u8])]) { + let file = fs::File::create(path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast()); + let mut builder = tar::Builder::new(encoder); + for (name, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, format!("{dir_name}/{name}"), *content) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap(); + } + + #[test] + fn archive_and_onnx_targets_use_per_model_dirs() { + let dir = scratch_dir("variant-paths"); + let downloader = ModelDownloader::new(&dir); + + let archive = tar_gz_target("parakeet-v3-int8"); + assert_eq!(downloader.model_dir(&archive), dir.join("parakeet-v3-int8")); + assert_eq!(downloader.file_path(&archive, "mmproj.gguf"), None); + + let onnx = file_target("silero-vad-v4"); + assert_eq!( + downloader.file_path(&onnx, "model.onnx").unwrap(), + dir.join("silero-vad-v4/model.onnx") + ); + assert_eq!( + 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.file_path(&onnx, "mmproj.gguf"), None); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn archive_download_extracts_into_model_dir() { + let dir = scratch_dir("variant-extract"); + let downloader = ModelDownloader::new(&dir); + let target = tar_gz_target("parakeet-v3-int8"); + assert!(!downloader.is_downloaded(&target)); + + fs::create_dir_all(dir.join(".staging")).unwrap(); + write_tar_gz( + &dir.join(".staging/parakeet-v3-int8.tar.gz"), + "parakeet-tdt-0.6b-v3-int8", + &[("encoder.onnx", b"enc"), ("tokens.txt", b"tok")], + ); + let mut statuses = Vec::new(); + downloader + .extract_if_archive(&target, &mut |progress| statuses.push(progress.status)) + .expect("extract"); + + assert!(downloader.is_downloaded(&target)); + assert_eq!( + fs::read(dir.join("parakeet-v3-int8/encoder.onnx")).unwrap(), + b"enc" + ); + assert_eq!(statuses, ["Extracting..."]); + assert!(!dir.join(".staging/parakeet-v3-int8.tar.gz").exists()); + + assert!(downloader.remove_downloaded(&target)); + assert!(!downloader.is_downloaded(&target)); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn failed_archive_extraction_cleans_staging() { + let dir = scratch_dir("variant-extract-failure"); + let downloader = ModelDownloader::new(&dir); + let target = tar_gz_target("parakeet-v3-int8"); + let staging_dir = dir.join(".staging"); + fs::create_dir_all(&staging_dir).unwrap(); + fs::write(staging_dir.join("parakeet-v3-int8.tar.gz"), b"invalid").unwrap(); + + assert!(downloader.extract_if_archive(&target, &mut |_| {}).is_err()); + assert!(!staging_dir.join("parakeet-v3-int8").exists()); + assert!(staging_dir.join("parakeet-v3-int8.tar.gz").exists()); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn onnx_target_downloaded_and_removed_via_model_file() { + let dir = scratch_dir("variant-onnx"); + let downloader = ModelDownloader::new(&dir); + let target = file_target("silero-vad-v4"); + assert!(!downloader.is_downloaded(&target)); + + write_gguf( + &downloader.file_path(&target, "model.onnx").unwrap(), + b"onnxbytes", + ); + assert!(downloader.is_downloaded(&target)); + + assert!(downloader.remove_downloaded(&target)); + assert!(!downloader.is_downloaded(&target)); + assert!(!dir.join("silero-vad-v4").exists()); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn display_progress_composes_percent_and_status() { + let progress = Progress { + label: "Model".to_string(), + downloaded_bytes: 512 * 1024 * 1024, + total_bytes: Some(2 * 1024 * 1024 * 1024), + file_downloaded_bytes: 512 * 1024 * 1024, + file_total_bytes: Some(2 * 1024 * 1024 * 1024), + percentage: 25.0, + elapsed_ms: 0, + bytes_per_second: 0.0, + file_elapsed_ms: 0, + file_bytes_per_second: 0.0, + retry_count: 0, + file_retry_count: 0, + file_complete: false, + complete: false, + }; + + let display = display_progress(progress); + assert_eq!(display.percent, 25); + assert_eq!(display.status, "Downloading... 512.0 MB / 2.0 GB"); + assert_eq!(display.log_line, None); + + let unknown_total = Progress { + total_bytes: None, + ..display_to_progress_stub() + }; + let display = display_progress(unknown_total); + assert_eq!(display.percent, 0); + assert_eq!(display.status, "Downloading model... 100.0 B"); + + let file_complete = Progress { + file_complete: true, + file_bytes_per_second: 2048.0, + ..display_to_progress_stub() + }; + let display = display_progress(file_complete); + assert_eq!( + display.log_line.as_deref(), + Some( + "Model download file complete label=Model bytes=100 elapsedMs=0 rate=2.0 KB/s retries=0" + ) + ); + } + + fn display_to_progress_stub() -> Progress { + Progress { + label: "Model".to_string(), + downloaded_bytes: 100, + total_bytes: None, + file_downloaded_bytes: 100, + file_total_bytes: None, + percentage: 0.0, + elapsed_ms: 0, + bytes_per_second: 0.0, + file_elapsed_ms: 0, + file_bytes_per_second: 0.0, + retry_count: 0, + file_retry_count: 0, + file_complete: false, + complete: false, + } + } +} diff --git a/rust/crates/paste/src/lib.rs b/rust/crates/paste/src/lib.rs index 3384d88fab8..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, }, )) } @@ -395,11 +405,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 +418,21 @@ mod tests { assert_eq!(payload.kdf_ops_limit, argon::Params::MODERATE.ops_limit); } + #[test] + fn decrypt_password_protected_paste_payload() { + 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"); + } + #[test] fn reject_wrong_paste_password() { - let (paste_key, payload) = encrypt("protected paste", Some("correct horse")).unwrap(); + 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)); @@ -420,7 +440,9 @@ 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, 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(); @@ -432,7 +454,9 @@ 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, 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(); @@ -512,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 @@ -547,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, - } - } } diff --git a/rust/crates/photos/Cargo.toml b/rust/crates/photos/Cargo.toml index 5e2937fc50e..8f6af9bcf24 100644 --- a/rust/crates/photos/Cargo.toml +++ b/rust/crates/photos/Cargo.toml @@ -56,13 +56,14 @@ 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" +tokio = { version = "1", features = ["rt"] } +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..61a4a04cca4 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,41 +164,69 @@ 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, } +struct AssetFetcher { + runtime: tokio::runtime::Runtime, + downloader: download::Downloader, +} + +impl AssetFetcher { + fn new() -> Result { + Ok(Self { + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build download runtime")?, + downloader: download::Downloader::new().context("build downloader")?, + }) + } + + fn fetch(&self, targets: Vec) -> Result<()> { + self.runtime.block_on(self.downloader.download( + targets, + |_| {}, + download::CancellationToken::default(), + ))?; + Ok(()) + } +} + impl MlIndexingTestContext { pub(crate) fn load() -> Result { 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 fetcher = AssetFetcher::new()?; let manifest_path = - resolve_document_asset(&client, &cache_dir, "manifest", &asset_lock.manifest)?; + resolve_document_asset(&fetcher, &cache_dir, "manifest", &asset_lock.manifest)?; let golden_path = resolve_document_asset( - &client, + &fetcher, &cache_dir, "python-golden", &asset_lock.python_golden, )?; let manifest = load_manifest(&manifest_path)?; let golden_results = load_golden_results(&golden_path)?; + fetch_fixtures( + &fetcher, + &cache_dir, + &asset_lock.fixture_base_url, + &manifest, + )?; let onnx_runtime_library = - resolve_onnx_runtime_library(&client, &cache_dir, &asset_lock.onnx_runtime)?; + resolve_onnx_runtime_library(&fetcher, &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(&fetcher, &cache_dir, &asset_lock.models)?, provider_policy: ExecutionProviderPolicy { prefer_coreml: false, prefer_nnapi: false, @@ -214,7 +238,6 @@ impl MlIndexingTestContext { Ok(Self { asset_lock, cache_dir, - client, golden_results, manifest, runtime_config, @@ -353,12 +376,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, - ) + cache_path_for(&self.cache_dir, "fixtures", &fixture.path, &fixture.sha256) } fn unsupported_decode_file_ids(&self) -> HashSet { @@ -540,33 +558,46 @@ fn file_id_for_manifest_path(path: &str) -> Result { } fn resolve_document_asset( - client: &Client, + fetcher: &AssetFetcher, 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(fetcher, &asset.url, &asset.sha256, &target, label) } -fn resolve_fixture_asset( - client: &Client, +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( + fetcher: &AssetFetcher, 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(client, &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)?, + sha256: normalize_sha256(&fixture.sha256), + destination: cache_path_for(cache_dir, "fixtures", &fixture.path, &fixture.sha256)?, + }) + }) + .collect::>>()?; + fetcher.fetch(targets).context("fetch fixtures") } fn resolve_onnx_runtime_library( - client: &Client, + fetcher: &AssetFetcher, cache_dir: &Path, assets: &OnnxRuntimeAssets, ) -> Result { @@ -591,7 +622,7 @@ fn resolve_onnx_runtime_library( &archive.sha256, )?; let archive_path = ensure_remote_asset( - client, + fetcher, &archive.url, &archive.sha256, &archive_path, @@ -683,15 +714,15 @@ fn extract_tgz_member(archive_path: &Path, member_path: &str, target: &Path) -> } fn resolve_model_paths( - client: &Client, + fetcher: &AssetFetcher, cache_dir: &Path, models: &ModelAssets, ) -> Result { let face_detection = - resolve_model_asset(client, cache_dir, "face-detection", &models.face_detection)?; + resolve_model_asset(fetcher, 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)?; + resolve_model_asset(fetcher, cache_dir, "face-embedding", &models.face_embedding)?; + let clip_image = resolve_model_asset(fetcher, cache_dir, "clip-image", &models.clip_image)?; Ok(ModelPaths { face_detection: face_detection.to_string_lossy().into_owned(), @@ -708,141 +739,33 @@ fn resolve_model_paths( } fn resolve_model_asset( - client: &Client, + fetcher: &AssetFetcher, 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(fetcher, &asset.url, &asset.sha256, &target, label) } fn ensure_remote_asset( - client: &Client, + fetcher: &AssetFetcher, 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)"), - ) -} - -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 - )) - })?; + fetcher + .fetch(vec![download::Target { + label: label.to_string(), + url: url.to_string(), + sha256: normalize_sha256(expected_sha256), + destination: target.to_path_buf(), + }]) + .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 +784,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 +798,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(_)) => {} 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/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= 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/apps/ensu/src/components/chat/ChatDialogs.tsx b/web/apps/ensu/src/components/chat/ChatDialogs.tsx index c186a245a2d..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,9 +53,7 @@ type ModelGateStatus = type SxEntry = Exclude, readonly unknown[]>; export interface ModelSettingsDraft { - useCustomModel: boolean; - modelUrl: string; - mmprojUrl: string; + modelId: string; contextLength: string; maxTokens: string; } @@ -84,15 +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; suggestedModels: SuggestedModel[]; contextLength: string; maxTokens: string; @@ -136,15 +128,10 @@ export const ChatDialogs = memo( handleConfirmDeleteSession, showModelSettings, closeModelSettings, - useCustomModel, + selectedModelId, defaultModelName, - defaultModelUrl, - defaultModelMmproj, loadedModelName, - allowMmproj, isTauriRuntime, - modelUrl, - mmprojUrl, suggestedModels, contextLength, maxTokens, @@ -184,18 +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 [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); @@ -204,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); @@ -213,63 +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; // Initialize model settings draft from parent state when dialog opens React.useEffect(() => { if (!showModelSettings) return; - setDraftUseCustomModel(useCustomModel); - setDraftModelUrl(modelUrl); - setDraftMmprojUrl(mmprojUrl); + setDraftModelId( + modelOptions.some((model) => model.id === selectedModelId) + ? selectedModelId + : "default", + ); setDraftContextLength(contextLength); setDraftMaxTokens(maxTokens); - setDraftModelUrlError(null); - setDraftMmprojError(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, + selectedModelId, showModelSettings, - useCustomModel, ]); // Initialize system prompt draft from parent state when dialog opens @@ -279,38 +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 contextErrorValue = draftContextLength && !/^\d+$/.test(draftContextLength) ? "Enter a number" @@ -332,28 +245,17 @@ export const ChatDialogs = memo( ? "Must be <= context length" : undefined; - setDraftModelUrlError(modelError ?? null); - setDraftMmprojError(mmprojErr ?? null); setDraftContextError(contextErrorValue ?? null); setDraftMaxTokensError( maxTokensErrorValue ?? maxTokensLimitError ?? null, ); return !( - modelError || - mmprojErr || contextErrorValue || maxTokensErrorValue || maxTokensLimitError ); - }, [ - draftContextLength, - draftMaxTokens, - draftMmprojUrl, - draftModelUrl, - draftUseCustomModel, - isTauriRuntime, - ]); + }, [draftContextLength, draftMaxTokens]); return ( <> @@ -783,37 +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(""); - return; - } - setDraftUseCustomModel(true); - if (nextId === "custom") { - setDraftModelUrl(""); - setDraftMmprojUrl(""); - return; - } - setDraftModelUrl(nextModel.url); - 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) => ( @@ -827,43 +706,6 @@ export const ChatDialogs = memo( - {isCustomSelected && ( - - - setDraftModelUrl( - event.target.value, - ) - } - error={!!draftModelUrlError} - helperText={ - draftModelUrlError ?? " " - } - /> - {allowMmproj && ( - - setDraftMmprojUrl( - event.target.value, - ) - } - error={!!draftMmprojError} - helperText={ - draftMmprojError ?? " " - } - /> - )} - - )} - 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/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.", 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", diff --git a/web/packages/new/photos/services/collection.ts b/web/packages/new/photos/services/collection.ts index 28236f4b442..3f94676c369 100644 --- a/web/packages/new/photos/services/collection.ts +++ b/web/packages/new/photos/services/collection.ts @@ -780,6 +780,8 @@ const userOwnedEquivalentFilesByHashAndType = ( // Bridges shared favorite toggles before the remote add/move has been pulled // into local DB. const pendingFavoriteFilesByHashAndType = new Map(); +let pendingUserFavoritesCollection: Collection | undefined; +let pendingUserFavoritesCollectionPromise: Promise | undefined; /** * @@ -1882,19 +1884,38 @@ export const createUncategorizedCollection = () => * * Reads local state but does not modify it. The effects are on remote. */ -const savedOrCreateUserFavoritesCollection = async () => - (await savedUserFavoritesCollection()) ?? createFavoritesCollection(); +const savedOrCreateUserFavoritesCollection = async () => { + const favoritesCollection = await savedUserFavoritesCollection(); + if (favoritesCollection) return favoritesCollection; + + pendingUserFavoritesCollectionPromise ??= createFavoritesCollection() + .then((collection) => { + pendingUserFavoritesCollection = collection; + return collection; + }) + .finally(() => { + pendingUserFavoritesCollectionPromise = undefined; + }); + + return pendingUserFavoritesCollectionPromise; +}; /** - * Return the user's own favorites collection if present in the local database. + * Return the user's own favorites collection if present in the local database, + * or one created during this session before the next remote pull. */ export const savedUserFavoritesCollection = async () => { const userID = ensureLocalUser().id; const collections = await savedCollections(); - return collections.find( - (collection) => - // See: [Note: User and shared favorites] - collection.type == "favorites" && collection.owner.id == userID, + return ( + collections.find( + (collection) => + // See: [Note: User and shared favorites] + collection.type == "favorites" && collection.owner.id == userID, + ) ?? + (pendingUserFavoritesCollection?.owner.id == userID + ? pendingUserFavoritesCollection + : undefined) ); };