Improve websocket stability and add settings page
This commit is contained in:
parent
38205fe8e1
commit
47e3b85451
8 changed files with 154 additions and 32 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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
6
lib/models/settings.dart
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
class Settings {
|
||||||
|
// No settings right now.
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
51
lib/widgets/settings_page.dart
Normal file
51
lib/widgets/settings_page.dart
Normal 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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue