Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: biometrics #528

Merged
merged 6 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />


<!-- https://pub.dev/packages/url_launcher -->
Expand Down
29 changes: 15 additions & 14 deletions android/app/src/main/kotlin/io/ion/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import com.banuba.sdk.pe.BanubaPhotoEditor
import com.banuba.sdk.pe.PhotoCreationActivity
import com.banuba.sdk.pe.BanubaPhotoEditor
import com.banuba.sdk.pe.data.PhotoEditorConfig
import dev.fluttercommunity.shake_gesture_android.ShakeGesturePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.File

class MainActivity : FlutterActivity() {
class MainActivity : FlutterFragmentActivity() {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_MENU) {
this.flutterEngine?.plugins?.get(ShakeGesturePlugin::class.java).let { plugin ->
Expand All @@ -39,15 +40,14 @@ class MainActivity : FlutterActivity() {
}

private var exportResult: MethodChannel.Result? = null

private var photoEditorSDK: BanubaPhotoEditor? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appFlutterEngine = requireNotNull(flutterEngine)
GeneratedPluginRegistrant.registerWith(appFlutterEngine)

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

// Set up your MethodChannel here after registration
MethodChannel(
appFlutterEngine.dartExecutor.binaryMessenger,
flutterEngine.dartExecutor.binaryMessenger,
"banubaSdkChannel"
).setMethodCallHandler { call, result ->
// Initialize export result callback to deliver the results back to Flutter
Expand All @@ -61,8 +61,9 @@ class MainActivity : FlutterActivity() {
if (photoEditorSDK == null) {
// The SDK token is incorrect - empty or truncated
result.error(ERR_CODE_SDK_NOT_INITIALIZED, "", null)
} else {
result.success(null)
}
result.success(null)
}

METHOD_START_PHOTO_EDITOR -> {
Expand All @@ -71,15 +72,16 @@ class MainActivity : FlutterActivity() {
result.error(ERR_CODE_SDK_NOT_INITIALIZED, "", null)
} else {
// ✅ The license is active
val imageUrl = call.argument<String>("imagePath") // Get the image URL from Flutter
val imageUrl =
call.argument<String>("imagePath") // Get the image URL from Flutter
if (imageUrl.isNullOrEmpty()) {
result.error("INVALID_ARGUMENT", "Image URL is required", null)
return@setMethodCallHandler
}
val imageUri = Uri.fromFile(File(imageUrl))
val config = PhotoEditorConfig.Builder(this).build();
startActivityForResult(
PhotoCreationActivity.startFromEditor(this, config, imageUri ),
PhotoCreationActivity.startFromEditor(this, config, imageUri),
PHOTO_EDITOR_REQUEST_CODE
)
}
Expand All @@ -103,9 +105,8 @@ class MainActivity : FlutterActivity() {
// You can use Map or JSON to pass custom data for your app.
private fun preparePhotoExportData(result: Intent?): Map<String, Any?> {
val photoUri = result?.getParcelableExtra(PhotoCreationActivity.EXTRA_EXPORTED) as? Uri
val data = mapOf(
return mapOf(
ARG_EXPORTED_PHOTO_FILE to photoUri?.toString()
)
return data
}
}
13 changes: 13 additions & 0 deletions assets/svg/action_wallet_faceid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
<string>This app needs access to your photo library to save images.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires microphone access for recording audio.</string>
<key>NSFaceIDUsageDescription</key>
<string>This app uses Face ID to authenticate the user.</string>

</dict>
</plist>
9 changes: 8 additions & 1 deletion lib/app/features/auth/providers/auth_provider.c.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/features/user/providers/biometrics_provider.c.dart';
import 'package:ion/app/features/wallets/providers/main_wallet_provider.c.dart';
import 'package:ion/app/services/ion_identity/ion_identity_provider.c.dart';
import 'package:ion/app/services/storage/local_storage.c.dart';
import 'package:ion_identity_client/ion_identity.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'auth_provider.c.freezed.dart';
Expand All @@ -15,12 +17,13 @@ class AuthState with _$AuthState {
const factory AuthState({
required List<String> authenticatedIdentityKeyNames,
required String? currentIdentityKeyName,
required bool suggestToAddBiometrics,
}) = _AuthState;

const AuthState._();

bool get hasAuthenticated {
return authenticatedIdentityKeyNames.isNotEmpty;
return authenticatedIdentityKeyNames.isNotEmpty && suggestToAddBiometrics == false;
}
}

Expand All @@ -35,10 +38,14 @@ class Auth extends _$Auth {
final currentIdentityKeyName = authenticatedIdentityKeyNames.contains(savedIdentityKeyName)
? savedIdentityKeyName
: authenticatedIdentityKeyNames.lastOrNull;
final biometricsStates = await ref.watch(biometricsStatesStreamProvider.future);
final userBiometricsState =
(currentIdentityKeyName != null) ? biometricsStates[currentIdentityKeyName] : null;

return AuthState(
authenticatedIdentityKeyNames: authenticatedIdentityKeyNames.toList(),
currentIdentityKeyName: currentIdentityKeyName,
suggestToAddBiometrics: userBiometricsState == BiometricsState.canSuggest,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ class CreateLocalCredsNotifier extends _$CreateLocalCredsNotifier {
state = await AsyncValue.guard(() async {
final ionIdentity = await ref.read(ionIdentityClientProvider.future);
await ionIdentity.auth.createNewCredentials(
({required onPasskeyFlow, required onPasswordFlow}) => onPasskeyFlow(),
({required onPasskeyFlow, required onPasswordFlow, required onBiometricsFlow}) =>
onPasskeyFlow(),
);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,17 @@ class TwoFAInputStep extends HookConsumerWidget {
.requestRecoveryTwoFaCode(twoFaType, recoveryIdentityKeyName, ({
required OnPasswordFlow<GenerateSignatureResponse> onPasswordFlow,
required OnPasskeyFlow<GenerateSignatureResponse> onPasskeyFlow,
required OnBiometricsFlow<GenerateSignatureResponse>
onBiometricsFlow,
}) {
return ref.read(
verifyUserIdentityProvider(
onGetPassword: onGetPassword,
onPasswordFlow: onPasswordFlow,
onPasskeyFlow: onPasskeyFlow,
onBiometricsFlow: onBiometricsFlow,
localisedReasonForBiometricsDialog:
context.i18n.verify_with_biometrics_title,
).future,
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:ion/app/features/auth/views/components/auth_scrolled_body/auth_s
import 'package:ion/app/features/auth/views/components/identity_key_name_input/identity_key_name_input.dart';
import 'package:ion/app/features/auth/views/pages/sign_up_password/password_validation.dart';
import 'package:ion/app/features/auth/views/pages/sign_up_password/sign_up_password_button.dart';
import 'package:ion/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart';
import 'package:ion/app/features/components/verify_identity/components/password_input.dart';
import 'package:ion/app/router/components/sheet_content/sheet_content.dart';
import 'package:ion/generated/assets.gen.dart';
Expand Down Expand Up @@ -62,6 +63,8 @@ class SignUpPasswordPage extends HookConsumerWidget {
],
);

final onSuggestToAddBiometrics = useOnSuggestToAddBiometrics(ref);

return SheetContent(
body: KeyboardDismissOnTap(
child: AuthScrollContainer(
Expand Down Expand Up @@ -104,13 +107,16 @@ class SignUpPasswordPage extends HookConsumerWidget {
),
SizedBox(height: 22.0.s),
SignUpPasswordButton(
onPressed: () {
onPressed: () async {
if (passwordController.text == passwordConfirmationController.text) {
if (formKey.value.currentState!.validate()) {
ref.read(registerActionNotifierProvider.notifier).signUpWithPassword(
await ref
.read(registerActionNotifierProvider.notifier)
.signUpWithPassword(
keyName: identityKeyNameController.text,
password: passwordController.text,
);
await onSuggestToAddBiometrics(identityKeyNameController.text);
}
} else {
passwordsError.value = context.i18n.error_passwords_are_not_equal;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: ice License 1.0

import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart';
import 'package:ion/app/features/user/providers/biometrics_provider.c.dart';
import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart';
import 'package:ion_identity_client/ion_identity.dart';

Future<void> Function(String username) useOnSuggestToAddBiometrics(WidgetRef ref) {
final context = useContext();
return useCallback(
(String username) async {
final userBiometricsState =
await ref.read(userBiometricsStateProvider(username: username).future);
if (userBiometricsState == BiometricsState.canSuggest && context.mounted) {
await showSimpleBottomSheet<void>(
context: context,
child: SuggestToAddBiometricsPopup(username: username),
);
}
},
[context],
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: ice License 1.0

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/components/button/button.dart';
import 'package:ion/app/components/card/info_card.dart';
import 'package:ion/app/components/progress_bar/ion_loading_indicator.dart';
import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart';
import 'package:ion/app/components/screen_offset/screen_side_offset.dart';
import 'package:ion/app/extensions/extensions.dart';
import 'package:ion/app/features/user/providers/biometrics_provider.c.dart';
import 'package:ion/generated/assets.gen.dart';

class SuggestToAddBiometricsPopup extends ConsumerWidget {
const SuggestToAddBiometricsPopup({
required this.username,
super.key,
});

final String username;

@override
Widget build(BuildContext context, WidgetRef ref) {
final minSize = Size(56.0.s, 56.0.s);
final biometricsActionsState = ref.watch(biometricsActionsNotifierProvider);

return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(left: 30.0.s, right: 30.0.s, top: 30.0.s),
child: InfoCard(
iconAsset: Assets.svg.actionWalletFaceid,
title: context.i18n.biometrics_suggestion_title,
description: context.i18n.biometrics_suggestion_desc,
),
),
SizedBox(height: 38.0.s),
ScreenSideOffset.small(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Button.compact(
type: ButtonType.outlined,
label: Text(context.i18n.button_cancel),
minimumSize: minSize,
onPressed: () {
ref
.read(biometricsActionsNotifierProvider.notifier)
.rejectToUseBiometrics(username: username);
Navigator.of(context).pop();
},
),
),
SizedBox(
width: 16.0.s,
),
Expanded(
child: Button.compact(
label: Text(context.i18n.button_continue),
minimumSize: minSize,
disabled: biometricsActionsState.isLoading,
trailingIcon: biometricsActionsState.isLoading
? const IONLoadingIndicator()
: const SizedBox.shrink(),
onPressed: () async {
await ref
.read(biometricsActionsNotifierProvider.notifier)
.enrollToUseBiometrics(
username: username,
localisedReason: context.i18n.biometrics_suggestion_title,
);
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
),
],
),
),
ScreenBottomOffset(),
],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/extensions/extensions.dart';
import 'package:ion/app/features/components/verify_identity/hooks/use_on_get_password.dart';
import 'package:ion/app/features/components/verify_identity/verify_identity_prompt_dialog.dart';
import 'package:ion/app/features/user/providers/user_verify_identity_provider.c.dart';
Expand Down Expand Up @@ -46,12 +47,15 @@ class RiverpodVerifyIdentityRequestBuilder<T, P> extends HookConsumerWidget {
requestWithVerifyIdentity(({
required OnPasswordFlow<P> onPasswordFlow,
required OnPasskeyFlow<P> onPasskeyFlow,
required OnBiometricsFlow<P> onBiometricsFlow,
}) {
return ref.read(
verifyUserIdentityProvider(
onGetPassword: onGetPassword,
onPasswordFlow: onPasswordFlow,
onPasskeyFlow: onPasskeyFlow,
onBiometricsFlow: onBiometricsFlow,
localisedReasonForBiometricsDialog: context.i18n.verify_with_biometrics_title,
).future,
);
});
Expand Down Expand Up @@ -81,13 +85,16 @@ class HookVerifyIdentityRequestBuilder<P> extends HookConsumerWidget {
requestWithVerifyIdentity(({
required OnPasswordFlow<P> onPasswordFlow,
required OnPasskeyFlow<P> onPasskeyFlow,
required OnBiometricsFlow<P> onBiometricsFlow,
}) async {
try {
return await ref.read(
verifyUserIdentityProvider(
onGetPassword: onGetPassword,
onPasswordFlow: onPasswordFlow,
onPasskeyFlow: onPasskeyFlow,
onBiometricsFlow: onBiometricsFlow,
localisedReasonForBiometricsDialog: context.i18n.verify_with_biometrics_title,
).future,
);
} finally {
Expand Down
Loading