Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Your Omi shows as connected again when you reopen the app, instead of staying stuck on Disconnected while it's actually connected"
}
5 changes: 3 additions & 2 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:omi/services/onboarding_sync_runtime.dart';

Check warning on line 1 in app/lib/main.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/main.dart is 823 lines; consider splitting files over 800 lines.
import 'package:omi/env/physical_qualification.dart';
import 'dart:async';
import 'package:omi/services/proactivity/proactivity_runtime.dart';
Expand Down Expand Up @@ -225,7 +225,7 @@
}
}

Future _init() async {

Check warning on line 228 in app/lib/main.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

Future _init() async is 174 lines; consider extracting focused helpers over 150 lines.
// Env. A rejected configuration cannot be fixed by retrying; the failure screen says so.
try {
if (F.env == Environment.prod) {
Expand Down Expand Up @@ -675,9 +675,10 @@
(previous?..setConversationProvider(conversation)) ?? LocalRecordingsProvider(),
),
ChangeNotifierProxyProvider2<CaptureProvider, LocalRecordingsProvider, DeviceProvider>(
create: (context) => DeviceProvider(),
create: (context) => DeviceProvider()..nativeLink.bindAppLifecycle(),
update: (BuildContext context, captureProvider, localRecordings, DeviceProvider? previous) =>
(previous?..setProviders(captureProvider, localRecordings)) ?? DeviceProvider(),
(previous?..setProviders(captureProvider, localRecordings)) ??
(DeviceProvider()..nativeLink.bindAppLifecycle()),
),
ChangeNotifierProxyProvider<DeviceProvider, OnboardingProvider>(
create: (context) => OnboardingProvider(),
Expand Down
94 changes: 91 additions & 3 deletions app/lib/providers/device_provider.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:async';

Check warning on line 1 in app/lib/providers/device_provider.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/providers/device_provider.dart is 1576 lines; consider splitting files over 800 lines.

import 'package:omi/services/devices/charge_start_tracker.dart';
import 'package:omi/services/devices/fully_charged_alert_gate.dart';
Expand All @@ -18,6 +18,7 @@
import 'package:omi/pages/home/omiglass_ota_update.dart';
import 'package:omi/providers/capture_provider.dart';
import 'package:omi/providers/local_recordings_provider.dart';
import 'package:omi/providers/native_link_reconciler.dart';
import 'package:omi/services/capture/capture_wedge_monitor.dart';
import 'package:omi/services/devices.dart';
import 'package:omi/services/devices/connectors/device_connection.dart';
Expand Down Expand Up @@ -144,6 +145,16 @@
/// coordinator wake, and capture streaming must use this token, not
/// `_sessionGeneration` at callback time. -1 means no admitted connect.
int _admittedConnectGeneration = -1;
late final NativeLinkReconciler nativeLink = NativeLinkReconciler(this);
(DeviceConnection, int)? _admittedLink;
// When a resume reconcile arms a readoption, admissions for that device are
// constrained to the pairing the reconcile probed: the persisted pairing
// must still name that device when an admission or its async continuation
// runs, or a pairing persisted in the windows between (an input picker
// seeding another microphone) is silently overwritten. Armed before the
// link starts forming, so the constraint holds for whichever connected
// event queues the admission.
String? _readoptExpectedPair;

DeviceProvider({
BleDiagnosticsLoader? bleDiagnosticsLoader,
Expand Down Expand Up @@ -203,6 +214,8 @@
/// account publication from the retired generation must not land.
void clearUserData() {
_sessionGeneration++;
_admittedLink = null;
_readoptExpectedPair = null;
_deviceHealthTimer?.cancel();
_deviceHealthTimer = null;
_admittedConnectGeneration = -1;
Expand Down Expand Up @@ -387,14 +400,14 @@
if (connectedDevice != null) {
if (pairedDevice?.firmwareRevision != null && pairedDevice?.firmwareRevision != 'Unknown') {
if (!_isCurrent(generation)) return;
SharedPreferencesUtil().btDevice = pairedDevice!;
_persistPairedDevice();
return;
}
var connection = await ServiceManager.instance().device.ensureConnection(connectedDevice!.id);
if (!_isCurrent(generation)) return;
pairedDevice = await connectedDevice?.getDeviceInfo(connection);
if (!_isCurrent(generation)) return;
SharedPreferencesUtil().btDevice = pairedDevice!;
_persistPairedDevice();
} else {
if (!_isCurrent(generation)) return;
if (SharedPreferencesUtil().btDevice.id.isEmpty) {
Expand All @@ -407,6 +420,23 @@
notifyListeners();
}

/// Publishes the paired device to preferences unless a readoption armed a
/// pairing constraint and the persisted pairing has since moved on: the
/// armed publication belongs to the probe-time pairing, and writing here
/// would silently overwrite the newer choice (an input picker's selection).
void _persistPairedDevice() {
if (_readoptPairMovedOn(pairedDevice!.id)) {
Logger.debug('Skipping paired-device persist: pairing moved on from $_readoptExpectedPair');
return;
}
SharedPreferencesUtil().btDevice = pairedDevice!;
}

/// True when a readoption armed a pairing constraint for [deviceId] and the
/// persisted pairing no longer names it.
bool _readoptPairMovedOn(String deviceId) =>
_readoptExpectedPair == deviceId && SharedPreferencesUtil().btDevice.id != deviceId;

/// Hardware find-device LED. Not account publication; left unfenced.
Future<bool> findDevice() {
final device = connectedDevice ?? pairedDevice;
Expand Down Expand Up @@ -795,6 +825,8 @@

void setIsConnected(bool value) {
isConnected = value;
// Every path that drops the mirror must allow the live link to be re-admitted.
if (!value) _admittedLink = null;
if (isConnected) {
_discoveryTimer?.cancel();
}
Expand All @@ -805,6 +837,7 @@
void dispose() {
_isDisposed = true;
_sessionGeneration++;
nativeLink.dispose();
_deviceHealthTimer?.cancel();
_deviceHealthTimer = null;
_admittedConnectGeneration = -1;
Expand Down Expand Up @@ -948,6 +981,14 @@
Logger.debug('Discarding device setup continuation from a stale connection session');
return;
}
// A readoption's authorization is only as fresh as its last check: device
// setup awaits BLE work, and a pairing persisted during that window (an
// input picker seeding another microphone) must not get battery, capture,
// and WAL side effects started for the retired pairing.
if (_readoptPairMovedOn(device.id)) {
Logger.debug('Discarding device setup continuation of ${device.id}: pairing moved on during setup');
return;
}

final normalizedDevice = pairedDevice ?? device;
if (captureProvider != null) {
Expand All @@ -961,6 +1002,10 @@
// Read initial battery level
int currentLevel = await _retrieveBatteryLevel(device.id);
if (!_isCurrent(generation)) return;
if (_readoptPairMovedOn(device.id)) {
Logger.debug('Discarding readoption of ${device.id}: pairing moved on before side effects');
return;
}
if (currentLevel != -1) {
batteryLevel = currentLevel;
BatteryWidgetService().updateBatteryInfo(
Expand All @@ -986,6 +1031,10 @@

await getDeviceInfo();
if (!_isCurrent(generation)) return;
if (_readoptPairMovedOn(device.id)) {
Logger.debug('Discarding readoption of ${device.id}: pairing moved on before final publication');
return;
}
SharedPreferencesUtil().deviceName = device.name;

// getDeviceInfo() may have reclassified the discovery object — an Omi-typed
Expand Down Expand Up @@ -1170,14 +1219,28 @@
}
}

void _handleDeviceConnected(String deviceId, int generation) async {
void _handleDeviceConnected(String deviceId, int generation, {bool requirePersistedPair = false}) async {
if (!_isCurrent(generation)) return;
_admittedConnectGeneration = generation;
var connection = await ServiceManager.instance().device.ensureConnection(deviceId);
if (!_isCurrent(generation)) return;
if (connection == null) {
return;
}
// A readoption authorized the persisted pairing at probe time, but this
// admission waited on DeviceService: pairing may have moved on (an input
// picker persisting a different microphone) and publishing the old device
// would overwrite that choice in preferences and start its side effects.
if ((requirePersistedPair || _readoptExpectedPair == deviceId) && SharedPreferencesUtil().btDevice.id != deviceId) {
Logger.debug('Discarding readoption of $deviceId: pairing moved on before admission');
return;
}
// One admission per physical connect: overlapping connected events (a
// native connect still initializing, a resume reconcile) must not run
// connect side effects twice.
final link = (connection, connection.connectionEpoch);
if (_admittedLink == link) return;
_admittedLink = link;
Comment on lines +1241 to +1243

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the admission latch when setup is abandoned

When resume admission for A reaches this assignment and the Ray-Ban picker then seeds B while setConnectedDevice(A) is awaiting, the new pairing guard abandons A but leaves (connection, epoch) latched. If B fails, rayban_meta_input_picker_sheet.dart:101-112 restores A; subsequent resume readoptions see the same live A/epoch and are dropped by the equality check immediately above, recreating the stuck Disconnected state until a manual or physical reconnect. Fresh evidence after the prior pairing comments is that none of the newly added guard returns clears _admittedLink; only commit the latch after setup succeeds or clear it on every abandoned setup.

Useful? React with 👍 / 👎.

_onDeviceConnected(connection.device, generation);
}

Expand Down Expand Up @@ -1429,6 +1492,31 @@
notifyListeners();
}

/// Readopts a service link native already holds while the mirror shows the
/// persisted pairing disconnected. The reconciler authorizes this at probe
/// time — and arms the pairing constraint before the link forms — so every
/// admission for this device revalidates the persisted pairing at admission
/// and through the publication continuation.
void readoptServiceLink(String deviceId) {
if (_isDisposed) return;
_readoptExpectedPair = deviceId;
final generation = _sessionGeneration;
_disconnectDebouncer.cancel();
_connectDebouncer.run(() {
if (!_isCurrent(generation)) return;
_handleDeviceConnected(deviceId, generation, requirePersistedPair: true);
});
}

/// Arms the readoption pairing constraint for [deviceId] before the link
/// starts forming: whichever connected event queues the admission — the
/// transport's own event or the reconciler's — it must still match the
/// persisted pairing when it and its continuation run.
void armReadoptPairConstraint(String deviceId) {
if (_isDisposed) return;
_readoptExpectedPair = deviceId;
}

@override
void onDeviceConnectionStateChanged(String deviceId, DeviceConnectionState state) async {
Logger.debug("provider > device connection state changed...$deviceId...$state...${connectedDevice?.id}");
Expand Down
83 changes: 83 additions & 0 deletions app/lib/providers/native_link_reconciler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'package:omi/backend/preferences.dart';
import 'package:omi/gen/pigeon_communicator.g.dart';
import 'package:omi/providers/device_provider.dart';
import 'package:omi/services/devices.dart';
import 'package:omi/services/services.dart';
import 'package:omi/utils/logger.dart';

/// Native owns the BLE link and Dart mirrors it. Any lost device-ready (a
/// launch connect that timed out, a retired transport, a dropped callback)
/// otherwise leaves the UI disconnected over a live link until the app is
/// killed. On foreground, re-adopt a link native already holds: native answers
/// manageDevice for a connected peripheral by replaying device-ready, so this
/// never touches the radio and never starts a physical connect.
class NativeLinkReconciler {
NativeLinkReconciler(this._provider, {Future<bool> Function(String deviceId)? nativeLinkProbe})
: _nativeLinkProbe = nativeLinkProbe ?? BleHostApi().isPeripheralConnected;

final DeviceProvider _provider;
final Future<bool> Function(String deviceId) _nativeLinkProbe;
AppLifecycleListener? _lifecycle;
bool _disposed = false;

void bindAppLifecycle() {
if (_disposed) return;
_lifecycle ??= AppLifecycleListener(onResume: () => unawaited(reconcile()));
}

/// The paired device id while the mirror shows it disconnected, else null.
String? _disconnectedPairedDeviceId() {
if (_disposed || _provider.isConnected || _provider.isConnecting || _provider.isFirmwareUpdateInProgress) {
return null;
}
final id = SharedPreferencesUtil().btDevice.id;
return id.isEmpty ? null : id;
}

Future<void> reconcile() async {
final deviceId = _disconnectedPairedDeviceId();
if (deviceId == null) return;
final bool linkUp;
try {
linkUp = await _nativeLinkProbe(deviceId);
} catch (_) {
return;
}
// Adopt only the device whose link was probed: pairing or the mirror may
// have changed while the probe was in flight.
if (!linkUp || _disconnectedPairedDeviceId() != deviceId) return;
Logger.debug('NativeLinkReconciler: native holds $deviceId while disconnected; re-adopting');
final deviceService = ServiceManager.instance().device;
if (deviceService.connectionFor(deviceId)?.status == DeviceConnectionState.connected) {
// DeviceService holds the link but the provider never heard. Admission
// revalidates the persisted pairing when the debounced handler runs, so
// a pairing that moved on during the queue is not published.
_provider.readoptServiceLink(deviceId);
return;
}
// Otherwise native replays device-ready into a fresh transport. Its
// connected event queues an admission through the generic path, which can
// start running before this reconcile finishes (DeviceConnection.connect
// emits connected before awaiting ping/getDeviceInfo). Arm the pairing
// constraint on the provider *before* the link starts forming so every
// admission for this device — whichever event queued it — revalidates the
// probe-time pairing, and keep it armed through publication: a pairing
// persisted while the link was forming (an input picker seeding another
// microphone) is never published over.
_provider.armReadoptPairConstraint(deviceId);
final connection = await deviceService.ensureConnection(deviceId, force: true);
if (connection?.status == DeviceConnectionState.connected) {
_provider.readoptServiceLink(deviceId);
}
}

void dispose() {
_disposed = true;
_lifecycle?.dispose();
_lifecycle = null;
}
}
22 changes: 19 additions & 3 deletions app/lib/services/bridges/ble_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class BleBridge implements BleFlutterApi {
final Map<String, CharacteristicValueCallback> _characteristicCallbacks = {};
final Map<String, ConnectionStateCallback> _disconnectCallbacks = {};
final Map<String, DeviceReadyCallback> _deviceReadyCallbacks = {};
final Map<String, Object> _peripheralOwners = {};
final Map<String, RssiUpdateCallback> _rssiCallbacks = {};
final Map<String, CaptureIngressHealth> _ingressHealth = {};
final Set<String> _nativeIngressOwners = {};
Expand Down Expand Up @@ -114,16 +115,28 @@ class BleBridge implements BleFlutterApi {
return () => _scanBlockedListeners.remove(listener);
}

void registerPeripheral({
/// Routes this peripheral's native events to the caller, replacing any
/// previous owner. Returns a disposer that unregisters only while the caller
/// still owns the peripheral: a retired transport disposed after its
/// replacement registered must not silence the replacement's device-ready.
VoidCallback registerPeripheral({
required String peripheralUuid,
CharacteristicValueCallback? onCharacteristicValue,
ConnectionStateCallback? onConnectionState,
DeviceReadyCallback? onDeviceReady,
}) {
final key = peripheralUuid.toUpperCase();
final owner = Object();
_peripheralOwners[key] = owner;
_characteristicCallbacks.remove(key);
_disconnectCallbacks.remove(key);
_deviceReadyCallbacks.remove(key);
if (onCharacteristicValue != null) _characteristicCallbacks[key] = onCharacteristicValue;
if (onConnectionState != null) _disconnectCallbacks[key] = onConnectionState;
if (onDeviceReady != null) _deviceReadyCallbacks[key] = onDeviceReady;
return () {
if (identical(_peripheralOwners[key], owner)) _unregisterPeripheral(key);
};
}

void registerRssiCallback(String peripheralUuid, RssiUpdateCallback callback) {
Expand All @@ -134,8 +147,11 @@ class BleBridge implements BleFlutterApi {
_rssiCallbacks.remove(peripheralUuid.toUpperCase());
}

void unregisterPeripheral(String peripheralUuid) {
final key = peripheralUuid.toUpperCase();
@visibleForTesting
void clearPeripheralForTesting(String peripheralUuid) => _unregisterPeripheral(peripheralUuid.toUpperCase());

void _unregisterPeripheral(String key) {
_peripheralOwners.remove(key);
_connectionRevisions[key] = (_connectionRevisions[key] ?? 0) + 1;
_characteristicCallbacks.remove(key);
_disconnectCallbacks.remove(key);
Expand Down
13 changes: 9 additions & 4 deletions app/lib/services/devices.dart
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,17 @@ class DeviceService {
return null;
}

/// A connection leaving [_connections] is always disposed: an orphaned
/// transport stays registered for native events and would swallow the next
/// device-ready, leaving the UI disconnected over a live link.
Future<void> disconnectDevice(String deviceId) async {
final connection = _connections[deviceId];
if (connection != null) {
Logger.debug("DeviceService: Disconnecting device $deviceId...");
final connection = _connections.remove(deviceId);
if (connection == null) return;
Logger.debug("DeviceService: Disconnecting device $deviceId...");
try {
await connection.disconnect();
_connections.remove(deviceId);
} finally {
await connection.transport.dispose();
}
}

Expand Down
Loading
Loading