feat: Implement room login state management and enhance SAR message handling

This commit is contained in:
Janez T
2025-10-14 22:55:38 +02:00
parent fd54392d3c
commit 846cc9dce4
16 changed files with 2652 additions and 208 deletions

View File

@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
import 'messages_provider.dart';
import '../services/tile_cache_service.dart';
import '../models/contact.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -80,6 +83,12 @@ class AppProvider with ChangeNotifier {
// Load contacts
await connectionProvider.getContacts();
// Small delay to ensure contacts are fully loaded
await Future.delayed(const Duration(milliseconds: 500));
// Automatically login to all saved rooms
await _autoLoginToRooms();
// Sync any waiting messages from device queue
await _syncMessages();
@@ -89,6 +98,103 @@ class AppProvider with ChangeNotifier {
}
}
/// Automatically login to all rooms with saved passwords on cold connect
Future<void> _autoLoginToRooms() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Get all room contacts (excluding Public Channel)
final rooms = contactsProvider.rooms
.where((room) => room.advName != 'Public Channel')
.toList();
if (rooms.isEmpty) {
debugPrint('📂 [AppProvider] No rooms found to auto-login');
return;
}
debugPrint('📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...');
final prefs = await SharedPreferences.getInstance();
for (final room in rooms) {
try {
// Load saved password for this room
final roomKey = 'room_password_${room.publicKeyHex}';
final savedPassword = prefs.getString(roomKey) ?? 'hello';
debugPrint('🔑 [AppProvider] Auto-logging into room: ${room.advName}');
// Set up one-time callbacks for this room login
await _loginToRoomWithCallback(room, savedPassword);
// Small delay between logins to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 300));
} catch (e) {
debugPrint('❌ [AppProvider] Failed to auto-login to ${room.advName}: $e');
}
}
} catch (e) {
debugPrint('❌ [AppProvider] Auto-login error: $e');
}
}
/// Login to a specific room with callback handling
Future<void> _loginToRoomWithCallback(Contact room, String password) async {
// Create a completer to wait for login result
final completer = Completer<bool>();
// Store original callbacks
final originalOnSuccess = connectionProvider.onLoginSuccess;
final originalOnFail = connectionProvider.onLoginFail;
// Set up temporary callbacks
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING');
completer.complete(true);
};
connectionProvider.onLoginFail = (publicKeyPrefix) {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)');
completer.complete(false);
};
try {
// Send login request
await connectionProvider.loginToRoom(
roomPublicKey: room.publicKey,
password: password,
);
// Wait for login result with timeout
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
// Restore callbacks on timeout
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}');
return false;
},
);
} catch (e) {
// Restore callbacks on error
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Error during auto-login to ${room.advName}: $e');
}
}
/// Sync messages from device queue
Future<void> _syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return;

View File

@@ -2,9 +2,11 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/device_info.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/room_login_state.dart';
import '../services/meshcore_ble_service.dart';
import '../services/cayenne_lpp_parser.dart';
import '../utils/sar_message_parser.dart';
@@ -44,6 +46,10 @@ class ConnectionProvider with ChangeNotifier {
// Message sync state
bool _noMoreMessages = false;
// Room login state tracking
final Map<String, RoomLoginState> _roomLoginStates = {};
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
@@ -120,19 +126,49 @@ class ConnectionProvider with ChangeNotifier {
syncAllMessages();
};
_bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) {
_bleService.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
print('📥 [Provider] Login successful to room');
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag');
// Update room login state
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
publicKeyPrefix: publicKeyPrefix,
permissions: permissions,
isAdmin: isAdmin,
tag: tag,
hasPassword: hasPassword,
);
notifyListeners();
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_bleService.onLoginFail = (publicKeyPrefix) {
print('📥 [Provider] Login failed to room');
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
// Update room login state to logged out
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
_roomLoginStates[prefixHex] = RoomLoginState.loggedOut(
publicKeyPrefix: publicKeyPrefix,
hasPassword: false, // Password was incorrect
);
notifyListeners();
onLoginFail?.call(publicKeyPrefix);
};
_bleService.onAdvertReceived = (publicKey) {
print('📥 [Provider] Advert received from node');
print(' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
print(' Note: Waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full contact details');
// The companion radio will automatically send PUSH_CODE_NEW_ADVERT if manual_add_contacts=0
// which will trigger onContactReceived callback and add/update the contact
};
_bleService.onDeviceInfoReceived = (deviceInfo) {
print('📥 [Provider] Received DeviceInfo:');
print(' Firmware Version: ${deviceInfo['firmwareVersion']}');
@@ -285,6 +321,7 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = DeviceInfo(
connectionState: ConnectionState.disconnected,
);
clearRoomLoginStates(); // Clear login states on disconnect
notifyListeners();
}
@@ -304,6 +341,25 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Add or update a contact on the companion radio
///
/// This manually adds a contact to the radio's internal contact table.
/// Useful when a room contact was deleted or never advertised yet.
Future<void> addOrUpdateContact(Contact contact) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.addOrUpdateContact(contact);
} catch (e) {
_error = 'Failed to add/update contact: $e';
notifyListeners();
}
}
/// Send text message to contact
Future<void> sendTextMessage({
required Uint8List contactPublicKey,
@@ -365,6 +421,22 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Get device time from companion radio to detect clock drift
Future<void> getDeviceTime() async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.getDeviceTime();
} catch (e) {
_error = 'Failed to get device time: $e';
notifyListeners();
}
}
/// Set device time to current time
Future<void> syncDeviceTime() async {
if (!_bleService.isConnected) return;
@@ -554,29 +626,37 @@ class ConnectionProvider with ChangeNotifier {
_noMoreMessages = false; // Reset flag
try {
print('🔄 [Provider] Starting message sync...');
print('🔄 [Provider] Starting message sync loop...');
print(' Initial _noMoreMessages state: $_noMoreMessages');
// Keep syncing until we get NoMoreMessages response
// The device will send ContactMsgRecv or ChannelMsgRecv responses
// until it sends NoMoreMessages
for (int i = 0; i < 100; i++) { // Safety limit
if (_noMoreMessages) {
print('✅ [Provider] Message sync complete - NoMoreMessages received after $count requests');
print('✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests');
break;
}
print('📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE');
await _bleService.syncNextMessage();
count++;
// Small delay to allow response to be processed
await Future.delayed(const Duration(milliseconds: 100));
await Future.delayed(const Duration(milliseconds: 150));
print(' After iteration ${i + 1}: _noMoreMessages=$_noMoreMessages');
}
if (!_noMoreMessages && count >= 100) {
print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests');
print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages');
}
print('🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages');
return count;
} catch (e) {
print('❌ [Provider] Failed to sync messages: $e');
_error = 'Failed to sync messages: $e';
notifyListeners();
return count;
@@ -628,6 +708,38 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners();
}
/// Check if a password exists for a room (by public key prefix)
Future<bool> _hasPasswordForRoom(Uint8List publicKeyPrefix) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert prefix to hex string for storage key
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final roomKey = 'room_password_$prefixHex';
return prefs.getString(roomKey) != null;
} catch (e) {
debugPrint('Error checking password for room: $e');
return false;
}
}
/// Get login state for a room by public key prefix
RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) {
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
return _roomLoginStates[prefixHex];
}
/// Check if logged into a specific room
bool isLoggedIntoRoom(Uint8List publicKeyPrefix) {
final state = getRoomLoginState(publicKeyPrefix);
return state?.isLoggedIn ?? false;
}
/// Clear all room login states (call on disconnect)
void clearRoomLoginStates() {
_roomLoginStates.clear();
notifyListeners();
}
@override
void dispose() {
_rxActivityTimer?.cancel();

View File

@@ -18,10 +18,10 @@ class ContactsProvider with ChangeNotifier {
void _ensurePublicChannelExists() {
const publicChannelKey = 'public_channel_0';
if (!_contacts.containsKey(publicChannelKey)) {
// Create a pseudo-contact for the public channel
// Create a pseudo-contact for the public channel (ephemeral broadcast)
_contacts[publicChannelKey] = Contact(
publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public
type: ContactType.room,
type: ContactType.channel, // Channel type (not room!)
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
@@ -42,10 +42,19 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get rooms {
// Always ensure public channel exists when getting rooms
List<Contact> get rooms =>
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
List<Contact> get channels {
// Always ensure public channel exists when getting channels
_ensurePublicChannelExists();
return contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen);
}
/// Get both rooms and channels (destinations for SAR markers)
List<Contact> get roomsAndChannels {
_ensurePublicChannelExists();
return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen);
}
/// Get contacts with location (for map display)