Improve websocket stability and add settings page

This commit is contained in:
Nutcake 2023-05-03 17:51:18 +02:00
parent 38205fe8e1
commit 47e3b85451
8 changed files with 154 additions and 32 deletions

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:contacts_plus_plus/neos_hub.dart'; import 'package:contacts_plus_plus/neos_hub.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:contacts_plus_plus/models/authentication_data.dart'; import 'package:contacts_plus_plus/models/authentication_data.dart';
@ -86,6 +87,17 @@ class ApiClient {
} }
return AuthenticationData.unauthenticated(); return AuthenticationData.unauthenticated();
} }
Future<void> 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) { static void checkResponse(http.Response response) {
if (response.statusCode == 429) { if (response.statusCode == 429) {

View file

@ -1,11 +1,16 @@
import 'dart:developer';
import 'package:contacts_plus_plus/widgets/home_screen.dart'; import 'package:contacts_plus_plus/widgets/home_screen.dart';
import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:logging/logging.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'models/authentication_data.dart'; import 'models/authentication_data.dart';
void main() { 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 { class ContactsPlusPlus extends StatefulWidget {
@ -32,7 +37,7 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
), ),
home: _authData.isAuthenticated ? home: _authData.isAuthenticated ?
const HomeScreen() : const FriendsList() :
LoginScreen( LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async { onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) { if (authData.isAuthenticated) {

6
lib/models/settings.dart Normal file
View file

@ -0,0 +1,6 @@
class Settings {
// No settings right now.
}

View file

@ -1,13 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:contacts_plus_plus/api_client.dart'; import 'package:contacts_plus_plus/api_client.dart';
import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/config.dart';
import 'package:contacts_plus_plus/models/message.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 { enum EventType {
unknown, unknown,
@ -31,10 +29,13 @@ enum EventTarget {
class NeosHub { class NeosHub {
static const String eofChar = ""; static const String eofChar = "";
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar"; static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar";
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
final ApiClient _apiClient; final ApiClient _apiClient;
final Map<String, MessageCache> _messageCache = {}; final Map<String, MessageCache> _messageCache = {};
final Map<String, Function> _updateListeners = {}; final Map<String, Function> _updateListeners = {};
WebSocketChannel? _wsChannel; final Logger _logger = Logger("NeosHub");
WebSocket? _wsChannel;
bool _isConnecting = false;
NeosHub({required ApiClient apiClient}) NeosHub({required ApiClient apiClient})
: _apiClient = apiClient { : _apiClient = apiClient {
@ -51,29 +52,58 @@ class NeosHub {
return cache; return cache;
} }
void _onDisconnected(error) {
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
start();
}
Future<void> start() async { Future<void> start() async {
if (!_apiClient.isAuthenticated) { if (!_apiClient.isAuthenticated) {
log("Hub not authenticated."); _logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now.");
return; return;
} }
final response = await http.post( if (_isConnecting) {
Uri.parse("${Config.neosHubUrl}/negotiate"), return;
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";
} }
_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")); Future<WebSocket> _tryConnect() async {
_wsChannel!.stream.listen(_handleEvent); int attempts = 0;
_wsChannel!.sink.add(_negotiationPacket); while (true) {
log("[Hub]: Connected!"); 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; void registerListener(String userId, Function function) => _updateListeners[userId] = function;
@ -84,12 +114,12 @@ class NeosHub {
final body = jsonDecode((event.toString().replaceAll(eofChar, ""))); final body = jsonDecode((event.toString().replaceAll(eofChar, "")));
final int rawType = body["type"] ?? 0; final int rawType = body["type"] ?? 0;
if (rawType > EventType.values.length) { if (rawType > EventType.values.length) {
log("[Hub]: Unhandled event type $rawType: $body"); _logger.info("Unhandled event type $rawType: $body");
return; return;
} }
switch (EventType.values[rawType]) { switch (EventType.values[rawType]) {
case EventType.unknown: case EventType.unknown:
log("[Hub]: Unknown event received: $rawType"); _logger.info("[Hub]: Unknown event received: $rawType: $body");
break; break;
case EventType.message: case EventType.message:
_handleMessageEvent(body); _handleMessageEvent(body);
@ -102,7 +132,7 @@ class NeosHub {
final args = body["arguments"]; final args = body["arguments"];
switch (target) { switch (target) {
case EventTarget.unknown: case EventTarget.unknown:
log("Unknown event-target in message: $body"); _logger.info("Unknown event-target in message: $body");
return; return;
case EventTarget.messageSent: case EventTarget.messageSent:
final msg = args[0]; final msg = args[0];
@ -142,7 +172,7 @@ class NeosHub {
}; };
final cache = await getCache(message.recipientId); final cache = await getCache(message.recipientId);
cache.messages.add(message); cache.messages.add(message);
_wsChannel!.sink.add(jsonEncode(data)+eofChar); _wsChannel!.add(jsonEncode(data)+eofChar);
notifyListener(message.recipientId); notifyListener(message.recipientId);
} }
} }

View file

@ -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/models/user.dart';
import 'package:contacts_plus_plus/widgets/expanding_input_fab.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/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:contacts_plus_plus/widgets/user_list_tile.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget { class FriendsList extends StatefulWidget {
const HomeScreen({super.key}); const FriendsList({super.key});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); State<FriendsList> createState() => _FriendsListState();
} }
class _HomeScreenState extends State<HomeScreen> { class _FriendsListState extends State<FriendsList> {
Future<List>? _listFuture; Future<List>? _listFuture;
Future<List>? _friendFuture; Future<List>? _friendFuture;
ClientHolder? _clientHolder; ClientHolder? _clientHolder;
@ -78,6 +79,14 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Contacts++"), title: const Text("Contacts++"),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
},
icon: const Icon(Icons.settings),
)
],
), ),
body: Stack( body: Stack(
children: [ children: [

View file

@ -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"),
),
],
),
);
},
)
],
),
);
}
}

View file

@ -150,6 +150,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -46,6 +46,7 @@ dependencies:
web_socket_channel: ^2.4.0 web_socket_channel: ^2.4.0
html: ^0.15.2 html: ^0.15.2
just_audio: ^0.9.32 just_audio: ^0.9.32
flutter_phoenix: ^1.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: