mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Behavior Adds optional mobile Builderlab sign-in, identity backup/recovery, community discovery/creation, and account-aware reconnect. **Builderlab features are disabled by default:** when `BUZZ_AUTH_DOMAINS` is unset or empty, account login, creation, backup/recovery, reconnect prompts, and background account restoration are hidden/disabled. QR/code pairing, links, and invites retain Nostr-only entry. Saved account state is retained for later opt-in. `--dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz` opts those DNS domains and their subdomains into required account setup. The same policy gates pairing, deep links, durable community admission, and reconnect. A nonempty list enables Builderlab features; `BUZZ_BUILDERLAB_URL` alone does not. Enabled flows still require their backend services. Existing Nostr relay authentication remains mandatory. `BUZZ_HOSTED_RELAY_AUTH=true` enables scoped relay assertions and reconnect prompts only for configured authentication domains. Configure assertion issuance and each community's issuer binding before enabling assertions; enable relay enforcement last. Account credentials stay at Builderlab. Wrong-account recovery preserves keys; failed writes are not replayed; authentication is bounded by request deadlines. Hosted flows preserve keys across cancellation/restart, reconcile interrupted creation/invitation acceptance, and support manual nsec file import/export. Account identities are scoped to backend and authenticated subject; reusing an existing local key for an unbound account requires an explicit selection. Recovery reconciles the advertised and recovered inventories before automatic selection. Successful self-hosted admission retires its pending key, and iOS startup removes interrupted export files. Adds authenticated relay `GET /operator/communities/joined` for the companion Builderlab membership endpoint. ## Validation - Follow-up review repair at `66a12ac92`: self-hosted entry presents readable choices and import despite incomplete enumeration, while generation still requires a complete empty inventory; explicit selection uses exact-key lookup. Consuming a value-equal duplicate now advances dispatch. Reconnect requires an existing account binding or a subject-scoped backup receipt before signing an assertion, and pins the subject inside the serialized account request. An empty different account is refused without sending key proof. Focused checks: configured domains 58 passed; default 49 passed, 9 hosted-only skips. Separate isolated mutations fail the incomplete-inventory sheet, exact-key reuse, duplicate dispatch and reconnect journeys (including an empty-account-only bypass); restored controls pass. Evidence uses synthetic storage/network boundaries, not physical Android or live backend association. All mandatory commit/push hooks passed; fresh GitHub CI is pending. - Bounded review repair at `d3cc0003f`: incomplete secure-storage enumeration now blocks automatic identity generation while explicit known-key reads retain their fallback; declined/failed onboarding links release the queue; HTTP 429/5xx keeps Retry regardless of response encoding. Default app-gate coverage exercises a bind link followed by ordinary community entry. Focused checks: configured domains 47 passed; default 38 passed, 9 hosted-only skips. Removing the three production fixes in an isolated source/Git/build copy fails the identity-resolution, declined/failed queue, and Retry journey assertions; clean and restored controls pass. Storage and HTTP failures are synthetic; physical Android and live outage behavior were not exercised. Published after all mandatory commit/push hooks passed (mobile analysis/tests, branch-skew and file-size gates); fresh GitHub CI remains pending. - Existing whole-PR source review cleared the reconnect defects at `fb15d5b82`. - Domain-policy increment: focused environment, entry, app-gate, pairing and onboarding journeys pass with empty and configured domains. Existing pairing provider/page tests pass. Changed Dart files analyze cleanly. - Isolated source/Git/build mutation: forcing paired credentials through hosted login fails the default-build journey; clean and restored controls pass. CI now runs configured-domain journeys in addition to the default-build suite. - Integrated main through `5b433d929` at `0067c069c`, including its pinned Flutter upgrade. Preserved the PR welcome UI with the updated transition API and added Material surfaces around onboarding selection/consent rows. Focused post-merge runs pass in both configurations; all mandatory local push gates passed (mobile analysis/tests, Rust tests, desktop-native checks, branch-skew and file-size checks). - Merge-review repair at `a315bce11`: focused identity/storage/admission and widget journeys pass, including account switching with an empty or already-bound second account, explicit reuse, and self-hosted removal/rejoin with another key. Default-domain journeys pass too (hosted-only cases intentionally skip there). - Isolated source/Git/build mutation checks: bypassing account scoping, reuse consent, pending-key retirement, or recovery inventory reconciliation fails the relevant journey/invariant assertions; clean and restored controls pass. - Native `RunnerTests/IdentityFilePluginTests`: both tests pass on the iPhone 18 Pro iOS 27 simulator with an isolated app identifier. At `24a0dcaac`, the picker journey seeds residue in Library/Caches, matching path_provider_foundation, and verifies plugin startup removes it while preserving an unrelated file. The earlier tmp-only fixture did not exercise the actual export location. This is synthetic native cleanup/picker evidence, not a real terminated-share or live account round trip. - Follow-up: Caches and tmp cleanup; fallback to individually known identity slots when Android bulk enumeration fails; explicit replacement of a refused self-hosted key preserves its original before swapping the retry slot. Configured-domain journeys/invariants: 34 passed; default: 31 passed, 3 hosted-only skips. Isolated enumeration/retry mutations fail with passing controls. Bulk-read fallback preserves but cannot enumerate arbitrary other-account slots until bulk reads recover. - Configured-domain CI exposed a stale pairing-status assertion after account scoping. The repaired test checks the admission mismatch, preserved account/transfer keys, and no admitted community. The configured-domain CI selection passes locally (56 tests); removing the production mismatch guard fails the updated test, with clean/restored controls passing. This is synthetic coordinator evidence, not physical QR acceptance. - Final review repair (`40e1e6101`): native iOS export creation uses atomic complete file protection; normal and restart cleanup remain. Account/policy/claim HTTP uses one 30-second total deadline with request abort and body cancellation. Removed the unrelated NIP-FA spec edit. - Focused account/admission/export tests: 25 pass; configured-domain account/admission/onboarding journeys: 33 pass. Isolated deadline and native-dispatch mutations fail, with passing clean/restored controls. Native export/picker tests: 2 pass on iOS 27 simulator. The real file-protection attribute assertion is physical-device-only because simulator provides no Data Protection attribute; locked-device protection and interrupted real sharing have not been observed. - Opt-in UI: configured-domain CI selection passes (57 tests); default selection passes (7 tests, 10 hosted-only skips); URL-only plus whitespace/empty domains passes (6 tests, 9 hosted-only skips). Changed Dart files analyze cleanly. Independent mutations that unconditionally initialize the coordinator in the app gate, welcome entry, or link form fail the default journey; clean/restored controls pass. - Native welcome screens exercised on iPhone 18 Pro / iOS 27 simulator in default and configured builds: default exposes community link / QR / pairing code only; configured build additionally exposes Builderlab connection and community creation. No live hosted login was exercised in this check. - Delivery at `e48fe73a9`: all mandatory push hooks passed (full mobile analysis/tests, Rust tests, desktop-native checks, branch and file-size checks). GitHub checks had not yet populated at the immediate post-push snapshot. ## Review disposition | Feedback | Disposition | | --- | --- | | Explicit self-hosted entry under unreadable inventory, duplicate invite dispatch, reconnect cross-account binding ([entry](https://github.com/block/buzz/pull/8375#discussion_r4318421622), [queue](https://github.com/block/buzz/pull/8375#discussion_r4318421640), [reconnect](https://github.com/block/buzz/pull/8375#discussion_r4318455512)) | Repaired at `66a12ac92`; evidence above. Reconnect refuses unproven ownership rather than silently establishing a binding. | | Incomplete Android identity inventory, stranded deep-link queue, transient HTTP retry classification ([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5576864143)) | All three repaired in `d3cc0003f`; focused journeys and isolated mutation evidence above. | | Account cross-link, incomplete recovery inventory, committed invite checkpoint, pending local key retirement/replacement | Repaired in the preceding commits; journey and mutation evidence above. | | iOS interrupted key export ([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575253252)) | Native cleanup plus complete protection at creation implemented. Physical-device lock and real interrupted sharing acceptance remains pending. | | Trickle responses bypass idle timeout; unrelated NIP-FA edit ([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575764556)) | Total request deadline/abort implemented; spec edit removed. | | Production domains rejected | Ordinary entry now needs no Builderlab configuration. Opt-in account-required domains must still use supported community mappings. | | Lost Android Keystore recovery | Backup exclusions prevent the reviewed stale-restore scenario; genuine key loss still has no in-app recovery/reset and remains deferred. | | Native secp256k1 dependency question | Obsolete against the current base: upstream C integration landed in #8398; no package diff remains here. | | Operator membership endpoint contract | Documentation now states the operator chooses the pubkey; no end-user session binding is implied. Additional handler-level coverage is optional follow-up. | | Optional Android partial document cleanup/activity retention; local work after backup cancellation | Follow-up hardening, not claimed implemented. Cancellation prevents remote backup upload. | | Semgrep crypto inventory notices | Informational inventory, not demonstrated defects. | ## Remaining draft gates Native QR/browser-return acceptance remains unverified for this revision. Optional Builderlab onboarding still needs backend backup-status and joined-community routes, recovery policy/configuration, and real backup/recovery/reconnect acceptance. The empty-domain build removes these services as prerequisites for ordinary pairing; it does not make the optional hosted flows backend-ready. Human acceptance and current CI are still required before merge. Review dispositions retained: production auth-domain configuration must use supported community mappings; enabling `builderlab.xyz` does not add support for `buzz.block.builderlab.xyz`. Recovery after genuinely losing an Android Keystore key remains a follow-up, not a newly implemented capability. Human acceptance steps: build once without `BUZZ_AUTH_DOMAINS` (including with a previously saved account) and confirm no Builderlab controls/restoration while ordinary link and QR/code entry work. Build with supported domains and confirm connection/creation appear; exercise browser return and live backup/recovery/reconnect. On physical iOS, exercise export and interrupted sharing/locked-device protection. Record results before marking review complete. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@wes.communities.public.builderlabstaging.xyz>
785 lines
26 KiB
Dart
785 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../features/age_gate/age_signal_provider.dart';
|
|
import 'huddle_auth.dart';
|
|
import '../hosted/hosted_relay_auth.dart';
|
|
import '../relay/relay_provider.dart';
|
|
import 'huddle_media.dart';
|
|
import 'huddle_transport.dart';
|
|
import 'huddle_wire.dart';
|
|
|
|
enum HuddleSessionPhase {
|
|
idle,
|
|
checkingSupport,
|
|
requestingPermission,
|
|
preparingMedia,
|
|
connecting,
|
|
reconnecting,
|
|
interrupted,
|
|
connected,
|
|
leaving,
|
|
failed,
|
|
}
|
|
|
|
const _notProvided = Object();
|
|
|
|
@immutable
|
|
final class HuddleSessionState {
|
|
final HuddleSessionPhase phase;
|
|
final String? parentChannelId;
|
|
final String? ephemeralChannelId;
|
|
final String? startedEventId;
|
|
final String? currentPubkey;
|
|
final bool isCreator;
|
|
|
|
/// Whether the audio relay admitted this identity during this session.
|
|
final bool wasAdmitted;
|
|
final bool isMuted;
|
|
final bool isSpeakerEnabled;
|
|
final int participantCount;
|
|
final List<String> participantPubkeys;
|
|
final Set<String> activeSpeakerPubkeys;
|
|
final Map<String, double> speakerLevels;
|
|
final int reconnectAttempt;
|
|
final int receivedFrameCount;
|
|
final int sentFrameCount;
|
|
final String? issue;
|
|
final String? error;
|
|
|
|
/// Whether the current [HuddleSessionPhase.failed] state was caused by a
|
|
/// denied microphone permission. On iOS a denied permission is not
|
|
/// re-prompted, so a blind retry deterministically fails again — the UI
|
|
/// uses this to offer an OS-settings recovery path instead of a bare retry.
|
|
final bool microphonePermissionRequired;
|
|
|
|
const HuddleSessionState({
|
|
required this.phase,
|
|
this.parentChannelId,
|
|
this.ephemeralChannelId,
|
|
this.startedEventId,
|
|
this.currentPubkey,
|
|
this.isCreator = false,
|
|
this.wasAdmitted = false,
|
|
this.isMuted = false,
|
|
this.isSpeakerEnabled = false,
|
|
this.participantCount = 0,
|
|
this.participantPubkeys = const [],
|
|
this.activeSpeakerPubkeys = const {},
|
|
this.speakerLevels = const {},
|
|
this.reconnectAttempt = 0,
|
|
this.receivedFrameCount = 0,
|
|
this.sentFrameCount = 0,
|
|
this.issue,
|
|
this.error,
|
|
this.microphonePermissionRequired = false,
|
|
});
|
|
|
|
static const idle = HuddleSessionState(phase: HuddleSessionPhase.idle);
|
|
|
|
bool get isConnected => phase == HuddleSessionPhase.connected;
|
|
|
|
bool get isInSession => switch (phase) {
|
|
HuddleSessionPhase.checkingSupport ||
|
|
HuddleSessionPhase.requestingPermission ||
|
|
HuddleSessionPhase.preparingMedia ||
|
|
HuddleSessionPhase.connecting ||
|
|
HuddleSessionPhase.reconnecting ||
|
|
HuddleSessionPhase.interrupted ||
|
|
HuddleSessionPhase.connected ||
|
|
HuddleSessionPhase.leaving => true,
|
|
HuddleSessionPhase.idle || HuddleSessionPhase.failed => false,
|
|
};
|
|
|
|
HuddleSessionState copyWith({
|
|
HuddleSessionPhase? phase,
|
|
Object? parentChannelId = _notProvided,
|
|
Object? ephemeralChannelId = _notProvided,
|
|
Object? startedEventId = _notProvided,
|
|
Object? currentPubkey = _notProvided,
|
|
bool? isCreator,
|
|
bool? wasAdmitted,
|
|
bool? isMuted,
|
|
bool? isSpeakerEnabled,
|
|
int? participantCount,
|
|
List<String>? participantPubkeys,
|
|
Set<String>? activeSpeakerPubkeys,
|
|
Map<String, double>? speakerLevels,
|
|
int? reconnectAttempt,
|
|
int? receivedFrameCount,
|
|
int? sentFrameCount,
|
|
Object? issue = _notProvided,
|
|
Object? error = _notProvided,
|
|
bool? microphonePermissionRequired,
|
|
}) => HuddleSessionState(
|
|
phase: phase ?? this.phase,
|
|
parentChannelId: parentChannelId == _notProvided
|
|
? this.parentChannelId
|
|
: parentChannelId as String?,
|
|
ephemeralChannelId: ephemeralChannelId == _notProvided
|
|
? this.ephemeralChannelId
|
|
: ephemeralChannelId as String?,
|
|
startedEventId: startedEventId == _notProvided
|
|
? this.startedEventId
|
|
: startedEventId as String?,
|
|
currentPubkey: currentPubkey == _notProvided
|
|
? this.currentPubkey
|
|
: currentPubkey as String?,
|
|
isCreator: isCreator ?? this.isCreator,
|
|
wasAdmitted: wasAdmitted ?? this.wasAdmitted,
|
|
isMuted: isMuted ?? this.isMuted,
|
|
isSpeakerEnabled: isSpeakerEnabled ?? this.isSpeakerEnabled,
|
|
participantCount: participantCount ?? this.participantCount,
|
|
participantPubkeys: participantPubkeys ?? this.participantPubkeys,
|
|
activeSpeakerPubkeys: activeSpeakerPubkeys ?? this.activeSpeakerPubkeys,
|
|
speakerLevels: speakerLevels ?? this.speakerLevels,
|
|
reconnectAttempt: reconnectAttempt ?? this.reconnectAttempt,
|
|
receivedFrameCount: receivedFrameCount ?? this.receivedFrameCount,
|
|
sentFrameCount: sentFrameCount ?? this.sentFrameCount,
|
|
issue: issue == _notProvided ? this.issue : issue as String?,
|
|
error: error == _notProvided ? this.error : error as String?,
|
|
microphonePermissionRequired:
|
|
microphonePermissionRequired ?? this.microphonePermissionRequired,
|
|
);
|
|
}
|
|
|
|
typedef HuddleMediaFactory = HuddleMedia Function();
|
|
typedef HuddleTransportFactory =
|
|
HuddleTransportClient Function(HuddleConnectionParameters parameters);
|
|
|
|
final huddleMediaFactoryProvider = Provider<HuddleMediaFactory>(
|
|
(_) => MethodChannelHuddleMedia.new,
|
|
);
|
|
|
|
final huddleTransportFactoryProvider = Provider<HuddleTransportFactory>((ref) {
|
|
final auth = ref.watch(hostedRelayAuthProvider);
|
|
return (parameters) => HuddleTransport(
|
|
parameters: parameters,
|
|
headers: () => auth.headers(
|
|
RelayConfig(baseUrl: parameters.relayWebSocketUrl, nsec: parameters.nsec),
|
|
),
|
|
);
|
|
});
|
|
|
|
final huddleReconnectDelaysProvider = Provider<List<Duration>>(
|
|
(_) => const [
|
|
Duration.zero,
|
|
Duration(milliseconds: 100),
|
|
Duration(milliseconds: 250),
|
|
Duration(milliseconds: 500),
|
|
Duration(seconds: 1),
|
|
Duration(seconds: 2),
|
|
Duration(seconds: 2),
|
|
],
|
|
);
|
|
|
|
final huddleSessionProvider =
|
|
NotifierProvider<HuddleSessionNotifier, HuddleSessionState>(
|
|
HuddleSessionNotifier.new,
|
|
);
|
|
|
|
/// Owns one foreground mobile Huddle and wires native Opus media to its socket.
|
|
final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
|
|
HuddleMedia? _media;
|
|
HuddleTransportClient? _transport;
|
|
StreamSubscription<HuddleMediaState>? _mediaStateSubscription;
|
|
StreamSubscription<HuddleLocalAudioFrame>? _localFrameSubscription;
|
|
StreamSubscription<HuddleTransportState>? _transportStateSubscription;
|
|
StreamSubscription<HuddleRemoteAudioFrame>? _remoteFrameSubscription;
|
|
StreamSubscription<HuddleTransportError>? _transportIssueSubscription;
|
|
StreamSubscription<HuddlePeerEvent>? _peerEventSubscription;
|
|
var _generation = 0;
|
|
bool _ageRestricted = false;
|
|
var _receivedFrames = 0;
|
|
var _sentFrames = 0;
|
|
static const _playbackQueueCapacityPerPeer = 10;
|
|
|
|
final Map<int, Queue<HuddleRemoteAudioFrame>> _playbackQueues = {};
|
|
Future<void>? _playbackDrain;
|
|
int? _lastPlaybackPeerIndex;
|
|
final Map<String, Timer> _speakerTimers = {};
|
|
final Map<String, double> _pendingSpeakerLevels = {};
|
|
Timer? _speakerLevelFlushTimer;
|
|
Timer? _reconnectTimer;
|
|
var _reconnectAttempt = 0;
|
|
var _reconnectInFlight = false;
|
|
var _wasConnected = false;
|
|
|
|
@override
|
|
HuddleSessionState build() {
|
|
_ageRestricted = ref.watch(ageSignalProvider) == AgeSignalState.restricted;
|
|
ref.onDispose(() {
|
|
_generation += 1;
|
|
unawaited(_disposeResources());
|
|
});
|
|
return HuddleSessionState.idle;
|
|
}
|
|
|
|
Future<void> join(
|
|
HuddleConnectionParameters parameters, {
|
|
String? currentPubkey,
|
|
bool isCreator = false,
|
|
String? startedEventId,
|
|
}) async {
|
|
if (_ageRestricted) {
|
|
throw StateError('Huddle unavailable while age-restricted');
|
|
}
|
|
if (state.isInSession) {
|
|
if (state.ephemeralChannelId == parameters.ephemeralChannelId) return;
|
|
state = state.copyWith(
|
|
error: 'Leave the current Huddle before joining another one.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final generation = ++_generation;
|
|
await _disposeResources();
|
|
if (!_isCurrent(generation)) return;
|
|
_receivedFrames = 0;
|
|
_sentFrames = 0;
|
|
_reconnectAttempt = 0;
|
|
_reconnectInFlight = false;
|
|
_wasConnected = false;
|
|
state = HuddleSessionState(
|
|
phase: HuddleSessionPhase.checkingSupport,
|
|
parentChannelId: parameters.parentChannelId,
|
|
ephemeralChannelId: parameters.ephemeralChannelId,
|
|
startedEventId: startedEventId,
|
|
currentPubkey: currentPubkey?.toLowerCase(),
|
|
isCreator: isCreator,
|
|
);
|
|
|
|
try {
|
|
final media = ref.read(huddleMediaFactoryProvider)();
|
|
_media = media;
|
|
_mediaStateSubscription = media.states.listen((mediaState) {
|
|
if (!_isCurrent(generation)) return;
|
|
if (mediaState.phase == HuddleMediaPhase.failed) {
|
|
unawaited(
|
|
_fail(
|
|
mediaState.error?.message ?? 'Native Huddle audio failed.',
|
|
generation,
|
|
microphonePermissionRequired:
|
|
mediaState.error?.code ==
|
|
HuddleMediaErrorCode.permissionDenied,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (mediaState.phase != HuddleMediaPhase.active) return;
|
|
if (mediaState.isInterrupted && state.isConnected) {
|
|
state = state.copyWith(phase: HuddleSessionPhase.interrupted);
|
|
} else if (!mediaState.isInterrupted &&
|
|
state.phase == HuddleSessionPhase.interrupted) {
|
|
state = state.copyWith(phase: HuddleSessionPhase.connected);
|
|
}
|
|
});
|
|
|
|
final capabilities = await media.discoverCapabilities();
|
|
_ensureCurrent(generation);
|
|
if (!capabilities.supportsFullDuplexOpus) {
|
|
throw const HuddleMediaError(
|
|
code: HuddleMediaErrorCode.unsupported,
|
|
message: 'Full-duplex mobile Huddle audio is unavailable here.',
|
|
);
|
|
}
|
|
|
|
state = state.copyWith(phase: HuddleSessionPhase.requestingPermission);
|
|
final permission = await media.requestMicrophonePermission();
|
|
_ensureCurrent(generation);
|
|
if (permission != HuddleMicrophonePermission.granted) {
|
|
throw const HuddleMediaError(
|
|
code: HuddleMediaErrorCode.permissionDenied,
|
|
message: 'Microphone permission is required to join a Huddle.',
|
|
);
|
|
}
|
|
|
|
state = state.copyWith(phase: HuddleSessionPhase.preparingMedia);
|
|
await media.prepare();
|
|
_ensureCurrent(generation);
|
|
await media.start();
|
|
_ensureCurrent(generation);
|
|
|
|
final transport = ref.read(huddleTransportFactoryProvider)(parameters);
|
|
_transport = transport;
|
|
_wireMediaAndTransport(media, transport, generation);
|
|
state = state.copyWith(
|
|
phase: HuddleSessionPhase.connecting,
|
|
isMuted: false,
|
|
);
|
|
await transport.connect();
|
|
_ensureCurrent(generation);
|
|
state = state.copyWith(
|
|
phase: media.state.isInterrupted
|
|
? HuddleSessionPhase.interrupted
|
|
: HuddleSessionPhase.connected,
|
|
isMuted: media.state.isMuted,
|
|
isSpeakerEnabled: media.state.isSpeakerEnabled,
|
|
wasAdmitted: true,
|
|
participantCount: transport.state.peers.length,
|
|
participantPubkeys: _participantPubkeys(transport.state),
|
|
reconnectAttempt: 0,
|
|
issue: null,
|
|
error: null,
|
|
);
|
|
} catch (error) {
|
|
if (_isCurrent(generation)) {
|
|
await _fail(
|
|
_messageFor(error),
|
|
generation,
|
|
microphonePermissionRequired: _isPermissionDenied(error),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open the OS app-settings screen so the user can grant a denied microphone
|
|
/// permission. Used by the failed-call UI's recovery path — a blind retry
|
|
/// deterministically fails again on iOS, which never re-prompts once denied.
|
|
/// Returns `true` when the settings UI was launched. Uses a transient media
|
|
/// instance because the session's own media was disposed on failure.
|
|
Future<bool> openMicrophoneSettings() async {
|
|
final media = _media ?? ref.read(huddleMediaFactoryProvider)();
|
|
try {
|
|
return await media.openSystemSettings();
|
|
} finally {
|
|
if (!identical(media, _media)) {
|
|
await media.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> setMuted(bool muted) async {
|
|
if (state.phase != HuddleSessionPhase.connected &&
|
|
state.phase != HuddleSessionPhase.interrupted) {
|
|
return;
|
|
}
|
|
final generation = _generation;
|
|
final media = _media;
|
|
if (media == null) return;
|
|
try {
|
|
await media.setMuted(muted);
|
|
if (_isCurrent(generation)) state = state.copyWith(isMuted: muted);
|
|
} catch (error) {
|
|
if (_isCurrent(generation)) {
|
|
await _fail(_messageFor(error), generation);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> setSpeakerEnabled(bool enabled) async {
|
|
if (state.phase != HuddleSessionPhase.connected &&
|
|
state.phase != HuddleSessionPhase.interrupted) {
|
|
return;
|
|
}
|
|
final generation = _generation;
|
|
final media = _media;
|
|
if (media == null) return;
|
|
try {
|
|
await media.setSpeakerEnabled(enabled);
|
|
if (_isCurrent(generation)) {
|
|
state = state.copyWith(isSpeakerEnabled: enabled);
|
|
}
|
|
} catch (error) {
|
|
if (_isCurrent(generation)) {
|
|
await _fail(_messageFor(error), generation);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> leave() async {
|
|
if (state.phase == HuddleSessionPhase.idle ||
|
|
state.phase == HuddleSessionPhase.leaving) {
|
|
return;
|
|
}
|
|
final generation = ++_generation;
|
|
state = state.copyWith(phase: HuddleSessionPhase.leaving, error: null);
|
|
try {
|
|
await _disposeResources();
|
|
} finally {
|
|
if (_isCurrent(generation)) state = HuddleSessionState.idle;
|
|
}
|
|
}
|
|
|
|
void _wireMediaAndTransport(
|
|
HuddleMedia media,
|
|
HuddleTransportClient transport,
|
|
int generation,
|
|
) {
|
|
_localFrameSubscription = media.localAudioFrames.listen((frame) {
|
|
if (!_isCurrent(generation) ||
|
|
state.phase != HuddleSessionPhase.connected ||
|
|
state.isMuted) {
|
|
return;
|
|
}
|
|
try {
|
|
transport.sendOpusFrame(
|
|
header: frame.header,
|
|
opusPayload: frame.opusPayload,
|
|
);
|
|
if (!frame.header.isDtx && frame.header.levelDbov >= -55) {
|
|
final currentPubkey = state.currentPubkey;
|
|
if (currentPubkey != null) {
|
|
_recordSpeakerPubkey(
|
|
currentPubkey,
|
|
frame.header.levelDbov,
|
|
generation,
|
|
);
|
|
}
|
|
}
|
|
_sentFrames += 1;
|
|
_emitStatsIfNeeded(sent: true);
|
|
} catch (error) {
|
|
if (_wasConnected) {
|
|
_scheduleReconnect(transport, generation, _messageFor(error));
|
|
} else {
|
|
unawaited(_fail(_messageFor(error), generation));
|
|
}
|
|
}
|
|
});
|
|
_remoteFrameSubscription = transport.remoteAudioFrames.listen((frame) {
|
|
if (!_isCurrent(generation)) return;
|
|
_recordSpeaker(frame, transport, generation);
|
|
_receivedFrames += 1;
|
|
_emitStatsIfNeeded(sent: false);
|
|
_enqueuePlayback(media, frame, generation);
|
|
});
|
|
_transportStateSubscription = transport.states.listen((transportState) {
|
|
if (!_isCurrent(generation)) return;
|
|
if (transportState.phase == HuddleTransportPhase.connected) {
|
|
_wasConnected = true;
|
|
_reconnectTimer?.cancel();
|
|
_reconnectTimer = null;
|
|
_reconnectAttempt = 0;
|
|
_reconnectInFlight = false;
|
|
state = state.copyWith(
|
|
phase: media.state.isInterrupted
|
|
? HuddleSessionPhase.interrupted
|
|
: HuddleSessionPhase.connected,
|
|
wasAdmitted: true,
|
|
participantCount: transportState.peers.length,
|
|
participantPubkeys: _participantPubkeys(transportState),
|
|
reconnectAttempt: 0,
|
|
issue: null,
|
|
error: null,
|
|
);
|
|
} else if (transportState.phase == HuddleTransportPhase.failed) {
|
|
final message =
|
|
transportState.error?.message ?? 'Huddle connection failed.';
|
|
if (_wasConnected) {
|
|
_scheduleReconnect(transport, generation, message);
|
|
} else {
|
|
unawaited(_fail(message, generation));
|
|
}
|
|
}
|
|
});
|
|
_peerEventSubscription = transport.peerEvents.listen((event) {
|
|
if (!_isCurrent(generation)) return;
|
|
if (event.type == HuddlePeerEventType.left ||
|
|
event.type == HuddlePeerEventType.replaced) {
|
|
_clearSpeaker(event.peer.pubkey);
|
|
_clearPlaybackPeer(event.peer.peerIndex);
|
|
unawaited(
|
|
media.removeRemotePeer(event.peer.peerIndex).catchError((
|
|
Object error,
|
|
) async {
|
|
if (_isCurrent(generation)) {
|
|
await _fail(_messageFor(error), generation);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
_transportIssueSubscription = transport.issues.listen((issue) {
|
|
if (_isCurrent(generation)) state = state.copyWith(issue: issue.message);
|
|
});
|
|
}
|
|
|
|
void _enqueuePlayback(
|
|
HuddleMedia media,
|
|
HuddleRemoteAudioFrame frame,
|
|
int generation,
|
|
) {
|
|
final queue = _playbackQueues.putIfAbsent(frame.peerIndex, Queue.new);
|
|
if (queue.length == _playbackQueueCapacityPerPeer) {
|
|
queue.removeFirst();
|
|
}
|
|
queue.addLast(frame);
|
|
if (_playbackDrain != null) return;
|
|
|
|
final drain = _drainPlayback(media, generation);
|
|
_playbackDrain = drain;
|
|
unawaited(
|
|
drain.whenComplete(() {
|
|
if (identical(_playbackDrain, drain)) _playbackDrain = null;
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> _drainPlayback(HuddleMedia media, int generation) async {
|
|
try {
|
|
while (_isCurrent(generation)) {
|
|
final peerIndexes = _playbackQueues.keys.toList()..sort();
|
|
if (peerIndexes.isEmpty) return;
|
|
final next = _lastPlaybackPeerIndex == null
|
|
? 0
|
|
: peerIndexes.indexWhere(
|
|
(index) => index > _lastPlaybackPeerIndex!,
|
|
);
|
|
final start = next < 0 ? 0 : next;
|
|
HuddleRemoteAudioFrame? frame;
|
|
for (var offset = 0; offset < peerIndexes.length; offset++) {
|
|
final index = peerIndexes[(start + offset) % peerIndexes.length];
|
|
final queue = _playbackQueues[index];
|
|
if (queue != null && queue.isNotEmpty) {
|
|
frame = queue.removeFirst();
|
|
_lastPlaybackPeerIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
if (frame == null) return;
|
|
await media.playRemoteFrame(frame);
|
|
}
|
|
} catch (error) {
|
|
if (_isCurrent(generation)) await _fail(_messageFor(error), generation);
|
|
}
|
|
}
|
|
|
|
void _clearPlaybackPeer(int peerIndex) {
|
|
_playbackQueues.remove(peerIndex)?.clear();
|
|
}
|
|
|
|
List<String> _participantPubkeys(HuddleTransportState transportState) {
|
|
final peers = transportState.peers.values.toList()
|
|
..sort((left, right) => left.peerIndex.compareTo(right.peerIndex));
|
|
return List.unmodifiable(peers.map((peer) => peer.pubkey.toLowerCase()));
|
|
}
|
|
|
|
void _recordSpeaker(
|
|
HuddleRemoteAudioFrame frame,
|
|
HuddleTransportClient transport,
|
|
int generation,
|
|
) {
|
|
if (frame.header.isDtx || frame.header.levelDbov < -55) return;
|
|
final pubkey = transport.state.peers[frame.peerIndex]?.pubkey.toLowerCase();
|
|
if (pubkey == null) return;
|
|
_recordSpeakerPubkey(pubkey, frame.header.levelDbov, generation);
|
|
}
|
|
|
|
void _recordSpeakerPubkey(String pubkey, int levelDbov, int generation) {
|
|
final normalized = pubkey.toLowerCase();
|
|
final wasActive = state.activeSpeakerPubkeys.contains(normalized);
|
|
final level = _speakerLevelFromDbov(levelDbov);
|
|
if (!wasActive) {
|
|
final speakers = {...state.activeSpeakerPubkeys, normalized};
|
|
final levels = {...state.speakerLevels, normalized: level};
|
|
state = state.copyWith(
|
|
activeSpeakerPubkeys: Set.unmodifiable(speakers),
|
|
speakerLevels: Map.unmodifiable(levels),
|
|
);
|
|
} else {
|
|
_pendingSpeakerLevels[normalized] = level;
|
|
_scheduleSpeakerLevelFlush(generation);
|
|
}
|
|
_speakerTimers.remove(normalized)?.cancel();
|
|
_speakerTimers[normalized] = Timer(const Duration(milliseconds: 600), () {
|
|
if (!_isCurrent(generation)) return;
|
|
_clearSpeaker(normalized);
|
|
});
|
|
}
|
|
|
|
void _clearSpeaker(String pubkey) {
|
|
final normalized = pubkey.toLowerCase();
|
|
_speakerTimers.remove(normalized)?.cancel();
|
|
_pendingSpeakerLevels.remove(normalized);
|
|
if (!state.activeSpeakerPubkeys.contains(normalized) &&
|
|
!state.speakerLevels.containsKey(normalized)) {
|
|
return;
|
|
}
|
|
final levels = Map<String, double>.from(state.speakerLevels)
|
|
..remove(normalized);
|
|
state = state.copyWith(
|
|
activeSpeakerPubkeys: Set.unmodifiable(
|
|
state.activeSpeakerPubkeys.where((value) => value != normalized),
|
|
),
|
|
speakerLevels: Map.unmodifiable(levels),
|
|
);
|
|
}
|
|
|
|
void _scheduleSpeakerLevelFlush(int generation) {
|
|
if (_speakerLevelFlushTimer != null) return;
|
|
_speakerLevelFlushTimer = Timer(const Duration(milliseconds: 50), () {
|
|
_speakerLevelFlushTimer = null;
|
|
if (!_isCurrent(generation)) {
|
|
_pendingSpeakerLevels.clear();
|
|
return;
|
|
}
|
|
if (_pendingSpeakerLevels.isEmpty) return;
|
|
final levels = {...state.speakerLevels, ..._pendingSpeakerLevels};
|
|
_pendingSpeakerLevels.clear();
|
|
state = state.copyWith(speakerLevels: Map.unmodifiable(levels));
|
|
});
|
|
}
|
|
|
|
void _scheduleReconnect(
|
|
HuddleTransportClient transport,
|
|
int generation,
|
|
String issue,
|
|
) {
|
|
if (!_isCurrent(generation) ||
|
|
_reconnectInFlight ||
|
|
_reconnectTimer != null ||
|
|
state.phase == HuddleSessionPhase.leaving) {
|
|
return;
|
|
}
|
|
final delays = ref.read(huddleReconnectDelaysProvider);
|
|
if (_reconnectAttempt >= delays.length) {
|
|
unawaited(_fail('Huddle connection could not be restored.', generation));
|
|
return;
|
|
}
|
|
final attempt = ++_reconnectAttempt;
|
|
state = state.copyWith(
|
|
phase: HuddleSessionPhase.reconnecting,
|
|
reconnectAttempt: attempt,
|
|
issue: issue,
|
|
);
|
|
_reconnectTimer = Timer(delays[attempt - 1], () async {
|
|
_reconnectTimer = null;
|
|
if (!_isCurrent(generation)) return;
|
|
_reconnectInFlight = true;
|
|
try {
|
|
await transport.connect();
|
|
} catch (error) {
|
|
if (!_isCurrent(generation)) return;
|
|
_reconnectInFlight = false;
|
|
_scheduleReconnect(transport, generation, _messageFor(error));
|
|
}
|
|
});
|
|
}
|
|
|
|
void _emitStatsIfNeeded({required bool sent}) {
|
|
final count = sent ? _sentFrames : _receivedFrames;
|
|
if (count != 1 && count % 10 != 0) return;
|
|
state = state.copyWith(
|
|
sentFrameCount: _sentFrames,
|
|
receivedFrameCount: _receivedFrames,
|
|
);
|
|
}
|
|
|
|
Future<void> _fail(
|
|
String message,
|
|
int generation, {
|
|
bool microphonePermissionRequired = false,
|
|
}) async {
|
|
if (!_isCurrent(generation)) return;
|
|
final failureGeneration = ++_generation;
|
|
final failedState = state.copyWith(
|
|
phase: HuddleSessionPhase.failed,
|
|
isMuted: true,
|
|
error: message,
|
|
microphonePermissionRequired: microphonePermissionRequired,
|
|
);
|
|
// Failure teardown follows the same local-first contract as an explicit
|
|
// hangup. Publish the failed state only after capture and transport are
|
|
// closed, so lifecycle observers cannot begin relay work while native
|
|
// audio is still active.
|
|
try {
|
|
await _disposeResources();
|
|
} catch (error) {
|
|
debugPrint('Unable to fully release failed Huddle resources: $error');
|
|
}
|
|
if (_isCurrent(failureGeneration)) state = failedState;
|
|
}
|
|
|
|
Future<void> _disposeResources() async {
|
|
final subscriptions = <StreamSubscription<dynamic>?>[
|
|
_mediaStateSubscription,
|
|
_localFrameSubscription,
|
|
_transportStateSubscription,
|
|
_remoteFrameSubscription,
|
|
_transportIssueSubscription,
|
|
_peerEventSubscription,
|
|
];
|
|
_mediaStateSubscription = null;
|
|
_localFrameSubscription = null;
|
|
_transportStateSubscription = null;
|
|
_remoteFrameSubscription = null;
|
|
_transportIssueSubscription = null;
|
|
_peerEventSubscription = null;
|
|
_reconnectTimer?.cancel();
|
|
_reconnectTimer = null;
|
|
_reconnectInFlight = false;
|
|
for (final timer in _speakerTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_speakerTimers.clear();
|
|
_speakerLevelFlushTimer?.cancel();
|
|
_speakerLevelFlushTimer = null;
|
|
_pendingSpeakerLevels.clear();
|
|
final transport = _transport;
|
|
final media = _media;
|
|
_transport = null;
|
|
_media = null;
|
|
|
|
// Stop native capture first. Cancelling a subscription to a synchronous
|
|
// media stream can wait for an in-flight callback, which must never keep
|
|
// the microphone active after the user hangs up. The generation guard
|
|
// above makes any final state/frame callbacks harmless during disposal.
|
|
Object? failure;
|
|
StackTrace? failureStackTrace;
|
|
try {
|
|
await media?.dispose();
|
|
} catch (error, stackTrace) {
|
|
failure = error;
|
|
failureStackTrace = stackTrace;
|
|
}
|
|
try {
|
|
await transport?.dispose();
|
|
} catch (error, stackTrace) {
|
|
failure ??= error;
|
|
failureStackTrace ??= stackTrace;
|
|
}
|
|
for (final subscription
|
|
in subscriptions.whereType<StreamSubscription<dynamic>>()) {
|
|
unawaited(
|
|
subscription.cancel().catchError((Object error, StackTrace stackTrace) {
|
|
debugPrint('Unable to detach a Huddle stream listener: $error');
|
|
}),
|
|
);
|
|
}
|
|
_playbackQueues.clear();
|
|
_playbackDrain = null;
|
|
_lastPlaybackPeerIndex = null;
|
|
if (failure != null) {
|
|
Error.throwWithStackTrace(failure, failureStackTrace!);
|
|
}
|
|
}
|
|
|
|
bool _isCurrent(int generation) => generation == _generation;
|
|
|
|
void _ensureCurrent(int generation) {
|
|
if (!_isCurrent(generation)) {
|
|
throw const HuddleTransportError(
|
|
code: HuddleTransportErrorCode.cancelled,
|
|
message: 'Huddle join was cancelled.',
|
|
);
|
|
}
|
|
}
|
|
|
|
String _messageFor(Object error) => switch (error) {
|
|
HuddleMediaError() => error.message,
|
|
HuddleTransportError() => error.message,
|
|
_ => 'Unable to join the Huddle.',
|
|
};
|
|
|
|
bool _isPermissionDenied(Object error) =>
|
|
error is HuddleMediaError &&
|
|
error.code == HuddleMediaErrorCode.permissionDenied;
|
|
}
|
|
|
|
double _speakerLevelFromDbov(int levelDbov) =>
|
|
((levelDbov + 55) / 55).clamp(0.0, 1.0).toDouble();
|