// Singleton import 'dart:async'; import 'dart:convert'; import 'package:appwrite/appwrite.dart' as appwrite; import 'package:appwrite/models.dart' as models; import 'package:bus_infotainment/audio_cache.dart'; import 'package:bus_infotainment/auth/api_constants.dart'; import 'package:bus_infotainment/auth/auth_api.dart'; import 'package:bus_infotainment/backend/modules/announcement.dart'; import 'package:bus_infotainment/backend/modules/commands.dart'; import 'package:bus_infotainment/backend/modules/directconnection.dart'; import 'package:bus_infotainment/backend/modules/networking.dart'; import 'package:bus_infotainment/backend/modules/synced_time.dart'; import 'package:bus_infotainment/backend/modules/tracker.dart'; import 'package:bus_infotainment/backend/modules/tube_info.dart'; import 'package:bus_infotainment/utils/audio%20wrapper.dart'; import 'package:bus_infotainment/utils/delegates.dart'; import 'package:bus_infotainment/workaround/keepalive_realtime.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:ntp/ntp.dart'; import 'package:permission_handler/permission_handler.dart'; import '../tfl_datasets.dart'; enum RoomConnectionMethod { Cloud, Local, P2P, None } class LiveInformation { static final LiveInformation _singleton = LiveInformation._internal(); factory LiveInformation() { return _singleton; } LiveInformation._internal(); Future initialize() async { { // By default, load the bus sequences from the assets print("Loading bus sequences from assets"); String destinations = await rootBundle.loadString("assets/datasets/destinations.json"); String routes = await rootBundle.loadString("assets/datasets/bus-sequences.csv"); // Try to grab the routes from TfL try { http.Response response = await http.get(Uri.parse('https://tfl.gov.uk/bus-sequences.csv')); routes = response.body; print("Loaded bus sequences from TFL"); } catch (e) { print("Failed to load bus sequences from TFL. Using local copy."); } // Try to grab the destinations from github try { http.Response response = await http.get(Uri.parse('https://raw.githubusercontent.com/RailboundStudios/LondonBusDatasets/main/destinations.json')); destinations = response.body; print("Loaded destinations from Github"); } catch (e) { print("Failed to load destinations from Github. Using local copy."); } busSequences = BusSequences.fromCSV( destinations, routes ); print("Loaded all datasets"); try { http.Response response = await http.get(Uri.parse('https://tfl.gov.uk/bus-sequences.csv')); busSequences = BusSequences.fromCSV( await rootBundle.loadString("assets/datasets/destinations.json"), response.body ); print("Loaded bus sequences from TFL"); } catch (e) { print("Failed to load bus sequences from TFL. Using local copy."); } // Load tube stations print("Loading tube stations from assets"); tubeStations = TubeStations.fromJson(json.decode(await rootBundle.loadString("assets/datasets/tube_stations.json"))); print("Loaded tube stations from assets"); } // Initialise modules syncedTimeModule = SyncedTimeModule(); announcementModule = AnnouncementModule(); initTrackerModule(); print("Initialised LiveInformation"); if (!auth.isAuthenticated()) { auth.loadAnonymousUser(); } networkingModule = NetworkingModule(); if (defaultTargetPlatform == TargetPlatform.android){ p2pModule = NearbyServiceWrapper(); } } Future initTrackerModule() async { if (await Permission.location.isGranted) { trackerModule = TrackerModule(); } } // Multi-device stuff RoomConnectionMethod connectionMethod = RoomConnectionMethod.None; // Auth AuthAPI auth = AuthAPI( autoLoad: false, ); String? roomCode; String? roomDocumentID; bool isHost = false; bool inRoom = false; appwrite.RealtimeSubscription? _subscription; RealtimeKeepAliveConnection? _keepAliveConnection; // This is a workaround for a bug in the appwrite SDK // Local room stuff ListenerReceipt? _listenerReciept; // Modules // late CommandModule commandModule; This needs to be deprecated late BusSequences busSequences; late AnnouncementModule announcementModule; late SyncedTimeModule syncedTimeModule; late TrackerModule trackerModule; late TubeStations tubeStations; late NetworkingModule networkingModule; late NearbyServiceWrapper p2pModule; // Important variables BusRouteVariant? _currentRouteVariant; // Events EventDelegate routeVariantDelegate = EventDelegate(); // Internal methods Future setRouteVariant(BusRouteVariant? routeVariant, {bool sendToServer = false}) async { if (routeVariant == null) { _currentRouteVariant = null; await announcementModule.queueAnnounceByAudioName( displayText: "*** NO MESSAGE ***", ); routeVariantDelegate.trigger(null); return; } if (roomCode != null) { try { final client = auth.client; final databases = appwrite.Databases(client); final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode!) ] ); final document = response.documents.first; // Check if the route is not the same if (document.data["CurrentRoute"] != routeVariant.busRoute.routeNumber || document.data["CurrentRouteVariant"] != routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant)) { final updatedDocument = await databases.updateDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: document.$id, data: { "CurrentRoute": routeVariant.busRoute.routeNumber, "CurrentRouteVariant": routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant), "LastUpdater": auth.userID, } ); print("Updated route on server"); } } catch (e) { print("Failed to update route on server"); } } if (inRoom && sendToServer) { SendCommand("setroute ${routeVariant.busRoute.routeNumber} ${routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant)}"); } Continuation: // Set the current route variant _currentRouteVariant = routeVariant; // Let everyone know that the route variant has been set/changed routeVariantDelegate.trigger(routeVariant); // Get all of the files that need to be cached List audioFiles = []; for (BusRouteStop stop in routeVariant.busStops) { audioFiles.add(stop.getAudioFileName()); } // Cache/Load the audio files await announcementModule .announcementCache .loadAnnouncementsFromBytes(await LiveInformation().announcementModule.getBundleBytes(), [ ...audioFiles, if (!routeVariant.busRoute.routeNumber.toLowerCase().startsWith("ul")) "R_${routeVariant.busRoute.routeNumber}_001.mp3" else "R_RAIL_REPLACEMENT_SERVICE_001.mp3", ] ); // Display the route variant announcementModule.queueAnnouncementByRouteVariant(routeVariant: routeVariant); } // Public methods BusRouteVariant? getRouteVariant() { return _currentRouteVariant; } Future setRouteVariantQuery(String routeNumber, int routeVariantIndex, {bool sendToServer = false}) async { BusRoute route = busSequences.routes[routeNumber]!; BusRouteVariant routeVariant = route.routeVariants.values.toList()[routeVariantIndex]; await setRouteVariant( routeVariant, sendToServer: sendToServer ); } // Multi device support Future createRoom(String roomCode) async { { // Local Room await networkingModule.startWebSocketServer(); inRoom = true; _listenerReciept = networkingModule.onMessageReceived?.addListener( (p0) { print("Received local command: $p0"); executeCommand(p0); } ); } { // Cloud Room print("Creating room with code $roomCode"); // Update the room code this.roomCode = roomCode; // Enable host mode isHost = true; inRoom = true; // Access the database final client = auth.client; final databases = appwrite.Databases(client); // Remove any existing documents final existingDocuments = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode) ] ); for (var document in existingDocuments.documents) { await databases.deleteDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: document.$id ); } // Create the document final document = await databases.createDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: appwrite.ID.unique(), data: { "SessionID": roomCode, "LastUpdater": auth.userID, } ); // Listen for changes if (_keepAliveConnection != null) { try { _keepAliveConnection!.close(); } catch (e) { print("Failed to close connection... oh well"); } } String APPWRITE_ENDPOINT_URL = "https://cloud.appwrite.io/v1"; String domain = APPWRITE_ENDPOINT_URL.replaceAll("https://", "").trim(); _keepAliveConnection = RealtimeKeepAliveConnection( channels: ['databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}'], onData: ServerListener, domain: domain, client: auth.client, onError: (e) { print("Workarround Error: $e"); }, ); _keepAliveConnection!.initialize(); // Update the room document ID roomDocumentID = document.$id; print("Created room with code $roomCode"); } } Future joinRoom(String info) async { String infoJson = utf8.decode(base64.decode(info)); try { { // sync String routeNumber = jsonDecode(infoJson)["sync"]["route"]; int routeVariantIndex = jsonDecode(infoJson)["sync"]["routeVariant"]; setRouteVariantQuery(routeNumber, routeVariantIndex); LiveInformation().announcementModule.queueAnnouncementByRouteVariant(routeVariant: _currentRouteVariant!, sendToServer: false); } } catch (e) { print("Failed to sync route"); } { // Local Room String host = jsonDecode(infoJson)["local"]["host"]; if (await networkingModule.connectToWebSocketServer(host)){ print("Connected to local room at $host"); _listenerReciept = networkingModule.onMessageReceived?.addListener( (p0) { print("Received local command: $p0"); executeCommand(p0); } ); inRoom = true; connectionMethod = RoomConnectionMethod.Local; return true; // We dont need to connect to the cloud room if we are connected to the local room. } else { print("Failed to connect to local room at $host"); print("Falling back to cloud room"); } } { // Cloud Room String roomCode = jsonDecode(infoJson)["cloud"]["roomCode"]; print("Joining cloud room with code $roomCode"); // Disable host mode isHost = false; // Update the room code this.roomCode = roomCode; // Access the database final client = auth.client; final databases = appwrite.Databases(client); // Get the document final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode) ] ); if (response.documents.isEmpty) { return false; } final document = response.documents.first; // Listen for changes if (_keepAliveConnection != null) { try { _keepAliveConnection!.close(); } catch (e) { print("Failed to close connection... oh well"); } } String APPWRITE_ENDPOINT_URL = "https://cloud.appwrite.io/v1"; String domain = APPWRITE_ENDPOINT_URL.replaceAll("https://", "").trim(); _keepAliveConnection = RealtimeKeepAliveConnection( channels: ['databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}'], onData: ServerListener, domain: domain, client: auth.client, onError: (e) { print("Workarround Error: $e"); }, ); _keepAliveConnection!.initialize(); // Update the room document ID roomDocumentID = document.$id; // Get the current route try { String routeNumber = document.data["CurrentRoute"]; int routeVariantIndex = document.data["CurrentRouteVariant"]; await setRouteVariantQuery(routeNumber, routeVariantIndex); print("Set route to $routeNumber $routeVariantIndex"); } catch (e) { print("Failed to set route"); } inRoom = true; connectionMethod = RoomConnectionMethod.Cloud; print("Joined cloud room with code $roomCode"); return true; } } Future leaveRoom() async { if (!inRoom) { return; } { // Local Room networkingModule.stopWebSocketServer(); inRoom = false; networkingModule.onMessageReceived?.removeListener(_listenerReciept!); } { // Cloud Room if (_keepAliveConnection != null) { _keepAliveConnection!.close(); _keepAliveConnection = null; } } inRoom = false; if (isHost) { // Access the database final client = auth.client; final databases = appwrite.Databases(client); // Remove any existing documents final existingDocuments = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode!) ] ); for (var document in existingDocuments.documents) { await databases.deleteDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: document.$id ); } } roomCode = null; roomDocumentID = null; isHost = false; inRoom = false; _keepAliveConnection?.close(); _keepAliveConnection = null; // Reset stuff setRouteVariant(null); } String generateRoomInfo() { // Room Info Example /* { "cloud": { "roomCode": "6633e85d0020f52f3771" }, "local": { "host": "ws://192.168.0.123:8080" }, "sync": { "route": "W11", "routeVariant": 1 } } */ String json = jsonEncode({ "cloud": { "roomCode": roomCode, }, "local": { "host": "ws://${networkingModule.localIP}:8080" }, if (_currentRouteVariant != null) "sync": { "route": _currentRouteVariant!.busRoute.routeNumber, "routeVariant": _currentRouteVariant!.busRoute.routeVariants.values.toList().indexOf(_currentRouteVariant!), } }); // Encode in base64 return base64Encode(utf8.encode(json)); } String? lastCommand; Future ServerListener(appwrite.RealtimeMessage response) async { print("Session update"); // Only do something if the document was created or updated if (!(response.events.first.contains("create") || response.events.first.contains("update"))) { return; } // Get the user that caused the event String senderID = response.payload["LastUpdater"]; // If the sender is the same as the client, then ignore the event if (senderID == auth.userID) { print("Ignoring event"); return; } // Check to see if the commands are updated try { // Get the new route String routeNumber = response.payload["CurrentRoute"]; int routeVariantIndex = response.payload["CurrentRouteVariant"]; // If the route arent the same, then update the route if (routeNumber != _currentRouteVariant!.busRoute.routeNumber || routeVariantIndex != _currentRouteVariant!.busRoute.routeVariants.values.toList().indexOf(_currentRouteVariant!)) { // Set the route await setRouteVariantQuery(routeNumber, routeVariantIndex, sendToServer: false ); // announce the route // announcementModule.queueAnnouncementByRouteVariant(routeVariant: _currentRouteVariant!); } } catch (e) { print("Failed to set route"); } // Execute the command List commands = response.payload["Commands"].cast(); executeCommand(commands.last); } void executeCommand(String command) { if (command == lastCommand) { return; } print("Executing command: $command"); lastCommand = command; List commandParts = _splitCommand(command); String commandName = commandParts[0]; if (commandName == "announce") { print("Announce command received"); String mode = commandParts[1]; print ("Command: $command"); if (mode == "info") { print("Announce info command received"); announcementModule.queueAnnouncementByInfoIndex( sendToServer: false, infoIndex: int.parse(commandParts[2]), scheduledTime: DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts[3])), ); } else if (mode == "dest") { print("Announce dest command received"); String routeNumber = commandParts[2]; int routeVariantIndex = int.parse(commandParts[3]); announcementModule.queueAnnouncementByRouteVariant( sendToServer: false, routeVariant: busSequences.routes[routeNumber]!.routeVariants.values.toList()[routeVariantIndex], scheduledTime: DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts[4])), ); } else if (mode == "manual") { print("Announce manual command received"); final displayText = commandParts[2]; List audioFileNames = commandParts.sublist(3); try { if (int.parse(audioFileNames.last) != null) { audioFileNames.removeLast(); } } catch (e) {} DateTime scheduledTime = LiveInformation().syncedTimeModule.Now().add(Duration(seconds: 1)); try { if (int.parse(commandParts.last) != null) { scheduledTime = DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts.last)); } } catch (e) {} announcementModule.queueAnnounceByAudioName( displayText: displayText, audioNames: audioFileNames, scheduledTime: scheduledTime, sendToServer: false ); } } else if (commandName == "setroute") { print("Set route command received"); String routeNumber = commandParts[1]; int routeVariantIndex = int.parse(commandParts[2]); setRouteVariantQuery(routeNumber, routeVariantIndex, sendToServer: false); } } String _extractId(String input) { RegExp regExp = RegExp(r'\("user:(.*)"\)'); Match match = regExp.firstMatch(input)!; return match.group(1)!; } Future SendCommand(String command) async { { // Wfi Direct Commands p2pModule.sendMessage(command); } { // Local Commands try { networkingModule.sendMessage(command); } catch (e) { print("Failed to send local command: $e"); } } { // Cloud Commands final client = auth.client; final databases = appwrite.Databases(client); final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode!) ] ); List pastCommands = []; response.documents.first.data["Commands"].forEach((element) { pastCommands.add(element); }); pastCommands.add(command); final document = await databases.updateDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: roomDocumentID!, data: { "Commands": pastCommands, "LastUpdater": auth.userID, } ); } } List _splitCommand(String command) { var regex = RegExp(r'([^\s"]+)|"([^"]*)"'); var matches = regex.allMatches(command); return matches.map((match) => match.group(0)!.replaceAll('"', '')).toList(); } /* Everything under this will be considered legacy code */ } class AnnouncementQueueEntry { final String displayText; final List audioSources; bool sendToServer = true; DateTime? scheduledTime; DateTime? timestamp; AnnouncementQueueEntry({required this.displayText, required this.audioSources, this.sendToServer = true, this.scheduledTime, this.timestamp}); } class NamedAnnouncementQueueEntry extends AnnouncementQueueEntry { final String shortName; NamedAnnouncementQueueEntry({ required this.shortName, required String displayText, required List audioSources, DateTime? scheduledTime, DateTime? timestamp, bool sendToServer = true, }) : super( displayText: displayText, audioSources: audioSources, sendToServer: sendToServer, scheduledTime: scheduledTime, timestamp: timestamp, ); } var abs = (int value) => value < 0 ? -value : value;