diff --git a/docs/docs/auth/faq/privacy-disclosure/index.md b/docs/docs/auth/faq/privacy-disclosure/index.md index 5b1a990836..7ed0bc05a8 100644 --- a/docs/docs/auth/faq/privacy-disclosure/index.md +++ b/docs/docs/auth/faq/privacy-disclosure/index.md @@ -18,29 +18,33 @@ AppStore](appstore-privacy-disclosure.png){width=620px} ## Data Linked to You > [!NOTE] -> +> > Only if you choose to create an account to backup your codes are the following > details collected. ### Contact Info + This is your email address, used for account creation and communication. ### User Content + This are your 2FA secrets, end-to-end encrypted with a key that only you have access to. ### Identifiers + This is your user ID generated by our server during sign up. ## Data Not Linked to You > [!NOTE] -> +> > Only if you opt-in to **Crash reporting** are the following details collected. ### Diagnostics -These are anonymized error reports and other diagnostics data that make it easier -for us to detect and fix any issues. + +These are anonymized error reports and other diagnostics data that make it +easier for us to detect and fix any issues. --- @@ -48,5 +52,5 @@ for us to detect and fix any issues. Ente Auth collects no data by default. -For more details, please refer to our [full privacy -policy](https://ente.io/privacy). +For more details, please refer to our +[full privacy policy](https://ente.io/privacy). diff --git a/docs/docs/auth/migration-guides/authy/index.md b/docs/docs/auth/migration-guides/authy/index.md index 1a92285472..c72becd001 100644 --- a/docs/docs/auth/migration-guides/authy/index.md +++ b/docs/docs/auth/migration-guides/authy/index.md @@ -101,7 +101,7 @@ to Ente Authenticator! > the codes yet, ignore this section. > > If the export itself failed, try using -> [**method 1**](#method-1-use-neerajs-export-tool) instead. +> [**method 1**](#method-1-use-neeraj-s-export-tool) instead. Usually, you should be able to import Bitwarden exports directly into Ente Authenticator. In case this didn't work for whatever reason, I've written a @@ -170,7 +170,7 @@ depending on which method you used to export your codes. 5. Select the JSON file that was made earlier. If this didn't work, refer to -[**method 2.1**](#method-21-if-the-export-worked-but-the-import-didnt).

+[**method 2.1**](#method-2-1-if-the-export-worked-but-the-import-didn-t).

And that's it! You have now successfully migrated from Authy to Ente Authenticator. diff --git a/docs/docs/photos/migration/from-google-photos/index.md b/docs/docs/photos/migration/from-google-photos/index.md index 577a3283e5..772908576e 100644 --- a/docs/docs/photos/migration/from-google-photos/index.md +++ b/docs/docs/photos/migration/from-google-photos/index.md @@ -59,4 +59,5 @@ If you run into any issues during this migration, please reach out to > JSON files and stich them together with corresponding files. However, one case > this will not work is when Google has split the export into multiple parts, > and did not put the JSON file associated with an image in the same exported -> zip. +> zip. So the best move is to unzip all of the items into a single folder, and +> to drop that folder into our desktop app. diff --git a/docs/docs/photos/troubleshooting/thumbnails.md b/docs/docs/photos/troubleshooting/thumbnails.md index 81c8ff8227..f26319c2e5 100644 --- a/docs/docs/photos/troubleshooting/thumbnails.md +++ b/docs/docs/photos/troubleshooting/thumbnails.md @@ -23,10 +23,12 @@ canvas. ## Desktop The only known case where thumbnails might be missing on desktop is when -uploading **videos** during a Google Takeout or folder sync on **Intel macOS** -machines. This is because the bundled ffmpeg that we use does not work with -Rosetta. For images, we are able to fallback to other mechanisms for generating -the thumbnails, but for videos because of their potentially huge size, the app +uploading **videos** during a Google Takeout or watched folder sync on **Intel +macOS** machines. This is because the bundled ffmpeg that we use does not work +on Intel machines. + +For images, we are able to fallback to other mechanisms for generating the +thumbnails, but for videos because of their potentially huge size, the app doesn't try the fallback to avoid running out of memory. In such cases, you will need to use the following workaround: @@ -39,10 +41,11 @@ In such cases, you will need to use the following workaround: 2. Copy or symlink it to `/Applications/ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`. -Even without the workaround, thumbnail generation during video uploads via the -normal folder selection or drag and drop will work fine (since in this case we -have access to the video's data directly without reading it from a zip and can -thus use the fallback). +Alternatively, you can drag and drop the videos. Even without the above +workaround, thumbnail generation during video uploads via the normal folder +selection or drag and drop will work fine, since in those case we have access to +the video's data directly without reading it from a zip and can thus use the +fallback. ## Regenerating thumbnails @@ -50,6 +53,5 @@ There is currently no functionality to regenerate thumbnails in the above cases. You will need to upload the affected files again. Ente skips over files that have already been uploaded, so you can drag and drop -the original folder or zip again after removing the files without thumbnails -(and fixing the issue on web or adding the workaround on Intel macOS), and it'll -only upload the files that are necessary. +the original folder or zip again after removing the files without thumbnails, +and it'll only upload the files that are necessary. diff --git a/docs/docs/self-hosting/guides/configuring-s3.md b/docs/docs/self-hosting/guides/configuring-s3.md index 56e922f02d..3ab3828dae 100644 --- a/docs/docs/self-hosting/guides/configuring-s3.md +++ b/docs/docs/self-hosting/guides/configuring-s3.md @@ -45,8 +45,10 @@ By default, you only need to configure the endpoint for the first bucket. > instance uses these to perform replication. > > However, in a self hosted setup replication is off by default (you can turn it -> on if you want). When replication is turned off, only the first bucket is -> used, and you can remove the other two if you wish or just ignore them. +> on if you want). When replication is turned off, only the first bucket (it +> must be named `b2-eu-cen`) is used, and you can ignore the other two. Use the +> `hot_bucket` option if you'd like to set one of the other predefined buckets +> as the "first" bucket. The `endpoint` for the first bucket in the starter `credentials.yaml` is `localhost:3200`. The way this works then is that both museum (`2`) and minio diff --git a/mobile/lib/events/file_swipe_lock_event.dart b/mobile/lib/events/file_swipe_lock_event.dart deleted file mode 100644 index 7e1a430202..0000000000 --- a/mobile/lib/events/file_swipe_lock_event.dart +++ /dev/null @@ -1,7 +0,0 @@ -import "package:photos/events/event.dart"; - -class FileSwipeLockEvent extends Event { - final bool shouldSwipeLock; - - FileSwipeLockEvent(this.shouldSwipeLock); -} diff --git a/mobile/lib/events/guest_view_event.dart b/mobile/lib/events/guest_view_event.dart new file mode 100644 index 0000000000..6c74f1f4ab --- /dev/null +++ b/mobile/lib/events/guest_view_event.dart @@ -0,0 +1,7 @@ +import "package:photos/events/event.dart"; + +class GuestViewEvent extends Event { + final bool isGuestView; + final bool swipeLocked; + GuestViewEvent(this.isGuestView, this.swipeLocked); +} diff --git a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart index 9485326abb..148d97f5e6 100644 --- a/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -8,6 +8,7 @@ import "package:logging/logging.dart"; import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; import 'package:photos/core/configuration.dart'; import "package:photos/core/event_bus.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/face/model/person.dart"; import "package:photos/generated/l10n.dart"; @@ -32,9 +33,9 @@ import 'package:photos/ui/components/action_sheet_widget.dart'; import "package:photos/ui/components/bottom_action_bar/selection_action_button_widget.dart"; import 'package:photos/ui/components/buttons/button_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; -// import 'package:photos/ui/sharing/manage_links_widget.dart'; import "package:photos/ui/sharing/show_images_prevew.dart"; import "package:photos/ui/tools/collage/collage_creator_page.dart"; +import "package:photos/ui/viewer/file/detail_page.dart"; import "package:photos/ui/viewer/location/update_location_data_widget.dart"; import 'package:photos/utils/delete_file_util.dart'; import "package:photos/utils/dialog_util.dart"; @@ -271,7 +272,13 @@ class _FileSelectionActionsWidgetState ), ); } - + items.add( + SelectionActionButton( + icon: Icons.lock, + labelText: "Guest view", + onTap: _onGuestViewClick, + ), + ); items.add( SelectionActionButton( icon: Icons.grid_view_outlined, @@ -559,6 +566,23 @@ class _FileSelectionActionsWidgetState } } + Future _onGuestViewClick() async { + final List selectedFiles = widget.selectedFiles.files.toList(); + final page = DetailPage( + DetailPageConfiguration( + selectedFiles, + null, + 0, + "guest_view", + ), + ); + routeToPage(context, page, forceCustomPageRoute: true).ignore(); + WidgetsBinding.instance.addPostFrameCallback((_) { + Bus.instance.fire(GuestViewEvent(true, false)); + }); + widget.selectedFiles.clearAll(); + } + Future _onArchiveClick() async { await changeVisibility( context, diff --git a/mobile/lib/ui/viewer/file/detail_page.dart b/mobile/lib/ui/viewer/file/detail_page.dart index 87c282d2ba..832f1fe051 100644 --- a/mobile/lib/ui/viewer/file/detail_page.dart +++ b/mobile/lib/ui/viewer/file/detail_page.dart @@ -10,7 +10,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; import "package:photos/core/event_bus.dart"; -import "package:photos/events/file_swipe_lock_event.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; @@ -89,9 +89,9 @@ class _DetailPageState extends State { bool _hasLoadedTillEnd = false; final _enableFullScreenNotifier = ValueNotifier(false); bool _isFirstOpened = true; - bool isFileSwipeLocked = false; - late final StreamSubscription - _fileSwipeLockEventSubscription; + bool isGuestView = false; + bool swipeLocked = false; + late final StreamSubscription _guestViewEventSubscription; @override void initState() { @@ -102,17 +102,18 @@ class _DetailPageState extends State { _selectedIndexNotifier.value = widget.config.selectedIndex; _preloadEntries(); _pageController = PageController(initialPage: _selectedIndexNotifier.value); - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { + _guestViewEventSubscription = + Bus.instance.on().listen((event) { setState(() { - isFileSwipeLocked = event.shouldSwipeLock; + isGuestView = event.isGuestView; + swipeLocked = event.swipeLocked; }); }); } @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); + _guestViewEventSubscription.cancel(); _pageController.dispose(); _enableFullScreenNotifier.dispose(); _selectedIndexNotifier.dispose(); @@ -141,12 +142,12 @@ class _DetailPageState extends State { " files .", ); return PopScope( - canPop: !isFileSwipeLocked, + canPop: !isGuestView, onPopInvoked: (didPop) async { - if (isFileSwipeLocked) { + if (isGuestView) { final authenticated = await _requestAuthentication(); if (authenticated) { - Bus.instance.fire(FileSwipeLockEvent(false)); + Bus.instance.fire(GuestViewEvent(false, false)); } } }, @@ -179,7 +180,7 @@ class _DetailPageState extends State { _files![selectedIndex], _onEditFileRequested, widget.config.mode == DetailPageMode.minimalistic && - !isFileSwipeLocked, + !isGuestView, onFileRemoved: _onFileRemoved, userID: Configuration.instance.getUserID(), enableFullScreenNotifier: _enableFullScreenNotifier, @@ -298,9 +299,10 @@ class _DetailPageState extends State { } else { _selectedIndexNotifier.value = index; } + Bus.instance.fire(GuestViewEvent(isGuestView, swipeLocked)); _preloadEntries(); }, - physics: _shouldDisableScroll || isFileSwipeLocked + physics: _shouldDisableScroll || swipeLocked ? const NeverScrollableScrollPhysics() : const FastScrollPhysics(speedFactor: 4.0), controller: _pageController, diff --git a/mobile/lib/ui/viewer/file/file_app_bar.dart b/mobile/lib/ui/viewer/file/file_app_bar.dart index e67795888f..28ba653e2c 100644 --- a/mobile/lib/ui/viewer/file/file_app_bar.dart +++ b/mobile/lib/ui/viewer/file/file_app_bar.dart @@ -6,7 +6,7 @@ import "package:local_auth/local_auth.dart"; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension.dart'; import "package:photos/core/event_bus.dart"; -import "package:photos/events/file_swipe_lock_event.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; @@ -51,9 +51,8 @@ class FileAppBar extends StatefulWidget { class FileAppBarState extends State { final _logger = Logger("FadingAppBar"); final List _actions = []; - late final StreamSubscription - _fileSwipeLockEventSubscription; - bool _isFileSwipeLocked = false; + late final StreamSubscription _guestViewEventSubscription; + bool isGuestView = false; @override void didUpdateWidget(FileAppBar oldWidget) { @@ -66,17 +65,17 @@ class FileAppBarState extends State { @override void initState() { super.initState(); - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { + _guestViewEventSubscription = + Bus.instance.on().listen((event) { setState(() { - _isFileSwipeLocked = event.shouldSwipeLock; + isGuestView = event.isGuestView; }); }); } @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); + _guestViewEventSubscription.cancel(); super.dispose(); } @@ -124,19 +123,19 @@ class FileAppBarState extends State { switchOutCurve: Curves.easeInOut, child: AppBar( clipBehavior: Clip.none, - key: ValueKey(_isFileSwipeLocked), + key: ValueKey(isGuestView), iconTheme: const IconThemeData( color: Colors.white, ), //same for both themes leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { - _isFileSwipeLocked + isGuestView ? _requestAuthentication() : Navigator.of(context).pop(); }, ), - actions: shouldShowActions && !_isFileSwipeLocked ? _actions : [], + actions: shouldShowActions && !isGuestView ? _actions : [], elevation: 0, backgroundColor: const Color(0x00000000), ), @@ -306,7 +305,7 @@ class FileAppBarState extends State { const Padding( padding: EdgeInsets.all(8), ), - const Text("Swipe lock"), + const Text("Guest view"), ], ), ), @@ -329,7 +328,7 @@ class FileAppBarState extends State { } else if (value == 5) { await _handleUnHideRequest(context); } else if (value == 6) { - await _onSwipeLock(); + await _onTapGuestView(); } }, ), @@ -413,9 +412,9 @@ class FileAppBarState extends State { } } - Future _onSwipeLock() async { + Future _onTapGuestView() async { if (await LocalAuthentication().isDeviceSupported()) { - Bus.instance.fire(FileSwipeLockEvent(!_isFileSwipeLocked)); + Bus.instance.fire(GuestViewEvent(true, true)); } else { await showErrorDialog( context, @@ -432,7 +431,7 @@ class FileAppBarState extends State { "Please authenticate to view more photos and videos.", ); if (hasAuthenticated) { - Bus.instance.fire(FileSwipeLockEvent(false)); + Bus.instance.fire(GuestViewEvent(false, false)); } } } diff --git a/mobile/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/lib/ui/viewer/file/file_bottom_bar.dart index 0d08abcb96..3d04fee809 100644 --- a/mobile/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/lib/ui/viewer/file/file_bottom_bar.dart @@ -5,7 +5,7 @@ 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/events/file_swipe_lock_event.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; @@ -45,25 +45,24 @@ class FileBottomBar extends StatefulWidget { class FileBottomBarState extends State { final GlobalKey shareButtonKey = GlobalKey(); - bool _isFileSwipeLocked = false; - late final StreamSubscription - _fileSwipeLockEventSubscription; + bool isGuestView = false; + late final StreamSubscription _guestViewEventSubscription; int? lastFileGenID; @override void initState() { super.initState(); - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { + _guestViewEventSubscription = + Bus.instance.on().listen((event) { setState(() { - _isFileSwipeLocked = event.shouldSwipeLock; + isGuestView = event.isGuestView; }); }); } @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); + _guestViewEventSubscription.cancel(); super.dispose(); } @@ -188,9 +187,9 @@ class FileBottomBarState extends State { valueListenable: widget.enableFullScreenNotifier, builder: (BuildContext context, bool isFullScreen, _) { return IgnorePointer( - ignoring: isFullScreen || _isFileSwipeLocked, + ignoring: isFullScreen || isGuestView, child: AnimatedOpacity( - opacity: isFullScreen || _isFileSwipeLocked ? 0 : 1, + opacity: isFullScreen || isGuestView ? 0 : 1, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, child: Align( diff --git a/mobile/lib/ui/viewer/file/video_widget.dart b/mobile/lib/ui/viewer/file/video_widget.dart index 2cbb14fed0..d700690edb 100644 --- a/mobile/lib/ui/viewer/file/video_widget.dart +++ b/mobile/lib/ui/viewer/file/video_widget.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/constants.dart'; import "package:photos/core/event_bus.dart"; -import "package:photos/events/file_swipe_lock_event.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; @@ -48,9 +48,8 @@ class _VideoWidgetState extends State { final _progressNotifier = ValueNotifier(null); bool _isPlaying = false; final EnteWakeLock _wakeLock = EnteWakeLock(); - bool _isFileSwipeLocked = false; - late final StreamSubscription - _fileSwipeLockEventSubscription; + bool isGuestView = false; + late final StreamSubscription _guestViewEventSubscription; @override void initState() { @@ -80,10 +79,10 @@ class _VideoWidgetState extends State { } }); } - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { + _guestViewEventSubscription = + Bus.instance.on().listen((event) { setState(() { - _isFileSwipeLocked = event.shouldSwipeLock; + isGuestView = event.isGuestView; }); }); } @@ -132,7 +131,7 @@ class _VideoWidgetState extends State { @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); + _guestViewEventSubscription.cancel(); removeCallBack(widget.file); _videoPlayerController?.dispose(); _chewieController?.dispose(); @@ -188,7 +187,7 @@ class _VideoWidgetState extends State { ? _getVideoPlayer() : _getLoadingWidget(); final contentWithDetector = GestureDetector( - onVerticalDragUpdate: _isFileSwipeLocked + onVerticalDragUpdate: isGuestView ? null : (d) => { if (d.delta.dy > dragSensitivity) diff --git a/mobile/lib/ui/viewer/file/video_widget_new.dart b/mobile/lib/ui/viewer/file/video_widget_new.dart index dec11b3ca1..274a5fce48 100644 --- a/mobile/lib/ui/viewer/file/video_widget_new.dart +++ b/mobile/lib/ui/viewer/file/video_widget_new.dart @@ -8,7 +8,7 @@ import "package:media_kit/media_kit.dart"; import "package:media_kit_video/media_kit_video.dart"; import "package:photos/core/constants.dart"; import "package:photos/core/event_bus.dart"; -import "package:photos/events/file_swipe_lock_event.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/events/pause_video_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; @@ -47,9 +47,8 @@ class _VideoWidgetNewState extends State late StreamSubscription playingStreamSubscription; bool _isAppInFG = true; late StreamSubscription pauseVideoSubscription; - bool _isFileSwipeLocked = false; - late final StreamSubscription - _fileSwipeLockEventSubscription; + bool isGuestView = false; + late final StreamSubscription _guestViewEventSubscription; @override void initState() { @@ -94,10 +93,10 @@ class _VideoWidgetNewState extends State pauseVideoSubscription = Bus.instance.on().listen((event) { player.pause(); }); - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { + _guestViewEventSubscription = + Bus.instance.on().listen((event) { setState(() { - _isFileSwipeLocked = event.shouldSwipeLock; + isGuestView = event.isGuestView; }); }); } @@ -113,7 +112,7 @@ class _VideoWidgetNewState extends State @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); + _guestViewEventSubscription.cancel(); pauseVideoSubscription.cancel(); removeCallBack(widget.file); _progressNotifier.dispose(); @@ -159,7 +158,7 @@ class _VideoWidgetNewState extends State ), fullscreen: const MaterialVideoControlsThemeData(), child: GestureDetector( - onVerticalDragUpdate: _isFileSwipeLocked + onVerticalDragUpdate: isGuestView ? null : (d) => { if (d.delta.dy > dragSensitivity) diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index 4df004b1a2..0743cba757 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -9,8 +9,7 @@ import 'package:photos/core/cache/thumbnail_in_memory_cache.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/db/files_db.dart'; -import "package:photos/events/file_swipe_lock_event.dart"; -import 'package:photos/events/files_updated_event.dart'; +import "package:photos/events/files_updated_event.dart"; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; @@ -26,6 +25,7 @@ class ZoomableImage extends StatefulWidget { final String? tagPrefix; final Decoration? backgroundDecoration; final bool shouldCover; + final bool isGuestView; const ZoomableImage( this.photo, { @@ -34,6 +34,7 @@ class ZoomableImage extends StatefulWidget { required this.tagPrefix, this.backgroundDecoration, this.shouldCover = false, + this.isGuestView = false, }); @override @@ -54,9 +55,6 @@ class _ZoomableImageState extends State { bool _isZooming = false; PhotoViewController _photoViewController = PhotoViewController(); final _scaleStateController = PhotoViewScaleStateController(); - bool _isFileSwipeLocked = false; - late final StreamSubscription - _fileSwipeLockEventSubscription; @override void initState() { @@ -72,17 +70,10 @@ class _ZoomableImageState extends State { debugPrint("isZooming = $_isZooming, currentState $value"); // _logger.info('is reakky zooming $_isZooming with state $value'); }; - _fileSwipeLockEventSubscription = - Bus.instance.on().listen((event) { - setState(() { - _isFileSwipeLocked = event.shouldSwipeLock; - }); - }); } @override void dispose() { - _fileSwipeLockEventSubscription.cancel(); _photoViewController.dispose(); _scaleStateController.dispose(); super.dispose(); @@ -159,7 +150,7 @@ class _ZoomableImageState extends State { } final GestureDragUpdateCallback? verticalDragCallback = - _isZooming || _isFileSwipeLocked + _isZooming || widget.isGuestView ? null : (d) => { if (!_isZooming) diff --git a/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart b/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart index 598ae60e02..036a3a62f0 100644 --- a/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart +++ b/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:io"; import 'package:flutter/material.dart'; @@ -5,6 +6,8 @@ import 'package:logging/logging.dart'; import "package:media_kit/media_kit.dart"; import "package:media_kit_video/media_kit_video.dart"; import 'package:motion_photos/motion_photos.dart'; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/guest_view_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; @@ -23,11 +26,11 @@ class ZoomableLiveImageNew extends StatefulWidget { const ZoomableLiveImageNew( this.enteFile, { - Key? key, + super.key, this.shouldDisableScroll, required this.tagPrefix, this.backgroundDecoration, - }) : super(key: key); + }); @override State createState() => _ZoomableLiveImageNewState(); @@ -43,6 +46,9 @@ class _ZoomableLiveImageNewState extends State late final _player = Player(); VideoController? _videoController; + bool isGuestView = false; + late final StreamSubscription _guestViewEventSubscription; + @override void initState() { _enteFile = widget.enteFile; @@ -52,6 +58,12 @@ class _ZoomableLiveImageNewState extends State if (_enteFile.isLivePhoto && _enteFile.isUploaded) { LocalFileUpdateService.instance.checkLivePhoto(_enteFile).ignore(); } + _guestViewEventSubscription = + Bus.instance.on().listen((event) { + setState(() { + isGuestView = event.isGuestView; + }); + }); super.initState(); } @@ -83,6 +95,7 @@ class _ZoomableLiveImageNewState extends State tagPrefix: widget.tagPrefix, shouldDisableScroll: widget.shouldDisableScroll, backgroundDecoration: widget.backgroundDecoration, + isGuestView: isGuestView, ); } return GestureDetector( @@ -98,6 +111,7 @@ class _ZoomableLiveImageNewState extends State _videoController!.player.stop(); _videoController!.player.dispose(); } + _guestViewEventSubscription.cancel(); super.dispose(); } diff --git a/web/apps/auth/public/_headers b/web/apps/auth/public/_headers index 72dc5bb5ce..13dd2408e7 100644 --- a/web/apps/auth/public/_headers +++ b/web/apps/auth/public/_headers @@ -5,6 +5,5 @@ X-Download-Options: noopen X-Frame-Options: deny X-XSS-Protection: 1; mode=block - Referrer-Policy: same-origin Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; child-src 'self' blob:; object-src 'none'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io; diff --git a/web/apps/photos/public/_headers b/web/apps/photos/public/_headers index 72dc5bb5ce..13dd2408e7 100644 --- a/web/apps/photos/public/_headers +++ b/web/apps/photos/public/_headers @@ -5,6 +5,5 @@ X-Download-Options: noopen X-Frame-Options: deny X-XSS-Protection: 1; mode=block - Referrer-Policy: same-origin Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; child-src 'self' blob:; object-src 'none'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io; diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx index 3879d3667a..c016bbe0dd 100644 --- a/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx +++ b/web/apps/photos/src/components/pages/gallery/PlanSelector.tsx @@ -39,7 +39,6 @@ import { cancelSubscription, getLocalUserSubscription, hasAddOnBonus, - hasMobileSubscription, hasPaidSubscription, hasStripeSubscription, isOnFreePlan, @@ -49,6 +48,7 @@ import { isUserSubscribedPlan, manageFamilyMethod, planForSubscription, + planSelectionOutcome, updatePaymentMethod, updateSubscription, } from "utils/billing"; @@ -177,58 +177,63 @@ function PlanSelectorCard(props: PlanSelectorCardProps) { }, []); async function onPlanSelect(plan: Plan) { - if ( - !hasPaidSubscription(subscription) && - !isSubscriptionCancelled(subscription) - ) { - try { - props.setLoading(true); - await billingService.buySubscription(plan.stripeID); - } catch (e) { - props.setLoading(false); + switch (planSelectionOutcome(subscription)) { + case "buyPlan": + try { + props.setLoading(true); + await billingService.buySubscription(plan.stripeID); + } catch (e) { + props.setLoading(false); + appContext.setDialogMessage({ + title: t("ERROR"), + content: t("SUBSCRIPTION_PURCHASE_FAILED"), + close: { variant: "critical" }, + }); + } + break; + + case "updateSubscriptionToPlan": appContext.setDialogMessage({ - title: t("ERROR"), - content: t("SUBSCRIPTION_PURCHASE_FAILED"), - close: { variant: "critical" }, + title: t("update_subscription_title"), + content: t("UPDATE_SUBSCRIPTION_MESSAGE"), + proceed: { + text: t("UPDATE_SUBSCRIPTION"), + action: updateSubscription.bind( + null, + plan, + appContext.setDialogMessage, + props.setLoading, + props.closeModal, + ), + variant: "accent", + }, + close: { text: t("cancel") }, }); - } - } else if (hasStripeSubscription(subscription)) { - appContext.setDialogMessage({ - title: t("update_subscription_title"), - content: t("UPDATE_SUBSCRIPTION_MESSAGE"), - proceed: { - text: t("UPDATE_SUBSCRIPTION"), - action: updateSubscription.bind( - null, - plan, - appContext.setDialogMessage, - props.setLoading, - props.closeModal, + break; + + case "cancelOnMobile": + appContext.setDialogMessage({ + title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"), + content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"), + close: { variant: "secondary" }, + }); + break; + + case "contactSupport": + appContext.setDialogMessage({ + title: t("MANAGE_PLAN"), + content: ( + , + }} + values={{ emailID: "support@ente.io" }} + /> ), - variant: "accent", - }, - close: { text: t("cancel") }, - }); - } else if (hasMobileSubscription(subscription)) { - appContext.setDialogMessage({ - title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"), - content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"), - close: { variant: "secondary" }, - }); - } else { - appContext.setDialogMessage({ - title: t("MANAGE_PLAN"), - content: ( - , - }} - values={{ emailID: "support@ente.io" }} - /> - ), - close: { variant: "secondary" }, - }); + close: { variant: "secondary" }, + }); + break; } } diff --git a/web/apps/photos/src/utils/billing/index.ts b/web/apps/photos/src/utils/billing/index.ts index 1eecbac553..50366e0407 100644 --- a/web/apps/photos/src/utils/billing/index.ts +++ b/web/apps/photos/src/utils/billing/index.ts @@ -13,8 +13,6 @@ import { getSubscriptionPurchaseSuccessMessage } from "utils/ui"; import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family"; const PAYMENT_PROVIDER_STRIPE = "stripe"; -const PAYMENT_PROVIDER_APPSTORE = "appstore"; -const PAYMENT_PROVIDER_PLAYSTORE = "playstore"; const FREE_PLAN = "free"; const THIRTY_DAYS_IN_MICROSECONDS = 30 * 24 * 60 * 60 * 1000 * 1000; @@ -31,6 +29,51 @@ enum RESPONSE_STATUS { fail = "fail", } +export type PlanSelectionOutcome = + | "buyPlan" + | "updateSubscriptionToPlan" + | "cancelOnMobile" + | "contactSupport"; + +/** + * Return the outcome that should happen when the user selects a paid plan on + * the plan selection screen. + * + * @param subscription Their current subscription details. + */ +export const planSelectionOutcome = ( + subscription: Subscription | undefined, +) => { + // This shouldn't happen, but we need this case to handle missing types. + if (!subscription) return "buyPlan"; + + // The user is a on a free plan and can buy the plan they selected. + if (subscription.productID == "free") return "buyPlan"; + + // Their existing subscription has expired. They can buy a new plan. + if (subscription.expiryTime < Date.now() * 1000) return "buyPlan"; + + // -- The user already has an active subscription to a paid plan. + + // Using stripe + if (subscription.paymentProvider == "stripe") { + // Update their existing subscription to the new plan. + return "updateSubscriptionToPlan"; + } + + // Using one of the mobile app stores + if ( + subscription.paymentProvider == "appstore" || + subscription.paymentProvider == "playstore" + ) { + // They need to cancel first on the mobile app stores. + return "cancelOnMobile"; + } + + // Some other bespoke case. They should contact support. + return "contactSupport"; +}; + export function hasPaidSubscription(subscription: Subscription) { return ( subscription && @@ -92,15 +135,6 @@ export function hasStripeSubscription(subscription: Subscription) { ); } -export function hasMobileSubscription(subscription: Subscription) { - return ( - hasPaidSubscription(subscription) && - subscription.paymentProvider.length > 0 && - (subscription.paymentProvider === PAYMENT_PROVIDER_APPSTORE || - subscription.paymentProvider === PAYMENT_PROVIDER_PLAYSTORE) - ); -} - export function hasExceededStorageQuota(userDetails: UserDetails) { const bonusStorage = userDetails.storageBonus ?? 0; if (isPartOfFamily(userDetails.familyData)) { diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index c28824b288..efdbf617ce 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -193,6 +193,9 @@ For more details, see [translations.md](translations.md). - [zod](https://github.com/colinhacks/zod) is used for runtime typechecking (e.g. verifying that API responses match the expected TypeScript shape). +- [nanoid](https://github.com/ai/nanoid) is used for generating unique + identifiers. + - [debounce](https://github.com/sindresorhus/debounce) and its promise-supporting sibling [pDebounce](https://github.com/sindresorhus/p-debounce) are used for diff --git a/web/packages/base/components/Head.tsx b/web/packages/base/components/Head.tsx index d756a9b09b..0373bd3aea 100644 --- a/web/packages/base/components/Head.tsx +++ b/web/packages/base/components/Head.tsx @@ -24,6 +24,7 @@ export const CustomHead: React.FC = ({ title }) => { name="viewport" content="width=device-width, initial-scale=1" /> + ); }; diff --git a/web/packages/base/id-worker.ts b/web/packages/base/id-worker.ts new file mode 100644 index 0000000000..593e2d236e --- /dev/null +++ b/web/packages/base/id-worker.ts @@ -0,0 +1,17 @@ +import { customAlphabet } from "nanoid/non-secure"; +import { alphabet } from "./id"; + +const nanoid = customAlphabet(alphabet, 22); + +/** + * This is a variant of the regular {@link newID} that can be used in web + * workers. + * + * Web workers don't have access to a secure random generator, so we need to use + * the non-secure variant. + * https://github.com/ai/nanoid?tab=readme-ov-file#web-workers + * + * For many of our use cases, where we're not using these IDs for cryptographic + * operations, this is okay. We also have an increased alphabet length. + */ +export const newNonSecureID = (prefix: string) => prefix + nanoid(); diff --git a/web/packages/base/id.ts b/web/packages/base/id.ts new file mode 100644 index 0000000000..5b9cebbbaa --- /dev/null +++ b/web/packages/base/id.ts @@ -0,0 +1,24 @@ +import { customAlphabet } from "nanoid"; + +/** + * Remove _ and - from the default set to have better looking IDs that can also + * be selected in the editor quickly ("-" prevents this), and which we can + * prefix unambigously ("_" is used for that). + * + * To compensate, increase length from the default of 21 to 22. + * + * To play around with these, use https://zelark.github.io/nano-id-cc/ + */ +export const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +const nanoid = customAlphabet(alphabet, 22); + +/** + * Generate a new random identifier with the given prefix. + * + * Internally this uses [nanoids](https://github.com/ai/nanoid). + * + * See {@link newNonSecureID} for a variant that can be used in web workers. + */ +export const newID = (prefix: string) => prefix + nanoid(); diff --git a/web/packages/base/package.json b/web/packages/base/package.json index 86f21d8fdf..36bcf203f9 100644 --- a/web/packages/base/package.json +++ b/web/packages/base/package.json @@ -12,6 +12,7 @@ "i18next": "^23.11", "i18next-resources-to-backend": "^1.2", "is-electron": "^2.2", + "nanoid": "^5.0.7", "next": "^14.2", "react": "^18", "react-dom": "^18", diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts new file mode 100644 index 0000000000..c5e3514a67 --- /dev/null +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -0,0 +1,134 @@ +import { newNonSecureID } from "@/base/id-worker"; +import log from "@/base/log"; +import { ensure } from "@/utils/ensure"; +import type { FaceIndex } from "./face"; +import { dotProduct } from "./math"; + +/** + * A cluster is an set of faces. + * + * Each cluster has an id so that a Person (a set of clusters) can refer to it. + */ +export interface Cluster { + /** + * A randomly generated ID to uniquely identify this cluster. + */ + id: string; + /** + * An unordered set of ids of the faces that belong to the cluster. + * + * For ergonomics of transportation and persistence this is an array but it + * should conceptually be thought of as a set. + */ + faceIDs: string[]; +} + +/** + * A Person is a set of clusters, with some attached metadata. + * + * The person is the user visible concept. It consists of a set of clusters, + * each of which itself is a set of faces. + * + * For ease of transportation, the Person entity on remote looks like + * + * { name, clusters: { cluster_id, face_ids }} + * + * That is, it has the clusters embedded within itself. + */ +export interface Person { + /** + * A randomly generated ID to uniquely identify this person. + */ + id: string; + /** + * An optional name assigned by the user to this person. + */ + name: string | undefined; + /** + * An unordered set of ids of the clusters that belong to this person. + * + * For ergonomics of transportation and persistence this is an array but it + * should conceptually be thought of as a set. + */ + clusterIDs: string[]; +} + +/** + * Cluster faces into groups. + * + * [Note: Face clustering algorithm] + * + * 1. clusters = [] + * 2. For each face, find its nearest neighbour in the embedding space from + * amongst the faces that have already been clustered. + * 3. If no such neighbour is found within our threshold, create a new cluster. + * 4. Otherwise assign this face to the same cluster as its nearest neighbour. + * + * [Note: Face clustering feedback] + * + * This user can tweak the output of the algorithm by providing feedback. They + * can perform the following actions: + * + * 1. Move a cluster from one person to another. + * 2. Break a cluster. + * + */ +export const clusterFaces = (faceIndexes: FaceIndex[]) => { + const t = Date.now(); + + const faces = [...faceIDAndEmbeddings(faceIndexes)]; + + const clusters: Cluster[] = []; + const clusterIndexByFaceID = new Map(); + for (const [i, { faceID, embedding }] of faces.entries()) { + let j = 0; + for (; j < i; j++) { + // Can't find a better way for avoiding the null assertion. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const n = faces[j]!; + + // TODO-ML: The distance metric and the thresholds are placeholders. + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct(embedding, n.embedding); + if (csim > 0.5) { + // Found a neighbour near enough. Add this face to the + // neighbour's cluster and call it a day. + const ci = ensure(clusterIndexByFaceID.get(n.faceID)); + clusters[ci]?.faceIDs.push(faceID); + clusterIndexByFaceID.set(faceID, ci); + break; + } + } + if (j == i) { + // We didn't find a neighbour. Create a new cluster with this face. + const cluster = { + id: newNonSecureID("cluster_"), + faceIDs: [faceID], + }; + clusters.push(cluster); + clusterIndexByFaceID.set(faceID, clusters.length); + } + } + + log.debug(() => ["ml/cluster", { faces, clusters, clusterIndexByFaceID }]); + log.debug( + () => + `Clustered ${faces.length} faces into ${clusters.length} clusters (${Date.now() - t} ms)`, + ); + + return undefined; +}; + +/** + * A generator function that returns a stream of {faceID, embedding} values, + * flattening all the all the faces present in the given {@link faceIndices}. + */ +function* faceIDAndEmbeddings(faceIndices: FaceIndex[]) { + for (const fi of faceIndices) { + for (const f of fi.faces) { + yield { faceID: f.faceID, embedding: f.embedding }; + } + } +} diff --git a/web/packages/new/photos/services/ml/db.ts b/web/packages/new/photos/services/ml/db.ts index a648aa8f06..3fe18d0731 100644 --- a/web/packages/new/photos/services/ml/db.ts +++ b/web/packages/new/photos/services/ml/db.ts @@ -231,6 +231,14 @@ export const faceIndex = async (fileID: number) => { return db.get("face-index", fileID); }; +/** + * Return all face indexes present locally. + */ +export const faceIndexes = async () => { + const db = await mlDB(); + return await db.getAll("face-index"); +}; + /** * Return all CLIP indexes present locally. */ diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 5b57dade21..55e482b464 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -5,6 +5,7 @@ import { isDesktop } from "@/base/app"; import { blobCache } from "@/base/blob-cache"; import { ensureElectron } from "@/base/electron"; +import { isDevBuild } from "@/base/env"; import log from "@/base/log"; import type { Electron } from "@/base/types/ipc"; import { ComlinkWorker } from "@/base/worker/comlink-worker"; @@ -16,8 +17,14 @@ import { proxy, transfer } from "comlink"; import { isInternalUser } from "../feature-flags"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import type { UploadItem } from "../upload/types"; +import { clusterFaces } from "./cluster-new"; import { regenerateFaceCrops } from "./crop"; -import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; +import { + clearMLDB, + faceIndex, + faceIndexes, + indexableAndIndexedCounts, +} from "./db"; import { MLWorker } from "./worker"; import type { CLIPMatches } from "./worker-types"; @@ -255,6 +262,8 @@ const mlSync = async () => { triggerStatusUpdate(); if (_isMLEnabled) void worker().then((w) => w.sync()); + // TODO-ML + if (_isMLEnabled) void wipCluster(); }; /** @@ -279,6 +288,16 @@ export const indexNewUpload = (enteFile: EnteFile, uploadItem: UploadItem) => { void worker().then((w) => w.onUpload(enteFile, uploadItem)); }; +/** + * WIP! Don't enable, dragon eggs are hatching here. + */ +export const wipCluster = async () => { + if (!isDevBuild || !(await isInternalUser())) return; + if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL) return; + + clusterFaces(await faceIndexes()); +}; + export type MLStatus = | { phase: "disabled" /* The ML remote flag is off */ } | { diff --git a/web/yarn.lock b/web/yarn.lock index caf41595f2..90cbd1d0b5 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3590,6 +3590,11 @@ nanoid@^3.3.6, nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.7.tgz#6452e8c5a816861fd9d2b898399f7e5fd6944cc6" + integrity sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"