diff --git a/packages/dart/npt_flutter/lib/constants.dart b/packages/dart/npt_flutter/lib/constants.dart index 2adbfd12b..063c746cc 100644 --- a/packages/dart/npt_flutter/lib/constants.dart +++ b/packages/dart/npt_flutter/lib/constants.dart @@ -1,10 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart' show dotenv; class Constants { + static bool dotenvLoaded = false; + static Future loadDotenv() async { + if (dotenvLoaded) return; + try { + await dotenv.load(); + dotenvLoaded = true; + } catch (_) { + dotenvLoaded = false; + } + } + static String? get namespace => 'noports'; - // TODO: issue & secure API key properly - static String? get appAPIKey => 'asdf'; + + static Future get appAPIKey async { + await loadDotenv(); + return dotenv.env["APP_API_KEY"]; + } static const pngIconDark = 'assets/noports-icon64-dark.png'; static const icoIconDark = 'assets/noports-icon64-dark.ico'; diff --git a/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart b/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart index 1f6bd41ce..d0c74fce1 100644 --- a/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart +++ b/packages/dart/npt_flutter/lib/features/favorite/bloc/favorite_bloc.dart @@ -17,8 +17,7 @@ class FavoriteBloc extends LoggingBloc { void clearAll() => emit(const FavoritesInitial()); - FutureOr _onLoad( - FavoriteLoadEvent event, Emitter emit) async { + FutureOr _onLoad(FavoriteLoadEvent event, Emitter emit) async { emit(const FavoritesLoading()); Map? favs; @@ -35,8 +34,7 @@ class FavoriteBloc extends LoggingBloc { emit(FavoritesLoaded(favs.values)); } - FutureOr _onAdd( - FavoriteAddEvent event, Emitter emit) async { + FutureOr _onAdd(FavoriteAddEvent event, Emitter emit) async { if (state is! FavoritesLoaded) { return; } @@ -49,17 +47,13 @@ class FavoriteBloc extends LoggingBloc { } catch (_) {} } - FutureOr _onRemove( - FavoriteRemoveEvent event, Emitter emit) async { + FutureOr _onRemove(FavoriteRemoveEvent event, Emitter emit) async { if (state is! FavoritesLoaded) { return; } emit(FavoritesLoaded( - (state as FavoritesLoaded) - .favorites - .toSet() - .difference(event.toRemove.toSet()), + (state as FavoritesLoaded).favorites.toSet().difference(event.toRemove.toSet()), )); try { diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart new file mode 100644 index 000000000..5f2a546d9 --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/activate_util.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_auth/at_auth.dart'; +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart' hide Response; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +// ignore: implementation_imports +import 'package:at_onboarding_flutter/src/utils/at_onboarding_response_status.dart'; +import 'package:at_server_status/at_server_status.dart'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; + +// Type returned from a method below +export 'package:at_onboarding_flutter/src/utils/at_onboarding_response_status.dart'; + +const apiBase = '/api/app/v3'; + +enum NoPortsActivateApiEndpoints { + login('$apiBase/authenticate/atsign'), + validate('$apiBase/authenticate/atsign/activate'); + + final String path; + const NoPortsActivateApiEndpoints(this.path); +} + +class ActivateUtil { + final String registrarUrl; + final String apiKey; + late final IOClient _http; + + ActivateUtil({required this.registrarUrl, required this.apiKey}) { + var innerClient = HttpClient(); + innerClient.badCertificateCallback = (X509Certificate cert, String host, int port) => true; + _http = IOClient(); + } + + Future registrarApiRequest(NoPortsActivateApiEndpoints endpoint, Map data) async { + Uri url = Uri.https(registrarUrl, endpoint.path); + + return _http.post( + url, + body: jsonEncode(data), + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + ); + } + + Future<({String? cramkey, String? errorMessage})> verifyActivation( + {required String atsign, required String otp}) async { + var res = await registrarApiRequest( + NoPortsActivateApiEndpoints.validate, + { + 'atsign': atsign, + 'otp': otp, + }, + ); + if (res.statusCode != 200) { + return ( + errorMessage: AtOnboardingLocalizations.current.error_server_unavailable, + cramkey: null, + ); + } + var payload = jsonDecode(res.body); + if (payload["message"] != "Verified") { + // The toString is for typesafety & to prevent unexpected crashes + return (errorMessage: payload["message"].toString(), cramkey: null); + } + String cramkey = payload["cramkey"]?.split(':').last ?? ''; + return (cramkey: cramkey, errorMessage: null); + } + + Future onboardFromCramKey({ + required String atsign, + required String cramkey, + required AtOnboardingConfig config, + }) async { + try { + atsign = atsign.startsWith('@') ? atsign : '@$atsign'; + OnboardingService onboardingService = OnboardingService.getInstance(); + bool isExist = await onboardingService.isExistingAtsign(atsign); + if (isExist) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.error_atSign_activated, + ); + } + + //Delay for waiting for ServerStatus change to teapot when activating an atsign + await Future.delayed(const Duration(seconds: 10)); + + config.atClientPreference.cramSecret = cramkey; + onboardingService.setAtClientPreference = config.atClientPreference; + + onboardingService.setAtsign = atsign; + AtOnboardingRequest req = AtOnboardingRequest(atsign); + var res = await onboardingService.onboard( + cramSecret: cramkey, + atOnboardingRequest: req, + ); + + if (res) { + int round = 1; + ServerStatus? atSignStatus = await onboardingService.checkAtSignServerStatus(atsign); + while (atSignStatus != ServerStatus.activated) { + if (round > 10) { + break; + } + await Future.delayed(const Duration(seconds: 3)); + round++; + atSignStatus = await onboardingService.checkAtSignServerStatus(atsign); + } + + if (atSignStatus == ServerStatus.teapot) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_atSign_unreachable, + ); + } else if (atSignStatus == ServerStatus.activated) { + return AtOnboardingResult.success(atsign: atsign); + } + } + + return AtOnboardingResult.error(message: AtOnboardingLocalizations.current.error_authenticated_failed); + } catch (e) { + if (e == AtOnboardingResponseStatus.authFailed) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.error_authenticated_failed, + ); + } else if (e == AtOnboardingResponseStatus.serverNotReached) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_atSign_unreachable, + ); + } else if (e == AtOnboardingResponseStatus.timeOut) { + return AtOnboardingResult.error( + message: AtOnboardingLocalizations.current.msg_response_time_out, + ); + } + return AtOnboardingResult.error(message: e.toString()); + } + } +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart new file mode 100644 index 000000000..623904723 --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/onboarding_util.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart' show AtKeysFileUploadService, FileUploadStatus; +import 'package:at_server_status/at_server_status.dart'; + +// These types are returned from methods in this class so exports are provided for ease of use +export 'package:at_onboarding_flutter/at_onboarding_services.dart' show FileUploadStatus; +export 'package:at_server_status/at_server_status.dart' show AtStatus; + +class NoPortsOnboardingUtil { + /// The upload service will be created when the first time [uploadAtKeysFile] is called + AtKeysFileUploadService? _uploadService; + AtServerStatus? _atServerStatus; + AtOnboardingConfig config; + NoPortsOnboardingUtil(this.config); + + /// A method to check whether an atSign has been activated or not + Future atServerStatus(String atSign) async { + _atServerStatus ??= + AtStatusImpl(rootUrl: config.atClientPreference.rootDomain, rootPort: config.atClientPreference.rootPort); + return _atServerStatus!.get(atSign); + } + + /// Upload an atKeys file, returning a stream with the progress so we can update the ui accordingly. + /// Example implementation: + /// https://github.com/atsign-foundation/at_widgets/blob/b4006854fa93c21eeb5bcea41044787bdf0f6f32/packages/at_onboarding_flutter/lib/src/screen/at_onboarding_home_screen.dart#L659 + Stream uploadAtKeysFile(String? atSign) { + _uploadService ??= AtKeysFileUploadService(config: config); + return _uploadService!.uploadKeyFile(atSign); + } + + // TODO: implement APKAM onboarding +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart b/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart index 4e9ebeee8..baa288ea7 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/util/post_onboard.dart @@ -9,7 +9,7 @@ Future postOnboard(String atSign, String rootDomain) async { status: OnboardingStatus.onboarded, ); // Start loading application data in the background as soon as we have an atClient + App.navState.currentContext?.read().add(const FavoriteLoadEvent()); App.navState.currentContext?.read().add(const ProfileListLoadEvent()); App.navState.currentContext?.read().add(const SettingsLoadEvent()); - App.navState.currentContext?.read().add(const FavoriteLoadEvent()); } diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart new file mode 100644 index 000000000..c4cfacefb --- /dev/null +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/activate_atsign_dialog.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; + +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:npt_flutter/features/onboarding/util/activate_util.dart'; +import 'package:npt_flutter/widgets/spinner.dart'; +import 'package:pin_code_fields/pin_code_fields.dart'; + +class ActivateAtsignDialog extends StatefulWidget { + final pinLength = 4; + final String registrarUrl; + final String apiKey; + final String atSign; + final AtOnboardingConfig config; + const ActivateAtsignDialog({ + super.key, + required this.atSign, + required this.apiKey, + required this.config, + required this.registrarUrl, + }); + + @override + State createState() => _ActivateAtsignDialogState(); +} + +enum ActivationStatus { + preparing, // contacting the registrar to send an OTP + otpWait, // Waiting for user to enter OTP + activating, // OTP received, trying to activate +} + +class _ActivateAtsignDialogState extends State { + late final ActivateUtil util; + ActivationStatus status = ActivationStatus.preparing; + TextEditingController pinController = TextEditingController(); + FocusNode pinFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + util = ActivateUtil( + registrarUrl: widget.registrarUrl, + apiKey: widget.apiKey, + ); + _getPinCode(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Center( + child: switch (status) { + // TODO localize + ActivationStatus.preparing => const Text("Preparing for activation"), + ActivationStatus.otpWait => const Text("Please enter the OTP from your email"), + ActivationStatus.activating => const Text("Activating"), + }, + ), + content: SizedBox( + height: 80, + width: 400, + child: switch (status) { + ActivationStatus.preparing || ActivationStatus.activating => const Spinner(), + ActivationStatus.otpWait => SizedBox( + height: 80, + child: Column( + children: [ + // TODO localize + PinCodeTextField( + focusNode: pinFocusNode, + appContext: context, + length: widget.pinLength, + controller: pinController, + onChanged: (value) { + setState(() { + pinController.text = value.toUpperCase(); + }); + }, + // Styling + animationType: AnimationType.fade, + pinTheme: PinTheme( + shape: PinCodeFieldShape.box, + borderRadius: BorderRadius.circular(5), + fieldHeight: 50, + fieldWidth: 40, + activeFillColor: Colors.white, + inactiveFillColor: Colors.white, + ), + cursorColor: Colors.black, + animationDuration: const Duration(milliseconds: 300), + enableActiveFill: true, + keyboardType: TextInputType.number, + boxShadows: const [ + BoxShadow( + offset: Offset(0, 1), + color: Colors.black12, + blurRadius: 10, + ) + ], + beforeTextPaste: (text) => true, + ), + ], + ), + ), + }, + ), + actions: switch (status) { + ActivationStatus.preparing => [cancelButton], + ActivationStatus.otpWait => [cancelButton, resendPinButton, confirmPinButton], + // Don't allow the user to cancel activate as this opens up a bunch of + // edge cases around navigation and onboarding state + ActivationStatus.activating => [], + }, + ); + } + + Future _getPinCode() async { + var res = await util.registrarApiRequest( + NoPortsActivateApiEndpoints.login, + {'atsign': widget.atSign}, + ); + + if (res.statusCode == 200 && jsonDecode(res.body)["message"] == "Sent Successfully") { + setState(() { + status = ActivationStatus.otpWait; + }); + } else { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.red, + // TODO localize + content: Text( + "Failed to request an OTP, try resending, or contact support if the issue persists", + ), + ), + ); + } + if (!pinFocusNode.hasFocus) { + pinFocusNode.requestFocus(); + } + } + + Widget get cancelButton => TextButton( + key: const Key("NoPortsActivateCancelButton"), + // TODO localize + child: const Text("Cancel"), + onPressed: () { + Navigator.of(context).pop(AtOnboardingResult.cancelled()); + }, + ); + + Widget get resendPinButton => TextButton( + key: const Key("NoPortsActivateResendButton"), + onPressed: _getPinCode, + // TODO localize + child: const Text("Resend Pin"), + ); + + Widget get confirmPinButton => TextButton( + key: const Key("NoPortsActivateConfirmButton"), + onPressed: pinController.text.length < 4 + ? null // disable the button when pin isn't complete + : () async { + setState(() { + status = ActivationStatus.activating; + }); + + var (:cramkey, :errorMessage) = await util.verifyActivation( + atsign: widget.atSign, + otp: pinController.text, + ); + + if (cramkey == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + backgroundColor: Colors.red, + content: Text( + // TODO localize + "Failed to verify the OTP with the activation server, please try again. Contact support if the issue persists", + ), + ), + ); + setState(() { + pinController = TextEditingController(); // controller was disposed, make a new one + status = ActivationStatus.otpWait; + }); + return; + } + + var result = await util.onboardFromCramKey( + atsign: widget.atSign, + cramkey: cramkey, + config: widget.config, + ); + + if (!mounted) return; + Navigator.of(context).pop(result); + }, + // TODO localize + child: const Text("Confirm"), + ); +} diff --git a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart index 84ce6704a..ef34f9081 100644 --- a/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart +++ b/packages/dart/npt_flutter/lib/features/onboarding/widgets/onboarding_button.dart @@ -1,18 +1,22 @@ import 'dart:developer'; -import 'dart:io'; import 'package:at_contacts_flutter/at_contacts_flutter.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; -import 'package:at_onboarding_flutter/screen/at_onboarding_home_screen.dart'; +import 'package:at_onboarding_flutter/at_onboarding_screens.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +// ignore: implementation_imports +import 'package:at_onboarding_flutter/src/utils/at_onboarding_app_constants.dart'; +import 'package:at_server_status/at_server_status.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:npt_flutter/constants.dart'; import 'package:npt_flutter/features/onboarding/onboarding.dart'; import 'package:npt_flutter/features/onboarding/util/atsign_manager.dart'; +import 'package:npt_flutter/features/onboarding/util/onboarding_util.dart'; +import 'package:npt_flutter/features/onboarding/widgets/activate_atsign_dialog.dart'; import 'package:npt_flutter/features/onboarding/widgets/onboarding_dialog.dart'; import 'package:npt_flutter/routes.dart'; -import 'package:npt_flutter/util/language.dart'; import 'package:path_provider/path_provider.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; @@ -37,130 +41,294 @@ class OnboardingButton extends StatefulWidget { State createState() => _OnboardingButtonState(); } +enum _OnboardingButtonStatus { + ready, + picking, + processingFile, +} + class _OnboardingButtonState extends State { - Future onboard({String? atsign, required String rootDomain, bool isFromInitState = false}) async { + _OnboardingButtonStatus buttonStatus = _OnboardingButtonStatus.ready; + + // TODO: when an atSign is being onboarded + // make this button go into a loading state or show some visual indication + // for progress for the loading screen + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return switch (buttonStatus) { + _OnboardingButtonStatus.ready => ElevatedButton.icon( + onPressed: () async { + bool shouldOnboard = await selectAtsign(); + if (shouldOnboard && context.mounted) { + var atsignInformation = context.read().state; + onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); + } + }, + icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), + label: Text( + strings.getStarted, + ), + iconAlignment: IconAlignment.end, + ), + // TODO: localize + _OnboardingButtonStatus.picking => const Text("Waiting for file to be picked"), + // TODO: localize + _OnboardingButtonStatus.processingFile => const Text("Processing file"), + }; + } + + Future selectAtsign() async { + var options = await getAtsignEntries(); + if (!mounted) return false; + + final cubit = context.read(); + String atsign = cubit.state.atSign; + String? rootDomain = cubit.state.rootDomain; + + if (options.isEmpty) { + atsign = ""; + } else if (atsign.isEmpty) { + atsign = options.keys.first; + } + if (options.keys.contains(atsign)) { + rootDomain = options[atsign]?.rootDomain; + } else { + rootDomain = Constants.getRootDomains(context).keys.first; + } + + cubit.setState(atSign: atsign, rootDomain: rootDomain); + final results = await showDialog( + context: context, + builder: (BuildContext context) => OnboardingDialog(options: options), + ); + return results ?? false; + } + + Future onboard({required String atsign, required String rootDomain, bool isFromInitState = false}) async { var atSigns = await KeyChainManager.getInstance().getAtSignListFromKeychain(); + var apiKey = await Constants.appAPIKey; var config = AtOnboardingConfig( atClientPreference: await loadAtClientPreference(rootDomain), rootEnvironment: RootEnvironment.Production, domain: rootDomain, - appAPIKey: Constants.appAPIKey, + appAPIKey: apiKey, ); + var util = NoPortsOnboardingUtil(config); AtOnboardingResult? onboardingResult; - if (!atSigns.contains(atsign)) { - // This is a hack. - // Ideally it should be possible to skip the home screen in onboarding - // and go straight to either of the following (based on current atSign status): - // A) opening the file picker - // B) activating the atSign - // But unfortunately that code is SO coupled to the widget that it is really - // not worth the effort to fix right now. - // - // Assumptions made in at_onboarding_flutter which have caused this problem: - // if [atsign] is non-null the atSign already exists in the keychain - // thus any atsign that isn't in the keychain isn't handled if explicitly passed... - // this means there is no edge case handling for new or unactivated atSigns - // nor for atSigns that are activated but not in the keychain... - // - // Given that we are working on new user flows, I'm not going to waste countless - // hours for this tiny UX fix - - // TODO: fix localizations - await AtOnboardingLocalizations.load(LanguageUtil.getLanguageFromLocale(Locale(Platform.localeName)).locale); - onboardingResult = await Navigator.push( - // ignore: use_build_context_synchronously - context, - MaterialPageRoute( - builder: (BuildContext context) { - return AtOnboardingHomeScreen( - config: config, - isFromIntroScreen: false, - ); - }, - ), - ); - } else { + + if (!mounted) return; + + if (atSigns.contains(atsign)) { onboardingResult = await AtOnboarding.onboard( atsign: atsign, - // ignore: use_build_context_synchronously context: context, - config: config, + config: util.config, ); + } else { + onboardingResult = await handleAtsignByStatus(atsign, util); } + setState(() { + buttonStatus = _OnboardingButtonStatus.ready; + }); + if (!mounted) return; + switch (onboardingResult?.status ?? AtOnboardingResultStatus.cancel) { + case AtOnboardingResultStatus.success: + await initializeContactsService(rootDomain: rootDomain); + postOnboard(onboardingResult!.atsign!, rootDomain); + final result = await saveAtsignInformation( + AtsignInformation( + atSign: onboardingResult.atsign!, + rootDomain: rootDomain, + ), + ); + log('atsign result is:$result'); - if (mounted) { - switch (onboardingResult?.status ?? AtOnboardingResultStatus.cancel) { - case AtOnboardingResultStatus.success: - await initializeContactsService(rootDomain: rootDomain); - postOnboard(onboardingResult!.atsign!, rootDomain); - final result = - await saveAtsignInformation(AtsignInformation(atSign: onboardingResult.atsign!, rootDomain: rootDomain)); - log('atsign result is:$result'); - - if (mounted) { - Navigator.of(context).pushReplacementNamed(Routes.dashboard); - } - break; - case AtOnboardingResultStatus.error: - if (isFromInitState) break; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: Colors.red, - content: Text(AppLocalizations.of(context)!.onboardingError), + if (!mounted) return; + Navigator.of(context).pushReplacementNamed(Routes.dashboard); + + break; + case AtOnboardingResultStatus.error: + if (isFromInitState) break; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + content: Text( + onboardingResult?.message ?? AppLocalizations.of(context)!.onboardingError, ), + ), + ); + break; + case AtOnboardingResultStatus.cancel: + break; + } + } + + Future handleAtsignByStatus(String atsign, NoPortsOnboardingUtil util) async { + AtStatus status; + try { + status = await util.atServerStatus(atsign); + } catch (_) { + return AtOnboardingResult.error( + // TODO localize + message: "Failed to retrieve the atserver status, make sure you have a stable internet connection", + ); + } + AtOnboardingResult? result; + if (!mounted) return null; + + switch (status.status()) { + // Automatically start activation with the already entered atSign + case AtSignStatus.teapot: + final apiKey = await Constants.appAPIKey; + + if (apiKey == null) { + result = AtOnboardingResult.error( + // TODO localize + message: "The atSign you have requested, doesn't exist in this root domain", ); break; - case AtOnboardingResultStatus.cancel: + } + AtOnboardingConstants.setApiKey(apiKey); + AtOnboardingConstants.rootDomain = util.config.atClientPreference.rootDomain; + // TODO: localize locale - right now hardcoded to english + await AtOnboardingLocalizations.load(const Locale("en")); + if (!mounted) return null; + Map apis = { + "root.atsign.org": "my.atsign.com", + "root.atsign.wtf": "my.atsign.wtf", + }; + var regUrl = apis[util.config.atClientPreference.rootDomain]; + if (regUrl == null) { + result ??= AtOnboardingResult.error( + // TODO: localize + message: "The specified root domain is not supported by automatic activation.", + ); break; - } + } + result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ActivateAtsignDialog( + atSign: atsign, + apiKey: apiKey, + config: util.config, + registrarUrl: regUrl, + ), + ); + + if (result is AtOnboardingResult) { + //Update primary atsign after onboard success + if (result.status == AtOnboardingResultStatus.success && result.atsign != null) { + var onboardingService = OnboardingService.getInstance(); + bool res = await onboardingService.changePrimaryAtsign(atsign: result.atsign!); + if (!res) { + result = AtOnboardingResult.error(message: "Failed to switch atSigns after activation"); + } + } + } + // TODO: finalize onboarding + case AtSignStatus.activated: + // NOTE: for now this is hard coded to do atKey file upload + // Later on, we can add the APKAM flow, and will need to make some + // UX decisions about how the user picks which they want to do + Stream statusStream = util.uploadAtKeysFile(atsign); + result = await handleFileUploadStatusStream(statusStream, atsign); + case AtSignStatus.notFound: + result = AtOnboardingResult.error( + // TODO: localize + message: "The atSign you have requested, doesn't exist in this root domain", + ); + case AtSignStatus.unavailable: + result = AtOnboardingResult.error( + // TODO: localize + message: "The atSign is unavailable. Make sure you have pressed \"Activate\" from your dashboard " + "and have a stable internet connection.", + ); + case null: // This case should never happen, treat it as an error + case AtSignStatus.error: + result = AtOnboardingResult.error( + // TODO: localize + message: "Failed to retrieve the atserver status", + ); } + return result; } - Future selectAtsign() async { - var options = await getAtsignEntries(); - if (mounted) { - final cubit = context.read(); - String atsign = cubit.state.atSign; - String? rootDomain = cubit.state.rootDomain; - - if (options.isEmpty) { - atsign = ""; - } else if (atsign.isEmpty) { - atsign = options.keys.first; - } - if (options.keys.contains(atsign)) { - rootDomain = options[atsign]?.rootDomain; - } else { - rootDomain = Constants.getRootDomains(context).keys.first; - } + Future handleFileUploadStatusStream(Stream statusStream, String atsign) async { + AtOnboardingResult? result; + outer: + await for (FileUploadStatus status in statusStream) { + // Don't return from inside this switch other wise the buttonStatus + // won't be reset to ready state + switch (status) { + case ErrorIncorrectKeyFile(): + result = AtOnboardingResult.error( + // TODO: localize + message: "Invalid atKeys file detected", + ); + break outer; + case ErrorAtSignMismatch(): + result = AtOnboardingResult.error( + // TODO: localize + message: "The atKeys file you uploaded did not match the atSign requested", + ); + break outer; + case ErrorFailedFileProcessing(): + result = AtOnboardingResult.error( + // TODO: localize + message: "Failed to process the atKeys file", + ); + break outer; + case ErrorAtServerUnreachable(): + result = AtOnboardingResult.error( + // TODO: localize + message: "Unable to connect to the atServer, make sure you have a stable internet connection", + ); + break outer; + case ErrorAuthFailed(): + result = AtOnboardingResult.error( + // TODO: localize + message: "Authentication failed", + ); + break outer; + case ErrorAuthTimeout(): + result = AtOnboardingResult.error( + // TODO: localize + message: "Authentication timed out", + ); + break outer; + case ErrorPairedAtsign _: + result = AtOnboardingResult.error( + // TODO: localize + message: "The atSign ${status.atSign ?? atsign} is already paired, please contact support.", + ); + break outer; + case FilePickingInProgress(): + setState(() { + buttonStatus = _OnboardingButtonStatus.picking; + }); + break; + case ProcessingAesKeyInProgress(): + setState(() { + buttonStatus = _OnboardingButtonStatus.processingFile; + }); + break; - cubit.setState(atSign: atsign, rootDomain: rootDomain); - final results = await showDialog( - context: context, - builder: (BuildContext context) => OnboardingDialog(options: options), - ); - return results ?? false; - } - return false; - } + // We don't really need to handle these + case FilePickingDone(): + case ProcessingAesKeyDone(): + break; - @override - Widget build(BuildContext context) { - final strings = AppLocalizations.of(context)!; - return ElevatedButton.icon( - onPressed: () async { - bool shouldOnboard = await selectAtsign(); - if (shouldOnboard && context.mounted) { - var atsignInformation = context.read().state; - onboard(atsign: atsignInformation.atSign, rootDomain: atsignInformation.rootDomain); - } - }, - icon: PhosphorIcon(PhosphorIcons.arrowUpRight()), - label: Text( - strings.getStarted, - ), - iconAlignment: IconAlignment.end, - ); + case FilePickingCanceled(): + result = AtOnboardingResult.cancelled(); + break outer; + case FileUploadAuthSuccess _: + result = AtOnboardingResult.success(atsign: status.atSign ?? atsign); + break outer; + } + } + return result; } } diff --git a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart index 237249600..8ada85d79 100644 --- a/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart +++ b/packages/dart/npt_flutter/lib/widgets/custom_text_button.dart @@ -1,6 +1,12 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:at_backupkey_flutter/services/backupkey_service.dart'; import 'package:at_contacts_flutter/services/contact_service.dart'; import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; -import 'package:at_onboarding_flutter/services/onboarding_service.dart'; +import 'package:at_onboarding_flutter/at_onboarding_services.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -123,11 +129,39 @@ class CustomTextButton extends StatelessWidget { // break; case CustomListTileType.backupYourKey: if (context.mounted) { - BackupKeyWidget(atsign: ContactService().currentAtsign).showBackupDialog(context); + var atsign = context.read().getAtSign(); + // Build file data + var aesEncryptedKeys = await BackUpKeyService.getEncryptedKeys(atsign); + var keyString = jsonEncode(aesEncryptedKeys); + final List codeUnits = keyString.codeUnits; + final Uint8List data = Uint8List.fromList(codeUnits); + + // Get file path to write to + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Please select a file to export to:', + fileName: '${atsign}_key.atKeys', + ); + if (outputFile == null) return; + // Create and write the file + try { + var f = File(outputFile); + await f.create(recursive: true); + await f.writeAsBytes(data); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + // TODO localize + content: Text("Failed to save the atKeys file: $e"), + ), + ); + } } break; case CustomListTileType.resetAtsign: final futurePreference = await loadAtClientPreference(rootDomain!); + final apiKey = await Constants.appAPIKey; if (context.mounted) { final result = await AtOnboarding.reset( context: context, @@ -135,7 +169,7 @@ class CustomTextButton extends StatelessWidget { atClientPreference: futurePreference, rootEnvironment: RootEnvironment.Testing, domain: rootDomain, - appAPIKey: Constants.appAPIKey, + appAPIKey: apiKey, ), ); final OnboardingService onboardingService = OnboardingService.getInstance(); diff --git a/packages/dart/npt_flutter/pubspec.lock b/packages/dart/npt_flutter/pubspec.lock index 9a1048c72..c21c06e3a 100644 --- a/packages/dart/npt_flutter/pubspec.lock +++ b/packages/dart/npt_flutter/pubspec.lock @@ -64,7 +64,7 @@ packages: source: hosted version: "2.11.0" at_auth: - dependency: transitive + dependency: "direct main" description: name: at_auth sha256: f4fec32e2a1ca8827604b5e54a7611ddad092c6ba607c138675c1cba5215b038 @@ -72,13 +72,13 @@ packages: source: hosted version: "2.0.7" at_backupkey_flutter: - dependency: transitive + dependency: "direct main" description: name: at_backupkey_flutter - sha256: "91425f34aabec7fc37f61259af690676aae6c1776c2feac7bfea5f7ea7fc3812" + sha256: "674d5ebf8443e0848c86cd14e25c326e33d7fef54a2a9732c4328a138f6c4fa5" url: "https://pub.dev" source: hosted - version: "4.0.15" + version: "4.0.16" at_base2e15: dependency: transitive description: @@ -171,8 +171,8 @@ packages: dependency: "direct main" description: path: "packages/at_onboarding_flutter" - ref: trunk - resolved-ref: "2a32ac2461673e0df16f5de2e24305309a8fcd95" + ref: at_onboarding_flutter_layers + resolved-ref: b4006854fa93c21eeb5bcea41044787bdf0f6f32 url: "https://github.com/atsign-foundation/at_widgets" source: git version: "6.1.9" @@ -193,7 +193,7 @@ packages: source: hosted version: "2.0.14" at_server_status: - dependency: transitive + dependency: "direct main" description: name: at_server_status sha256: "2773fa7c4377802b671f6854863214aabe8ee8cd49be87226352dd14562a5d6b" @@ -630,6 +630,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_keychain: dependency: transitive description: @@ -734,7 +742,7 @@ packages: source: hosted version: "2.2.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 @@ -1101,7 +1109,7 @@ packages: source: hosted version: "2.1.0" pin_code_fields: - dependency: transitive + dependency: "direct main" description: name: pin_code_fields sha256: "4c0db7fbc889e622e7c71ea54b9ee624bb70c7365b532abea0271b17ea75b729" diff --git a/packages/dart/npt_flutter/pubspec.yaml b/packages/dart/npt_flutter/pubspec.yaml index 1bef2600f..0c0d6861b 100644 --- a/packages/dart/npt_flutter/pubspec.yaml +++ b/packages/dart/npt_flutter/pubspec.yaml @@ -29,10 +29,13 @@ environment: # versions available, run `flutter pub outdated`. dependencies: adaptive_theme: ^3.6.0 + at_auth: ^2.0.7 + at_backupkey_flutter: ^4.0.16 at_client_mobile: ^3.2.18 at_contact: ^3.0.8 at_contacts_flutter: ^4.0.15 at_onboarding_flutter: ^6.1.8 + at_server_status: ^1.0.5 at_utils: ^3.0.16 cupertino_icons: ^1.0.8 equatable: ^2.0.5 @@ -40,9 +43,11 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^8.1.6 + flutter_dotenv: ^5.2.1 flutter_localizations: sdk: flutter flutter_svg: ^2.0.10+1 + http: ^1.2.2 intl: any json_annotation: ^4.9.0 json_serializable: ^6.8.0 @@ -52,6 +57,7 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.4 phosphor_flutter: ^2.1.0 + pin_code_fields: ^8.0.1 socket_connector: ^2.3.3 toml: ^0.16.0 tray_manager: ^0.2.3 @@ -79,7 +85,7 @@ dependency_overrides: at_onboarding_flutter: git: url: https://github.com/atsign-foundation/at_widgets - ref: trunk + ref: at_onboarding_flutter_layers path: packages/at_onboarding_flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -101,6 +107,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/ + - .env # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images # For details regarding adding assets from package dependencies, see @@ -145,4 +152,4 @@ flutter_launcher_icons: image_path: "assets/logo.png" windows: generate: true - image_path: "assets/logo.png" \ No newline at end of file + image_path: "assets/logo.png"