diff --git a/mobile/devtools_options.yaml b/mobile/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile/lib/emergency/emergency_page.dart b/mobile/lib/emergency/emergency_page.dart new file mode 100644 index 0000000000..329c953123 --- /dev/null +++ b/mobile/lib/emergency/emergency_page.dart @@ -0,0 +1,521 @@ +import "package:flutter/foundation.dart"; +import 'package:flutter/material.dart'; +import "package:logging/logging.dart"; +import 'package:photos/core/configuration.dart'; +import "package:photos/emergency/emergency_service.dart"; +import "package:photos/emergency/model.dart"; +import "package:photos/emergency/other_contact_page.dart"; +import "package:photos/emergency/select_contact_page.dart"; +import "package:photos/generated/l10n.dart"; +import 'package:photos/theme/ente_theme.dart'; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/components/action_sheet_widget.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_title.dart'; +import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/ui/components/notification_widget.dart"; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import "package:photos/ui/sharing/user_avator_widget.dart"; +import "package:photos/utils/navigation_util.dart"; +import "package:photos/utils/toast_util.dart"; + +class EmergencyPage extends StatefulWidget { + const EmergencyPage({ + super.key, + }); + + @override + State createState() => _EmergencyPageState(); +} + +class _EmergencyPageState extends State { + late int currentUserID; + EmergencyInfo? info; + final Logger _logger = Logger('EmergencyPage'); + + @override + void initState() { + super.initState(); + currentUserID = Configuration.instance.getUserID()!; + // set info to null after 5 second + Future.delayed( + const Duration(seconds: 0), + () async { + await _fetchData(); + }, + ); + } + + Future _fetchData() async { + try { + final result = await EmergencyContactService.instance.getInfo(); + if (mounted) { + setState(() { + info = result; + }); + } + } catch (e) { + showShortToast( + context, + S.of(context).somethingWentWrong, + ); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final currentUserID = Configuration.instance.getUserID()!; + final List othersTrustedContacts = + info?.othersEmergencyContact ?? []; + final List trustedContacts = info?.contacts ?? []; + + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).trustedContacts, + ), + ), + if (info == null) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: EnteLoadingWidget(), + ), + ), + if (info != null) + if (info!.recoverSessions.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.only( + top: 20, + left: 16, + right: 16, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: NotificationWidget( + startIcon: Icons.warning_amber_rounded, + text: "Your trusted contact is trying to " + "access your account", + actionIcon: null, + onTap: () {}, + ), + ); + } + final RecoverySessions recoverSession = + info!.recoverSessions[index - 1]; + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: recoverSession.emergencyContact.email, + makeTextBold: recoverSession.status.isNotEmpty, + textColor: colorScheme.warning500, + ), + leadingIconWidget: UserAvatarWidget( + recoverSession.emergencyContact, + currentUserID: currentUserID, + ), + leadingIconSize: 24, + menuItemColor: colorScheme.fillFaint, + singleBorderRadius: 8, + trailingIcon: Icons.chevron_right, + onTap: () async { + await showRejectRecoveryDialog(recoverSession); + }, + ); + }, + childCount: 1 + info!.recoverSessions.length, + ), + ), + ), + if (info != null) + SliverPadding( + padding: const EdgeInsets.only(top: 12, left: 16, right: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0 && trustedContacts.isNotEmpty) { + return MenuSectionTitle( + title: S.of(context).myTrustedContact, + iconData: Icons.emergency_outlined, + ); + } else if (index > 0 && index <= trustedContacts.length) { + final listIndex = index - 1; + final contact = trustedContacts[listIndex]; + final isLastItem = index == trustedContacts.length; + return Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: contact.emergencyContact.email, + subTitle: contact.isPendingInvite() ? "⚠" : null, + makeTextBold: contact.isPendingInvite(), + ), + leadingIconSize: 24.0, + surfaceExecutionStates: false, + alwaysShowSuccessState: false, + leadingIconWidget: UserAvatarWidget( + contact.emergencyContact, + type: AvatarType.mini, + currentUserID: currentUserID, + ), + menuItemColor: + getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right, + trailingIconIsMuted: true, + onTap: () async { + await showRevokeOrRemoveDialog(context, contact); + }, + isTopBorderRadiusRemoved: listIndex > 0, + isBottomBorderRadiusRemoved: !isLastItem, + singleBorderRadius: 8, + ), + isLastItem + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: + getEnteColorScheme(context).fillFaint, + ), + ], + ); + } else if (index == (1 + trustedContacts.length)) { + if (trustedContacts.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 12, + ), + child: Column( + children: [ + Icon( + Icons.emergency_outlined, + color: colorScheme.strokeMuted, + size: 48, + ), + const SizedBox(height: 24), + Text( + "Your trusted contacts can help in recovering " + "your account.", + textAlign: TextAlign.center, + style: getEnteTextTheme(context).bodyMuted, + ), + const SizedBox(height: 24), + ButtonWidget( + buttonType: ButtonType.primary, + labelText: S.of(context).addTrustedContact, + onTap: () async { + await routeToPage( + context, + AddContactPage(info!), + ); + _fetchData(); + }, + ), + ], + ), + ); + } + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: trustedContacts.isNotEmpty + ? S.of(context).addMore + : S.of(context).addTrustedContact, + makeTextBold: true, + ), + leadingIcon: Icons.add_outlined, + surfaceExecutionStates: false, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + await routeToPage( + context, + AddContactPage(info!), + ); + _fetchData(); + }, + isTopBorderRadiusRemoved: trustedContacts.isNotEmpty, + singleBorderRadius: 8, + ); + } + return const SizedBox.shrink(); + }, + childCount: 1 + trustedContacts.length + 1, + ), + ), + ), + if (info != null && info!.othersEmergencyContact.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.only(top: 24, left: 16, right: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0 && (othersTrustedContacts.isNotEmpty)) { + return const MenuSectionTitle( + title: "You're Their Trusted Contact", + iconData: Icons.workspace_premium_outlined, + ); + } else if (index > 0 && + index <= othersTrustedContacts.length) { + final listIndex = index - 1; + final currentUser = othersTrustedContacts[listIndex]; + final isLastItem = index == othersTrustedContacts.length; + return Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: currentUser.user.email, + makeTextBold: currentUser.isPendingInvite(), + subTitle: + currentUser.isPendingInvite() ? "⚠" : null, + ), + leadingIconSize: 24.0, + leadingIconWidget: UserAvatarWidget( + currentUser.user, + type: AvatarType.mini, + currentUserID: currentUserID, + ), + menuItemColor: + getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right, + trailingIconIsMuted: true, + onTap: () async { + if (currentUser.isPendingInvite()) { + await showAcceptOrDeclineDialog( + context, + currentUser, + ); + } else { + await routeToPage( + context, + OtherContactPage( + contact: currentUser, + emergencyInfo: info!, + ), + ); + if (mounted) { + _fetchData(); + } + } + }, + isTopBorderRadiusRemoved: listIndex > 0, + isBottomBorderRadiusRemoved: !isLastItem, + singleBorderRadius: 8, + surfaceExecutionStates: false, + ), + isLastItem + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: + getEnteColorScheme(context).fillFaint, + ), + ], + ); + } + return const SizedBox.shrink(); + }, + childCount: 1 + othersTrustedContacts.length + 1, + ), + ), + ), + ], + ), + ); + } + + Future showRevokeOrRemoveDialog( + BuildContext context, + EmergencyContact contact, + ) async { + if (contact.isPendingInvite()) { + await showActionSheet( + context: context, + body: + "You have invited ${contact.emergencyContact.email} to be a trusted contact", + bodyHighlight: "They are yet to accept your invite", + buttons: [ + ButtonWidget( + labelText: S.of(context).removeInvite, + buttonType: ButtonType.critical, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.first, + shouldStickToDarkTheme: true, + shouldSurfaceExecutionStates: true, + shouldShowSuccessConfirmation: false, + onTap: () async { + await EmergencyContactService.instance + .updateContact(contact, ContactState.UserRevokedContact); + info?.contacts.remove(contact); + if (mounted) { + setState(() {}); + _fetchData(); + } + }, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).cancel, + buttonType: ButtonType.tertiary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.second, + shouldStickToDarkTheme: true, + isInAlert: true, + ), + ], + ); + } else { + await showActionSheet( + context: context, + body: + "You have added ${contact.emergencyContact.email} as a trusted contact", + bodyHighlight: "They have accepted your invite", + buttons: [ + ButtonWidget( + labelText: S.of(context).remove, + buttonType: ButtonType.critical, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.second, + shouldStickToDarkTheme: true, + shouldSurfaceExecutionStates: true, + shouldShowSuccessConfirmation: false, + onTap: () async { + await EmergencyContactService.instance + .updateContact(contact, ContactState.UserRevokedContact); + info?.contacts.remove(contact); + if (mounted) { + setState(() {}); + _fetchData(); + } + }, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).cancel, + buttonType: ButtonType.tertiary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.third, + shouldStickToDarkTheme: true, + isInAlert: true, + ), + ], + ); + } + } + + Future showAcceptOrDeclineDialog( + BuildContext context, + EmergencyContact contact, + ) async { + await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + labelText: S.of(context).acceptTrustInvite, + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + onTap: () async { + await EmergencyContactService.instance + .updateContact(contact, ContactState.ContactAccepted); + final updatedContact = + contact.copyWith(state: ContactState.ContactAccepted); + info?.othersEmergencyContact.remove(contact); + info?.othersEmergencyContact.add(updatedContact); + if (mounted) { + setState(() {}); + } + }, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).declineTrustInvite, + buttonType: ButtonType.critical, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.second, + shouldStickToDarkTheme: true, + onTap: () async { + await EmergencyContactService.instance + .updateContact(contact, ContactState.ContactDenied); + info?.othersEmergencyContact.remove(contact); + if (mounted) { + setState(() {}); + } + }, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).cancel, + buttonType: ButtonType.tertiary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.third, + shouldStickToDarkTheme: true, + isInAlert: true, + ), + ], + body: + "You have been invited to be a trusted contact by ${contact.user.email}", + actionSheetType: ActionSheetType.defaultActionSheet, + ); + return; + } + + Future showRejectRecoveryDialog(RecoverySessions session) async { + final String emergencyContactEmail = session.emergencyContact.email; + await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + labelText: "Reject Recovery", + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + buttonType: ButtonType.critical, + buttonAction: ButtonAction.first, + onTap: () async { + await EmergencyContactService.instance.rejectRecovery(session); + info?.recoverSessions + .removeWhere((element) => element.id == session.id); + if (mounted) { + setState(() {}); + } + _fetchData(); + }, + isInAlert: true, + ), + if (kDebugMode) + ButtonWidget( + labelText: "Approve Recovery", + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.second, + shouldStickToDarkTheme: true, + onTap: () async { + showToast(context, "Coming soon for internal users"); + }, + isInAlert: true, + ), + ButtonWidget( + labelText: S.of(context).cancel, + buttonType: ButtonType.tertiary, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.third, + shouldStickToDarkTheme: true, + isInAlert: true, + ), + ], + body: "$emergencyContactEmail is trying to recover your account.", + actionSheetType: ActionSheetType.defaultActionSheet, + ); + return; + } +} diff --git a/mobile/lib/emergency/emergency_service.dart b/mobile/lib/emergency/emergency_service.dart new file mode 100644 index 0000000000..e4a147c65c --- /dev/null +++ b/mobile/lib/emergency/emergency_service.dart @@ -0,0 +1,258 @@ +import "dart:convert"; +import "dart:math"; +import "dart:typed_data"; + +import "package:dio/dio.dart"; +import "package:flutter/cupertino.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/configuration.dart"; +import "package:photos/core/network/network.dart"; +import "package:photos/emergency/model.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/api/user/srp.dart"; +import "package:photos/models/key_attributes.dart"; +import "package:photos/models/set_keys_request.dart"; +import "package:photos/services/user_service.dart"; +import "package:photos/ui/common/user_dialogs.dart"; +import "package:photos/utils/crypto_util.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/email_util.dart"; +import "package:pointycastle/pointycastle.dart"; +import "package:pointycastle/random/fortuna_random.dart"; +import "package:pointycastle/srp/srp6_client.dart"; +import "package:pointycastle/srp/srp6_standard_groups.dart"; +import "package:pointycastle/srp/srp6_util.dart"; +import "package:pointycastle/srp/srp6_verifier_generator.dart"; +import "package:uuid/uuid.dart"; + +class EmergencyContactService { + late Dio _enteDio; + late UserService _userService; + late Configuration _config; + late final Logger _logger = Logger("EmergencyContactService"); + + EmergencyContactService._privateConstructor() { + _enteDio = NetworkClient.instance.enteDio; + _userService = UserService.instance; + _config = Configuration.instance; + } + + static final EmergencyContactService instance = + EmergencyContactService._privateConstructor(); + + Future addContact(BuildContext context, String email) async { + if (!isValidEmail(email)) { + await showErrorDialog( + context, + S.of(context).invalidEmailAddress, + S.of(context).enterValidEmail, + ); + return false; + } else if (email.trim() == Configuration.instance.getEmail()) { + await showErrorDialog( + context, + S.of(context).oops, + S.of(context).youCannotShareWithYourself, + ); + return false; + } + final String? publicKey = await _userService.getPublicKey(email); + if (publicKey == null) { + await showInviteDialog(context, email); + return false; + } + final Uint8List recoveryKey = Configuration.instance.getRecoveryKey(); + final encryptedKey = CryptoUtil.sealSync( + recoveryKey, + CryptoUtil.base642bin(publicKey), + ); + await _enteDio.post( + "/emergency-contacts/add", + data: { + "email": email.trim(), + "encryptedKey": CryptoUtil.bin2base64(encryptedKey), + }, + ); + return true; + } + + Future getInfo() async { + try { + final response = await _enteDio.get("/emergency-contacts/info"); + return EmergencyInfo.fromJson(response.data); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to get info', e, s); + rethrow; + } + } + + Future updateContact( + EmergencyContact contact, + ContactState state, + ) async { + try { + await _enteDio.post( + "/emergency-contacts/update", + data: { + "userID": contact.user.id, + "emergencyContactID": contact.emergencyContact.id, + "state": state.stringValue, + }, + ); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to update contact', e, s); + rethrow; + } + } + + Future startRecovery(EmergencyContact contact) async { + try { + await _enteDio.post( + "/emergency-contacts/start-recovery", + data: { + "userID": contact.user.id, + "emergencyContactID": contact.emergencyContact.id, + }, + ); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to start recovery', e, s); + rethrow; + } + } + + Future stopRecovery(RecoverySessions session) async { + try { + await _enteDio.post( + "/emergency-contacts/stop-recovery", + data: { + "userID": session.user.id, + "emergencyContactID": session.emergencyContact.id, + "id": session.id, + }, + ); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to stop recovery', e, s); + rethrow; + } + } + + Future rejectRecovery(RecoverySessions session) async { + try { + await _enteDio.post( + "/emergency-contacts/reject-recovery", + data: { + "userID": session.user.id, + "emergencyContactID": session.emergencyContact.id, + "id": session.id, + }, + ); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to stop recovery', e, s); + rethrow; + } + } + + Future<(String, KeyAttributes)> getRecoveryInfo( + RecoverySessions sessions, + ) async { + try { + final resp = await _enteDio.get( + "/emergency-contacts/recovery-info/${sessions.id}", + ); + final String encryptedKey = resp.data["encryptedKey"]!; + final decryptedKey = CryptoUtil.openSealSync( + CryptoUtil.base642bin(encryptedKey), + CryptoUtil.base642bin(_config.getKeyAttributes()!.publicKey), + _config.getSecretKey()!, + ); + final String hexRecoveryKey = CryptoUtil.bin2hex(decryptedKey); + final KeyAttributes keyAttributes = + KeyAttributes.fromMap(resp.data['userKeyAttr']); + return (hexRecoveryKey, keyAttributes); + } catch (e, s) { + Logger("EmergencyContact").severe('failed to stop recovery', e, s); + rethrow; + } + } + + Future changePasswordForOther( + Uint8List loginKey, + SetKeysRequest setKeysRequest, + RecoverySessions recoverySessions, + ) async { + try { + final SRP6GroupParameters kDefaultSrpGroup = + SRP6StandardGroups.rfc5054_4096; + final String username = const Uuid().v4().toString(); + final SecureRandom random = _getSecureRandom(); + final Uint8List identity = Uint8List.fromList(utf8.encode(username)); + final Uint8List password = loginKey; + final Uint8List salt = random.nextBytes(16); + final gen = SRP6VerifierGenerator( + group: kDefaultSrpGroup, + digest: Digest('SHA-256'), + ); + final v = gen.generateVerifier(salt, identity, password); + + final client = SRP6Client( + group: kDefaultSrpGroup, + digest: Digest('SHA-256'), + random: random, + ); + + final A = client.generateClientCredentials(salt, identity, password); + final request = SetupSRPRequest( + srpUserID: username, + srpSalt: base64Encode(salt), + srpVerifier: base64Encode(SRP6Util.encodeBigInt(v)), + srpA: base64Encode(SRP6Util.encodeBigInt(A!)), + isUpdate: false, + ); + final response = await _enteDio.post( + "/emergency-contacts/init-change-password", + data: { + "recoveryID": recoverySessions.id, + "setupSRPRequest": request.toMap(), + }, + ); + if (response.statusCode == 200) { + final SetupSRPResponse setupSRPResponse = + SetupSRPResponse.fromJson(response.data); + final serverB = + SRP6Util.decodeBigInt(base64Decode(setupSRPResponse.srpB)); + // ignore: need to calculate secret to get M1, unused_local_variable + final clientS = client.calculateSecret(serverB); + final clientM = client.calculateClientEvidenceMessage(); + // ignore: unused_local_variable + late Response srpCompleteResponse; + srpCompleteResponse = await _enteDio.post( + "/emergency-contacts/change-password", + data: { + "recoveryID": recoverySessions.id, + 'updateSrpAndKeysRequest': { + 'setupID': setupSRPResponse.setupID, + 'srpM1': base64Encode(SRP6Util.encodeBigInt(clientM!)), + 'updatedKeyAttr': setKeysRequest.toMap(), + }, + }, + ); + } else { + throw Exception("register-srp action failed"); + } + } catch (e, s) { + _logger.severe("failed to change password for other", e, s); + rethrow; + } + } + + SecureRandom _getSecureRandom() { + final List seeds = []; + final random = Random.secure(); + for (int i = 0; i < 32; i++) { + seeds.add(random.nextInt(255)); + } + final secureRandom = FortunaRandom(); + secureRandom.seed(KeyParameter(Uint8List.fromList(seeds))); + return secureRandom; + } +} diff --git a/mobile/lib/emergency/other_contact_page.dart b/mobile/lib/emergency/other_contact_page.dart new file mode 100644 index 0000000000..609dd4cb2a --- /dev/null +++ b/mobile/lib/emergency/other_contact_page.dart @@ -0,0 +1,240 @@ +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:photos/emergency/emergency_service.dart"; +import "package:photos/emergency/model.dart"; +import "package:photos/emergency/recover_others_account.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/key_attributes.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import "package:photos/ui/components/captioned_text_widget.dart"; +import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; +import "package:photos/ui/components/menu_section_title.dart"; +import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; +import "package:photos/utils/date_time_util.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; + +// OtherContactPage is used to start recovery process for other user's account +// Based on the state of the contact & recovery session, it will show +// different UI +class OtherContactPage extends StatefulWidget { + final EmergencyContact contact; + final EmergencyInfo emergencyInfo; + + const OtherContactPage({ + required this.contact, + required this.emergencyInfo, + super.key, + }); + + @override + State createState() => _OtherContactPageState(); +} + +class _OtherContactPageState extends State { + late String recoverDelayTime; + late String accountEmail = widget.contact.user.email; + RecoverySessions? recoverySession; + String? waitTill; + final Logger _logger = Logger("_OtherContactPageState"); + late EmergencyInfo emergencyInfo = widget.emergencyInfo; + + @override + void initState() { + super.initState(); + recoverDelayTime = "${(widget.contact.recoveryNoticeInDays ~/ 24)} days"; + recoverySession = widget.emergencyInfo.othersRecoverySession + .firstWhereOrNull((session) => session.user.email == accountEmail); + _fetchData(); + } + + Future _fetchData() async { + try { + final result = await EmergencyContactService.instance.getInfo(); + if (mounted) { + setState(() { + recoverySession = result.othersRecoverySession.firstWhereOrNull( + (session) => session.user.email == accountEmail, + ); + }); + } + } catch (ignored) {} + } + + @override + Widget build(BuildContext context) { + _logger.info('session ${widget.emergencyInfo}'); + if (recoverySession != null) { + final dateTime = DateTime.now().add( + Duration( + microseconds: recoverySession!.waitTill, + ), + ); + waitTill = getFormattedTime(context, dateTime); + } + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Scaffold( + appBar: AppBar(), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 12, + ), + const TitleBarTitleWidget( + title: "Recover account", + ), + Text( + accountEmail, + textAlign: TextAlign.left, + style: + textTheme.small.copyWith(color: colorScheme.textMuted), + ), + ], + ), + ), + const SizedBox(height: 12), + recoverySession == null + ? Text( + "You can recover $accountEmail account $recoverDelayTime" + " after starting recovery process.", + style: textTheme.body, + ) + : Text( + "You can recover $accountEmail's" + " account after $waitTill ", + style: textTheme.bodyBold, + ), + const SizedBox(height: 12), + if (recoverySession == null) + ButtonWidget( + // icon: Icons.start_outlined, + buttonType: ButtonType.trailingIconPrimary, + icon: Icons.start_outlined, + labelText: S.of(context).startAccountRecoveryTitle, + onTap: widget.contact.isPendingInvite() + ? null + : () async { + final actionResult = await showChoiceActionSheet( + context, + title: S.of(context).startAccountRecoveryTitle, + firstButtonLabel: S.of(context).yes, + body: "Are you sure you want to initiate recovery?", + isCritical: true, + ); + if (actionResult?.action != null) { + if (actionResult!.action == ButtonAction.first) { + try { + await EmergencyContactService.instance + .startRecovery(widget.contact); + if (mounted) { + _fetchData().ignore(); + await showErrorDialog( + context, + "Done", + "Please visit page after $recoverDelayTime to" + " recover $accountEmail's account.", + ); + } + } catch (e) { + showGenericErrorDialog(context: context, error: e) + .ignore(); + } + } + } + }, + // isTopBorderRadiusRemoved: true, + ), + if (recoverySession != null && recoverySession!.status == "READY") + ButtonWidget( + // icon: Icons.start_outlined, + buttonType: ButtonType.primary, + labelText: "Recover account", + onTap: () async { + final (String key, KeyAttributes attributes) = + await EmergencyContactService.instance + .getRecoveryInfo(recoverySession!); + routeToPage( + context, + RecoverOthersAccount(key, attributes, recoverySession!), + ); + }, + ), + if (recoverySession != null && recoverySession!.status == "WAITING") + ButtonWidget( + // icon: Icons.start_outlined, + buttonType: ButtonType.neutral, + labelText: S.of(context).cancelAccountRecovery, + shouldSurfaceExecutionStates: false, + onTap: () async { + final actionResult = await showChoiceActionSheet( + context, + title: S.of(context).cancelAccountRecovery, + firstButtonLabel: S.of(context).yes, + body: S.of(context).cancelAccountRecoveryBody, + isCritical: true, + firstButtonOnTap: () async { + EmergencyContactService.instance + .stopRecovery(recoverySession!); + }, + ); + if (actionResult?.action == ButtonAction.first) { + _fetchData(); + } + }, + ), + SizedBox(height: recoverySession == null ? 48 : 24), + MenuSectionTitle( + title: S.of(context).removeYourselfAsTrustedContact, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).remove, + textColor: warning500, + makeTextBold: true, + ), + leadingIcon: Icons.not_interested_outlined, + leadingIconColor: warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, + onTap: () async { + final actionResult = await showChoiceActionSheet( + context, + title: "Remove", + firstButtonLabel: S.of(context).yes, + body: "Are you sure your want to stop being a trusted " + "contact for $accountEmail?", + isCritical: true, + firstButtonOnTap: () async { + try { + await EmergencyContactService.instance.updateContact( + widget.contact, + ContactState.ContactLeft, + ); + Navigator.of(context).pop(true); + } catch (e) { + showGenericErrorDialog(context: context, error: e) + .ignore(); + } + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/emergency/recover_others_account.dart b/mobile/lib/emergency/recover_others_account.dart new file mode 100644 index 0000000000..7e639bdb9a --- /dev/null +++ b/mobile/lib/emergency/recover_others_account.dart @@ -0,0 +1,364 @@ +import "dart:convert"; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:password_strength/password_strength.dart'; +import "package:photos/emergency/emergency_service.dart"; +import "package:photos/emergency/model.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/key_attributes.dart"; +import "package:photos/models/set_keys_request.dart"; +import 'package:photos/ui/common/dynamic_fab.dart'; +import "package:photos/utils/crypto_util.dart"; +import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/utils/toast_util.dart'; + +class RecoverOthersAccount extends StatefulWidget { + final String recoveryKey; + final KeyAttributes attributes; + final RecoverySessions sessions; + + const RecoverOthersAccount( + this.recoveryKey, + this.attributes, + this.sessions, { + Key? key, + }) : super(key: key); + + @override + State createState() => _RecoverOthersAccountState(); +} + +class _RecoverOthersAccountState extends State { + static const kMildPasswordStrengthThreshold = 0.4; + static const kStrongPasswordStrengthThreshold = 0.7; + + final _logger = Logger((_RecoverOthersAccountState).toString()); + final _passwordController1 = TextEditingController(), + _passwordController2 = TextEditingController(); + final Color _validFieldValueColor = const Color.fromRGBO(45, 194, 98, 0.2); + String _passwordInInputBox = ''; + String _passwordInInputConfirmationBox = ''; + double _passwordStrength = 0.0; + bool _password1Visible = false; + bool _password2Visible = false; + final _password1FocusNode = FocusNode(); + final _password2FocusNode = FocusNode(); + bool _password1InFocus = false; + bool _password2InFocus = false; + + bool _passwordsMatch = false; + bool _isPasswordValid = false; + + @override + void initState() { + super.initState(); + _password1FocusNode.addListener(() { + setState(() { + _password1InFocus = _password1FocusNode.hasFocus; + }); + }); + _password2FocusNode.addListener(() { + setState(() { + _password2InFocus = _password2FocusNode.hasFocus; + }); + }); + } + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation? fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + String title = S.of(context).setPasswordTitle; + title = S.of(context).resetPasswordTitle; + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + elevation: 0, + ), + body: _getBody(title), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _passwordsMatch && _isPasswordValid, + buttonText: title, + onPressedFunction: () { + _updatePassword(); + FocusScope.of(context).unfocus(); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody(String buttonTextAndHeading) { + final email = widget.sessions!.user.email; + var passwordStrengthText = S.of(context).weakStrength; + var passwordStrengthColor = Colors.redAccent; + if (_passwordStrength > kStrongPasswordStrengthThreshold) { + passwordStrengthText = S.of(context).strongStrength; + passwordStrengthColor = Colors.greenAccent; + } else if (_passwordStrength > kMildPasswordStrengthThreshold) { + passwordStrengthText = S.of(context).moderateStrength; + passwordStrengthColor = Colors.orangeAccent; + } + return Column( + children: [ + Expanded( + child: AutofillGroup( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + buttonTextAndHeading, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + "Enter new password for $email account. You will be able " + "to use this password to login into $email account.", + textAlign: TextAlign.start, + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontSize: 14), + ), + ), + const Padding(padding: EdgeInsets.all(12)), + Visibility( + // hidden textForm for suggesting auto-fill service for saving + // password + visible: false, + child: TextFormField( + autofillHints: const [ + AutofillHints.email, + ], + autocorrect: false, + keyboardType: TextInputType.emailAddress, + initialValue: email, + textInputAction: TextInputAction.next, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + fillColor: + _isPasswordValid ? _validFieldValueColor : null, + filled: true, + hintText: S.of(context).password, + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + suffixIcon: _password1InFocus + ? IconButton( + icon: Icon( + _password1Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password1Visible = !_password1Visible; + }); + }, + ) + : _isPasswordValid + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder! + .borderSide + .color, + ) + : null, + ), + obscureText: !_password1Visible, + controller: _passwordController1, + autofocus: false, + autocorrect: false, + keyboardType: TextInputType.visiblePassword, + onChanged: (password) { + setState(() { + _passwordInInputBox = password; + _passwordStrength = estimatePasswordStrength(password); + _isPasswordValid = + _passwordStrength >= kMildPasswordStrengthThreshold; + _passwordsMatch = _passwordInInputBox == + _passwordInInputConfirmationBox; + }); + }, + textInputAction: TextInputAction.next, + focusNode: _password1FocusNode, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + keyboardType: TextInputType.visiblePassword, + controller: _passwordController2, + obscureText: !_password2Visible, + autofillHints: const [AutofillHints.newPassword], + onEditingComplete: () => TextInput.finishAutofillContext(), + decoration: InputDecoration( + fillColor: _passwordsMatch ? _validFieldValueColor : null, + filled: true, + hintText: S.of(context).confirmPassword, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 20, + ), + suffixIcon: _password2InFocus + ? IconButton( + icon: Icon( + _password2Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password2Visible = !_password2Visible; + }); + }, + ) + : _passwordsMatch + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder! + .borderSide + .color, + ) + : null, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + focusNode: _password2FocusNode, + onChanged: (cnfPassword) { + setState(() { + _passwordInInputConfirmationBox = cnfPassword; + if (_passwordInInputBox != '') { + _passwordsMatch = _passwordInInputBox == + _passwordInInputConfirmationBox; + } + }); + }, + ), + ), + Opacity( + opacity: + (_passwordInInputBox != '') && _password1InFocus ? 1 : 0, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text( + S.of(context).passwordStrength(passwordStrengthText), + style: TextStyle( + color: passwordStrengthColor, + ), + ), + ), + ), + const SizedBox(height: 8), + const Padding(padding: EdgeInsets.all(20)), + ], + ), + ), + ), + ], + ); + } + + void _updatePassword() async { + final dialog = + createProgressDialog(context, S.of(context).generatingEncryptionKeys); + await dialog.show(); + try { + final String password = _passwordController1.text; + final KeyAttributes attributes = widget.attributes; + Uint8List? masterKey; + try { + // Decrypt the master key that was earlier encrypted with the recovery key + masterKey = await CryptoUtil.decrypt( + CryptoUtil.base642bin(attributes.masterKeyEncryptedWithRecoveryKey!), + CryptoUtil.hex2bin(widget.recoveryKey), + CryptoUtil.base642bin(attributes.masterKeyDecryptionNonce!), + ); + } catch (e) { + _logger.severe(e, "Failed to get master key using recoveryKey"); + rethrow; + } + + // Derive a key from the password that will be used to encrypt and + // decrypt the master key + final kekSalt = CryptoUtil.getSaltToDeriveKey(); + final derivedKeyResult = await CryptoUtil.deriveSensitiveKey( + utf8.encode(password) as Uint8List, + kekSalt, + ); + final loginKey = await CryptoUtil.deriveLoginKey(derivedKeyResult.key); + // Encrypt the key with this derived key + final encryptedKeyData = + CryptoUtil.encryptSync(masterKey, derivedKeyResult.key); + + final updatedAttributes = attributes.copyWith( + kekSalt: CryptoUtil.bin2base64(kekSalt), + encryptedKey: CryptoUtil.bin2base64(encryptedKeyData.encryptedData!), + keyDecryptionNonce: CryptoUtil.bin2base64(encryptedKeyData.nonce!), + memLimit: derivedKeyResult.memLimit, + opsLimit: derivedKeyResult.opsLimit, + ); + final setKeyRequest = SetKeysRequest( + kekSalt: updatedAttributes.kekSalt, + encryptedKey: updatedAttributes.encryptedKey, + keyDecryptionNonce: updatedAttributes.keyDecryptionNonce, + memLimit: updatedAttributes.memLimit!, + opsLimit: updatedAttributes.opsLimit!, + ); + await EmergencyContactService.instance.changePasswordForOther( + loginKey, + setKeyRequest, + widget.sessions, + ); + await dialog.hide(); + showShortToast(context, S.of(context).passwordChangedSuccessfully); + Navigator.of(context).pop(); + } catch (e, s) { + _logger.severe(e, s); + await dialog.hide(); + showGenericErrorDialog(context: context, error: e).ignore(); + } + } +} diff --git a/mobile/lib/emergency/select_contact_page.dart b/mobile/lib/emergency/select_contact_page.dart new file mode 100644 index 0000000000..37bd9ccc6c --- /dev/null +++ b/mobile/lib/emergency/select_contact_page.dart @@ -0,0 +1,352 @@ +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import "package:logging/logging.dart"; +import 'package:photos/core/configuration.dart'; +import "package:photos/emergency/emergency_service.dart"; +import "package:photos/emergency/model.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/api/collection/user.dart"; +import 'package:photos/services/collections_service.dart'; +import "package:photos/services/user_service.dart"; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; +import 'package:photos/ui/components/buttons/button_widget.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/components/menu_section_title.dart'; +import 'package:photos/ui/components/models/button_type.dart'; +import 'package:photos/ui/sharing/user_avator_widget.dart'; +import "package:photos/ui/sharing/verify_identity_dialog.dart"; +import "package:photos/utils/dialog_util.dart"; + +class AddContactPage extends StatefulWidget { + final EmergencyInfo emergencyInfo; + + const AddContactPage(this.emergencyInfo, {super.key}); + + @override + State createState() => _AddContactPage(); +} + +class _AddContactPage extends State { + String selectedEmail = ''; + String _email = ''; + bool isEmailListEmpty = false; + bool _emailIsValid = false; + bool isKeypadOpen = false; + late CollectionActions collectionActions; + late final Logger _logger = Logger('AddContactPage'); + + // Focus nodes are necessary + final textFieldFocusNode = FocusNode(); + final _textController = TextEditingController(); + + @override + void initState() { + collectionActions = CollectionActions(CollectionsService.instance); + super.initState(); + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + final enteTextTheme = getEnteTextTheme(context); + final enteColorScheme = getEnteColorScheme(context); + final List suggestedUsers = _getSuggestedUser(); + isEmailListEmpty = suggestedUsers.isEmpty; + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + title: Text( + S.of(context).addTrustedContact, + ), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + S.of(context).addANewEmail, + style: enteTextTheme.small + .copyWith(color: enteColorScheme.textMuted), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _getEmailField(), + ), + if (isEmailListEmpty) + const Expanded(child: SizedBox.shrink()) + else + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + !isEmailListEmpty + ? MenuSectionTitle( + title: S.of(context).orPickAnExistingOne, + ) + : const SizedBox.shrink(), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + if (index >= suggestedUsers.length) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: MenuSectionDescriptionWidget( + content: S.of(context).whyAddTrustContact, + ), + ); + } + final currentUser = suggestedUsers[index]; + return Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: currentUser.email, + ), + leadingIconSize: 24.0, + leadingIconWidget: UserAvatarWidget( + currentUser, + type: AvatarType.mini, + ), + menuItemColor: + getEnteColorScheme(context).fillFaint, + pressedColor: + getEnteColorScheme(context).fillFaint, + trailingIcon: + (selectedEmail == currentUser.email) + ? Icons.check + : null, + onTap: () async { + textFieldFocusNode.unfocus(); + if (selectedEmail == currentUser.email) { + selectedEmail = ''; + } else { + selectedEmail = currentUser.email; + } + setState(() => {}); + }, + isTopBorderRadiusRemoved: index > 0, + isBottomBorderRadiusRemoved: + index < (suggestedUsers.length - 1), + ), + (index == (suggestedUsers.length - 1)) + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: + getEnteColorScheme(context).fillFaint, + ), + ], + ); + }, + itemCount: suggestedUsers.length + 1, + // physics: const ClampingScrollPhysics(), + ), + ), + ], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only( + top: 8, + bottom: 8, + left: 16, + right: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 8), + ButtonWidget( + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: "Add", + isDisabled: (selectedEmail == '' && !_emailIsValid), + onTap: (selectedEmail == '' && !_emailIsValid) + ? null + : () async { + final emailToAdd = + selectedEmail == '' ? _email : selectedEmail; + try { + final result = await EmergencyContactService + .instance + .addContact(context, emailToAdd); + if (result && mounted) { + Navigator.of(context).pop(true); + } + } catch (e) { + _logger.severe('Failed to add contact', e); + await showErrorDialog( + context, + S.of(context).error, + S.of(context).somethingWentWrong, + ); + } + }, + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () async { + if ((selectedEmail == '' && !_emailIsValid)) { + await showErrorDialog( + context, + S.of(context).invalidEmailAddress, + S.of(context).enterValidEmail, + ); + return; + } + final emailToAdd = + selectedEmail == '' ? _email : selectedEmail; + showDialog( + context: context, + builder: (BuildContext context) { + return VerifyIdentifyDialog( + self: false, + email: emailToAdd, + ); + }, + ); + }, + child: Text( + S.of(context).verifyIDLabel, + textAlign: TextAlign.center, + style: enteTextTheme.smallMuted.copyWith( + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ], + ), + ); + } + + void clearFocus() { + _textController.clear(); + _email = _textController.text; + _emailIsValid = false; + textFieldFocusNode.unfocus(); + setState(() => {}); + } + + Widget _getEmailField() { + return TextFormField( + controller: _textController, + focusNode: textFieldFocusNode, + style: getEnteTextTheme(context).body, + autofillHints: const [AutofillHints.email], + decoration: InputDecoration( + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + borderSide: + BorderSide(color: getEnteColorScheme(context).strokeMuted), + ), + fillColor: getEnteColorScheme(context).fillFaint, + filled: true, + hintText: S.of(context).enterEmail, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(4), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: getEnteColorScheme(context).strokeMuted, + ), + suffixIcon: _email == '' + ? null + : IconButton( + onPressed: clearFocus, + icon: Icon( + Icons.cancel, + color: getEnteColorScheme(context).strokeMuted, + ), + ), + ), + onChanged: (value) { + if (selectedEmail != '') { + selectedEmail = ''; + } + _email = value.trim(); + _emailIsValid = EmailValidator.validate(_email); + setState(() {}); + }, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + //initialValue: _email, + textInputAction: TextInputAction.next, + ); + } + + List _getSuggestedUser() { + final List suggestedUsers = []; + final Set existingEmails = {}; + final int ownerID = Configuration.instance.getUserID()!; + existingEmails.add(Configuration.instance.getEmail()!); + for (final c in CollectionsService.instance.getActiveCollections()) { + if (c.owner?.id == ownerID) { + for (final User? u in c.sharees ?? []) { + if (u != null && + u.id != null && + u.email.isNotEmpty && + !existingEmails.contains(u.email)) { + existingEmails.add(u.email); + suggestedUsers.add(u); + } + } + } else if (c.owner != null && + c.owner!.id != null && + c.owner!.email.isNotEmpty && + !existingEmails.contains(c.owner!.email)) { + existingEmails.add(c.owner!.email); + suggestedUsers.add(c.owner!); + } + } + final cachedUserDetails = UserService.instance.getCachedUserDetails(); + if (cachedUserDetails != null && + (cachedUserDetails.familyData?.members?.isNotEmpty ?? false)) { + for (final member in cachedUserDetails.familyData!.members!) { + if (!existingEmails.contains(member.email)) { + existingEmails.add(member.email); + suggestedUsers.add(User(email: member.email)); + } + } + } + if (_textController.text.trim().isNotEmpty) { + suggestedUsers.removeWhere( + (element) => !element.email + .toLowerCase() + .contains(_textController.text.trim().toLowerCase()), + ); + } + suggestedUsers.sort((a, b) => a.email.compareTo(b.email)); + + return suggestedUsers; + } +} diff --git a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart index 0e427b2532..dc9f58d9f3 100644 --- a/mobile/lib/ui/actions/collection/collection_sharing_actions.dart +++ b/mobile/lib/ui/actions/collection/collection_sharing_actions.dart @@ -19,6 +19,7 @@ import 'package:photos/services/user_service.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/common/progress_dialog.dart'; +import "package:photos/ui/common/user_dialogs.dart"; import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/buttons/button_widget.dart'; import 'package:photos/ui/components/dialog_widget.dart'; @@ -233,28 +234,7 @@ class CollectionActions { if (publicKey == null || publicKey == '') { // todo: neeraj replace this as per the design where a new screen // is used for error. Do this change along with handling of network errors - await showDialogWidget( - context: context, - title: S.of(context).inviteToEnte, - icon: Icons.info_outline, - body: S.of(context).emailNoEnteAccount(email), - isDismissible: true, - buttons: [ - ButtonWidget( - buttonType: ButtonType.neutral, - icon: Icons.adaptive.share, - labelText: S.of(context).sendInvite, - isInAlert: true, - onTap: () async { - unawaited( - shareText( - S.of(context).shareTextRecommendUsingEnte, - ), - ); - }, - ), - ], - ); + await showInviteDialog(context, email); return false; } else { return true; diff --git a/mobile/lib/ui/common/user_dialogs.dart b/mobile/lib/ui/common/user_dialogs.dart new file mode 100644 index 0000000000..4ddbe85efe --- /dev/null +++ b/mobile/lib/ui/common/user_dialogs.dart @@ -0,0 +1,33 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import "package:photos/ui/components/dialog_widget.dart"; +import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/utils/share_util.dart"; + +Future showInviteDialog(BuildContext context, String email) async { + await showDialogWidget( + context: context, + title: S.of(context).inviteToEnte, + icon: Icons.info_outline, + body: S.of(context).emailNoEnteAccount(email), + isDismissible: true, + buttons: [ + ButtonWidget( + buttonType: ButtonType.neutral, + icon: Icons.adaptive.share, + labelText: S.of(context).sendInvite, + isInAlert: true, + onTap: () async { + unawaited( + shareText( + S.of(context).shareTextRecommendUsingEnte, + ), + ); + }, + ), + ], + ); +} diff --git a/mobile/lib/ui/components/notification_widget.dart b/mobile/lib/ui/components/notification_widget.dart index 2a8e18c712..6036b44771 100644 --- a/mobile/lib/ui/components/notification_widget.dart +++ b/mobile/lib/ui/components/notification_widget.dart @@ -17,7 +17,7 @@ enum NotificationType { class NotificationWidget extends StatelessWidget { final IconData startIcon; - final IconData actionIcon; + final IconData? actionIcon; final String text; final String? subText; final GestureTapCallback onTap; @@ -155,14 +155,15 @@ class NotificationWidget extends StatelessWidget { ), ), const SizedBox(width: 12), - IconButtonWidget( - icon: actionIcon, - iconButtonType: IconButtonType.rounded, - iconColor: strokeColorScheme.strokeBase, - defaultColor: strokeColorScheme.fillFaint, - pressedColor: strokeColorScheme.fillMuted, - onTap: onTap, - ), + if (actionIcon != null) + IconButtonWidget( + icon: actionIcon!, + iconButtonType: IconButtonType.rounded, + iconColor: strokeColorScheme.strokeBase, + defaultColor: strokeColorScheme.fillFaint, + pressedColor: strokeColorScheme.fillMuted, + onTap: onTap, + ), ], ), ), diff --git a/mobile/lib/ui/settings/account_section_widget.dart b/mobile/lib/ui/settings/account_section_widget.dart index 48193dc3b9..2dd34932c1 100644 --- a/mobile/lib/ui/settings/account_section_widget.dart +++ b/mobile/lib/ui/settings/account_section_widget.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; +import "package:photos/emergency/emergency_page.dart"; import "package:photos/generated/l10n.dart"; import 'package:photos/services/local_authentication_service.dart'; import 'package:photos/services/user_service.dart'; @@ -142,6 +144,33 @@ class AccountSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).trustedContacts, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + showOnlyLoadingState: true, + onTap: () async { + final hasAuthenticated = kDebugMode || + await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + S.of(context).authToChangeYourPassword, + ); + if (hasAuthenticated) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const EmergencyPage(); + }, + ), + ).ignore(); + } + }, + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).exportYourData,