fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was acknowledged and dropped; the call screen column now fills the width (the avatar jumped left after hang-up); a ring started from a push ends after 12 s when no offer arrives; STUN fallback when no TURN credentials. Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing identity; retry backs off from 30 s to 10 min and exposes a conflict flag. Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST /v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly. Messaging: crypto operations run serially (concurrent encrypts derived the same message key); stop() waits for in-flight deliveries (auto-lock between decrypt and persist lost messages); disappearing-message cleanup no longer overwrites fresh messages; a push wake-up reconnects stale connections; the outbox carries polls and mentions; length limits on wire input. Storage: a duress session never exposes the real identity or phrase, does not start networking and cannot touch the keystore; deleting data in a duress session leaves the real account alone; PIN guessing is throttled (5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt so unlock time does not reveal which PIN was entered; iOS keychain items are this-device only; biometrics are biometric-only; screen security defaults to on and is applied after unlock; tapjacking filter in MainActivity. Tests: content types, shared-salt unwrap, duress isolation.
This commit is contained in:
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
|
||||
_ringId = pushRing.id;
|
||||
if (pushRing.accepted) {
|
||||
await accept();
|
||||
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
return;
|
||||
}
|
||||
if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
return;
|
||||
}
|
||||
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
|
||||
// nahradí se jménem volajícího (bez značky push).
|
||||
await _claimPushRing(pushRing.id, name, video);
|
||||
return;
|
||||
}
|
||||
final foreground =
|
||||
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
|
||||
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
|
||||
Future<void> _claimPushRing(String pushId, String name, bool video) async {
|
||||
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
|
||||
await CallRinger.end(pushId);
|
||||
_ringId = state.callId;
|
||||
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
|
||||
}
|
||||
|
||||
void _onRingAccept(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(accept());
|
||||
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
|
||||
return pc;
|
||||
}
|
||||
|
||||
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
|
||||
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
|
||||
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
|
||||
/// kandidáti = hovor jen v jedné LAN.
|
||||
static const _stunFallback = {
|
||||
'urls': ['stun:turn.sepot.cz:3478'],
|
||||
};
|
||||
|
||||
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
||||
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
||||
Future<List<Map<String, dynamic>>> _iceServers() async {
|
||||
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
||||
if (turn == null || turn.urls.isEmpty) return const [];
|
||||
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||
return [
|
||||
{
|
||||
'urls': turn.urls,
|
||||
|
||||
@@ -22,6 +22,13 @@ class CallRinger {
|
||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||
static const ringMillis = 45000;
|
||||
|
||||
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
|
||||
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
|
||||
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
|
||||
/// 45 s na každou obálku s hintem CALL. Nabídka od
|
||||
/// skutečného volajícího dorazí do pár sekund po připojení.
|
||||
static const pushRingGrace = Duration(seconds: 12);
|
||||
|
||||
static StreamSubscription<CallEvent?>? _sub;
|
||||
|
||||
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
||||
@@ -65,12 +72,41 @@ class CallRinger {
|
||||
_params(id: callId, name: name, video: video));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
static Future<void> ringFromPush() => FlutterCallkitIncoming
|
||||
.showCallkitIncoming(_params(
|
||||
id: const Uuid().v4(),
|
||||
name: _appName,
|
||||
video: false,
|
||||
extra: {pushMarker: true}));
|
||||
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||
static Future<void> ringFromPush() async {
|
||||
final id = const Uuid().v4();
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(_params(
|
||||
id: id, name: _appName, video: false, extra: {pushMarker: true}));
|
||||
await Future<void>.delayed(pushRingGrace);
|
||||
await endIfUnmatched(id);
|
||||
}
|
||||
|
||||
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
|
||||
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
|
||||
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
|
||||
static Future<void> expireStalePushRings() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
final id = call.id;
|
||||
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
|
||||
/// o zmeškaném hovoru — nebylo od koho).
|
||||
static Future<void> endIfUnmatched(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
await FlutterCallkitIncoming.endCall(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||
@@ -84,6 +120,14 @@ class CallRinger {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> isAccepted(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id) return call.isAccepted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> connected(String id) =>
|
||||
FlutterCallkitIncoming.setCallConnected(id);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user