Merge branch 'main' into mobile-panorama-fix

This commit is contained in:
Prateek Sunal
2024-08-09 20:21:49 +05:30
committed by GitHub
28 changed files with 452 additions and 167 deletions

View File

@@ -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).

View File

@@ -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).<br><br>
[**method 2.1**](#method-2-1-if-the-export-worked-but-the-import-didn-t).<br><br>
And that's it! You have now successfully migrated from Authy to Ente
Authenticator.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -1,7 +0,0 @@
import "package:photos/events/event.dart";
class FileSwipeLockEvent extends Event {
final bool shouldSwipeLock;
FileSwipeLockEvent(this.shouldSwipeLock);
}

View File

@@ -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);
}

View File

@@ -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<void> _onGuestViewClick() async {
final List<EnteFile> 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<void> _onArchiveClick() async {
await changeVisibility(
context,

View File

@@ -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<DetailPage> {
bool _hasLoadedTillEnd = false;
final _enableFullScreenNotifier = ValueNotifier(false);
bool _isFirstOpened = true;
bool isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
bool isGuestView = false;
bool swipeLocked = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
@override
void initState() {
@@ -102,17 +102,18 @@ class _DetailPageState extends State<DetailPage> {
_selectedIndexNotifier.value = widget.config.selectedIndex;
_preloadEntries();
_pageController = PageController(initialPage: _selectedIndexNotifier.value);
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().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<DetailPage> {
" 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<DetailPage> {
_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<DetailPage> {
} 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,

View File

@@ -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<FileAppBar> {
final _logger = Logger("FadingAppBar");
final List<Widget> _actions = [];
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
bool _isFileSwipeLocked = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
bool isGuestView = false;
@override
void didUpdateWidget(FileAppBar oldWidget) {
@@ -66,17 +65,17 @@ class FileAppBarState extends State<FileAppBar> {
@override
void initState() {
super.initState();
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().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<FileAppBar> {
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<FileAppBar> {
const Padding(
padding: EdgeInsets.all(8),
),
const Text("Swipe lock"),
const Text("Guest view"),
],
),
),
@@ -329,7 +328,7 @@ class FileAppBarState extends State<FileAppBar> {
} else if (value == 5) {
await _handleUnHideRequest(context);
} else if (value == 6) {
await _onSwipeLock();
await _onTapGuestView();
}
},
),
@@ -413,9 +412,9 @@ class FileAppBarState extends State<FileAppBar> {
}
}
Future<void> _onSwipeLock() async {
Future<void> _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<FileAppBar> {
"Please authenticate to view more photos and videos.",
);
if (hasAuthenticated) {
Bus.instance.fire(FileSwipeLockEvent(false));
Bus.instance.fire(GuestViewEvent(false, false));
}
}
}

View File

@@ -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<FileBottomBar> {
final GlobalKey shareButtonKey = GlobalKey();
bool _isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
bool isGuestView = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
int? lastFileGenID;
@override
void initState() {
super.initState();
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().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<FileBottomBar> {
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(

View File

@@ -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<VideoWidget> {
final _progressNotifier = ValueNotifier<double?>(null);
bool _isPlaying = false;
final EnteWakeLock _wakeLock = EnteWakeLock();
bool _isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
bool isGuestView = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
@override
void initState() {
@@ -80,10 +79,10 @@ class _VideoWidgetState extends State<VideoWidget> {
}
});
}
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().listen((event) {
setState(() {
_isFileSwipeLocked = event.shouldSwipeLock;
isGuestView = event.isGuestView;
});
});
}
@@ -132,7 +131,7 @@ class _VideoWidgetState extends State<VideoWidget> {
@override
void dispose() {
_fileSwipeLockEventSubscription.cancel();
_guestViewEventSubscription.cancel();
removeCallBack(widget.file);
_videoPlayerController?.dispose();
_chewieController?.dispose();
@@ -188,7 +187,7 @@ class _VideoWidgetState extends State<VideoWidget> {
? _getVideoPlayer()
: _getLoadingWidget();
final contentWithDetector = GestureDetector(
onVerticalDragUpdate: _isFileSwipeLocked
onVerticalDragUpdate: isGuestView
? null
: (d) => {
if (d.delta.dy > dragSensitivity)

View File

@@ -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<VideoWidgetNew>
late StreamSubscription<bool> playingStreamSubscription;
bool _isAppInFG = true;
late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
bool _isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
bool isGuestView = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
@override
void initState() {
@@ -94,10 +93,10 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
pauseVideoSubscription = Bus.instance.on<PauseVideoEvent>().listen((event) {
player.pause();
});
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().listen((event) {
setState(() {
_isFileSwipeLocked = event.shouldSwipeLock;
isGuestView = event.isGuestView;
});
});
}
@@ -113,7 +112,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
@override
void dispose() {
_fileSwipeLockEventSubscription.cancel();
_guestViewEventSubscription.cancel();
pauseVideoSubscription.cancel();
removeCallBack(widget.file);
_progressNotifier.dispose();
@@ -159,7 +158,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
),
fullscreen: const MaterialVideoControlsThemeData(),
child: GestureDetector(
onVerticalDragUpdate: _isFileSwipeLocked
onVerticalDragUpdate: isGuestView
? null
: (d) => {
if (d.delta.dy > dragSensitivity)

View File

@@ -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<ZoomableImage> {
bool _isZooming = false;
PhotoViewController _photoViewController = PhotoViewController();
final _scaleStateController = PhotoViewScaleStateController();
bool _isFileSwipeLocked = false;
late final StreamSubscription<FileSwipeLockEvent>
_fileSwipeLockEventSubscription;
@override
void initState() {
@@ -72,17 +70,10 @@ class _ZoomableImageState extends State<ZoomableImage> {
debugPrint("isZooming = $_isZooming, currentState $value");
// _logger.info('is reakky zooming $_isZooming with state $value');
};
_fileSwipeLockEventSubscription =
Bus.instance.on<FileSwipeLockEvent>().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<ZoomableImage> {
}
final GestureDragUpdateCallback? verticalDragCallback =
_isZooming || _isFileSwipeLocked
_isZooming || widget.isGuestView
? null
: (d) => {
if (!_isZooming)

View File

@@ -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<ZoomableLiveImageNew> createState() => _ZoomableLiveImageNewState();
@@ -43,6 +46,9 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
late final _player = Player();
VideoController? _videoController;
bool isGuestView = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
@override
void initState() {
_enteFile = widget.enteFile;
@@ -52,6 +58,12 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
if (_enteFile.isLivePhoto && _enteFile.isUploaded) {
LocalFileUpdateService.instance.checkLivePhoto(_enteFile).ignore();
}
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().listen((event) {
setState(() {
isGuestView = event.isGuestView;
});
});
super.initState();
}
@@ -83,6 +95,7 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
tagPrefix: widget.tagPrefix,
shouldDisableScroll: widget.shouldDisableScroll,
backgroundDecoration: widget.backgroundDecoration,
isGuestView: isGuestView,
);
}
return GestureDetector(
@@ -98,6 +111,7 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
_videoController!.player.stop();
_videoController!.player.dispose();
}
_guestViewEventSubscription.cancel();
super.dispose();
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href="mailto:support@ente.io" />,
}}
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: (
<Trans
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
components={{
a: <Link href="mailto:support@ente.io" />,
}}
values={{ emailID: "support@ente.io" }}
/>
),
close: { variant: "secondary" },
});
close: { variant: "secondary" },
});
break;
}
}

View File

@@ -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)) {

View File

@@ -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

View File

@@ -24,6 +24,7 @@ export const CustomHead: React.FC<CustomHeadProps> = ({ title }) => {
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="referrer" content="strict-origin-when-cross-origin" />
</Head>
);
};

View File

@@ -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();

24
web/packages/base/id.ts Normal file
View File

@@ -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();

View File

@@ -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",

View File

@@ -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<string, number>();
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 };
}
}
}

View File

@@ -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.
*/

View File

@@ -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 */ }
| {

View File

@@ -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"