diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..1ff481e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,13 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + errors: + invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980 + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 374309d..60dafd4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -43,7 +43,7 @@ PODS: - Flutter (1.0.0) - flutter_native_splash (0.0.1): - Flutter - - flutter_secure_storage (3.3.1): + - flutter_secure_storage (6.0.0): - Flutter - image_picker_ios (0.0.1): - Flutter @@ -119,7 +119,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 - flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 diff --git a/lib/main.dart b/lib/main.dart index a1193ac..4201c0b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/theme.dart'; import 'package:surface/providers/userinfo.dart'; +import 'package:surface/providers/websocket.dart'; import 'package:surface/router.dart'; void main() async { @@ -39,11 +40,15 @@ class SolianApp extends StatelessWidget { assetLoader: JsonAssetLoader(), child: MultiProvider( providers: [ + // Display layer + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (ctx) => NavigationProvider()), + + // Data layer Provider(create: (_) => SnNetworkProvider()), Provider(create: (ctx) => SnAttachmentProvider(ctx)), - ChangeNotifierProvider(create: (ctx) => NavigationProvider()), ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), - ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), ], child: AppMainContent(), ), @@ -63,7 +68,7 @@ class AppMainContent extends StatelessWidget { @override Widget build(BuildContext context) { context.read(); - context.read(); + context.read(); final th = context.watch(); diff --git a/lib/providers/sn_network.dart b/lib/providers/sn_network.dart index cfbd7a7..bcdea9e 100644 --- a/lib/providers/sn_network.dart +++ b/lib/providers/sn_network.dart @@ -44,48 +44,11 @@ class SnNetworkProvider { RequestOptions options, RequestInterceptorHandler handler, ) async { - try { - var atk = await _storage.read(key: kAtkStoreKey); - 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('Access token need refresh, doing it at ${DateTime.now()}'); - atk = await refreshToken(); - } - - if (atk != null) { - options.headers['Authorization'] = 'Bearer $atk'; - } else { - log('Access token refresh failed...'); - } - } - } catch (err) { - log('Failed to authenticate user: $err'); - } finally { - handler.next(options); + final atk = await getFreshAtk(); + if (atk != null) { + options.headers['Authorization'] = 'Bearer $atk'; } + return handler.next(options); }, ), ); @@ -99,6 +62,50 @@ class SnNetworkProvider { }); } + Future getFreshAtk() async { + try { + var atk = await _storage.read(key: kAtkStoreKey); + 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('Access token need refresh, doing it at ${DateTime.now()}'); + atk = await refreshToken(); + } + + if (atk != null) { + return atk; + } else { + log('Access token refresh failed...'); + } + } + } catch (err) { + log('Failed to authenticate user: $err'); + } + + return null; + } + String getAttachmentUrl(String ky) { if (ky.startsWith("http")) return ky; return '${client.options.baseUrl}/cgi/uc/attachments/$ky'; diff --git a/lib/providers/userinfo.dart b/lib/providers/userinfo.dart index 607ec52..c0b1597 100644 --- a/lib/providers/userinfo.dart +++ b/lib/providers/userinfo.dart @@ -13,6 +13,8 @@ class UserProvider extends ChangeNotifier { late final SnNetworkProvider _sn; late final FlutterSecureStorage _storage = FlutterSecureStorage(); + Future get atk => _storage.read(key: kAtkStoreKey); + UserProvider(BuildContext context) { _sn = context.read(); diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart new file mode 100644 index 0000000..07b1d05 --- /dev/null +++ b/lib/providers/websocket.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/userinfo.dart'; +import 'package:surface/types/websocket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class WebSocketProvider extends ChangeNotifier { + bool isBusy = false; + bool isConnected = false; + + WebSocketChannel? conn; + + late final SnNetworkProvider _sn; + late final UserProvider _ua; + + StreamController stream = StreamController.broadcast(); + + WebSocketProvider(BuildContext context) { + _sn = context.read(); + _ua = context.read(); + + // Wait for the userinfo provide initialize authorization status + Future.delayed(const Duration(milliseconds: 250), () async { + if (_ua.isAuthorized) { + log('[WebSocket] Connecting to the server...'); + await connect(); + } else { + log('[WebSocket] Unable connect to the server, unauthorized.'); + } + }); + } + + Future connect({noRetry = false}) async { + if (!_ua.isAuthorized) return; + if (isConnected) { + disconnect(); + } + + final atk = await _sn.getFreshAtk(); + final uri = Uri.parse( + '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk', + ); + + isBusy = true; + notifyListeners(); + + try { + conn = WebSocketChannel.connect(uri); + await conn!.ready; + log('[WebSocket] Connected to server!'); + } catch (err) { + if (err is WebSocketChannelException) { + log('Failed to connect to websocket: ${(err.inner as dynamic).message}'); + } else { + log('Failed to connect to websocket: $err'); + } + + if (!noRetry) { + log('Retry connecting to websocket in 3 seconds...'); + return Future.delayed( + const Duration(seconds: 3), + () => connect(noRetry: true), + ); + } + } finally { + isBusy = false; + notifyListeners(); + } + } + + void disconnect() { + if (conn != null) { + conn!.sink.close(); + } + isConnected = false; + notifyListeners(); + } + + void listen() { + conn?.stream.listen( + (event) { + final packet = WebSocketPackage.fromJson(jsonDecode(event)); + log('Websocket incoming message: ${packet.method} ${packet.message}'); + stream.sink.add(packet); + // TODO handle notification + // if (packet.method == 'notifications.new') { + // final NotificationProvider nty = Get.find(); + // nty.notifications.add(Notification.fromJson(packet.payload!)); + // nty.notificationUnread.value++; + // } + }, + onDone: () { + isConnected = false; + notifyListeners(); + Future.delayed(const Duration(seconds: 1), () => connect()); + }, + onError: (err) { + isConnected = false; + notifyListeners(); + Future.delayed(const Duration(seconds: 11), () => connect()); + }, + ); + } +} diff --git a/lib/types/websocket.dart b/lib/types/websocket.dart new file mode 100644 index 0000000..481500c --- /dev/null +++ b/lib/types/websocket.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'websocket.freezed.dart'; +part 'websocket.g.dart'; + +@freezed +class WebSocketPackage with _$WebSocketPackage { + const factory WebSocketPackage({ + @JsonKey(name: 'w') @Default('unknown') String method, + @JsonKey(name: 'e') String? endpoint, + @JsonKey(name: 'm') String? message, + @JsonKey(name: 'p') @Default({}) Map? payload, + }) = _WebSocketPackage; + + factory WebSocketPackage.fromJson(Map json) => + _$WebSocketPackageFromJson(json); +} diff --git a/lib/types/websocket.freezed.dart b/lib/types/websocket.freezed.dart new file mode 100644 index 0000000..246e455 --- /dev/null +++ b/lib/types/websocket.freezed.dart @@ -0,0 +1,252 @@ +// 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 'websocket.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'); + +WebSocketPackage _$WebSocketPackageFromJson(Map json) { + return _WebSocketPackage.fromJson(json); +} + +/// @nodoc +mixin _$WebSocketPackage { + @JsonKey(name: 'w') + String get method => throw _privateConstructorUsedError; + @JsonKey(name: 'e') + String? get endpoint => throw _privateConstructorUsedError; + @JsonKey(name: 'm') + String? get message => throw _privateConstructorUsedError; + @JsonKey(name: 'p') + Map? get payload => throw _privateConstructorUsedError; + + /// Serializes this WebSocketPackage to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of WebSocketPackage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $WebSocketPackageCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WebSocketPackageCopyWith<$Res> { + factory $WebSocketPackageCopyWith( + WebSocketPackage value, $Res Function(WebSocketPackage) then) = + _$WebSocketPackageCopyWithImpl<$Res, WebSocketPackage>; + @useResult + $Res call( + {@JsonKey(name: 'w') String method, + @JsonKey(name: 'e') String? endpoint, + @JsonKey(name: 'm') String? message, + @JsonKey(name: 'p') Map? payload}); +} + +/// @nodoc +class _$WebSocketPackageCopyWithImpl<$Res, $Val extends WebSocketPackage> + implements $WebSocketPackageCopyWith<$Res> { + _$WebSocketPackageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of WebSocketPackage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? method = null, + Object? endpoint = freezed, + Object? message = freezed, + Object? payload = freezed, + }) { + return _then(_value.copyWith( + method: null == method + ? _value.method + : method // ignore: cast_nullable_to_non_nullable + as String, + endpoint: freezed == endpoint + ? _value.endpoint + : endpoint // ignore: cast_nullable_to_non_nullable + as String?, + message: freezed == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String?, + payload: freezed == payload + ? _value.payload + : payload // ignore: cast_nullable_to_non_nullable + as Map?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$WebSocketPackageImplCopyWith<$Res> + implements $WebSocketPackageCopyWith<$Res> { + factory _$$WebSocketPackageImplCopyWith(_$WebSocketPackageImpl value, + $Res Function(_$WebSocketPackageImpl) then) = + __$$WebSocketPackageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'w') String method, + @JsonKey(name: 'e') String? endpoint, + @JsonKey(name: 'm') String? message, + @JsonKey(name: 'p') Map? payload}); +} + +/// @nodoc +class __$$WebSocketPackageImplCopyWithImpl<$Res> + extends _$WebSocketPackageCopyWithImpl<$Res, _$WebSocketPackageImpl> + implements _$$WebSocketPackageImplCopyWith<$Res> { + __$$WebSocketPackageImplCopyWithImpl(_$WebSocketPackageImpl _value, + $Res Function(_$WebSocketPackageImpl) _then) + : super(_value, _then); + + /// Create a copy of WebSocketPackage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? method = null, + Object? endpoint = freezed, + Object? message = freezed, + Object? payload = freezed, + }) { + return _then(_$WebSocketPackageImpl( + method: null == method + ? _value.method + : method // ignore: cast_nullable_to_non_nullable + as String, + endpoint: freezed == endpoint + ? _value.endpoint + : endpoint // ignore: cast_nullable_to_non_nullable + as String?, + message: freezed == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String?, + payload: freezed == payload + ? _value._payload + : payload // ignore: cast_nullable_to_non_nullable + as Map?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketPackageImpl implements _WebSocketPackage { + const _$WebSocketPackageImpl( + {@JsonKey(name: 'w') this.method = 'unknown', + @JsonKey(name: 'e') this.endpoint, + @JsonKey(name: 'm') this.message, + @JsonKey(name: 'p') final Map? payload = const {}}) + : _payload = payload; + + factory _$WebSocketPackageImpl.fromJson(Map json) => + _$$WebSocketPackageImplFromJson(json); + + @override + @JsonKey(name: 'w') + final String method; + @override + @JsonKey(name: 'e') + final String? endpoint; + @override + @JsonKey(name: 'm') + final String? message; + final Map? _payload; + @override + @JsonKey(name: 'p') + Map? get payload { + final value = _payload; + if (value == null) return null; + if (_payload is EqualUnmodifiableMapView) return _payload; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + String toString() { + return 'WebSocketPackage(method: $method, endpoint: $endpoint, message: $message, payload: $payload)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketPackageImpl && + (identical(other.method, method) || other.method == method) && + (identical(other.endpoint, endpoint) || + other.endpoint == endpoint) && + (identical(other.message, message) || other.message == message) && + const DeepCollectionEquality().equals(other._payload, _payload)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, method, endpoint, message, + const DeepCollectionEquality().hash(_payload)); + + /// Create a copy of WebSocketPackage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith => + __$$WebSocketPackageImplCopyWithImpl<_$WebSocketPackageImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$WebSocketPackageImplToJson( + this, + ); + } +} + +abstract class _WebSocketPackage implements WebSocketPackage { + const factory _WebSocketPackage( + {@JsonKey(name: 'w') final String method, + @JsonKey(name: 'e') final String? endpoint, + @JsonKey(name: 'm') final String? message, + @JsonKey(name: 'p') final Map? payload}) = + _$WebSocketPackageImpl; + + factory _WebSocketPackage.fromJson(Map json) = + _$WebSocketPackageImpl.fromJson; + + @override + @JsonKey(name: 'w') + String get method; + @override + @JsonKey(name: 'e') + String? get endpoint; + @override + @JsonKey(name: 'm') + String? get message; + @override + @JsonKey(name: 'p') + Map? get payload; + + /// Create a copy of WebSocketPackage + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/types/websocket.g.dart b/lib/types/websocket.g.dart new file mode 100644 index 0000000..c1e3b09 --- /dev/null +++ b/lib/types/websocket.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'websocket.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketPackageImpl _$$WebSocketPackageImplFromJson( + Map json) => + _$WebSocketPackageImpl( + method: json['w'] as String? ?? 'unknown', + endpoint: json['e'] as String?, + message: json['m'] as String?, + payload: json['p'] as Map? ?? const {}, + ); + +Map _$$WebSocketPackageImplToJson( + _$WebSocketPackageImpl instance) => + { + 'w': instance.method, + 'e': instance.endpoint, + 'm': instance.message, + 'p': instance.payload, + }; diff --git a/pubspec.lock b/pubspec.lock index 500982e..6a5a7b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1480,7 +1480,7 @@ packages: source: hosted version: "0.1.6" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" diff --git a/pubspec.yaml b/pubspec.yaml index d164078..d4df94d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,7 @@ dependencies: path_provider: ^2.1.5 collection: ^1.18.0 mime: ^2.0.0 + web_socket_channel: ^3.0.1 dev_dependencies: flutter_test: