175 lines
5.8 KiB
Dart
175 lines
5.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../core/crypto/key_manager.dart';
|
|
import 'audio_service.dart';
|
|
import 'secure_channel.dart';
|
|
|
|
/// Session lifecycle states.
|
|
enum SessionState {
|
|
noKey, // no passphrase / key loaded
|
|
keyLoaded, // key ready, not yet in call
|
|
inCall, // channel open, waiting for PTT or RX
|
|
transmitting,
|
|
receiving,
|
|
error,
|
|
}
|
|
|
|
/// Top-level session manager exposed to the UI.
|
|
///
|
|
/// Handles:
|
|
/// • Passphrase entry and PBKDF2 key derivation (persists key hash only).
|
|
/// • Creating / tearing down [SecureChannel].
|
|
/// • Delegating PTT and RX control.
|
|
/// • Exposing a unified [onStateChange] stream and stats for the UI.
|
|
class CallSession {
|
|
final AudioService _audio;
|
|
final KeyManager _keys = KeyManager();
|
|
|
|
SecureChannel? _channel;
|
|
|
|
SessionState _state = SessionState.noKey;
|
|
SessionState get state => _state;
|
|
bool get hasKey => _keys.hasKey;
|
|
|
|
String? _lastError;
|
|
String? get lastError => _lastError;
|
|
|
|
// Stream of [SessionState] for reactive UI updates.
|
|
final _ctrl = StreamController<SessionState>.broadcast();
|
|
Stream<SessionState> get onStateChange => _ctrl.stream;
|
|
|
|
CallSession(this._audio);
|
|
|
|
// ── Key management ─────────────────────────────────────────────────
|
|
|
|
/// Derive session key from [passphrase].
|
|
/// Optionally accepts a peer-supplied [salt] (from handshake packet).
|
|
Future<bool> loadPassphrase(String passphrase, {Uint8List? peerSalt}) async {
|
|
try {
|
|
_keys.deriveKey(passphrase, salt: peerSalt);
|
|
_setState(SessionState.keyLoaded);
|
|
// Persist a salted SHA-256 fingerprint so the UI can show "key loaded"
|
|
// without storing the raw passphrase.
|
|
await _saveKeyFingerprint(passphrase);
|
|
return true;
|
|
} catch (e) {
|
|
_lastError = 'Key derivation failed: $e';
|
|
_setState(SessionState.error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Clear the current key (end session).
|
|
Future<void> clearKey() async {
|
|
_keys.clear();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('key_fingerprint');
|
|
_setState(SessionState.noKey);
|
|
}
|
|
|
|
/// Returns true if a fingerprint was previously saved (key loaded).
|
|
Future<bool> hasSavedKey() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.containsKey('key_fingerprint');
|
|
}
|
|
|
|
// ── Call lifecycle ─────────────────────────────────────────────────
|
|
|
|
/// Open the secure channel (call start).
|
|
Future<void> startCall() async {
|
|
if (!_keys.hasKey) {
|
|
_lastError = 'No key loaded';
|
|
_setState(SessionState.error);
|
|
return;
|
|
}
|
|
_channel = SecureChannel(_audio, _keys);
|
|
_channel!.onStateChange.listen(_onChannelState);
|
|
await _channel!.open();
|
|
_setState(SessionState.inCall);
|
|
}
|
|
|
|
/// Close the secure channel (call end).
|
|
Future<void> endCall() async {
|
|
await _channel?.close();
|
|
await _channel?.dispose();
|
|
_channel = null;
|
|
if (_keys.hasKey) {
|
|
_setState(SessionState.keyLoaded);
|
|
} else {
|
|
_setState(SessionState.noKey);
|
|
}
|
|
}
|
|
|
|
// ── PTT ────────────────────────────────────────────────────────────
|
|
|
|
/// Press-to-talk: start encoding and transmitting voice.
|
|
Future<void> pttPress() async {
|
|
await _channel?.startTransmit();
|
|
}
|
|
|
|
/// Release PTT: stop transmitting.
|
|
Future<void> pttRelease() async {
|
|
await _channel?.stopTransmit();
|
|
}
|
|
|
|
// ── RX toggle ─────────────────────────────────────────────────────
|
|
|
|
Future<void> startListening() async {
|
|
await _channel?.startReceive();
|
|
}
|
|
|
|
Future<void> stopListening() async {
|
|
await _channel?.stopReceive();
|
|
}
|
|
|
|
// ── Stats ──────────────────────────────────────────────────────────
|
|
|
|
int get txFrames => _channel?.txFrames ?? 0;
|
|
int get rxFrames => _channel?.rxFrames ?? 0;
|
|
int get rxErrors => _channel?.rxErrors ?? 0;
|
|
double get txRms => _channel?.txRms ?? 0.0;
|
|
double get rxSignalRms => _channel?.rxSignalRms ?? 0.0;
|
|
|
|
// ── Private helpers ────────────────────────────────────────────────
|
|
|
|
void _onChannelState(ChannelState cs) {
|
|
switch (cs) {
|
|
case ChannelState.transmitting:
|
|
_setState(SessionState.transmitting);
|
|
case ChannelState.receiving:
|
|
_setState(SessionState.receiving);
|
|
case ChannelState.txReady:
|
|
_setState(SessionState.inCall);
|
|
case ChannelState.error:
|
|
_setState(SessionState.error);
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _setState(SessionState s) {
|
|
_state = s;
|
|
_ctrl.add(s);
|
|
}
|
|
|
|
Future<void> _saveKeyFingerprint(String passphrase) async {
|
|
// Store a simple truncated hash for display — NOT the key itself.
|
|
final hash = passphrase.codeUnits.fold<int>(0,
|
|
(prev, c) => (prev * 31 + c) & 0xFFFFFFFF);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('key_fingerprint',
|
|
hash.toRadixString(16).padLeft(8, '0').toUpperCase());
|
|
}
|
|
|
|
Future<String?> getSavedFingerprint() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString('key_fingerprint');
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await endCall();
|
|
await _ctrl.close();
|
|
}
|
|
}
|