mirror of
https://github.com/hanskokx/arcane_implementations.git
synced 2026-05-14 02:19:05 +02:00
@@ -0,0 +1,94 @@
|
|||||||
|
import "dart:developer";
|
||||||
|
|
||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:arcane_helper_utils/arcane_helper_utils.dart";
|
||||||
|
import "package:dio/dio.dart";
|
||||||
|
|
||||||
|
class AuthorizationInterceptor extends Interceptor {
|
||||||
|
AuthorizationInterceptor();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onRequest(
|
||||||
|
RequestOptions options,
|
||||||
|
RequestInterceptorHandler handler,
|
||||||
|
) async {
|
||||||
|
if (options.path == EnvVar.accessTokenUrl.value) {
|
||||||
|
return super.onRequest(options, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool isSignedIn = Arcane.auth.isSignedIn.value;
|
||||||
|
String? accessToken;
|
||||||
|
|
||||||
|
if (isSignedIn) accessToken = await Arcane.auth.accessToken;
|
||||||
|
|
||||||
|
// Gather IDs for each request so we can track each HTTP request by session,
|
||||||
|
// install, and per-request. These IDs can be used to correlate requests
|
||||||
|
// between the front-end and backend in the logs.
|
||||||
|
final String requestId = IdService.I.newId;
|
||||||
|
final String? installId = IdService.I.installId;
|
||||||
|
final String? sessionId = IdService.I.sessionId.value;
|
||||||
|
|
||||||
|
// Logs the auth token to the debug console. Useful for debugging requests
|
||||||
|
// using third-party tools where you need to authenticate. Only works if the
|
||||||
|
// auth token isn't null and [Feature.debugPrintAuthToken] is enabled.
|
||||||
|
if (accessToken != null && Feature.debugPrintAuthToken.enabled) {
|
||||||
|
log("Token expires at ${accessToken.jwtExpiryTime()!.toIso8601String()}\n\n$accessToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assembles a Map of metadata that we're going to add to each HTTP request
|
||||||
|
// so we can log out all the information in the debug console. We also
|
||||||
|
// store all of the IDs we've collected into a Map for future use.
|
||||||
|
final Map<String, String> metadata = {
|
||||||
|
"request_id": requestId,
|
||||||
|
if (installId.isNotNullOrEmpty) "install_id": "$installId",
|
||||||
|
if (sessionId.isNotNullOrEmpty) "session_id": "$sessionId",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempts to map the "query" parameter in a GraphQL request to a String
|
||||||
|
// so that we can log it out in the debug console.
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> query = options.data as Map<String, dynamic>;
|
||||||
|
|
||||||
|
for (final MapEntry<String, dynamic> entry in query.entries) {
|
||||||
|
if (entry.key == "query") {
|
||||||
|
final String queryString = entry.value.toString();
|
||||||
|
final int queryStringOriginalLength = queryString.length;
|
||||||
|
const int trimQueryStringAtLength = 64;
|
||||||
|
final int trimToLength =
|
||||||
|
queryStringOriginalLength > trimQueryStringAtLength
|
||||||
|
? trimQueryStringAtLength
|
||||||
|
: queryStringOriginalLength;
|
||||||
|
final String finalString =
|
||||||
|
"${queryString.substring(0, trimToLength)}${trimToLength < queryStringOriginalLength ? "[...]" : ""}";
|
||||||
|
metadata.addAll({entry.key: finalString});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes the extra query string from the headers so we're not sending it
|
||||||
|
// to the server along with our request.
|
||||||
|
metadata.removeWhere((key, value) => key == "query");
|
||||||
|
|
||||||
|
// Tries to add the authentication token to the header metadata that we're
|
||||||
|
// going to add to the request.
|
||||||
|
if (accessToken.isNotNullOrEmpty) {
|
||||||
|
metadata.addAll({"Authorization": "Bearer $accessToken"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds all the collected metadata (auth token, request ID, etc.) to the
|
||||||
|
// request headers.
|
||||||
|
options.headers.addAll(metadata);
|
||||||
|
|
||||||
|
// Logs the HTTP request so we can see the request ID and truncated query
|
||||||
|
// string (if available).
|
||||||
|
Arcane.log(
|
||||||
|
"Making HTTP Request ${options.path}",
|
||||||
|
level: Level.info,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
return super.onRequest(options, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:dio/dio.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:gql_dio_link/gql_dio_link.dart";
|
||||||
|
import "package:graphql/client.dart";
|
||||||
|
|
||||||
|
class AppHttpClient {
|
||||||
|
static bool _isMocked = false;
|
||||||
|
|
||||||
|
static final AppHttpClient _service = AppHttpClient._internal();
|
||||||
|
|
||||||
|
factory AppHttpClient() {
|
||||||
|
return _service;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppHttpClient._internal();
|
||||||
|
|
||||||
|
static late GraphQLClient _graphQlClient;
|
||||||
|
static GraphQLClient get graphQlClient => _graphQlClient;
|
||||||
|
|
||||||
|
static late GraphQLClient _webSocketClient;
|
||||||
|
static GraphQLClient get webSocketClient => _webSocketClient;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void setMocked() => _isMocked = true;
|
||||||
|
|
||||||
|
static Future<void> init(Dio dio, {String? url}) async {
|
||||||
|
if (_isMocked) return;
|
||||||
|
|
||||||
|
late final String? authToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
authToken = await Arcane.auth.accessToken;
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_webSocketClient = GraphQLClient(
|
||||||
|
cache: GraphQLCache(),
|
||||||
|
queryRequestTimeout: const Duration(seconds: 40),
|
||||||
|
defaultPolicies: DefaultPolicies(
|
||||||
|
query: Policies(
|
||||||
|
fetch: FetchPolicy.networkOnly,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
link: WebSocketLink(
|
||||||
|
"wss://$url",
|
||||||
|
config: SocketClientConfig(
|
||||||
|
autoReconnect: true,
|
||||||
|
initialPayload: {
|
||||||
|
"Authorization": "Bearer $authToken",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
subProtocol: GraphQLProtocol.graphqlTransportWs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_graphQlClient = GraphQLClient(
|
||||||
|
cache: GraphQLCache(),
|
||||||
|
queryRequestTimeout: const Duration(seconds: 40),
|
||||||
|
defaultPolicies: DefaultPolicies(
|
||||||
|
query: Policies(
|
||||||
|
fetch: FetchPolicy.networkOnly,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
link: DioLink(
|
||||||
|
"https://$url",
|
||||||
|
client: dio,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QueryResultExtension on QueryResult {
|
||||||
|
int get errorStatusCode {
|
||||||
|
if (exception?.linkException != null &&
|
||||||
|
exception!.linkException is HttpLinkServerException) {
|
||||||
|
final HttpLinkServerException httpLinkException =
|
||||||
|
exception!.linkException as HttpLinkServerException;
|
||||||
|
return httpLinkException.response.statusCode;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get failed {
|
||||||
|
return !success;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get success {
|
||||||
|
return !hasException && (data ?? const <dynamic, dynamic>{}).isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:dio/dio.dart";
|
||||||
|
import "package:dio_smart_retry/dio_smart_retry.dart";
|
||||||
|
import "package:get_it/get_it.dart";
|
||||||
|
import "package:native_dio_adapter/native_dio_adapter.dart";
|
||||||
|
import "package:pretty_dio_logger/pretty_dio_logger.dart";
|
||||||
|
|
||||||
|
import "authorization_interceptor.dart";
|
||||||
|
|
||||||
|
abstract class DioHelper {
|
||||||
|
DioHelper._();
|
||||||
|
|
||||||
|
static Dio createDioInstance(GetIt container) {
|
||||||
|
final Dio dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: "https://${EnvVar.graphQlUrl.value}",
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
sendTimeout: const Duration(seconds: 30),
|
||||||
|
receiveTimeout: const Duration(seconds: 40),
|
||||||
|
followRedirects: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
dio.interceptors
|
||||||
|
..add(container.get<AuthorizationInterceptor>())
|
||||||
|
..add(dioLogger)
|
||||||
|
..add(smartRetry(dio));
|
||||||
|
|
||||||
|
dio.httpClientAdapter = NativeAdapter();
|
||||||
|
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Interceptor smartRetry(Dio dio) => RetryInterceptor(
|
||||||
|
dio: dio,
|
||||||
|
logPrint: Arcane.log,
|
||||||
|
retries: 3,
|
||||||
|
retryDelays: const [
|
||||||
|
Duration(seconds: 1),
|
||||||
|
Duration(seconds: 2),
|
||||||
|
Duration(seconds: 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static PrettyDioLogger get dioLogger => PrettyDioLogger(
|
||||||
|
requestHeader: true,
|
||||||
|
requestBody: true,
|
||||||
|
responseBody: true,
|
||||||
|
responseHeader: false,
|
||||||
|
error: true,
|
||||||
|
compact: true,
|
||||||
|
maxWidth: 90,
|
||||||
|
enabled: Feature.httpLogging.enabled,
|
||||||
|
filter: (options, args) {
|
||||||
|
if (options.path.contains("/graphql")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't print responses with unit8 list data
|
||||||
|
return !args.isResponse || !args.hasUint8ListData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import "package:amplify_auth_cognito/amplify_auth_cognito.dart";
|
||||||
|
import "package:amplify_flutter/amplify_flutter.dart";
|
||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/widgets.dart";
|
||||||
|
|
||||||
|
class AmplifyInterface implements ArcaneAuthInterface {
|
||||||
|
AmplifyInterface._internal();
|
||||||
|
|
||||||
|
static bool _mocked = false;
|
||||||
|
|
||||||
|
static final ArcaneAuthInterface _instance = AmplifyInterface._internal();
|
||||||
|
static ArcaneAuthInterface get I => _instance;
|
||||||
|
|
||||||
|
AmplifyAuthCognito get _cognito =>
|
||||||
|
Amplify.Auth.getPlugin(AmplifyAuthCognito.pluginKey);
|
||||||
|
|
||||||
|
Future<CognitoAuthSession?> get _session async {
|
||||||
|
try {
|
||||||
|
return await _cognito.fetchAuthSession();
|
||||||
|
} on AuthException catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> get isSignedIn =>
|
||||||
|
_session.then((value) => value?.isSignedIn == true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> get accessToken => isSignedIn.then(
|
||||||
|
(loggedIn) => loggedIn
|
||||||
|
? _session.then(
|
||||||
|
(value) => value?.userPoolTokensResult.value.accessToken.raw,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> get refreshToken => isSignedIn.then(
|
||||||
|
(loggedIn) => loggedIn
|
||||||
|
? _session.then(
|
||||||
|
(value) => value?.userPoolTokensResult.value.refreshToken,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void, String>> logout() async {
|
||||||
|
final result = await _cognito.signOut();
|
||||||
|
|
||||||
|
if (result is CognitoFailedSignOut) {
|
||||||
|
return Result.error(result.exception.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void, String>> loginWithEmailAndPassword({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final bool alreadyLoggedIn = await isSignedIn;
|
||||||
|
|
||||||
|
if (alreadyLoggedIn) return Result.ok(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final CognitoSignInResult result = await _cognito.signIn(
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
return await _handleSignInResult(result, email);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
return Result.error("Error signing in: ${e.message}");
|
||||||
|
} catch (e) {
|
||||||
|
return Result.error("Error signing in: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void, String>> _handleSignInResult(
|
||||||
|
SignInResult result,
|
||||||
|
String email,
|
||||||
|
) async {
|
||||||
|
switch (result.nextStep.signInStep) {
|
||||||
|
case AuthSignInStep.confirmSignInWithSmsMfaCode:
|
||||||
|
final codeDeliveryDetails = result.nextStep.codeDeliveryDetails!;
|
||||||
|
return Result.error(_handleCodeDelivery(codeDeliveryDetails));
|
||||||
|
case AuthSignInStep.confirmSignInWithNewPassword:
|
||||||
|
return Result.error("Enter a new password to continue signing in");
|
||||||
|
case AuthSignInStep.confirmSignInWithCustomChallenge:
|
||||||
|
final parameters = result.nextStep.additionalInfo;
|
||||||
|
final prompt = parameters["prompt"]!;
|
||||||
|
return Result.error(prompt);
|
||||||
|
case AuthSignInStep.resetPassword:
|
||||||
|
final resetResult = await _cognito.resetPassword(
|
||||||
|
username: email,
|
||||||
|
);
|
||||||
|
return Result.error("Reset password result: $resetResult");
|
||||||
|
case AuthSignInStep.confirmSignUp:
|
||||||
|
// Resend the sign up code to the registered device.
|
||||||
|
final resendResult = await _cognito.resendSignUpCode(
|
||||||
|
username: email,
|
||||||
|
);
|
||||||
|
return Result.error(
|
||||||
|
_handleCodeDelivery(resendResult.codeDeliveryDetails),
|
||||||
|
);
|
||||||
|
case AuthSignInStep.done:
|
||||||
|
return Result.ok(null);
|
||||||
|
default:
|
||||||
|
Arcane.log(
|
||||||
|
"Sign-in failed",
|
||||||
|
level: Level.warning,
|
||||||
|
metadata: {
|
||||||
|
"result": result.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Result.error(
|
||||||
|
"Unexpected sign-in result: ${result.nextStep.signInStep}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _handleCodeDelivery(AuthCodeDeliveryDetails codeDeliveryDetails) {
|
||||||
|
// TODO(any): localize this
|
||||||
|
return "A confirmation code has been sent to ${codeDeliveryDetails.destination}. "
|
||||||
|
"Please check your ${codeDeliveryDetails.deliveryMedium.name} for the code.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<String, String>> resendVerificationCode(String email) async {
|
||||||
|
try {
|
||||||
|
final result = await _cognito.resendSignUpCode(
|
||||||
|
username: email.toLowerCase(),
|
||||||
|
);
|
||||||
|
final codeDeliveryDetails = result.codeDeliveryDetails;
|
||||||
|
final String returnValue = _handleCodeDelivery(codeDeliveryDetails);
|
||||||
|
return Result.ok(returnValue);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
return Result.error("Error resending verification code: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<SignUpStep, String>> signup({
|
||||||
|
required String password,
|
||||||
|
required String email,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final String accountEmail = email.toLowerCase();
|
||||||
|
final userAttributes = {
|
||||||
|
AuthUserAttributeKey.email: accountEmail,
|
||||||
|
};
|
||||||
|
final SignUpResult result = await _cognito.signUp(
|
||||||
|
username: accountEmail,
|
||||||
|
password: password,
|
||||||
|
options: SignUpOptions(
|
||||||
|
userAttributes: userAttributes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.nextStep.signUpStep == AuthSignUpStep.confirmSignUp) {
|
||||||
|
return Result.ok(SignUpStep.confirmSignUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(SignUpStep.done);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
return Result.error("Error signing up user: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<bool, String>> confirmSignup({
|
||||||
|
required String username,
|
||||||
|
required String confirmationCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final CognitoSignUpResult result = await _cognito.confirmSignUp(
|
||||||
|
username: username.toLowerCase(),
|
||||||
|
confirmationCode: confirmationCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.ok(result.isSignUpComplete);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
return Result.error("Error confirming user: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<bool, String>> resetPassword({
|
||||||
|
required String email,
|
||||||
|
String? newPassword,
|
||||||
|
String? code,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
late ResetPasswordResult result;
|
||||||
|
if (newPassword != null && code != null) {
|
||||||
|
result = await _cognito.confirmResetPassword(
|
||||||
|
username: email,
|
||||||
|
newPassword: newPassword,
|
||||||
|
confirmationCode: code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword == null && code == null) {
|
||||||
|
result = await _cognito.resetPassword(
|
||||||
|
username: email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(result.isPasswordReset);
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
return Result.error("Error resetting the password: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_mocked) return;
|
||||||
|
|
||||||
|
if (Amplify.isConfigured) return;
|
||||||
|
|
||||||
|
final plugin = AmplifyAuthCognito();
|
||||||
|
|
||||||
|
await Amplify.addPlugin(plugin);
|
||||||
|
await Amplify.configure(_amplifyconfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void setMocked() {
|
||||||
|
_mocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final String _amplifyconfig = '''
|
||||||
|
{
|
||||||
|
"UserAgent": "aws-amplify-cli/2.0",
|
||||||
|
"Version": "1.0",
|
||||||
|
"auth": {
|
||||||
|
"plugins": {
|
||||||
|
"awsCognitoAuthPlugin": {
|
||||||
|
"IdentityManager": {
|
||||||
|
"Default": {}
|
||||||
|
},
|
||||||
|
"CredentialsProvider": {
|
||||||
|
"CognitoIdentity": {
|
||||||
|
"Default": {
|
||||||
|
"PoolId": "${EnvVar.cognitoPoolId.value}",
|
||||||
|
"Region": "${EnvVar.cognitoRegion.value}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CognitoUserPool": {
|
||||||
|
"Default": {
|
||||||
|
"PoolId": "${EnvVar.cognitoPoolId.value}",
|
||||||
|
"AppClientId": "${EnvVar.cognitoClientId.value}",
|
||||||
|
"Region": "${EnvVar.cognitoRegion.value}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"Default": {
|
||||||
|
"authenticationFlowType": "USER_SRP_AUTH",
|
||||||
|
"OAuth": {
|
||||||
|
"WebDomain": "${EnvVar.authUrl.value}",
|
||||||
|
"AppClientId": "${EnvVar.cognitoClientId.value}",
|
||||||
|
"SignInRedirectURI": "${EnvVar.redirectUri.value}",
|
||||||
|
"SignOutRedirectURI": "${EnvVar.redirectUri.value}",
|
||||||
|
"Scopes": [
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"aws.cognito.signin.user.admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}''';
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "dart:io" show Platform;
|
||||||
|
|
||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:arcane_helper_utils/arcane_helper_utils.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:logger/logger.dart" as l;
|
||||||
|
|
||||||
|
class DebugConsole implements LoggingInterface {
|
||||||
|
static final DebugConsole _instance = DebugConsole._internal();
|
||||||
|
static DebugConsole get I => _instance;
|
||||||
|
|
||||||
|
final bool _initialized = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get initialized => I._initialized;
|
||||||
|
|
||||||
|
DebugConsole._internal();
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void setMocked() => _mocked = true;
|
||||||
|
bool _mocked = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void log(
|
||||||
|
String message, {
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
Level? level,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
if (Feature.logging.disabled) return;
|
||||||
|
if (Feature.debugConsoleLogging.disabled) return;
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
if (!initialized) init();
|
||||||
|
|
||||||
|
const Level cutoff = AppConfig.debugLoggingThreshold;
|
||||||
|
|
||||||
|
if ((level?.value ?? Level.debug.value) < cutoff.value) return;
|
||||||
|
|
||||||
|
final l.Level logLevel = l.Level.values
|
||||||
|
.firstWhere((value) => value.name == (level ?? Level.debug).name);
|
||||||
|
|
||||||
|
final Map<String, dynamic> localMetadata = metadata ?? {};
|
||||||
|
|
||||||
|
final String? module = localMetadata["module"] as String?;
|
||||||
|
final String? method = localMetadata["method"] as String?;
|
||||||
|
|
||||||
|
const JsonEncoder encoder = JsonEncoder.withIndent(" ");
|
||||||
|
final String? prettyprint =
|
||||||
|
(localMetadata.isNotEmpty) ? encoder.convert(localMetadata) : null;
|
||||||
|
|
||||||
|
final l.Logger logger = l.Logger(
|
||||||
|
level: logLevel,
|
||||||
|
printer: l.PrettyPrinter(
|
||||||
|
methodCount: 2,
|
||||||
|
errorMethodCount: kDebugMode &&
|
||||||
|
!(level == Level.error ||
|
||||||
|
level == Level.warning ||
|
||||||
|
level == Level.trace ||
|
||||||
|
level == Level.fatal)
|
||||||
|
? 4
|
||||||
|
: 8,
|
||||||
|
stackTraceBeginIndex: 1,
|
||||||
|
lineLength: 120,
|
||||||
|
colors: !Platform.isIOS,
|
||||||
|
printEmojis: kDebugMode,
|
||||||
|
dateTimeFormat: l.DateTimeFormat.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print the message to the debug console
|
||||||
|
String prefix = "";
|
||||||
|
if (module != null) prefix += "[$module]";
|
||||||
|
if (method != null) prefix += "[$method]";
|
||||||
|
if (prefix.isNotEmpty) prefix += " ";
|
||||||
|
message = "$prefix$message";
|
||||||
|
|
||||||
|
if (prettyprint.isNotNullOrEmpty) message += "\n\n$prettyprint";
|
||||||
|
|
||||||
|
localMetadata.removeWhere((key, value) => key == "module");
|
||||||
|
localMetadata.removeWhere((key, value) => key == "method");
|
||||||
|
localMetadata.removeWhere((key, value) => key == "filenameAndLineNumber");
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
logLevel,
|
||||||
|
message,
|
||||||
|
error: localMetadata["error"] ?? "",
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LoggingInterface?> init() async {
|
||||||
|
if (_mocked) return null;
|
||||||
|
|
||||||
|
return I;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
|
||||||
|
class DebugPrint implements LoggingInterface {
|
||||||
|
DebugPrint._internal();
|
||||||
|
static final DebugPrint _instance = DebugPrint._internal();
|
||||||
|
static DebugPrint get I => _instance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get initialized => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void log(
|
||||||
|
String message, {
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
Level? level = Level.debug,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
debugPrint("[${level!.name}] $message ($metadata)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LoggingInterface?> init() async => I;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import "dart:io" show Platform;
|
||||||
|
|
||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:arcane_helper_utils/arcane_helper_utils.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:newrelic_mobile/config.dart";
|
||||||
|
import "package:newrelic_mobile/newrelic_mobile.dart";
|
||||||
|
|
||||||
|
class NewRelic implements LoggingInterface {
|
||||||
|
static final NewRelic _instance = NewRelic._internal();
|
||||||
|
static NewRelic get I => _instance;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
@override
|
||||||
|
bool get initialized => I._initialized;
|
||||||
|
|
||||||
|
NewRelic._internal();
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void setMocked() => _mocked = true;
|
||||||
|
bool _mocked = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void log(
|
||||||
|
String message, {
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
Level? level,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
if (Feature.logging.disabled) return;
|
||||||
|
if (Feature.newRelic.disabled) return;
|
||||||
|
if (!initialized) return;
|
||||||
|
|
||||||
|
final Map<String, dynamic> metadataToSend = metadata ?? {};
|
||||||
|
|
||||||
|
// New Relic strips this out, anyway, so let's not cause additional logs.
|
||||||
|
metadataToSend.removeWhere((key, _) => key == "timestamp");
|
||||||
|
|
||||||
|
// Add the logging level to the metadata
|
||||||
|
metadataToSend.putIfAbsent(
|
||||||
|
"level",
|
||||||
|
() => (level?.name ?? "debug").capitalize,
|
||||||
|
);
|
||||||
|
|
||||||
|
NewrelicMobile.instance.recordCustomEvent(
|
||||||
|
"App",
|
||||||
|
eventName: message,
|
||||||
|
eventAttributes: metadataToSend,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stackTrace != null) {
|
||||||
|
NewrelicMobile.instance.recordError(message, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NewRelic?> init() async {
|
||||||
|
if (_mocked) return null;
|
||||||
|
if (Feature.newRelic.disabled) return null;
|
||||||
|
if (initialized) return I;
|
||||||
|
|
||||||
|
final String appToken = AppEnv.valueOf(
|
||||||
|
Platform.isAndroid
|
||||||
|
? EnvVar.newRelicAppTokenAndroid
|
||||||
|
: EnvVar.newRelicAppTokenIos,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Config config = Config(
|
||||||
|
accessToken: appToken,
|
||||||
|
|
||||||
|
//Android Specific
|
||||||
|
// Optional: Enable or disable collection of event data.
|
||||||
|
analyticsEventEnabled: true,
|
||||||
|
// Optional: Enable or disable reporting successful HTTP requests to the MobileRequest event type.
|
||||||
|
networkErrorRequestEnabled: true,
|
||||||
|
// Optional: Enable or disable reporting network and HTTP request errors to the MobileRequestError event type.
|
||||||
|
networkRequestEnabled: true,
|
||||||
|
// Optional: Enable or disable crash reporting.
|
||||||
|
crashReportingEnabled: true,
|
||||||
|
// Optional: Enable or disable interaction tracing. Trace instrumentation still occurs, but no traces are harvested. This will disable default and custom interactions.
|
||||||
|
interactionTracingEnabled: true,
|
||||||
|
// Optional: Enable or disable capture of HTTP response bodies for HTTP error traces and MobileRequestError events.
|
||||||
|
httpResponseBodyCaptureEnabled: true,
|
||||||
|
// Optional: Enable or disable agent logging.
|
||||||
|
loggingEnabled: true,
|
||||||
|
// iOS specific
|
||||||
|
// Optional: Enable or disable automatic instrumentation of WebViews
|
||||||
|
webViewInstrumentation: false,
|
||||||
|
//Optional: Enable or disable Print Statements as Analytics Events
|
||||||
|
printStatementAsEventsEnabled: false,
|
||||||
|
// Optional: Enable or disable automatic instrumentation of HTTP Request
|
||||||
|
httpInstrumentationEnabled: true,
|
||||||
|
// Optional: Enable or disable reporting data using different endpoints for US government clients
|
||||||
|
fedRampEnabled: false,
|
||||||
|
// Optional: Enable or disable offline data storage when no internet connection is available.
|
||||||
|
offlineStorageEnabled: true,
|
||||||
|
// iOS Specific
|
||||||
|
// Optional: Enable or disable background reporting functionality.
|
||||||
|
backgroundReportingEnabled: false,
|
||||||
|
// iOS Specific
|
||||||
|
// Optional: Enable or disable to use our new, more stable, event system for iOS agent.
|
||||||
|
newEventSystemEnabled: true,
|
||||||
|
|
||||||
|
// Optional: Enable or disable distributed tracing.
|
||||||
|
distributedTracingEnabled: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await NewrelicMobile.instance.startAgent(config);
|
||||||
|
|
||||||
|
// Initialization complete.
|
||||||
|
I._initialized = true;
|
||||||
|
|
||||||
|
return I;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:flutter_dotenv/flutter_dotenv.dart";
|
||||||
|
|
||||||
|
class AppEnv {
|
||||||
|
/// Fetches the environment variable value for the given [EnvVar]. If the
|
||||||
|
/// value is not set, defaults to an empty string.
|
||||||
|
static String valueOf(EnvVar val) {
|
||||||
|
return dotenv.maybeGet(val.key) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns [true] if all of the [EnvVar] variables are set.
|
||||||
|
static bool get hasEnv =>
|
||||||
|
dotenv.isEveryDefined(EnvVar.values.map((e) => e.key).toList());
|
||||||
|
|
||||||
|
static Future<void> init() async {
|
||||||
|
await dotenv.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FlutterMode flutterMode = kDebugMode
|
||||||
|
? FlutterMode.debug
|
||||||
|
: kReleaseMode
|
||||||
|
? FlutterMode.release
|
||||||
|
: kProfileMode
|
||||||
|
? FlutterMode.profile
|
||||||
|
: FlutterMode.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlutterMode {
|
||||||
|
debug,
|
||||||
|
profile,
|
||||||
|
release,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvVarValue on EnvVar {
|
||||||
|
/// The value of the [EnvVar] as a string. If the environment variable is not
|
||||||
|
/// set, returns an empty string.
|
||||||
|
String get value => AppEnv.valueOf(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EnvVar {
|
||||||
|
/// The environment to use for the API calls. Returns either [dev] or [prod].
|
||||||
|
///
|
||||||
|
/// Example `.env` configuration:
|
||||||
|
/// ```
|
||||||
|
/// API_ENVIRONMENT="dev"
|
||||||
|
/// ```
|
||||||
|
apiEnvironment("API_ENVIRONMENT"),
|
||||||
|
;
|
||||||
|
|
||||||
|
/// The environment variable to use when retrieving the value of this [EnvVar].
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
const EnvVar(this.key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart";
|
||||||
|
import "package:flutter_gen/gen_l10n/app_localizations.dart";
|
||||||
|
import "package:flutter_svg/flutter_svg.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
|
||||||
|
class AppScaffold extends StatelessWidget {
|
||||||
|
final Widget body;
|
||||||
|
final Widget? secondaryBody;
|
||||||
|
|
||||||
|
const AppScaffold({
|
||||||
|
required this.body,
|
||||||
|
this.secondaryBody,
|
||||||
|
super.key = const ValueKey("AppScaffold"),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AdaptiveLayout(
|
||||||
|
internalAnimations: false,
|
||||||
|
body: SlotLayout(
|
||||||
|
config: <Breakpoint, SlotLayoutConfig>{
|
||||||
|
Breakpoints.small: SlotLayout.from(
|
||||||
|
key: const Key("Body Small"),
|
||||||
|
builder: (context) => secondaryBody ?? body,
|
||||||
|
),
|
||||||
|
Breakpoints.mediumAndUp: SlotLayout.from(
|
||||||
|
key: const Key("Body Medium and Up"),
|
||||||
|
builder: (context) => body,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
secondaryBody: SlotLayout(
|
||||||
|
config: <Breakpoint, SlotLayoutConfig>{
|
||||||
|
Breakpoints.small: SlotLayout.from(
|
||||||
|
key: const Key("Body Small"),
|
||||||
|
builder: null,
|
||||||
|
),
|
||||||
|
Breakpoints.mediumAndUp: SlotLayout.from(
|
||||||
|
key: const Key("Body Medium"),
|
||||||
|
builder: secondaryBody,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppScaffoldShell extends StatelessWidget {
|
||||||
|
final StatefulNavigationShell navigationShell;
|
||||||
|
const AppScaffoldShell({
|
||||||
|
required this.navigationShell,
|
||||||
|
super.key = const ValueKey("AppScaffoldShell"),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AdaptiveScaffold(
|
||||||
|
useDrawer: false,
|
||||||
|
internalAnimations: false,
|
||||||
|
selectedIndex: navigationShell.currentIndex,
|
||||||
|
onSelectedIndexChange: onNavigationEvent,
|
||||||
|
destinations: [
|
||||||
|
NavigationDestination(
|
||||||
|
label: AppLocalizations.of(context).tabHome,
|
||||||
|
icon: const Icon(Icons.home),
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
label: AppLocalizations.of(context).tabHistory,
|
||||||
|
icon: const Icon(Icons.change_history_rounded),
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
label: AppLocalizations.of(context).tabProfile,
|
||||||
|
icon: const Icon(Icons.account_circle),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
body: (_) => navigationShell,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNavigationEvent(int index) {
|
||||||
|
navigationShell.goBranch(
|
||||||
|
index,
|
||||||
|
initialLocation: index == navigationShell.currentIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
enum Feature {
|
||||||
|
/// Prints the current auth token to the debug log each time the API makes a
|
||||||
|
/// request.
|
||||||
|
debugPrintAuthToken(kDebugMode),
|
||||||
|
;
|
||||||
|
|
||||||
|
/// Determines whether the given [Feature] is enabled by default when the
|
||||||
|
/// application launches. Features can be enabled or disabled during runtime,
|
||||||
|
/// regardless of this value.
|
||||||
|
final bool enabledAtStartup;
|
||||||
|
|
||||||
|
const Feature(this.enabledAtStartup);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:dio/dio.dart";
|
||||||
|
import "package:flutter_secure_storage/flutter_secure_storage.dart";
|
||||||
|
import "package:get_it/get_it.dart";
|
||||||
|
|
||||||
|
abstract class AppInjector {
|
||||||
|
static final GetIt getIt = GetIt.I;
|
||||||
|
|
||||||
|
static void _registerApis() {
|
||||||
|
getIt.registerSingleton<MyApi>(
|
||||||
|
SetupApi(getIt<SecureStorageRepository>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _registerHelpers() {
|
||||||
|
getIt.registerSingleton<FlutterSecureStorage>(
|
||||||
|
const FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(
|
||||||
|
encryptedSharedPreferences: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
getIt.registerSingleton<SecureStorageRepository>(
|
||||||
|
SecureStorageRepository(getIt<FlutterSecureStorage>()),
|
||||||
|
);
|
||||||
|
getIt.registerLazySingleton<AuthorizationInterceptor>(
|
||||||
|
() => AuthorizationInterceptor(),
|
||||||
|
);
|
||||||
|
getIt.registerLazySingleton<Dio>(
|
||||||
|
() => DioHelper.createDioInstance(getIt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> init() async {
|
||||||
|
Arcane.log(
|
||||||
|
"Initializing injector...",
|
||||||
|
level: Level.info,
|
||||||
|
);
|
||||||
|
|
||||||
|
await getIt.reset();
|
||||||
|
_registerHelpers();
|
||||||
|
_registerApis();
|
||||||
|
await getIt.allReady();
|
||||||
|
|
||||||
|
await IdService.I.init();
|
||||||
|
|
||||||
|
Arcane.log(
|
||||||
|
"Injector initialized.",
|
||||||
|
level: Level.info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resetDio() {
|
||||||
|
if (getIt.isRegistered<Dio>()) {
|
||||||
|
getIt.unregister<Dio>();
|
||||||
|
}
|
||||||
|
|
||||||
|
getIt.registerLazySingleton<Dio>(
|
||||||
|
() => DioHelper.createDioInstance(getIt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> configureAsDebug() async {
|
||||||
|
Arcane.log(
|
||||||
|
"Unregistering production APIs and replacing with Debug versions",
|
||||||
|
level: Level.fatal,
|
||||||
|
);
|
||||||
|
|
||||||
|
getIt.unregister<MyApi>();
|
||||||
|
|
||||||
|
await getIt.allReady();
|
||||||
|
|
||||||
|
getIt.registerSingleton<SecureStorageRepository>(
|
||||||
|
DebugSecureStorageRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final SecureStorageRepository storage = getIt<SecureStorageRepository>();
|
||||||
|
|
||||||
|
getIt.registerSingleton<MyApi>(DebugMyApi(storage));
|
||||||
|
|
||||||
|
await getIt.allReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_bloc/flutter_bloc.dart";
|
||||||
|
import "package:get_it/get_it.dart";
|
||||||
|
|
||||||
|
class AppStateProvider extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AppStateProvider({
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ValueKey key = ValueKey(
|
||||||
|
"${context.watch<ArcaneEnvironment>().state.name}-${IdService.I.sessionId.value}",
|
||||||
|
);
|
||||||
|
|
||||||
|
return MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(
|
||||||
|
key: key,
|
||||||
|
create: (context) => MyBloc(GetIt.I<MyApi>()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:get_it/get_it.dart";
|
||||||
|
import "package:uuid/uuid.dart";
|
||||||
|
|
||||||
|
/// A singleton service that manages unique IDs, including install and session IDs.
|
||||||
|
///
|
||||||
|
/// The `IdService` provides a way to generate and retrieve unique identifiers
|
||||||
|
/// for application installs and sessions. It interacts with secure storage to persist
|
||||||
|
/// the install ID across app launches and generates new session IDs for each session.
|
||||||
|
class IdService extends ArcaneService {
|
||||||
|
/// Whether the service is mocked for testing purposes.
|
||||||
|
static bool _mocked = false;
|
||||||
|
|
||||||
|
/// The singleton instance of `ArcaneIdService`.
|
||||||
|
static final IdService _instance = IdService._internal();
|
||||||
|
|
||||||
|
/// Provides access to the singleton instance of `IdService`.
|
||||||
|
static IdService get I => _instance;
|
||||||
|
|
||||||
|
IdService._internal();
|
||||||
|
|
||||||
|
SecureStorageRepository get _storage => GetIt.I<SecureStorageRepository>();
|
||||||
|
|
||||||
|
/// Whether the service has been initialized.
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Returns `true` if the service has been initialized.
|
||||||
|
bool get initialized => I._initialized;
|
||||||
|
|
||||||
|
/// The unique install ID.
|
||||||
|
///
|
||||||
|
/// This ID is persisted across app launches and is used to uniquely identify
|
||||||
|
/// the installation of the app.
|
||||||
|
String? _installId;
|
||||||
|
|
||||||
|
/// Retrieves the install ID.
|
||||||
|
///
|
||||||
|
/// If the install ID is not yet initialized, this method initializes the service
|
||||||
|
/// and generates a new ID if necessary.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// String? id = IdService.I.installId;
|
||||||
|
/// ```
|
||||||
|
String? get installId => I._installId;
|
||||||
|
|
||||||
|
/// The unique session ID.
|
||||||
|
///
|
||||||
|
/// This ID is generated for each app session and is used to uniquely identify
|
||||||
|
/// the current session.
|
||||||
|
String? _sessionId;
|
||||||
|
|
||||||
|
/// Retrieves the session ID.
|
||||||
|
///
|
||||||
|
/// If the session ID is not yet initialized, this method initializes the service
|
||||||
|
/// and generates a new session ID.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// String? sessionId = ArcaneIdService.I.sessionId.value;
|
||||||
|
/// ```
|
||||||
|
ValueListenable<String?> get sessionId =>
|
||||||
|
ValueNotifier<String?>(I._sessionId);
|
||||||
|
|
||||||
|
/// Generates a new unique ID.
|
||||||
|
///
|
||||||
|
/// This method uses UUID version 7 to generate a new unique ID.
|
||||||
|
String get newId => uuid.v7();
|
||||||
|
|
||||||
|
/// The `Uuid` instance used for generating unique IDs.
|
||||||
|
static const Uuid uuid = Uuid();
|
||||||
|
|
||||||
|
/// Initializes the `IdService`.
|
||||||
|
///
|
||||||
|
/// This method retrieves the install ID from secure storage, generating and storing a new
|
||||||
|
/// one if it does not exist. It also generates a new session ID.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// await IdService.I._init();
|
||||||
|
/// ```
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_mocked) return;
|
||||||
|
Arcane.log(
|
||||||
|
"Initializing ID Service",
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
I._installId = await _storage.getValue(
|
||||||
|
SecureStorageRepository.installIdKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (I._installId == null) {
|
||||||
|
// Generate a new ID and store it
|
||||||
|
I._installId = uuid.v7();
|
||||||
|
await _storage.setValue(
|
||||||
|
SecureStorageRepository.installIdKey,
|
||||||
|
I._installId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
I._sessionId = uuid.v7();
|
||||||
|
I._initialized = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the service as mocked for testing purposes.
|
||||||
|
///
|
||||||
|
/// When the service is mocked, it bypasses certain initializations and uses
|
||||||
|
/// mocked data for testing.
|
||||||
|
@visibleForTesting
|
||||||
|
static void setMocked() => _mocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enum representing different types of IDs managed by the `ArcaneIdService`.
|
||||||
|
///
|
||||||
|
/// The `ID` enum has two possible values:
|
||||||
|
/// - `session`: Represents the session ID.
|
||||||
|
/// - `install`: Represents the install ID.
|
||||||
|
enum ID {
|
||||||
|
/// Represents the session ID.
|
||||||
|
session,
|
||||||
|
|
||||||
|
/// Represents the install ID.
|
||||||
|
install,
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:app_tracking_transparency/app_tracking_transparency.dart";
|
||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_gen/gen_l10n/app_localizations.dart";
|
||||||
|
|
||||||
|
class TrackingService {
|
||||||
|
TrackingService._internal();
|
||||||
|
|
||||||
|
static final TrackingService _instance = TrackingService._internal();
|
||||||
|
|
||||||
|
static TrackingService get I => _instance;
|
||||||
|
|
||||||
|
TrackingStatus _trackingStatus = TrackingStatus.notDetermined;
|
||||||
|
|
||||||
|
TrackingStatus get trackingStatus => I._trackingStatus;
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
bool get initialized => I._initialized;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void setMocked() => _mocked = true;
|
||||||
|
bool _mocked = false;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_mocked) return;
|
||||||
|
|
||||||
|
_trackingStatus = await AppTrackingTransparency.trackingAuthorizationStatus;
|
||||||
|
|
||||||
|
if (!(Platform.isIOS || Platform.isMacOS)) {
|
||||||
|
_trackingStatus = TrackingStatus.authorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TrackingStatus?> initalizeAppTracking(BuildContext context) async {
|
||||||
|
if (_mocked) return null;
|
||||||
|
if (!initialized) await init();
|
||||||
|
|
||||||
|
// If the system can show an authorization request dialog
|
||||||
|
if (trackingStatus == TrackingStatus.notDetermined) {
|
||||||
|
// Show a custom explainer dialog before the system dialog
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
await _showTrackingDialog(context);
|
||||||
|
// Wait for dialog popping animation
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
// Request system's tracking authorization dialog
|
||||||
|
await AppTrackingTransparency.requestTrackingAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
_trackingStatus = await AppTrackingTransparency.trackingAuthorizationStatus;
|
||||||
|
|
||||||
|
if (trackingStatus == TrackingStatus.authorized) {
|
||||||
|
await Arcane.logger.initializeInterfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackingStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showTrackingDialog(BuildContext context) async {
|
||||||
|
await showAdaptiveDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
final buttonStyle = Theme.of(context).textButtonTheme.style?.copyWith(
|
||||||
|
textStyle: const WidgetStatePropertyAll(
|
||||||
|
TextStyle(
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return AlertDialog.adaptive(
|
||||||
|
title: Text(AppLocalizations.of(context).appTrackingTitle),
|
||||||
|
content: Text(AppLocalizations.of(context).appTrackingBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
style: buttonStyle,
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context).continueText),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:arcane_helper_utils/arcane_helper_utils.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:flutter_secure_storage/flutter_secure_storage.dart";
|
||||||
|
|
||||||
|
class SecureStorageRepository {
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
SecureStorageRepository(this._storage);
|
||||||
|
|
||||||
|
static const String installIdKey = "install_id";
|
||||||
|
|
||||||
|
Future<bool> deleteAll() async {
|
||||||
|
try {
|
||||||
|
final String? cachedInstallId = await getValue(installIdKey);
|
||||||
|
|
||||||
|
await _storage.deleteAll();
|
||||||
|
|
||||||
|
if (cachedInstallId.isNotNullOrEmpty) {
|
||||||
|
await setValue(installIdKey, cachedInstallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getValue(String key) async {
|
||||||
|
if (Feature.secureStorageRepositoryLogs.enabled) {
|
||||||
|
Arcane.log(
|
||||||
|
"Value requested from secure storage",
|
||||||
|
level: Level.debug,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
value = await _storage.read(key: key);
|
||||||
|
if (value.isNullOrEmpty && Feature.secureStorageRepositoryLogs.enabled) {
|
||||||
|
Arcane.log(
|
||||||
|
"Value retrieved from secure storage is empty",
|
||||||
|
level: Level.info,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Feature.secureStorageRepositoryLogs.enabled) {
|
||||||
|
Arcane.log(
|
||||||
|
"Successfully retrived value from secure storage",
|
||||||
|
level: Level.debug,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
if (kDebugMode) "value": "$value",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Arcane.log(
|
||||||
|
"Unable to retrieve value from secure storage",
|
||||||
|
level: Level.error,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> setValue(String key, String? value) async {
|
||||||
|
if (Feature.secureStorageRepositoryLogs.enabled) {
|
||||||
|
Arcane.log(
|
||||||
|
"Setting value in secure storage",
|
||||||
|
level: Level.debug,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
"value": "$value",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _storage.write(key: key, value: value);
|
||||||
|
|
||||||
|
if (Feature.secureStorageRepositoryLogs.enabled) {
|
||||||
|
Arcane.log(
|
||||||
|
"Successfully set value in secure storage",
|
||||||
|
level: Level.debug,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
if (kDebugMode) "value": "$value",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
Arcane.log(
|
||||||
|
"Unable to set value in secure storage",
|
||||||
|
level: Level.error,
|
||||||
|
metadata: {
|
||||||
|
"key": key,
|
||||||
|
if (kDebugMode) "value": "$value",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DebugSecureStorageRepository implements SecureStorageRepository {
|
||||||
|
@override
|
||||||
|
FlutterSecureStorage get _storage => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> deleteAll() async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getValue(String key) async {
|
||||||
|
return switch (key) {
|
||||||
|
"install_id" => "install id",
|
||||||
|
_ => throw Exception("Unhandled case in secure storage"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> setValue(String key, String? value) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user