diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8a400e8..0b8d3d9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,43 +1,53 @@
+ package="social.myportal.relatica">
+ android:name="${applicationName}"
+ android:icon="@mipmap/ic_launcher_round"
+ android:label="Relatica">
+ android:name=".MainActivity"
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+ android:exported="true"
+ android:hardwareAccelerated="true"
+ android:launchMode="singleTop"
+ android:theme="@style/LaunchTheme"
+ android:windowSoftInputMode="adjustResize">
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme"/>
-
-
+
+
+
+
+
+
+
+
+
+
+ android:name="flutterEmbedding"
+ android:value="2"/>
-
-
+
+
-
-
+
+
diff --git a/lib/models/auth/basic_credentials.dart b/lib/models/auth/basic_credentials.dart
index a41fe1f..d05e69e 100644
--- a/lib/models/auth/basic_credentials.dart
+++ b/lib/models/auth/basic_credentials.dart
@@ -5,7 +5,7 @@ import 'package:relatica/models/exec_error.dart';
import 'package:result_monad/src/result_monad_base.dart';
import 'package:uuid/uuid.dart';
-class BasicCredentials extends ICredentials {
+class BasicCredentials implements ICredentials {
late final String id;
final String username;
final String password;
diff --git a/lib/models/auth/oauth_credentials.dart b/lib/models/auth/oauth_credentials.dart
new file mode 100644
index 0000000..6f28372
--- /dev/null
+++ b/lib/models/auth/oauth_credentials.dart
@@ -0,0 +1,168 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
+import 'package:http/http.dart' as http;
+import 'package:logging/logging.dart';
+import 'package:relatica/models/auth/credentials_intf.dart';
+import 'package:relatica/models/exec_error.dart';
+import 'package:result_monad/result_monad.dart';
+import 'package:uuid/uuid.dart';
+
+class OAuthCredentials implements ICredentials {
+ static final _logger = Logger('$OAuthCredentials');
+ String clientId;
+ String clientSecret;
+ final String redirectUrl;
+ final String redirectScheme;
+ String accessToken;
+
+ @override
+ final String serverName;
+
+ @override
+ final String id;
+
+ OAuthCredentials({
+ required this.clientId,
+ required this.clientSecret,
+ required this.redirectUrl,
+ required this.redirectScheme,
+ required this.accessToken,
+ required this.serverName,
+ required this.id,
+ });
+
+ factory OAuthCredentials.bootstrap(String serverName) {
+ final redirectScheme = Platform.isWindows || Platform.isLinux
+ ? 'http://localhost:43824'
+ : 'relatica';
+ final redirectUrl = Platform.isWindows || Platform.isLinux
+ ? redirectScheme
+ : '$redirectScheme:/';
+ return OAuthCredentials(
+ clientId: '',
+ clientSecret: '',
+ redirectUrl: redirectUrl,
+ redirectScheme: redirectScheme,
+ accessToken: '',
+ serverName: serverName,
+ id: const Uuid().v4(),
+ );
+ }
+
+ @override
+ String get authHeaderValue => 'Bearer $accessToken';
+
+ @override
+ FutureResult signIn() async {
+ final result = await _getIds()
+ .andThenAsync((_) async => await _login())
+ .andThenSuccessAsync((_) async => this);
+ return result.execErrorCast();
+ }
+
+ @override
+ Map toJson() => {
+ 'clientId': clientId,
+ 'clientSecret': clientSecret,
+ 'redirectUrl': redirectUrl,
+ 'redirectScheme': redirectScheme,
+ 'accessToken': accessToken,
+ 'serverName': serverName,
+ 'id': id,
+ };
+
+ static OAuthCredentials fromJson(Map json) =>
+ OAuthCredentials(
+ clientId: json['clientId'],
+ clientSecret: json['clientSecret'],
+ redirectUrl: json['redirectUrl'],
+ redirectScheme: json['redirectScheme'],
+ accessToken: json['accessToken'],
+ serverName: json['serverName'],
+ id: json['id'],
+ );
+
+ FutureResult _getIds() async {
+ if (clientId.isNotEmpty || clientSecret.isNotEmpty) {
+ _logger.info(
+ 'Client ID and Client Secret are already set, skipping ID fetching');
+ return Result.ok(true);
+ }
+ final idEndpoint =
+ Uri.parse('https://friendicadevtest1.myportal.social/api/v1/apps');
+ final response = await http.post(idEndpoint, body: {
+ 'client_name': 'Relatica',
+ 'redirect_uris': '$redirectUrl',
+ 'scopes': 'read write push',
+ 'website': 'https://myportal.social',
+ });
+
+ if (response.statusCode != 200) {
+ _logger.severe('Error: ${response.statusCode}: ${response.body}');
+ return buildErrorResult(
+ type: ErrorType.serverError,
+ message: 'Error: ${response.statusCode}: ${response.body}',
+ );
+ }
+
+ final json = jsonDecode(response.body);
+ clientId = json['client_id'];
+ clientSecret = json['client_secret'];
+ return Result.ok(true);
+ }
+
+ FutureResult _login() async {
+ if (accessToken.isNotEmpty) {
+ _logger.info('Already have access token, skipping');
+ return Result.ok(true);
+ }
+ // Construct the url
+ final url = Uri.https(serverName, '/oauth/authorize', {
+ 'response_type': 'code',
+ 'client_id': clientId,
+ 'redirect_uri': redirectUrl,
+ 'scope': 'read write push',
+ });
+
+ try {
+ final result = await FlutterWebAuth2.authenticate(
+ url: url.toString(), callbackUrlScheme: redirectScheme);
+ final code = Uri.parse(result).queryParameters['code'];
+ if (code == null) {
+ _logger.severe(
+ 'Error code was not returned with the query parameters: $result');
+ return buildErrorResult(
+ type: ErrorType.serverError,
+ message: 'Error getting the response code during authentication',
+ );
+ }
+ final url2 = Uri.parse('https://$serverName/oauth/token');
+ final body = {
+ 'client_id': clientId,
+ 'client_secret': clientSecret,
+ 'redirect_uri': redirectUrl,
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ };
+ final response = await http.post(url2, body: body);
+ if (response.statusCode != 200) {
+ _logger.severe('Error: ${response.statusCode}: ${response.body}');
+ return buildErrorResult(
+ type: ErrorType.serverError,
+ message: 'Error: ${response.statusCode}: ${response.body}',
+ );
+ }
+ accessToken = jsonDecode(response.body)['access_token'];
+ } catch (e) {
+ _logger.severe('Exception while Doing OAuth Process: $e');
+ return buildErrorResult(
+ type: ErrorType.serverError,
+ message: 'Exception while Doing OAuth Process: $e',
+ );
+ }
+
+ return Result.ok(true);
+ }
+}
diff --git a/lib/screens/sign_in.dart b/lib/screens/sign_in.dart
index fe2025b..604b7be 100644
--- a/lib/screens/sign_in.dart
+++ b/lib/screens/sign_in.dart
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:logging/logging.dart';
import 'package:relatica/models/auth/basic_credentials.dart';
import 'package:relatica/routes.dart';
import 'package:string_validator/string_validator.dart';
import '../controls/padding.dart';
import '../globals.dart';
+import '../models/auth/credentials_intf.dart';
+import '../models/auth/oauth_credentials.dart';
import '../services/auth_service.dart';
import '../utils/snackbar_builder.dart';
@@ -15,21 +18,57 @@ class SignInScreen extends StatefulWidget {
}
class _SignInScreenState extends State {
+ static final _logger = Logger('$SignInScreen');
+ static const usernamePasswordType = 'Username/Password';
+ static const oauthType = 'OAuth';
+ static final authTypes = [usernamePasswordType, oauthType];
final formKey = GlobalKey();
final usernameController = TextEditingController();
final serverNameController = TextEditingController();
final passwordController = TextEditingController();
+ var authType = oauthType;
var hidePassword = true;
+ var showUsernameAndPasswordFields = true;
@override
void initState() {
super.initState();
final service = getIt();
if (service.loggedIn) {
- usernameController.text = service.currentProfile.username;
- passwordController.text =
- (service.currentProfile.credentials as BasicCredentials).password;
- serverNameController.text = service.currentProfile.serverName;
+ setCredentials(null, service.currentProfile.credentials);
+ }
+ }
+
+ void setBasicCredentials(BasicCredentials credentials) {
+ usernameController.text = credentials.username;
+ passwordController.text = credentials.password;
+ serverNameController.text = credentials.serverName;
+ showUsernameAndPasswordFields = true;
+ authType = usernamePasswordType;
+ }
+
+ void setOauthCredentials(OAuthCredentials credentials) {
+ serverNameController.text = credentials.serverName;
+ showUsernameAndPasswordFields = false;
+ authType = oauthType;
+ }
+
+ void setCredentials(BuildContext? context, ICredentials credentials) {
+ if (credentials is BasicCredentials) {
+ setBasicCredentials(credentials);
+ return;
+ }
+
+ if (credentials is OAuthCredentials) {
+ setOauthCredentials(credentials);
+ return;
+ }
+
+ final msg = 'Unknown credentials type: ${credentials.runtimeType}';
+ _logger.severe(msg);
+
+ if (context?.mounted ?? false) {
+ buildSnackbar(context!, msg);
}
}
@@ -51,6 +90,27 @@ class _SignInScreenState extends State {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
+ DropdownButton(
+ value: authType,
+ items: authTypes
+ .map((a) => DropdownMenuItem(value: a, child: Text(a)))
+ .toList(),
+ onChanged: (value) {
+ setState(() {
+ authType = value ?? '';
+ switch (value) {
+ case usernamePasswordType:
+ showUsernameAndPasswordFields = true;
+ break;
+ case oauthType:
+ showUsernameAndPasswordFields = false;
+ break;
+ default:
+ print("Don't know this");
+ }
+ });
+ }),
+ const VerticalPadding(),
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
controller: serverNameController,
@@ -69,64 +129,66 @@ class _SignInScreenState extends State {
),
),
const VerticalPadding(),
- TextFormField(
- autovalidateMode: AutovalidateMode.onUserInteraction,
- controller: usernameController,
- keyboardType: TextInputType.emailAddress,
- validator: (value) {
- if (value == null) {
- return null;
- }
+ if (showUsernameAndPasswordFields) ...[
+ TextFormField(
+ autovalidateMode: AutovalidateMode.onUserInteraction,
+ controller: usernameController,
+ keyboardType: TextInputType.emailAddress,
+ validator: (value) {
+ if (value == null) {
+ return null;
+ }
- if (value.contains('@')) {
- return isEmail(value ?? '')
+ if (value.contains('@')) {
+ return isEmail(value)
+ ? null
+ : 'Not a valid Friendica Account Address';
+ }
+
+ return isAlphanumeric(value.replaceAll('-', ''))
? null
- : 'Not a valid Friendica Account Address';
- }
-
- return isAlphanumeric(value.replaceAll('-', ''))
- ? null
- : 'Username should be alpha-numeric';
- },
- decoration: InputDecoration(
- prefixIcon: const Icon(Icons.alternate_email),
- hintText: 'Username (user@example.com)',
- border: OutlineInputBorder(
- borderSide: BorderSide(
- color: Theme.of(context).backgroundColor,
+ : 'Username should be alpha-numeric';
+ },
+ decoration: InputDecoration(
+ prefixIcon: const Icon(Icons.alternate_email),
+ hintText: 'Username (user@example.com)',
+ border: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Theme.of(context).backgroundColor,
+ ),
+ borderRadius: BorderRadius.circular(5.0),
),
- borderRadius: BorderRadius.circular(5.0),
+ labelText: 'Username',
),
- labelText: 'Username',
),
- ),
- const VerticalPadding(),
- TextFormField(
- obscureText: hidePassword,
- controller: passwordController,
- decoration: InputDecoration(
- prefixIcon: const Icon(Icons.password),
- suffixIcon: IconButton(
- onPressed: () {
- setState(() {
- hidePassword = !hidePassword;
- });
- },
- icon: hidePassword
- ? const Icon(Icons.remove_red_eye_outlined)
- : const Icon(Icons.remove_red_eye),
- ),
- hintText: 'Password',
- border: OutlineInputBorder(
- borderSide: BorderSide(
- color: Theme.of(context).backgroundColor,
+ const VerticalPadding(),
+ TextFormField(
+ obscureText: hidePassword,
+ controller: passwordController,
+ decoration: InputDecoration(
+ prefixIcon: const Icon(Icons.password),
+ suffixIcon: IconButton(
+ onPressed: () {
+ setState(() {
+ hidePassword = !hidePassword;
+ });
+ },
+ icon: hidePassword
+ ? const Icon(Icons.remove_red_eye_outlined)
+ : const Icon(Icons.remove_red_eye),
),
- borderRadius: BorderRadius.circular(5.0),
+ hintText: 'Password',
+ border: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Theme.of(context).backgroundColor,
+ ),
+ borderRadius: BorderRadius.circular(5.0),
+ ),
+ labelText: 'Password',
),
- labelText: 'Password',
),
- ),
- const VerticalPadding(),
+ const VerticalPadding(),
+ ],
ElevatedButton(
onPressed: () => _signIn(context),
child: const Text('Signin'),
@@ -140,12 +202,8 @@ class _SignInScreenState extends State {
final p = loggedOutProfiles[index];
return ListTile(
onTap: () {
- setState(() {
- serverNameController.text = p.serverName;
- usernameController.text = p.username;
- passwordController.text =
- (p.credentials as BasicCredentials).password;
- });
+ setCredentials(context, p.credentials);
+ setState(() {});
},
title: Text(p.handle),
subtitle: Text(p.id),
@@ -167,7 +225,10 @@ class _SignInScreenState extends State {
: false;
return ListTile(
onTap: () async {
+ setCredentials(context, p.credentials);
+ setState(() {});
await service.setActiveProfile(p);
+
if (mounted) {
clearCaches();
context.goNamed(ScreenPaths.timelines);
@@ -196,10 +257,25 @@ class _SignInScreenState extends State {
void _signIn(BuildContext context) async {
if (formKey.currentState?.validate() ?? false) {
- final creds = BasicCredentials(
- username: usernameController.text,
- password: passwordController.text,
- serverName: serverNameController.text);
+ ICredentials? creds;
+ switch (authType) {
+ case usernamePasswordType:
+ creds = BasicCredentials(
+ username: usernameController.text,
+ password: passwordController.text,
+ serverName: serverNameController.text);
+ break;
+ case oauthType:
+ creds = OAuthCredentials.bootstrap(serverNameController.text);
+ break;
+ default:
+ buildSnackbar(context, 'Unknown authorization type: $authType');
+ break;
+ }
+
+ if (creds == null) {
+ return;
+ }
final result = await getIt().signIn(creds);
if (!mounted) {
diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart
index c7c8d75..c7d9565 100644
--- a/lib/services/auth_service.dart
+++ b/lib/services/auth_service.dart
@@ -119,6 +119,26 @@ class AccountsService extends ChangeNotifier {
}
}
+ Future removeProfile(Profile profile, {bool withNotification = true}) async {
+ if (_currentProfile == profile) {
+ await clearActiveProfile(withNotification: withNotification);
+ }
+ _loggedInProfiles.remove(profile);
+ _loggedOutProfiles.remove(profile);
+ await secretsService.removeProfile(profile);
+
+ if (_loggedInProfiles.isNotEmpty) {
+ setActiveProfile(
+ _loggedInProfiles.first,
+ withNotification: withNotification,
+ );
+ }
+
+ if (withNotification) {
+ notifyListeners();
+ }
+ }
+
Future clearActiveProfile({bool withNotification = true}) async {
_currentProfile = null;
if (withNotification) {
diff --git a/lib/services/secrets_service.dart b/lib/services/secrets_service.dart
index 5abf836..cf92ec0 100644
--- a/lib/services/secrets_service.dart
+++ b/lib/services/secrets_service.dart
@@ -1,11 +1,12 @@
import 'dart:collection';
import 'dart:convert';
-import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:relatica/models/auth/credentials_intf.dart';
import 'package:result_monad/result_monad.dart';
import '../models/auth/basic_credentials.dart';
+import '../models/auth/oauth_credentials.dart';
import '../models/auth/profile.dart';
import '../models/exec_error.dart';
@@ -33,10 +34,10 @@ class SecretsService {
await _secureStorage.delete(key: _basicProfilesKey);
await _secureStorage.delete(key: _oauthProfilesKey);
return Result.ok(profiles);
- } on PlatformException catch (e) {
+ } catch (e) {
return Result.error(ExecError(
type: ErrorType.localError,
- message: e.message ?? '',
+ message: e.toString(),
));
}
}
@@ -47,10 +48,10 @@ class SecretsService {
_cachedProfiles.remove(profile);
_cachedProfiles.add(profile);
return await saveCredentials();
- } on PlatformException catch (e) {
+ } catch (e) {
return Result.error(ExecError(
type: ErrorType.localError,
- message: e.message ?? '',
+ message: e.toString(),
));
}
}
@@ -59,48 +60,62 @@ class SecretsService {
try {
_cachedProfiles.remove(profile);
return await saveCredentials();
- } on PlatformException catch (e) {
+ } catch (e) {
return Result.error(ExecError(
type: ErrorType.localError,
- message: e.message ?? '',
+ message: e.toString(),
));
}
}
FutureResult, ExecError> loadProfiles() async {
try {
- final basicJson = await _secureStorage.read(key: _basicProfilesKey);
- if (basicJson == null) {
- return Result.ok(profiles);
- }
- final basicCreds = (jsonDecode(basicJson) as List)
- .map((json) => Profile.fromJson(json, BasicCredentials.fromJson))
- .toList();
- _cachedProfiles.addAll(basicCreds);
+ await _loadJson(_basicProfilesKey, BasicCredentials.fromJson);
+ await _loadJson(_oauthProfilesKey, OAuthCredentials.fromJson);
return Result.ok(profiles);
- } on PlatformException catch (e) {
+ } catch (e) {
return Result.error(ExecError(
type: ErrorType.localError,
- message: e.message ?? '',
+ message: e.toString(),
));
}
}
FutureResult, ExecError> saveCredentials() async {
try {
- final basicCredsJson = _cachedProfiles
- .where((p) => p.credentials is BasicCredentials)
- .map((p) => p.toJson())
- .toList();
- final basicCredsString = jsonEncode(basicCredsJson);
- await _secureStorage.write(
- key: _basicProfilesKey, value: basicCredsString);
+ await _saveJson(_basicProfilesKey);
+ await _saveJson(_oauthProfilesKey);
return Result.ok(profiles);
- } on PlatformException catch (e) {
+ } catch (e) {
return Result.error(ExecError(
type: ErrorType.localError,
- message: e.message ?? '',
+ message: e.toString(),
));
}
}
+
+ Future _loadJson(
+ String key,
+ ICredentials Function(Map) fromJson,
+ ) async {
+ final jsonString = await _secureStorage.read(key: key);
+ if (jsonString == null || jsonString.isEmpty) {
+ return;
+ }
+ final profiles = (jsonDecode(jsonString) as List)
+ .map((json) => Profile.fromJson(json, fromJson))
+ .toList();
+ _cachedProfiles.addAll(profiles);
+ }
+
+ Future _saveJson(
+ String key,
+ ) async {
+ final json = _cachedProfiles
+ .where((p) => p.credentials is T)
+ .map((p) => p.toJson())
+ .toList();
+ final jsonString = jsonEncode(json);
+ await _secureStorage.write(key: key, value: jsonString);
+ }
}
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index a9d8564..e662fd7 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -8,6 +8,7 @@ import Foundation
import desktop_window
import device_info_plus
import flutter_secure_storage_macos
+import flutter_web_auth_2
import objectbox_flutter_libs
import path_provider_foundation
import shared_preferences_foundation
@@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
+ FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 9d20e16..f7e9554 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -427,6 +427,23 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_web_auth_2:
+ dependency: "direct main"
+ description:
+ path: flutter_web_auth_2
+ ref: add-linux-support
+ resolved-ref: ce43f48b3b6c0b055e3871eedf1feee1cbe1a2d9
+ url: "https://github.com/HankG/flutter_web_auth_2.git"
+ source: git
+ version: "2.0.4"
+ flutter_web_auth_2_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_web_auth_2_platform_interface
+ sha256: "777df76e0a3b3c3ec2c33bda2fd832d58ba68183644ede0738665399c8810048"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
flutter_web_plugins:
dependency: transitive
description: flutter
diff --git a/pubspec.yaml b/pubspec.yaml
index eb29482..158ef96 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -17,6 +17,11 @@ dependencies:
flutter_dotenv: ^5.0.2
flutter_file_dialog: ^2.3.2
flutter_secure_storage: ^7.0.1
+ flutter_web_auth_2:
+ git:
+ url: https://github.com/HankG/flutter_web_auth_2.git
+ path: flutter_web_auth_2
+ ref: add-linux-support
flutter_widget_from_html_core: ^0.9.0
get_it: ^7.2.0
get_it_mixin: ^3.1.4