From 61aeaf429fbd7ea9b62b6c9b9c16581aec70542e Mon Sep 17 00:00:00 2001
From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com>
Date: Mon, 16 Sep 2024 23:51:50 -0400
Subject: [PATCH] feat: add deeplinking support for oauth login
---
.vscode/launch.json | 8 +-
.vscode/settings.json | 1 +
android/app/src/main/AndroidManifest.xml | 15 +-
.../view/library_item_actions.dart | 4 +-
lib/features/onboarding/models/flow.dart | 17 +
.../onboarding/models/flow.freezed.dart | 249 ++++++++++++++
.../onboarding/providers/oauth_provider.dart | 94 +++++
.../providers/oauth_provider.g.dart | 224 ++++++++++++
.../onboarding/view/callback_page.dart | 121 +++++++
lib/features/onboarding/view/user_login.dart | 323 ++----------------
.../view/user_login_with_open_id.dart | 116 +++++++
.../view/user_login_with_password.dart | 224 ++++++++++++
.../view/user_login_with_token.dart | 110 ++++++
.../providers/currently_playing_provider.dart | 8 +-
.../currently_playing_provider.g.dart | 2 +-
lib/features/you/view/server_manager.dart | 2 +-
lib/main.dart | 26 +-
lib/models/error_response.dart | 6 +-
lib/pages/home_page.dart | 25 +-
lib/router/constants.dart | 11 +-
lib/router/router.dart | 61 +++-
lib/settings/constants.dart | 3 +
lib/shared/widgets/add_new_server.dart | 3 +-
23 files changed, 1310 insertions(+), 343 deletions(-)
create mode 100644 lib/features/onboarding/models/flow.dart
create mode 100644 lib/features/onboarding/models/flow.freezed.dart
create mode 100644 lib/features/onboarding/providers/oauth_provider.dart
create mode 100644 lib/features/onboarding/providers/oauth_provider.g.dart
create mode 100644 lib/features/onboarding/view/callback_page.dart
create mode 100644 lib/features/onboarding/view/user_login_with_open_id.dart
create mode 100644 lib/features/onboarding/view/user_login_with_password.dart
create mode 100644 lib/features/onboarding/view/user_login_with_token.dart
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 2745f3e..18ecea1 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -20,6 +20,12 @@
"request": "launch",
"type": "dart",
"flutterMode": "release"
+ },
+ {
+ "name": "debug debug.dart",
+ "request": "launch",
+ "type": "dart",
+ "program": "${workspaceFolder}/shelfsdk/playground/debug.dart"
}
]
-}
\ No newline at end of file
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d49462f..6b19190 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -11,6 +11,7 @@
"cSpell.words": [
"audioplayers",
"Autovalidate",
+ "deeplinking",
"fullscreen",
"Lerp",
"miniplayer",
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 62d44d0..41b686e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,7 +12,7 @@
android:icon="@mipmap/ic_launcher">
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/features/item_viewer/view/library_item_actions.dart b/lib/features/item_viewer/view/library_item_actions.dart
index 2b021e5..7be83f4 100644
--- a/lib/features/item_viewer/view/library_item_actions.dart
+++ b/lib/features/item_viewer/view/library_item_actions.dart
@@ -82,11 +82,11 @@ class LibraryItemActions extends HookConsumerWidget {
Uri.parse(
currentServerUrl.toString() +
(Routes.libraryItem.pathParamName != null
- ? Routes.libraryItem.path.replaceAll(
+ ? Routes.libraryItem.fullPath.replaceAll(
':${Routes.libraryItem.pathParamName!}',
item.id,
)
- : Routes.libraryItem.path),
+ : Routes.libraryItem.fullPath),
),
);
},
diff --git a/lib/features/onboarding/models/flow.dart b/lib/features/onboarding/models/flow.dart
new file mode 100644
index 0000000..80e8514
--- /dev/null
+++ b/lib/features/onboarding/models/flow.dart
@@ -0,0 +1,17 @@
+import 'dart:io';
+
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'flow.freezed.dart';
+
+@freezed
+class Flow with _$Flow {
+ const factory Flow({
+ required Uri serverUri,
+ required String state,
+ required String verifier,
+ required Cookie cookie,
+ @Default(false) bool isFlowComplete,
+ String? authToken,
+ }) = _Flow;
+}
diff --git a/lib/features/onboarding/models/flow.freezed.dart b/lib/features/onboarding/models/flow.freezed.dart
new file mode 100644
index 0000000..1437f2e
--- /dev/null
+++ b/lib/features/onboarding/models/flow.freezed.dart
@@ -0,0 +1,249 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'flow.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+ 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
+
+/// @nodoc
+mixin _$Flow {
+ Uri get serverUri => throw _privateConstructorUsedError;
+ String get state => throw _privateConstructorUsedError;
+ String get verifier => throw _privateConstructorUsedError;
+ Cookie get cookie => throw _privateConstructorUsedError;
+ bool get isFlowComplete => throw _privateConstructorUsedError;
+ String? get authToken => throw _privateConstructorUsedError;
+
+ /// Create a copy of Flow
+ /// with the given fields replaced by the non-null parameter values.
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ $FlowCopyWith get copyWith => throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $FlowCopyWith<$Res> {
+ factory $FlowCopyWith(Flow value, $Res Function(Flow) then) =
+ _$FlowCopyWithImpl<$Res, Flow>;
+ @useResult
+ $Res call(
+ {Uri serverUri,
+ String state,
+ String verifier,
+ Cookie cookie,
+ bool isFlowComplete,
+ String? authToken});
+}
+
+/// @nodoc
+class _$FlowCopyWithImpl<$Res, $Val extends Flow>
+ implements $FlowCopyWith<$Res> {
+ _$FlowCopyWithImpl(this._value, this._then);
+
+ // ignore: unused_field
+ final $Val _value;
+ // ignore: unused_field
+ final $Res Function($Val) _then;
+
+ /// Create a copy of Flow
+ /// with the given fields replaced by the non-null parameter values.
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? serverUri = null,
+ Object? state = null,
+ Object? verifier = null,
+ Object? cookie = null,
+ Object? isFlowComplete = null,
+ Object? authToken = freezed,
+ }) {
+ return _then(_value.copyWith(
+ serverUri: null == serverUri
+ ? _value.serverUri
+ : serverUri // ignore: cast_nullable_to_non_nullable
+ as Uri,
+ state: null == state
+ ? _value.state
+ : state // ignore: cast_nullable_to_non_nullable
+ as String,
+ verifier: null == verifier
+ ? _value.verifier
+ : verifier // ignore: cast_nullable_to_non_nullable
+ as String,
+ cookie: null == cookie
+ ? _value.cookie
+ : cookie // ignore: cast_nullable_to_non_nullable
+ as Cookie,
+ isFlowComplete: null == isFlowComplete
+ ? _value.isFlowComplete
+ : isFlowComplete // ignore: cast_nullable_to_non_nullable
+ as bool,
+ authToken: freezed == authToken
+ ? _value.authToken
+ : authToken // ignore: cast_nullable_to_non_nullable
+ as String?,
+ ) as $Val);
+ }
+}
+
+/// @nodoc
+abstract class _$$FlowImplCopyWith<$Res> implements $FlowCopyWith<$Res> {
+ factory _$$FlowImplCopyWith(
+ _$FlowImpl value, $Res Function(_$FlowImpl) then) =
+ __$$FlowImplCopyWithImpl<$Res>;
+ @override
+ @useResult
+ $Res call(
+ {Uri serverUri,
+ String state,
+ String verifier,
+ Cookie cookie,
+ bool isFlowComplete,
+ String? authToken});
+}
+
+/// @nodoc
+class __$$FlowImplCopyWithImpl<$Res>
+ extends _$FlowCopyWithImpl<$Res, _$FlowImpl>
+ implements _$$FlowImplCopyWith<$Res> {
+ __$$FlowImplCopyWithImpl(_$FlowImpl _value, $Res Function(_$FlowImpl) _then)
+ : super(_value, _then);
+
+ /// Create a copy of Flow
+ /// with the given fields replaced by the non-null parameter values.
+ @pragma('vm:prefer-inline')
+ @override
+ $Res call({
+ Object? serverUri = null,
+ Object? state = null,
+ Object? verifier = null,
+ Object? cookie = null,
+ Object? isFlowComplete = null,
+ Object? authToken = freezed,
+ }) {
+ return _then(_$FlowImpl(
+ serverUri: null == serverUri
+ ? _value.serverUri
+ : serverUri // ignore: cast_nullable_to_non_nullable
+ as Uri,
+ state: null == state
+ ? _value.state
+ : state // ignore: cast_nullable_to_non_nullable
+ as String,
+ verifier: null == verifier
+ ? _value.verifier
+ : verifier // ignore: cast_nullable_to_non_nullable
+ as String,
+ cookie: null == cookie
+ ? _value.cookie
+ : cookie // ignore: cast_nullable_to_non_nullable
+ as Cookie,
+ isFlowComplete: null == isFlowComplete
+ ? _value.isFlowComplete
+ : isFlowComplete // ignore: cast_nullable_to_non_nullable
+ as bool,
+ authToken: freezed == authToken
+ ? _value.authToken
+ : authToken // ignore: cast_nullable_to_non_nullable
+ as String?,
+ ));
+ }
+}
+
+/// @nodoc
+
+class _$FlowImpl implements _Flow {
+ const _$FlowImpl(
+ {required this.serverUri,
+ required this.state,
+ required this.verifier,
+ required this.cookie,
+ this.isFlowComplete = false,
+ this.authToken});
+
+ @override
+ final Uri serverUri;
+ @override
+ final String state;
+ @override
+ final String verifier;
+ @override
+ final Cookie cookie;
+ @override
+ @JsonKey()
+ final bool isFlowComplete;
+ @override
+ final String? authToken;
+
+ @override
+ String toString() {
+ return 'Flow(serverUri: $serverUri, state: $state, verifier: $verifier, cookie: $cookie, isFlowComplete: $isFlowComplete, authToken: $authToken)';
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) ||
+ (other.runtimeType == runtimeType &&
+ other is _$FlowImpl &&
+ (identical(other.serverUri, serverUri) ||
+ other.serverUri == serverUri) &&
+ (identical(other.state, state) || other.state == state) &&
+ (identical(other.verifier, verifier) ||
+ other.verifier == verifier) &&
+ (identical(other.cookie, cookie) || other.cookie == cookie) &&
+ (identical(other.isFlowComplete, isFlowComplete) ||
+ other.isFlowComplete == isFlowComplete) &&
+ (identical(other.authToken, authToken) ||
+ other.authToken == authToken));
+ }
+
+ @override
+ int get hashCode => Object.hash(runtimeType, serverUri, state, verifier,
+ cookie, isFlowComplete, authToken);
+
+ /// Create a copy of Flow
+ /// with the given fields replaced by the non-null parameter values.
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ @override
+ @pragma('vm:prefer-inline')
+ _$$FlowImplCopyWith<_$FlowImpl> get copyWith =>
+ __$$FlowImplCopyWithImpl<_$FlowImpl>(this, _$identity);
+}
+
+abstract class _Flow implements Flow {
+ const factory _Flow(
+ {required final Uri serverUri,
+ required final String state,
+ required final String verifier,
+ required final Cookie cookie,
+ final bool isFlowComplete,
+ final String? authToken}) = _$FlowImpl;
+
+ @override
+ Uri get serverUri;
+ @override
+ String get state;
+ @override
+ String get verifier;
+ @override
+ Cookie get cookie;
+ @override
+ bool get isFlowComplete;
+ @override
+ String? get authToken;
+
+ /// Create a copy of Flow
+ /// with the given fields replaced by the non-null parameter values.
+ @override
+ @JsonKey(includeFromJson: false, includeToJson: false)
+ _$$FlowImplCopyWith<_$FlowImpl> get copyWith =>
+ throw _privateConstructorUsedError;
+}
diff --git a/lib/features/onboarding/providers/oauth_provider.dart b/lib/features/onboarding/providers/oauth_provider.dart
new file mode 100644
index 0000000..79445d9
--- /dev/null
+++ b/lib/features/onboarding/providers/oauth_provider.dart
@@ -0,0 +1,94 @@
+import 'dart:io';
+
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:vaani/api/api_provider.dart';
+import 'package:vaani/models/error_response.dart';
+
+import '../models/flow.dart';
+
+part 'oauth_provider.g.dart';
+
+/// the string state of a flow started by user
+typedef State = String;
+
+/// the verifier string of a flow started by user
+typedef Verifier = String;
+
+/// the code returned by the oauth provider
+typedef Code = String;
+
+@Riverpod(keepAlive: true)
+class OauthFlows extends _$OauthFlows {
+ @override
+ Map build() {
+ return {};
+ }
+
+ void addFlow(
+ State oauthState, {
+ required Verifier verifier,
+ required Uri serverUri,
+ required Cookie cookie,
+ bool replaceExisting = false,
+ }) {
+ if (state.containsKey(oauthState) && !replaceExisting) {
+ return;
+ }
+ state = {
+ ...state,
+ oauthState: Flow(
+ state: oauthState,
+ verifier: verifier,
+ serverUri: serverUri,
+ cookie: cookie,
+ isFlowComplete: false,
+ ),
+ };
+ }
+
+ void markComplete(State oauthState, String? authToken) {
+ if (!state.containsKey(oauthState)) {
+ return;
+ }
+ state = {
+ ...state,
+ oauthState: state[oauthState]!
+ .copyWith(isFlowComplete: true, authToken: authToken),
+ };
+ }
+}
+
+/// the code returned by the server in exchange for the verifier
+@riverpod
+Future loginInExchangeForCode(
+ LoginInExchangeForCodeRef ref, {
+ required State oauthState,
+ required Code code,
+ ErrorResponseHandler? responseHandler,
+}) async {
+ final flows = ref.watch(oauthFlowsProvider);
+ final flow = flows[oauthState];
+ if (flow == null) {
+ throw StateError('No flow active for state: $oauthState');
+ }
+
+ if (flow.authToken != null) {
+ return flow.authToken;
+ }
+
+ final api = ref.read(audiobookshelfApiProvider(flow.serverUri));
+ final response = await api.server.oauth2Callback(
+ code: code,
+ codeVerifier: flow.verifier,
+ state: oauthState,
+ cookie: flow.cookie,
+ responseErrorHandler: responseHandler?.storeError,
+ );
+
+ if (response == null) {
+ return null;
+ }
+
+ ref.read(oauthFlowsProvider.notifier).markComplete(oauthState, api.token);
+ return api.token;
+}
diff --git a/lib/features/onboarding/providers/oauth_provider.g.dart b/lib/features/onboarding/providers/oauth_provider.g.dart
new file mode 100644
index 0000000..9b7c4f5
--- /dev/null
+++ b/lib/features/onboarding/providers/oauth_provider.g.dart
@@ -0,0 +1,224 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'oauth_provider.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+String _$loginInExchangeForCodeHash() =>
+ r'e931254959d9eb8196439c6b0c884c26cbe17c2f';
+
+/// Copied from Dart SDK
+class _SystemHash {
+ _SystemHash._();
+
+ static int combine(int hash, int value) {
+ // ignore: parameter_assignments
+ hash = 0x1fffffff & (hash + value);
+ // ignore: parameter_assignments
+ hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
+ return hash ^ (hash >> 6);
+ }
+
+ static int finish(int hash) {
+ // ignore: parameter_assignments
+ hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
+ // ignore: parameter_assignments
+ hash = hash ^ (hash >> 11);
+ return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
+ }
+}
+
+/// the code returned by the server in exchange for the verifier
+///
+/// Copied from [loginInExchangeForCode].
+@ProviderFor(loginInExchangeForCode)
+const loginInExchangeForCodeProvider = LoginInExchangeForCodeFamily();
+
+/// the code returned by the server in exchange for the verifier
+///
+/// Copied from [loginInExchangeForCode].
+class LoginInExchangeForCodeFamily extends Family> {
+ /// the code returned by the server in exchange for the verifier
+ ///
+ /// Copied from [loginInExchangeForCode].
+ const LoginInExchangeForCodeFamily();
+
+ /// the code returned by the server in exchange for the verifier
+ ///
+ /// Copied from [loginInExchangeForCode].
+ LoginInExchangeForCodeProvider call({
+ required String oauthState,
+ required String code,
+ ErrorResponseHandler? responseHandler,
+ }) {
+ return LoginInExchangeForCodeProvider(
+ oauthState: oauthState,
+ code: code,
+ responseHandler: responseHandler,
+ );
+ }
+
+ @override
+ LoginInExchangeForCodeProvider getProviderOverride(
+ covariant LoginInExchangeForCodeProvider provider,
+ ) {
+ return call(
+ oauthState: provider.oauthState,
+ code: provider.code,
+ responseHandler: provider.responseHandler,
+ );
+ }
+
+ static const Iterable? _dependencies = null;
+
+ @override
+ Iterable? get dependencies => _dependencies;
+
+ static const Iterable? _allTransitiveDependencies = null;
+
+ @override
+ Iterable? get allTransitiveDependencies =>
+ _allTransitiveDependencies;
+
+ @override
+ String? get name => r'loginInExchangeForCodeProvider';
+}
+
+/// the code returned by the server in exchange for the verifier
+///
+/// Copied from [loginInExchangeForCode].
+class LoginInExchangeForCodeProvider
+ extends AutoDisposeFutureProvider {
+ /// the code returned by the server in exchange for the verifier
+ ///
+ /// Copied from [loginInExchangeForCode].
+ LoginInExchangeForCodeProvider({
+ required String oauthState,
+ required String code,
+ ErrorResponseHandler? responseHandler,
+ }) : this._internal(
+ (ref) => loginInExchangeForCode(
+ ref as LoginInExchangeForCodeRef,
+ oauthState: oauthState,
+ code: code,
+ responseHandler: responseHandler,
+ ),
+ from: loginInExchangeForCodeProvider,
+ name: r'loginInExchangeForCodeProvider',
+ debugGetCreateSourceHash:
+ const bool.fromEnvironment('dart.vm.product')
+ ? null
+ : _$loginInExchangeForCodeHash,
+ dependencies: LoginInExchangeForCodeFamily._dependencies,
+ allTransitiveDependencies:
+ LoginInExchangeForCodeFamily._allTransitiveDependencies,
+ oauthState: oauthState,
+ code: code,
+ responseHandler: responseHandler,
+ );
+
+ LoginInExchangeForCodeProvider._internal(
+ super._createNotifier, {
+ required super.name,
+ required super.dependencies,
+ required super.allTransitiveDependencies,
+ required super.debugGetCreateSourceHash,
+ required super.from,
+ required this.oauthState,
+ required this.code,
+ required this.responseHandler,
+ }) : super.internal();
+
+ final String oauthState;
+ final String code;
+ final ErrorResponseHandler? responseHandler;
+
+ @override
+ Override overrideWith(
+ FutureOr Function(LoginInExchangeForCodeRef provider) create,
+ ) {
+ return ProviderOverride(
+ origin: this,
+ override: LoginInExchangeForCodeProvider._internal(
+ (ref) => create(ref as LoginInExchangeForCodeRef),
+ from: from,
+ name: null,
+ dependencies: null,
+ allTransitiveDependencies: null,
+ debugGetCreateSourceHash: null,
+ oauthState: oauthState,
+ code: code,
+ responseHandler: responseHandler,
+ ),
+ );
+ }
+
+ @override
+ AutoDisposeFutureProviderElement createElement() {
+ return _LoginInExchangeForCodeProviderElement(this);
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is LoginInExchangeForCodeProvider &&
+ other.oauthState == oauthState &&
+ other.code == code &&
+ other.responseHandler == responseHandler;
+ }
+
+ @override
+ int get hashCode {
+ var hash = _SystemHash.combine(0, runtimeType.hashCode);
+ hash = _SystemHash.combine(hash, oauthState.hashCode);
+ hash = _SystemHash.combine(hash, code.hashCode);
+ hash = _SystemHash.combine(hash, responseHandler.hashCode);
+
+ return _SystemHash.finish(hash);
+ }
+}
+
+mixin LoginInExchangeForCodeRef on AutoDisposeFutureProviderRef {
+ /// The parameter `oauthState` of this provider.
+ String get oauthState;
+
+ /// The parameter `code` of this provider.
+ String get code;
+
+ /// The parameter `responseHandler` of this provider.
+ ErrorResponseHandler? get responseHandler;
+}
+
+class _LoginInExchangeForCodeProviderElement
+ extends AutoDisposeFutureProviderElement
+ with LoginInExchangeForCodeRef {
+ _LoginInExchangeForCodeProviderElement(super.provider);
+
+ @override
+ String get oauthState =>
+ (origin as LoginInExchangeForCodeProvider).oauthState;
+ @override
+ String get code => (origin as LoginInExchangeForCodeProvider).code;
+ @override
+ ErrorResponseHandler? get responseHandler =>
+ (origin as LoginInExchangeForCodeProvider).responseHandler;
+}
+
+String _$oauthFlowsHash() => r'4e278baa0bf26f2a10694ca2caadb68dd5b6156f';
+
+/// See also [OauthFlows].
+@ProviderFor(OauthFlows)
+final oauthFlowsProvider =
+ NotifierProvider>.internal(
+ OauthFlows.new,
+ name: r'oauthFlowsProvider',
+ debugGetCreateSourceHash:
+ const bool.fromEnvironment('dart.vm.product') ? null : _$oauthFlowsHash,
+ dependencies: null,
+ allTransitiveDependencies: null,
+);
+
+typedef _$OauthFlows = Notifier