Skip to content

Commit b0de12d

Browse files
committed
feat: Added otpauth protocol support on Android.
1 parent 48e3a2b commit b0de12d

File tree

6 files changed

+114
-61
lines changed

6 files changed

+114
-61
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,50 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2-
<uses-permission android:name="android.permission.INTERNET"/>
3-
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
2+
3+
<uses-permission android:name="android.permission.INTERNET" />
4+
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
5+
46
<application
5-
android:label="Open Authenticator"
67
android:name="${applicationName}"
7-
android:networkSecurityConfig="@xml/network_security_config"
8-
android:icon="@mipmap/ic_launcher">
8+
android:icon="@mipmap/ic_launcher"
9+
android:label="Open Authenticator"
10+
android:networkSecurityConfig="@xml/network_security_config">
911
<activity
1012
android:name=".MainActivity"
13+
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
1114
android:exported="true"
15+
android:hardwareAccelerated="true"
1216
android:launchMode="singleTop"
1317
android:theme="@style/LaunchTheme"
14-
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
15-
android:hardwareAccelerated="true"
1618
android:windowSoftInputMode="adjustResize">
1719
<!-- Specifies an Android theme to apply to this Activity as soon as
1820
the Android process has started. This theme is visible to the user
1921
while the Flutter UI initializes. After that, this theme continues
2022
to determine the Window background behind the Flutter UI. -->
2123
<meta-data
22-
android:name="io.flutter.embedding.android.NormalTheme"
23-
android:resource="@style/NormalTheme"
24-
/>
24+
android:name="io.flutter.embedding.android.NormalTheme"
25+
android:resource="@style/NormalTheme" />
26+
2527
<intent-filter>
26-
<action android:name="android.intent.action.MAIN"/>
27-
<category android:name="android.intent.category.LAUNCHER"/>
28+
<action android:name="android.intent.action.MAIN" />
29+
<category android:name="android.intent.category.LAUNCHER" />
2830
</intent-filter>
2931
<intent-filter android:autoVerify="true">
30-
<action android:name="android.intent.action.VIEW"/>
31-
<category android:name="android.intent.category.DEFAULT"/>
32-
<category android:name="android.intent.category.BROWSABLE"/>
32+
<action android:name="android.intent.action.VIEW" />
33+
34+
<category android:name="android.intent.category.DEFAULT" />
35+
<category android:name="android.intent.category.BROWSABLE" />
36+
3337
<data
3438
android:host="login.openauthenticator.app"
35-
android:scheme="https"/>
39+
android:scheme="https" />
40+
</intent-filter>
41+
<intent-filter>
42+
<action android:name="android.intent.action.VIEW" />
43+
44+
<category android:name="android.intent.category.DEFAULT" />
45+
<category android:name="android.intent.category.BROWSABLE" />
46+
47+
<data android:scheme="otpauth" />
3648
</intent-filter>
3749
</activity>
3850
<!-- Don't delete the meta-data below.

lib/main.dart

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
1111
import 'package:open_authenticator/app.dart';
1212
import 'package:open_authenticator/firebase_options.dart';
1313
import 'package:open_authenticator/i18n/translations.g.dart';
14-
import 'package:open_authenticator/model/authentication/app_links.dart';
14+
import 'package:open_authenticator/model/app_links.dart';
1515
import 'package:open_authenticator/model/authentication/providers/email_link.dart';
1616
import 'package:open_authenticator/model/authentication/providers/provider.dart';
1717
import 'package:open_authenticator/model/settings/show_intro.dart';
@@ -29,6 +29,7 @@ import 'package:open_authenticator/utils/result.dart';
2929
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
3030
import 'package:open_authenticator/widgets/dialog/totp_limit.dart';
3131
import 'package:open_authenticator/widgets/route/unlock_challenge.dart';
32+
import 'package:open_authenticator/widgets/waiting_overlay.dart';
3233
import 'package:rate_my_app/rate_my_app.dart';
3334
import 'package:simple_secure_storage/simple_secure_storage.dart';
3435
import 'package:window_manager/window_manager.dart';
@@ -201,7 +202,7 @@ class _RouteWidget extends ConsumerStatefulWidget {
201202
/// The route widget.
202203
final Widget child;
203204

204-
/// Listen to dynamic links and [totpLimitExceededProvider].
205+
/// Listen to [appLinksListenerProvider] and [totpLimitExceededProvider].
205206
final bool listen;
206207

207208
/// Whether to provide an [UnlockChallengeRouteWidget].
@@ -236,30 +237,22 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
236237
if (next.valueOrNull == null) {
237238
return;
238239
}
239-
Uri? link = Uri.tryParse(next.value?.queryParameters['link'] ?? '');
240-
if (link == null) {
240+
Uri uri = next.value!;
241+
if (uri.host == Uri.parse(App.firebaseLoginUrl).host) {
242+
handleLoginLink(uri);
241243
return;
242244
}
243-
String? mode = link.queryParameters['mode'];
244-
switch (mode) {
245-
case 'signIn':
246-
EmailLinkAuthenticationProvider emailAuthenticationProvider = ref.read(emailLinkAuthenticationProvider.notifier);
247-
Result<AuthenticationObject> result = await emailAuthenticationProvider.confirm(context, link.toString());
248-
if (mounted) {
249-
AccountUtils.handleAuthenticationResult(context, ref, result);
250-
}
251-
break;
245+
if (uri.scheme == 'otpauth') {
246+
handleTotpLink(uri);
247+
return;
252248
}
253249
});
254250
}
255251
ref.listenManual(totpLimitExceededProvider, (previous, next) async {
256252
if (next.valueOrNull != true) {
257253
return;
258254
}
259-
bool result = false;
260-
while (!result && mounted) {
261-
result = await MandatoryTotpLimitDialog.show(context);
262-
}
255+
MandatoryTotpLimitDialog.showAndBlock(context);
263256
});
264257
}
265258
if (widget.rateMyApp) {
@@ -291,4 +284,35 @@ class _RouteWidgetState extends ConsumerState<_RouteWidget> {
291284
rateMyApp!.showRateDialog(context);
292285
}
293286
}
287+
288+
/// Handles a login link.
289+
Future<void> handleLoginLink(Uri loginLink) async {
290+
if (!mounted) {
291+
return;
292+
}
293+
Uri? link = Uri.tryParse(loginLink.queryParameters['link'] ?? '');
294+
if (link == null) {
295+
return;
296+
}
297+
String? mode = link.queryParameters['mode'];
298+
switch (mode) {
299+
case 'signIn':
300+
EmailLinkAuthenticationProvider emailAuthenticationProvider = ref.read(emailLinkAuthenticationProvider.notifier);
301+
Result<AuthenticationObject> result = await emailAuthenticationProvider.confirm(context, link.toString());
302+
if (mounted) {
303+
AccountUtils.handleAuthenticationResult(context, ref, result);
304+
}
305+
break;
306+
}
307+
}
308+
309+
/// Handles a TOTP link.
310+
Future<void> handleTotpLink(Uri totpLink) async {
311+
if (mounted) {
312+
await showWaitingOverlay(
313+
context,
314+
future: TotpPage.openFromUri(context, ref, totpLink),
315+
);
316+
}
317+
}
294318
}

lib/pages/scan.dart

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import 'package:open_authenticator/i18n/translations.g.dart';
4-
import 'package:open_authenticator/main.dart';
5-
import 'package:open_authenticator/model/crypto.dart';
6-
import 'package:open_authenticator/model/totp/decrypted.dart';
7-
import 'package:open_authenticator/pages/home.dart';
84
import 'package:open_authenticator/pages/totp.dart';
95
import 'package:open_authenticator/widgets/centered_circular_progress_indicator.dart';
106
import 'package:open_authenticator/widgets/code_scan.dart';
117
import 'package:open_authenticator/widgets/dialog/confirmation_dialog.dart';
128
import 'package:open_authenticator/widgets/snackbar_icon.dart';
9+
import 'package:open_authenticator/widgets/waiting_overlay.dart';
1310

1411
/// Allows to scan QR codes.
1512
class ScanPage extends ConsumerWidget {
@@ -27,31 +24,18 @@ class ScanPage extends ConsumerWidget {
2724
formats: const [BarcodeFormat.qrCode],
2825
loading: const CenteredCircularProgressIndicator(),
2926
onScan: (code, details, listener) async {
30-
if (code != null && context.mounted) {
31-
Uri? uri = Uri.tryParse(code);
32-
if (uri == null) {
33-
SnackBarIcon.showErrorSnackBar(context, text: translations.error.scan.noUri);
34-
return;
35-
}
36-
CryptoStore? cryptoStore = await ref.read(cryptoStoreProvider.future);
37-
DecryptedTotp? totp = await DecryptedTotp.fromUri(uri, cryptoStore);
38-
if (!context.mounted) {
39-
return;
40-
}
41-
if (totp == null) {
42-
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.withException(exception: Exception('Failed to decrypt TOTP.')));
43-
return;
44-
}
45-
Navigator.pushNamedAndRemoveUntil(
46-
context,
47-
TotpPage.name,
48-
(route) => route.settings.name == HomePage.name,
49-
arguments: {
50-
OpenAuthenticatorApp.kRouteParameterTotp: totp,
51-
OpenAuthenticatorApp.kRouteParameterAddTotp: true,
52-
},
53-
);
27+
if (code == null || !context.mounted) {
28+
return;
5429
}
30+
Uri? uri = Uri.tryParse(code);
31+
if (uri == null) {
32+
SnackBarIcon.showErrorSnackBar(context, text: translations.error.scan.noUri);
33+
return;
34+
}
35+
await showWaitingOverlay(
36+
context,
37+
future: TotpPage.openFromUri(context, ref, uri),
38+
);
5539
},
5640
onAccessDenied: (exception, listener) => ConfirmationDialog.ask(
5741
context,

lib/pages/totp.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import 'package:flutter/services.dart';
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import 'package:open_authenticator/app.dart';
55
import 'package:open_authenticator/i18n/translations.g.dart';
6+
import 'package:open_authenticator/main.dart';
67
import 'package:open_authenticator/model/crypto.dart';
78
import 'package:open_authenticator/model/totp/algorithm.dart';
89
import 'package:open_authenticator/model/totp/decrypted.dart';
910
import 'package:open_authenticator/model/totp/repository.dart';
1011
import 'package:open_authenticator/model/totp/totp.dart';
12+
import 'package:open_authenticator/pages/home.dart';
1113
import 'package:open_authenticator/utils/brightness_listener.dart';
1214
import 'package:open_authenticator/utils/form_label.dart';
1315
import 'package:open_authenticator/utils/result.dart';
@@ -17,6 +19,7 @@ import 'package:open_authenticator/widgets/dialog/totp_limit.dart';
1719
import 'package:open_authenticator/widgets/form/password_form_field.dart';
1820
import 'package:open_authenticator/widgets/list/expand_list_tile.dart';
1921
import 'package:open_authenticator/widgets/list/list_tile_padding.dart';
22+
import 'package:open_authenticator/widgets/snackbar_icon.dart';
2023
import 'package:open_authenticator/widgets/totp/image.dart';
2124
import 'package:open_authenticator/widgets/waiting_overlay.dart';
2225
import 'package:qr_flutter/qr_flutter.dart';
@@ -45,6 +48,28 @@ class TotpPage extends ConsumerStatefulWidget {
4548

4649
@override
4750
ConsumerState<ConsumerStatefulWidget> createState() => _TotpPageState();
51+
52+
/// Opens this page from a scanned [uri].
53+
static Future<void> openFromUri(BuildContext context, WidgetRef ref, Uri uri) async {
54+
CryptoStore? cryptoStore = await ref.read(cryptoStoreProvider.future);
55+
DecryptedTotp? totp = await DecryptedTotp.fromUri(uri, cryptoStore);
56+
if (!context.mounted) {
57+
return;
58+
}
59+
if (totp == null) {
60+
SnackBarIcon.showErrorSnackBar(context, text: translations.error.generic.withException(exception: Exception('Failed to decrypt TOTP.')));
61+
return;
62+
}
63+
Navigator.pushNamedAndRemoveUntil(
64+
context,
65+
TotpPage.name,
66+
(route) => route.settings.name == HomePage.name,
67+
arguments: {
68+
OpenAuthenticatorApp.kRouteParameterTotp: totp,
69+
OpenAuthenticatorApp.kRouteParameterAddTotp: true,
70+
},
71+
);
72+
}
4873
}
4974

5075
/// The TOTP edit page state.

lib/widgets/dialog/totp_limit.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ class MandatoryTotpLimitDialog extends ConsumerWidget {
5555
],
5656
);
5757

58+
/// Shows the dialog until it returns `true`.
59+
static Future<void> showAndBlock(BuildContext context) async {
60+
bool result = false;
61+
while (!result && context.mounted) {
62+
result = await MandatoryTotpLimitDialog.show(context);
63+
}
64+
}
65+
5866
/// Shows a mandatory totp limit dialog.
5967
static Future<bool> show(
6068
BuildContext context, {

0 commit comments

Comments
 (0)