diff --git a/lib/core/configuration.dart b/lib/core/configuration.dart index 8917842f6..685cf4416 100644 --- a/lib/core/configuration.dart +++ b/lib/core/configuration.dart @@ -323,6 +323,32 @@ class Configuration { ); } + Future verifyPassword(String password) async { + final KeyAttributes attributes = getKeyAttributes()!; + _logger.info('state validation done'); + final kek = await CryptoUtil.deriveKey( + utf8.encode(password) as Uint8List, + Sodium.base642bin(attributes.kekSalt), + attributes.memLimit, + attributes.opsLimit, + ).onError((e, s) { + _logger.severe('deriveKey failed', e, s); + throw KeyDerivationError(); + }); + + _logger.info('user-key done'); + try { + final Uint8List key = CryptoUtil.decryptSync( + Sodium.base642bin(attributes.encryptedKey), + kek, + Sodium.base642bin(attributes.keyDecryptionNonce), + ); + } catch (e) { + _logger.severe('master-key failed, incorrect password?', e); + throw Exception("Incorrect password"); + } + } + Future createNewRecoveryKey() async { final masterKey = getKey()!; final existingAttributes = getKeyAttributes(); diff --git a/lib/services/billing_service.dart b/lib/services/billing_service.dart index 10a51382d..6a38c3be8 100644 --- a/lib/services/billing_service.dart +++ b/lib/services/billing_service.dart @@ -7,7 +7,6 @@ import 'package:dio/dio.dart'; // import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:logging/logging.dart'; -import 'package:photos/core/configuration.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/core/network.dart'; import 'package:photos/models/billing_plan.dart'; @@ -30,9 +29,7 @@ class BillingService { static final BillingService instance = BillingService._privateConstructor(); final _logger = Logger("BillingService"); - final _dio = Network.instance.getDio(); final _enteDio = Network.instance.enteDio; - final _config = Configuration.instance; bool _isOnSubscriptionPage = false; @@ -69,23 +66,16 @@ class BillingService { } Future getBillingPlans() { - _future ??= (_config.isLoggedIn() - ? _fetchPublicBillingPlans() - : _fetchPrivateBillingPlans()) - .then((response) { + _future ??= _fetchBillingPlans().then((response) { return BillingPlans.fromMap(response.data); }); return _future; } - Future> _fetchPrivateBillingPlans() { + Future> _fetchBillingPlans() { return _enteDio.get("/billing/user-plans/"); } - Future> _fetchPublicBillingPlans() { - return _dio.get(_config.getHttpEndpoint() + "/billing/plans/v2"); - } - Future verifySubscription( final productID, final verificationData, { diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 71a03b9bc..ae7e8eed0 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -38,7 +38,8 @@ class UpdateService { return _prefs.setInt(changeLogVersionKey, currentChangeLogVersion); } - Future resetChangeLog() { + Future resetChangeLog() async { + await _prefs.remove("userNotify.passwordReminderFlag"); return _prefs.remove(changeLogVersionKey); } diff --git a/lib/services/user_remote_flag_service.dart b/lib/services/user_remote_flag_service.dart index b850deae8..0c10150bf 100644 --- a/lib/services/user_remote_flag_service.dart +++ b/lib/services/user_remote_flag_service.dart @@ -20,6 +20,8 @@ class UserRemoteFlagService { UserRemoteFlagService._privateConstructor(); static const String recoveryVerificationFlag = "recoveryKeyVerified"; + static const String _passwordReminderFlag = "userNotify" + ".passwordReminderFlag"; static const String needRecoveryKeyVerification = "needRecoveryKeyVerification"; @@ -27,6 +29,20 @@ class UserRemoteFlagService { _prefs = await SharedPreferences.getInstance(); } + bool showPasswordReminder() { + if (Platform.isAndroid) { + return false; + } + return !_prefs.containsKey(_passwordReminderFlag); + } + + Future stopPasswordReminder() async { + if (Platform.isAndroid) { + return Future.value(true); + } + return _prefs.setBool(_passwordReminderFlag, true); + } + bool shouldShowRecoveryVerification() { if (!_prefs.containsKey(needRecoveryKeyVerification)) { // fetch the status from remote @@ -46,14 +62,13 @@ class UserRemoteFlagService { // recovery key in the past or not. This helps in avoid showing the same // prompt to the user on re-install or signing into a different device Future markRecoveryVerificationAsDone() async { - await _updateKeyValue(recoveryVerificationFlag, true.toString()); + await _updateKeyValue(_passwordReminderFlag, true.toString()); await _prefs.setBool(needRecoveryKeyVerification, false); } Future _refreshRecoveryVerificationFlag() async { _logger.finest('refresh recovery key verification flag'); - final remoteStatusValue = - await _getValue(recoveryVerificationFlag, "false"); + final remoteStatusValue = await _getValue(_passwordReminderFlag, "false"); final bool isNeedVerificationFlagSet = _prefs.containsKey(needRecoveryKeyVerification); if (remoteStatusValue.toLowerCase() == "true") { diff --git a/lib/ui/home/landing_page_widget.dart b/lib/ui/home/landing_page_widget.dart index e3281ce44..88c34faf1 100644 --- a/lib/ui/home/landing_page_widget.dart +++ b/lib/ui/home/landing_page_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/services/update_service.dart'; +import 'package:photos/services/user_remote_flag_service.dart'; import 'package:photos/ui/account/email_entry_page.dart'; import 'package:photos/ui/account/login_page.dart'; import 'package:photos/ui/account/password_entry_page.dart'; @@ -154,6 +155,7 @@ class _LandingPageWidgetState extends State { void _navigateToSignUpPage() { UpdateService.instance.hideChangeLog().ignore(); + UserRemoteFlagService.instance.stopPasswordReminder().ignore(); Widget page; if (Configuration.instance.getEncryptedToken() == null) { page = const EmailEntryPage(); @@ -181,6 +183,7 @@ class _LandingPageWidgetState extends State { void _navigateToSignInPage() { UpdateService.instance.hideChangeLog().ignore(); + UserRemoteFlagService.instance.stopPasswordReminder().ignore(); Widget page; if (Configuration.instance.getEncryptedToken() == null) { page = const LoginPage(); diff --git a/lib/ui/home_widget.dart b/lib/ui/home_widget.dart index 7060d7d19..b5fafbf60 100644 --- a/lib/ui/home_widget.dart +++ b/lib/ui/home_widget.dart @@ -25,6 +25,7 @@ import 'package:photos/models/selected_files.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/services/update_service.dart'; +import 'package:photos/services/user_remote_flag_service.dart'; import 'package:photos/services/user_service.dart'; import 'package:photos/states/user_details_state.dart'; import 'package:photos/theme/colors.dart'; @@ -41,6 +42,7 @@ import 'package:photos/ui/home/landing_page_widget.dart'; import 'package:photos/ui/home/preserve_footer_widget.dart'; import 'package:photos/ui/home/start_backup_hook_widget.dart'; import 'package:photos/ui/loading_photos_widget.dart'; +import 'package:photos/ui/notification/prompts/password_reminder.dart'; import 'package:photos/ui/notification/update/change_log_page.dart'; import 'package:photos/ui/settings/app_update_dialog.dart'; import 'package:photos/ui/settings_page.dart'; @@ -318,6 +320,10 @@ class _HomeWidgetState extends State { if (!LocalSyncService.instance.hasCompletedFirstImport()) { return const LoadingPhotosWidget(); } + + if (UserRemoteFlagService.instance.showPasswordReminder()) { + return const PasswordReminder(); + } if (_sharedFiles != null && _sharedFiles.isNotEmpty) { ReceiveSharingIntent.reset(); return CreateCollectionPage(null, _sharedFiles); diff --git a/lib/ui/notification/prompts/password_reminder.dart b/lib/ui/notification/prompts/password_reminder.dart new file mode 100644 index 000000000..f054a5acd --- /dev/null +++ b/lib/ui/notification/prompts/password_reminder.dart @@ -0,0 +1,371 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/ente_theme_data.dart'; +import 'package:photos/services/local_authentication_service.dart'; +import 'package:photos/services/user_remote_flag_service.dart'; +import 'package:photos/theme/colors.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/account/password_entry_page.dart'; +import 'package:photos/ui/common/gradient_button.dart'; +import 'package:photos/ui/home_widget.dart'; +import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/utils/navigation_util.dart'; + +class PasswordReminder extends StatefulWidget { + const PasswordReminder({Key? key}) : super(key: key); + + @override + State createState() => _PasswordReminderState(); +} + +class _PasswordReminderState extends State { + final _passwordController = TextEditingController(); + final Logger _logger = Logger((_PasswordReminderState).toString()); + bool _password2Visible = false; + bool _incorrectPassword = false; + + Future _verifyRecoveryKey() async { + final dialog = createProgressDialog(context, "Verifying password..."); + await dialog.show(); + try { + final String inputKey = _passwordController.text; + await Configuration.instance.verifyPassword(inputKey); + await dialog.hide(); + UserRemoteFlagService.instance.stopPasswordReminder().ignore(); + // todo: change this as per figma once the component is ready + await showErrorDialog( + context, + "Password verified", + "Great! Thank you for verifying.\n" + "\nPlease" + " remember to keep your recovery key safely backed up.", + ); + + unawaited( + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ), + ); + } catch (e, s) { + _logger.severe("failed to verify password", e, s); + await dialog.hide(); + _incorrectPassword = true; + if (mounted) { + setState(() => {}); + } + } + } + + Future _onChangePasswordClick() async { + try { + final hasAuthenticated = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + "Please authenticate to change your password", + ); + if (hasAuthenticated) { + UserRemoteFlagService.instance.stopPasswordReminder().ignore(); + await routeToPage( + context, + const PasswordEntryPage( + mode: PasswordEntryMode.update, + ), + forceCustomPageRoute: true, + ); + unawaited( + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomeWidget(); + }, + ), + (route) => false, + ), + ); + } + } catch (e) { + showGenericErrorDialog(context); + return; + } + } + + Future _onSkipClick() async { + final enteTextTheme = getEnteTextTheme(context); + final enteColor = getEnteColorScheme(context); + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "You will not be able to access your photos if you forget " + "your password.\n\nIf you do not remember your password, " + "now is a good time to change it.", + style: enteTextTheme.body.copyWith( + color: enteColor.textMuted, + ), + ), + const Padding(padding: EdgeInsets.all(8)), + SizedBox( + width: double.infinity, + height: 52, + child: OutlinedButton( + style: Theme.of(context).outlinedButtonTheme.style?.copyWith( + textStyle: MaterialStateProperty.resolveWith( + (Set states) { + return enteTextTheme.bodyBold; + }, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + + _onChangePasswordClick(); + }, + child: const Text( + "Change password", + ), + ), + ), + const Padding(padding: EdgeInsets.all(8)), + SizedBox( + width: double.infinity, + height: 52, + child: OutlinedButton( + style: Theme.of(context).outlinedButtonTheme.style?.copyWith( + textStyle: MaterialStateProperty.resolveWith( + (Set states) { + return enteTextTheme.bodyBold; + }, + ), + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return enteColor.fillFaint; + }, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.defaultTextColor; + }, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + child: Text( + "Cancel", + style: enteTextTheme.bodyBold, + ), + ), + ) + ], + ); + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: enteColor.backgroundElevated, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.report_outlined, + size: 36, + color: getEnteColorScheme(context).strokeBase, + ), + ], + ), + content: content, + ); + }, + barrierColor: enteColor.backdropBaseMute, + ); + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final enteTheme = Theme.of(context).colorScheme.enteTheme; + final List actions = []; + actions.add( + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: 1, + child: SizedBox( + width: 120, + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.report_outlined, + color: warning500, + size: 20, + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 6)), + Text( + "Skip", + style: getEnteTextTheme(context) + .bodyBold + .copyWith(color: warning500), + ), + ], + ), + ), + ), + ]; + }, + onSelected: (value) async { + _onSkipClick(); + }, + ), + ); + + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: null, + automaticallyImplyLeading: false, + actions: actions, + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + 'Password reminder', + style: enteTheme.textTheme.h3Bold, + ), + Text( + Configuration.instance.getEmail()!, + style: enteTheme.textTheme.small.copyWith( + color: enteTheme.colorScheme.textMuted, + ), + ), + ], + ), + ), + const SizedBox(height: 18), + Text( + "Enter your password to ensure you remember it." + "\n\nThe developer account we use to publish ente on App Store will change in the next version, so you will need to login again when the next version is released.", + style: enteTheme.textTheme.small + .copyWith(color: enteTheme.colorScheme.textMuted), + ), + const SizedBox(height: 24), + TextFormField( + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + filled: true, + hintText: "Password", + suffixIcon: IconButton( + icon: Icon( + _password2Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password2Visible = !_password2Visible; + }); + }, + ), + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + style: const TextStyle( + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()], + ), + controller: _passwordController, + autofocus: false, + autocorrect: false, + obscureText: !_password2Visible, + keyboardType: TextInputType.visiblePassword, + onChanged: (_) { + _incorrectPassword = false; + setState(() {}); + }, + ), + _incorrectPassword + ? const SizedBox(height: 2) + : const SizedBox.shrink(), + _incorrectPassword + ? Align( + alignment: Alignment.centerLeft, + child: Text( + "Incorrect password", + style: enteTheme.textTheme.small.copyWith( + color: enteTheme.colorScheme.warning700, + ), + ), + ) + : const SizedBox.shrink(), + const SizedBox(height: 12), + Expanded( + child: Container( + alignment: Alignment.bottomCenter, + width: double.infinity, + padding: const EdgeInsets.fromLTRB(0, 12, 0, 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GradientButton( + onTap: _verifyRecoveryKey, + text: "Verify", + ), + const SizedBox(height: 8), + ], + ), + ), + ), + const SizedBox(height: 20) + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/utils/date_time_util.dart b/lib/utils/date_time_util.dart index 6df7c77e4..a06d31bd0 100644 --- a/lib/utils/date_time_util.dart +++ b/lib/utils/date_time_util.dart @@ -45,7 +45,7 @@ Map _days = { 7: "Sun", }; -final currentYear = int.parse(DateTime.now().year.toString()); +final currentYear = DateTime.now().year; const searchStartYear = 1970; //Jun 2022 @@ -267,30 +267,18 @@ bool isValidDate({ return true; } -@Deprecated("Use parseDateTimeV2 ") -DateTime? parseDateFromFileName(String fileName) { - if (fileName.startsWith('IMG-') || fileName.startsWith('VID-')) { -// Whatsapp media files - return DateTime.tryParse(fileName.split('-')[1]); - } else if (fileName.startsWith("Screenshot_")) { -// Screenshots on droid - return DateTime.tryParse( - (fileName).replaceAll('Screenshot_', '').replaceAll('-', 'T'), - ); - } else { - return DateTime.tryParse( - (fileName) - .replaceAll("IMG_", "") - .replaceAll("VID_", "") - .replaceAll("DCIM_", "") - .replaceAll("_", " "), - ); - } -} - final RegExp exp = RegExp('[\\.A-Za-z]*'); -DateTime? parseDateTimeFromFileNameV2(String fileName) { +DateTime? parseDateTimeFromFileNameV2( + String fileName, { + /* to avoid parsing incorrect date time from the filename, the max and min + year limits the chances of parsing incorrect date times + */ + int minYear = 1990, + int? maxYear, +}) { + // add next year to avoid corner cases for 31st Dec + maxYear ??= currentYear + 1; String val = fileName.replaceAll(exp, ''); if (val.isNotEmpty && !isNumeric(val[0])) { val = val.substring(1, val.length); @@ -319,7 +307,10 @@ DateTime? parseDateTimeFromFileNameV2(String fileName) { if (kDebugMode && result == null) { debugPrint("Failed to parse $fileName dateTime from $valForParser"); } - return result; + if (result != null && result.year >= minYear && result.year <= maxYear) { + return result; + } + return null; } bool isNumeric(String? s) { diff --git a/lib/utils/file_uploader.dart b/lib/utils/file_uploader.dart index 00a1206a1..da4b0dabc 100644 --- a/lib/utils/file_uploader.dart +++ b/lib/utils/file_uploader.dart @@ -153,7 +153,7 @@ class FileUploader { } return CollectionsService.instance .addToCollection(collectionID, [uploadedFile]).then((aVoid) { - return uploadedFile; + return uploadedFile as File; }); }); } diff --git a/test/utils/date_time_util_test.dart b/test/utils/date_time_util_test.dart index 2491282cc..0924721fb 100644 --- a/test/utils/date_time_util_test.dart +++ b/test/utils/date_time_util_test.dart @@ -9,14 +9,14 @@ void main() { "IMG-20221109-WA0000", '''Screenshot_20220807-195908_Firefox''', '''Screenshot_20220507-195908''', - "2019-02-18 16.00.12-DCMX.png", + "2022-02-18 16.00.12-DCMX.png", "20221107_231730", "2020-11-01 02.31.02", "IMG_20210921_144423", "2019-10-31 155703", "IMG_20210921_144423_783", "Screenshot_2022-06-21-16-51-29-164_newFormat.heic", - "Screenshot 20221106 211633.com.google.android.apps.nbu.paisa.user.jpg" + "Screenshot 20221106 211633.com.google.android.apps.nbu.paisa.user.jpg", ]; for (String val in validParsing) { final parsedValue = parseDateTimeFromFileNameV2(val); @@ -31,6 +31,21 @@ void main() { } }); + test("test invalid datetime parsing", () { + final List badParsing = ["Snapchat-431959199.mp4."]; + for (String val in badParsing) { + final parsedValue = parseDateTimeFromFileNameV2(val); + expect( + parsedValue == null, + true, + reason: "parsing should have failed $val", + ); + if (kDebugMode) { + debugPrint("Parsed $val as ${parsedValue?.toIso8601String()}"); + } + } + }); + test("verify constants", () { final date = DateTime.fromMicrosecondsSinceEpoch(jan011981Time).toUtc(); expect(