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: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';
|
||||
|
@ -87,6 +88,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) {
|
||||
throw "Sorry, you are being rate limited";
|
||||
|
|
|
@ -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
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: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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
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"
|
||||
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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue