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: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<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) {
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/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<ContactsPlusPlus> {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
),
home: _authData.isAuthenticated ?
const HomeScreen() :
const FriendsList() :
LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async {
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: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<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
final ApiClient _apiClient;
final Map<String, MessageCache> _messageCache = {};
final Map<String, Function> _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<void> 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<WebSocket> _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);
}
}

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/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<HomeScreen> createState() => _HomeScreenState();
State<FriendsList> createState() => _FriendsListState();
}
class _HomeScreenState extends State<HomeScreen> {
class _FriendsListState extends State<FriendsList> {
Future<List>? _listFuture;
Future<List>? _friendFuture;
ClientHolder? _clientHolder;
@ -78,6 +79,14 @@ class _HomeScreenState extends State<HomeScreen> {
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: [

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"
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:

View file

@ -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: