diff --git a/lib/api_client.dart b/lib/api_client.dart index 0f91efb..eea12d8 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:contacts_plus_plus/neos_hub.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:contacts_plus_plus/models/authentication_data.dart'; @@ -86,6 +87,17 @@ class ApiClient { } return AuthenticationData.unauthenticated(); } + + Future logout(BuildContext context) async { + const FlutterSecureStorage storage = FlutterSecureStorage(); + await storage.delete(key: userIdKey); + await storage.delete(key: machineIdKey); + await storage.delete(key: tokenKey); + await storage.delete(key: passwordKey); + if (context.mounted) { + Phoenix.rebirth(context); + } + } static void checkResponse(http.Response response) { if (response.statusCode == 429) { diff --git a/lib/main.dart b/lib/main.dart index dbb83f0..fbdc2df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,16 @@ +import 'dart:developer'; + import 'package:contacts_plus_plus/widgets/home_screen.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_phoenix/flutter_phoenix.dart'; +import 'package:logging/logging.dart'; import 'api_client.dart'; import 'models/authentication_data.dart'; void main() { - runApp(const ContactsPlusPlus()); + Logger.root.onRecord.listen((event) => log(event.message, name: event.loggerName)); + runApp(Phoenix(child: const ContactsPlusPlus())); } class ContactsPlusPlus extends StatefulWidget { @@ -32,7 +37,7 @@ class _ContactsPlusPlusState extends State { colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) ), home: _authData.isAuthenticated ? - const HomeScreen() : + const FriendsList() : LoginScreen( onLoginSuccessful: (AuthenticationData authData) async { if (authData.isAuthenticated) { diff --git a/lib/models/settings.dart b/lib/models/settings.dart new file mode 100644 index 0000000..454d66f --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,6 @@ + +class Settings { + // No settings right now. + + +} \ No newline at end of file diff --git a/lib/neos_hub.dart b/lib/neos_hub.dart index 8c0ecc5..720251a 100644 --- a/lib/neos_hub.dart +++ b/lib/neos_hub.dart @@ -1,13 +1,11 @@ - import 'dart:convert'; -import 'dart:developer'; -import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:contacts_plus_plus/api_client.dart'; import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/models/message.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:logging/logging.dart'; enum EventType { unknown, @@ -31,10 +29,13 @@ enum EventTarget { class NeosHub { static const String eofChar = ""; static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar"; + static const List _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60]; final ApiClient _apiClient; final Map _messageCache = {}; final Map _updateListeners = {}; - WebSocketChannel? _wsChannel; + final Logger _logger = Logger("NeosHub"); + WebSocket? _wsChannel; + bool _isConnecting = false; NeosHub({required ApiClient apiClient}) : _apiClient = apiClient { @@ -51,29 +52,58 @@ class NeosHub { return cache; } + void _onDisconnected(error) { + _logger.warning("Neos Hub connection died with error '$error', reconnecting..."); + start(); + } + Future start() async { if (!_apiClient.isAuthenticated) { - log("Hub not authenticated."); + _logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now."); return; } - final response = await http.post( - Uri.parse("${Config.neosHubUrl}/negotiate"), - headers: _apiClient.authorizationHeader, - ); - - ApiClient.checkResponse(response); - final body = jsonDecode(response.body); - final url = (body["url"] as String?)?.replaceFirst("https://", "wss://"); - final wsToken = body["accessToken"]; - - if (url == null || wsToken == null) { - throw "Invalid response from server"; + if (_isConnecting) { + return; } + _isConnecting = true; + _wsChannel = await _tryConnect(); + _isConnecting = false; + _logger.info("Connected to Neos Hub."); + _wsChannel!.done.then((error) => _onDisconnected(error)); + _wsChannel!.listen(_handleEvent, onDone: () => _onDisconnected("Connection closed."), onError: _onDisconnected); + _wsChannel!.add(_negotiationPacket); + } - _wsChannel = WebSocketChannel.connect(Uri.parse("$url&access_token=$wsToken")); - _wsChannel!.stream.listen(_handleEvent); - _wsChannel!.sink.add(_negotiationPacket); - log("[Hub]: Connected!"); + Future _tryConnect() async { + int attempts = 0; + while (true) { + try { + final http.Response response; + try { + response = await http.post( + Uri.parse("${Config.neosHubUrl}/negotiate"), + headers: _apiClient.authorizationHeader, + ); + ApiClient.checkResponse(response); + } catch (e) { + throw "Failed to acquire connection info from Neos API: $e"; + } + final body = jsonDecode(response.body); + final url = (body["url"] as String?)?.replaceFirst("https://", "wss://"); + final wsToken = body["accessToken"]; + + if (url == null || wsToken == null) { + throw "Invalid response from server."; + } + return await WebSocket.connect("$url&access_token=$wsToken"); + } catch (e) { + final timeout = _reconnectTimeoutsSeconds[attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)]; + _logger.severe(e); + _logger.severe("Retrying in $timeout seconds"); + await Future.delayed(Duration(seconds: timeout)); + attempts++; + } + } } void registerListener(String userId, Function function) => _updateListeners[userId] = function; @@ -84,12 +114,12 @@ class NeosHub { final body = jsonDecode((event.toString().replaceAll(eofChar, ""))); final int rawType = body["type"] ?? 0; if (rawType > EventType.values.length) { - log("[Hub]: Unhandled event type $rawType: $body"); + _logger.info("Unhandled event type $rawType: $body"); return; } switch (EventType.values[rawType]) { case EventType.unknown: - log("[Hub]: Unknown event received: $rawType"); + _logger.info("[Hub]: Unknown event received: $rawType: $body"); break; case EventType.message: _handleMessageEvent(body); @@ -102,7 +132,7 @@ class NeosHub { final args = body["arguments"]; switch (target) { case EventTarget.unknown: - log("Unknown event-target in message: $body"); + _logger.info("Unknown event-target in message: $body"); return; case EventTarget.messageSent: final msg = args[0]; @@ -142,7 +172,7 @@ class NeosHub { }; final cache = await getCache(message.recipientId); cache.messages.add(message); - _wsChannel!.sink.add(jsonEncode(data)+eofChar); + _wsChannel!.add(jsonEncode(data)+eofChar); notifyListener(message.recipientId); } } diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index 73ae4ba..05be296 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -7,17 +7,18 @@ import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/user.dart'; import 'package:contacts_plus_plus/widgets/expanding_input_fab.dart'; import 'package:contacts_plus_plus/widgets/friend_list_tile.dart'; +import 'package:contacts_plus_plus/widgets/settings_page.dart'; import 'package:contacts_plus_plus/widgets/user_list_tile.dart'; import 'package:flutter/material.dart'; -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); +class FriendsList extends StatefulWidget { + const FriendsList({super.key}); @override - State createState() => _HomeScreenState(); + State createState() => _FriendsListState(); } -class _HomeScreenState extends State { +class _FriendsListState extends State { Future? _listFuture; Future? _friendFuture; ClientHolder? _clientHolder; @@ -78,6 +79,14 @@ class _HomeScreenState extends State { return Scaffold( appBar: AppBar( title: const Text("Contacts++"), + actions: [ + IconButton( + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); + }, + icon: const Icon(Icons.settings), + ) + ], ), body: Stack( children: [ diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart new file mode 100644 index 0000000..50d9b8b --- /dev/null +++ b/lib/widgets/settings_page.dart @@ -0,0 +1,51 @@ + +import 'package:contacts_plus_plus/api_client.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_phoenix/flutter_phoenix.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back), + ), + title: const Text("Settings"), + ), + body: ListView( + children: [ + ListTile( + title: const Text("Sign out"), + onTap: () { + showDialog( + context: context, + builder: (context) => + AlertDialog( + title: Text("Are you sure you want to sign out?", style: Theme + .of(context) + .textTheme + .titleLarge,), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), + TextButton( + onPressed: () async { + await ClientHolder.of(context).client.logout(context); + }, + child: const Text("Yes"), + ), + ], + ), + ); + }, + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 26606d8..25250ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -150,6 +150,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_phoenix: + dependency: "direct main" + description: + name: flutter_phoenix + sha256: "39589dac934ea476d0e43fb60c1ddfba58f14960743640c8250dea11c4333378" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter_secure_storage: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5375501..fbe30a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: web_socket_channel: ^2.4.0 html: ^0.15.2 just_audio: ^0.9.32 + flutter_phoenix: ^1.1.1 dev_dependencies: flutter_test: