import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import 'package:image_picker/image_picker.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:island/models/auth.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'config.dart'; final imagePickerProvider = Provider((ref) => ImagePicker()); final userAgentProvider = FutureProvider((ref) async { final String platformInfo; if (kIsWeb) { final deviceInfo = await DeviceInfoPlugin().webBrowserInfo; platformInfo = 'Web; ${deviceInfo.vendor}'; } else if (Platform.isAndroid) { final deviceInfo = await DeviceInfoPlugin().androidInfo; platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}'; } else if (Platform.isIOS) { final deviceInfo = await DeviceInfoPlugin().iosInfo; platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}'; } else if (Platform.isMacOS) { final deviceInfo = await DeviceInfoPlugin().macOsInfo; platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}'; } else if (Platform.isWindows) { final deviceInfo = await DeviceInfoPlugin().windowsInfo; platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}'; } else if (Platform.isLinux) { final deviceInfo = await DeviceInfoPlugin().linuxInfo; platformInfo = 'Linux; ${deviceInfo.prettyName}'; } else { platformInfo = 'Unknown'; } final packageInfo = await PackageInfo.fromPlatform(); return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)'; }); final apiClientProvider = Provider((ref) { final serverUrl = ref.watch(serverUrlProvider); final dio = Dio( BaseOptions( baseUrl: serverUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, ), ); dio.interceptors.add( InterceptorsWrapper( onRequest: ( RequestOptions options, RequestInterceptorHandler handler, ) async { try { final atk = await getFreshAtk( ref.watch(tokenPairProvider), ref.watch(serverUrlProvider), onRefreshed: (atk, rtk) { setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); ref.invalidate(tokenPairProvider); }, ); if (atk != null) { options.headers['Authorization'] = 'Bearer $atk'; } } catch (err) { // ignore } final userAgent = ref.read(userAgentProvider); if (userAgent.value != null) { options.headers['User-Agent'] = userAgent.value; } return handler.next(options); }, ), ); return dio; }); final tokenPairProvider = Provider((ref) { final prefs = ref.watch(sharedPreferencesProvider); final tkPairString = prefs.getString(kTokenPairStoreKey); if (tkPairString == null) return null; return AppTokenPair.fromJson(jsonDecode(tkPairString)); }); Future<(String, String)?> refreshToken(String baseUrl, String? rtk) async { if (rtk == null) return null; final dio = Dio(); dio.options.baseUrl = baseUrl; final resp = await dio.post( '/auth/token', data: {'grant_type': 'refresh_token', 'refresh_token': rtk}, ); final String atk = resp.data['access_token']; final String nRtk = resp.data['refresh_token']; return (atk, nRtk); } Completer? _refreshCompleter; Future getFreshAtk( AppTokenPair? tkPair, String baseUrl, { Function(String, String)? onRefreshed, }) async { var atk = tkPair?.accessToken; var rtk = tkPair?.refreshToken; if (_refreshCompleter != null) { return await _refreshCompleter!.future; } else { _refreshCompleter = Completer(); } try { if (atk != null) { final atkParts = atk.split('.'); if (atkParts.length != 3) { throw Exception('invalid format of access token'); } var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/'); switch (rawPayload.length % 4) { case 0: break; case 2: rawPayload += '=='; break; case 3: rawPayload += '='; break; default: throw Exception('illegal format of access token payload'); } final b64 = utf8.fuse(base64Url); final payload = b64.decode(rawPayload); final exp = jsonDecode(payload)['exp']; if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) { log('[Auth] Access token need refresh, doing it at ${DateTime.now()}'); final result = await refreshToken(baseUrl, rtk); if (result == null) { atk = null; } else { onRefreshed?.call(result.$1, result.$2); atk = result.$1; } } if (atk != null) { _refreshCompleter!.complete(atk); return atk; } else { log('[Auth] Access token refresh failed...'); _refreshCompleter!.complete(null); } } } catch (err) { log('[Auth] Failed to authenticate user... $err'); _refreshCompleter!.completeError(err); } finally { _refreshCompleter = null; } return null; } Future setTokenPair( SharedPreferences prefs, String atk, String rtk, ) async { final tkPair = AppTokenPair(accessToken: atk, refreshToken: rtk); final tkPairString = jsonEncode(tkPair); prefs.setString(kTokenPairStoreKey, tkPairString); }