diff --git a/lib/app/layouts/settings/pages/profile/profile_panel.dart b/lib/app/layouts/settings/pages/profile/profile_panel.dart index 1b54ce0e9c..9be2784c15 100644 --- a/lib/app/layouts/settings/pages/profile/profile_panel.dart +++ b/lib/app/layouts/settings/pages/profile/profile_panel.dart @@ -19,6 +19,7 @@ import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; +import 'package:bluebubbles/services/rustpush/relay_health.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; @@ -63,6 +64,32 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin Rxn quotaInfo = Rxn(null); Rxn googleCreds = Rxn(null); + String relayHealthSubtitle() { + if (pushService.relayHealthChecking.value) { + return "Testing the iPhone relay..."; + } + + final checked = pushService.relayLastChecked.value; + final lastSuccess = pushService.relayLastSuccess.value; + final checkedText = + checked == null ? null : buildChatListDateMaterial(checked); + final successText = + lastSuccess == null ? null : buildChatListDateMaterial(lastSuccess); + + switch (pushService.relayHealthStatus) { + case RelayHealthStatus.healthy: + return "Reachable${checkedText == null ? "" : " as of $checkedText"}. Tap to test again."; + case RelayHealthStatus.stale: + return "Last known reachable as of ${checkedText ?? "an earlier check"}. Tap to test again."; + case RelayHealthStatus.offline: + final lastSuccessSuffix = + successText == null ? "" : " Last successful check: $successText."; + return "Unavailable${checkedText == null ? "" : " as of $checkedText"}.$lastSuccessSuffix Turn on the relay and tap to retry."; + case RelayHealthStatus.unknown: + return "Not checked yet. Tap to verify the iPhone is online before registration renewal."; + } + } + Future handleSubscriptionToken(String subscription) async { var activated = await http.dio.post("https://hw.openbubbles.app/ticket/${ticket!}/activate", data: {"purchase_token": subscription}); var useTicket = activated.data["ticket"]; @@ -664,6 +691,63 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin ), )); }), + if ((accountInfo["can_pnr"] ?? false) && + !ss.settings.deviceIsHosted.value) + Obx(() { + if (!pushService.relayHealthAvailable.value) { + return const SizedBox.shrink(); + } + final checking = + pushService.relayHealthChecking.value; + final status = pushService.relayHealthStatus; + final color = checking + ? context.theme.colorScheme.outline + : status == RelayHealthStatus.healthy + ? getIndicatorColor(SocketState.connected) + : status == RelayHealthStatus.offline + ? getIndicatorColor( + SocketState.disconnected) + : context.theme.colorScheme.outline; + return SettingsTile( + title: "iPhone Relay", + subtitle: relayHealthSubtitle(), + isThreeLine: true, + leading: + Icon(Icons.phone_iphone, color: color), + trailing: checking + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation( + color), + ), + ) + : Icon( + status == RelayHealthStatus.healthy + ? Icons.check_circle + : status == RelayHealthStatus.offline + ? Icons.error + : Icons.help_outline, + color: color, + ), + onTap: checking + ? null + : () async { + final result = + await pushService.checkRelayHealth(); + if (result == true) { + showSnackbar("iPhone Relay", + "The relay is online and responding."); + } else if (result == false) { + showSnackbar("iPhone Relay", + "The relay could not be reached. Check its power, Wi-Fi, and ValidationRelay status."); + } + }, + ); + }), if (accountInfo['login_status_message']?.startsWith("Deregistered") ?? false) Container( color: tileColor, diff --git a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart index cbae9a5604..d876e2a3e7 100644 --- a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart +++ b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart @@ -118,12 +118,28 @@ class HwInpState extends OptimizedState { } String lastCheckedCode = ""; - String relayHost = "https://registration-relay.beeper.com"; + String relayHost = registrationRelayHost; + + String normalizeRelayHost(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != "https" || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (uri.path.isNotEmpty && uri.path != "/") || + uri.query.isNotEmpty || + uri.fragment.isNotEmpty) { + throw const FormatException( + "Relay server must be a secure HTTPS origin without credentials, a path, a query, or a fragment."); + } + return uri.toString().replaceFirst(RegExp(r"/+$"), ""); + } Future handleBeeper(String code) async { if (code == lastCheckedCode) return; lastCheckedCode = code; try { + relayHost = normalizeRelayHost(relayHost); if (staging == null) { FocusManager.instance.primaryFocus?.unfocus(); } @@ -134,7 +150,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -143,7 +160,10 @@ class HwInpState extends OptimizedState { api.JoinedOsConfig parsed; if (response2.data["versions"]["software_name"] == "iPhone OS") { Logger.debug("Using as iOS"); - parsed = await api.configFromRelay(code: code, host: relayHost, token: "5c175851953ecaf5209185d897591badb6c3e712"); + parsed = await api.configFromRelay( + code: code, + host: relayHost, + token: registrationRelayAccessToken); usingBeeper = false; } else { final response = await http.dio.post( @@ -152,7 +172,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -172,6 +193,8 @@ class HwInpState extends OptimizedState { usingBeeper = true; } showSnackbar("Fetching validation data", "Done"); + await ss.prefs.setString( + "registration-relay-host", relayHost); stagingNonInp = true; select(parsed, true); } catch (e) { @@ -451,6 +474,8 @@ class HwInpState extends OptimizedState { @override void initState() { super.initState(); + relayHost = ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; subscription = pushService.client.purchasesUpdatedStream.listen((PurchasesResultWrapper details) { handlePurchases(details); @@ -785,10 +810,19 @@ class HwInpState extends OptimizedState { TextButton( child: Text("OK", style: Get.context!.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)), onPressed: () async { - relayHost = server.text; - lastCheckedCode = ""; - Get.back(); - checkCode(codeController.text); + try { + relayHost = + normalizeRelayHost( + server.text); + lastCheckedCode = ""; + Get.back(); + checkCode( + codeController.text); + } on FormatException catch (e) { + showSnackbar( + "Invalid relay URL", + e.message); + } }, ), ], @@ -1087,4 +1121,4 @@ class HwInpState extends OptimizedState { // Get.delete(force: true); } -} \ No newline at end of file +} diff --git a/lib/services/backend/notifications/notifications_service.dart b/lib/services/backend/notifications/notifications_service.dart index b042d45c9e..0ea5786217 100644 --- a/lib/services/backend/notifications/notifications_service.dart +++ b/lib/services/backend/notifications/notifications_service.dart @@ -51,10 +51,12 @@ class NotificationsService extends GetxService { final FlutterLocalNotificationsPlugin flnp = FlutterLocalNotificationsPlugin(); StreamSubscription? countSub; int currentCount = 0; + Timer? relayReminderTimer; /// For desktop use only static LocalNotification? allToast; static LocalNotification? failedToast; + static LocalNotification? relayToast; static LocalNotification? socketToast; static LocalNotification? aliasesToast; static Map> notifications = {}; @@ -169,6 +171,8 @@ class NotificationsService extends GetxService { @override void onClose() { countSub?.cancel(); + relayReminderTimer?.cancel(); + relayReminderTimer = null; super.onClose(); } @@ -1070,6 +1074,75 @@ class NotificationsService extends GetxService { ); } + Future cancelRelayCheckReminder() async { + relayReminderTimer?.cancel(); + relayReminderTimer = null; + + if (kIsDesktop) { + await relayToast?.close(); + relayToast = null; + return; + } + if (!kIsWeb) { + await flnp.cancel(-7 - 50); + } + } + + Future scheduleRelayCheckReminder(DateTime time) async { + await cancelRelayCheckReminder(); + + const title = "Check your iPhone relay"; + const subtitle = + "Phone number registration renews soon. Tap to verify that the relay is online."; + if (kIsDesktop) { + final delay = time.difference(DateTime.now()); + relayReminderTimer = + Timer(delay.isNegative ? Duration.zero : delay, () async { + relayToast = LocalNotification( + title: title, + body: subtitle, + actions: [], + ); + + relayToast!.onClick = () async { + relayToast = null; + await windowManager.show(); + if (ss.settings.finishedSetup.value) { + ns.pushLeft(Get.context!, ProfilePanel()); + } + }; + + await relayToast!.show(); + }); + return; + } + if (kIsWeb) { + return; + } + + await flnp.zonedSchedule( + -7 - 50, + title, + subtitle, + TZDateTime.from(time, local), + NotificationDetails( + android: AndroidNotificationDetails( + ERROR_CHANNEL, + "Errors", + channelDescription: + "Displays message send failures, connection failures, and more", + priority: Priority.max, + importance: Importance.max, + color: HexColor("4990de"), + ), + ), + payload: "-51", + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } + Future createSubscriptionFailed() async { const title = "Your subscription is no longer active!"; const subtitle = diff --git a/lib/services/rustpush/relay_health.dart b/lib/services/rustpush/relay_health.dart new file mode 100644 index 0000000000..6e15778923 --- /dev/null +++ b/lib/services/rustpush/relay_health.dart @@ -0,0 +1,67 @@ +enum RelayHealthStatus { unknown, healthy, stale, offline } + +const relayHealthStaleAfter = Duration(minutes: 30); + +bool isUserManagedIPhoneRelay({ + required String deviceName, + required bool hosted, +}) { + if (hosted) return false; + + final normalizedName = deviceName.toLowerCase(); + return normalizedName.contains("iphone") || + normalizedName.contains("ipad") || + normalizedName.contains("ipod"); +} + +class RelayHealthSnapshot { + final bool? reachable; + final DateTime? lastChecked; + final DateTime? lastSuccess; + + const RelayHealthSnapshot({ + this.reachable, + this.lastChecked, + this.lastSuccess, + }); + + RelayHealthStatus statusAt( + DateTime now, { + Duration staleAfter = relayHealthStaleAfter, + }) { + if (reachable == false) return RelayHealthStatus.offline; + if (reachable != true || lastChecked == null) { + return RelayHealthStatus.unknown; + } + + final age = now.toUtc().difference(lastChecked!.toUtc()); + return age <= staleAfter + ? RelayHealthStatus.healthy + : RelayHealthStatus.stale; + } + + RelayHealthTransition afterProbe({ + required bool isReachable, + required DateTime checkedAt, + }) { + final next = RelayHealthSnapshot( + reachable: isReachable, + lastChecked: checkedAt, + lastSuccess: isReachable ? checkedAt : lastSuccess, + ); + return RelayHealthTransition( + snapshot: next, + recovered: reachable == false && isReachable, + ); + } +} + +class RelayHealthTransition { + final RelayHealthSnapshot snapshot; + final bool recovered; + + const RelayHealthTransition({ + required this.snapshot, + required this.recovered, + }); +} diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..648cbb276c 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -51,6 +51,8 @@ import 'package:mixpanel_flutter/mixpanel_flutter.dart'; import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:flutter_isolate/flutter_isolate.dart'; import 'package:google_sign_in_all_platforms/google_sign_in_all_platforms.dart'; +import 'package:synchronized/synchronized.dart'; +import 'relay_health.dart'; var uuid = const Uuid(); RustPushService pushService = @@ -58,6 +60,9 @@ RustPushService pushService = const rpApiRoot = "https://hw.openbubbles.app/code"; +const registrationRelayHost = "https://registration-relay.beeper.com"; +const registrationRelayAccessToken = + "5c175851953ecaf5209185d897591badb6c3e712"; const clientId = '1041242226917-ik21n86fp43e82iu1e5soh6bu6gvuste.apps.googleusercontent.com'; const clientSecret = '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'; @@ -1313,6 +1318,269 @@ class RustPushService extends GetxService { var disableOutgoingSms = false; + final RxBool relayHealthChecking = false.obs; + final RxBool relayHealthAvailable = false.obs; + final RxnBool relayReachable = RxnBool(); + final Rxn relayLastChecked = Rxn(); + final Rxn relayLastSuccess = Rxn(); + Future? _relayHealthInFlight; + String? _relayHealthFingerprint; + final Lock _relayReminderLock = Lock(); + RelayHealthSnapshot _relayHealthSnapshot = const RelayHealthSnapshot(); + + RelayHealthStatus get relayHealthStatus => + _relayHealthSnapshot.statusAt(DateTime.now()); + + Future _persistRelayHealth(Future write) async { + try { + await write; + } catch (e, s) { + Logger.warn("Failed to persist iPhone relay health", error: e, trace: s); + } + } + + Future getUserManagedIPhoneRelayDevice( + {api.SharedPushState? fromState}) async { + final currentState = fromState ?? state; + if (currentState == null || ss.settings.deviceIsHosted.value) { + return null; + } + + final device = + await api.getDeviceInfo(config: currentState.osConfig); + if (isUserManagedIPhoneRelay( + deviceName: device.name, + hosted: ss.settings.deviceIsHosted.value)) { + return device; + } + return null; + } + + String relayHealthFingerprint(api.DeviceInfo device) { + final relayHost = ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final fingerprintSource = + "${device.serial}|${ss.settings.iCloudAccount.value}|$relayHost"; + return sha256.convert(utf8.encode(fingerprintSource)).toString(); + } + + Future clearRelayHealthState({bool clearPreferences = true}) async { + relayHealthAvailable.value = false; + relayReachable.value = null; + relayLastChecked.value = null; + relayLastSuccess.value = null; + _relayHealthSnapshot = const RelayHealthSnapshot(); + _relayHealthFingerprint = null; + if (!clearPreferences) { + return; + } + + await Future.wait([ + ss.prefs.remove("relay-health-fingerprint"), + ss.prefs.remove("relay-health-last-checked"), + ss.prefs.remove("relay-health-last-success"), + ss.prefs.remove("relay-health-reachable"), + ]); + } + + Future restoreRelayHealthState() async { + final device = await getUserManagedIPhoneRelayDevice(); + if (device == null) { + await clearRelayHealthState(); + return; + } + + final fingerprint = relayHealthFingerprint(device); + relayHealthAvailable.value = true; + final savedFingerprint = + ss.prefs.getString("relay-health-fingerprint"); + if (savedFingerprint != fingerprint) { + await clearRelayHealthState(); + relayHealthAvailable.value = true; + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + return; + } + + _relayHealthFingerprint = fingerprint; + final lastChecked = ss.prefs.getInt("relay-health-last-checked"); + final lastSuccess = ss.prefs.getInt("relay-health-last-success"); + relayReachable.value = ss.prefs.getBool("relay-health-reachable"); + relayLastChecked.value = lastChecked == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastChecked); + relayLastSuccess.value = lastSuccess == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastSuccess); + _relayHealthSnapshot = RelayHealthSnapshot( + reachable: relayReachable.value, + lastChecked: relayLastChecked.value, + lastSuccess: relayLastSuccess.value, + ); + } + + Future usesUserManagedIPhoneRelay() async { + return await getUserManagedIPhoneRelayDevice() != null; + } + + Future checkRelayHealth() async { + final existingCheck = _relayHealthInFlight; + if (existingCheck != null) { + return await existingCheck; + } + + final check = _performRelayHealthCheck(); + _relayHealthInFlight = check; + try { + return await check; + } finally { + if (identical(_relayHealthInFlight, check)) { + _relayHealthInFlight = null; + } + } + } + + Future _performRelayHealthCheck() async { + final currentState = state; + if (currentState == null) { + return null; + } + + api.DeviceInfo? relayDevice; + try { + relayDevice = await getUserManagedIPhoneRelayDevice( + fromState: currentState); + } catch (e, s) { + Logger.warn("Failed to identify iPhone relay", + error: e, trace: s); + return null; + } + if (relayDevice == null) { + await clearRelayHealthState(); + return null; + } + relayHealthAvailable.value = true; + + final fingerprint = relayHealthFingerprint(relayDevice); + if (_relayHealthFingerprint != fingerprint) { + await clearRelayHealthState(); + relayHealthAvailable.value = true; + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + } + + relayHealthChecking.value = true; + final checkedAt = DateTime.now(); + relayLastChecked.value = checkedAt; + + try { + await _persistRelayHealth( + ss.prefs.setInt( + "relay-health-last-checked", checkedAt.millisecondsSinceEpoch), + ); + final relayCode = + await api.validateRelay(configRef: currentState.osConfig); + var reachable = false; + if (relayCode != null) { + final relayHost = + ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final response = await http.dio.post( + "$relayHost/api/v1/bridge/get-version-info", + data: {}, + options: Options( + headers: { + "X-Beeper-Access-Token": + registrationRelayAccessToken, + "Authorization": "Bearer $relayCode", + }, + ), + ); + final responseData = response.data; + final versions = + responseData is Map ? responseData["versions"] : null; + reachable = response.statusCode == 200 && + versions is Map && + versions["software_name"] == "iPhone OS"; + } + + final transition = _relayHealthSnapshot.afterProbe( + isReachable: reachable, + checkedAt: checkedAt, + ); + _relayHealthSnapshot = transition.snapshot; + relayReachable.value = reachable; + await _persistRelayHealth( + ss.prefs.setBool("relay-health-reachable", reachable)); + + if (reachable) { + relayLastSuccess.value = checkedAt; + await _persistRelayHealth(ss.prefs.setInt( + "relay-health-last-success", checkedAt.millisecondsSinceEpoch)); + } + + return reachable; + } catch (e, s) { + final transition = _relayHealthSnapshot.afterProbe( + isReachable: false, + checkedAt: checkedAt, + ); + _relayHealthSnapshot = transition.snapshot; + relayReachable.value = false; + relayLastChecked.value = checkedAt; + relayLastSuccess.value = transition.snapshot.lastSuccess; + await _persistRelayHealth( + ss.prefs.setBool("relay-health-reachable", false)); + Logger.warn("iPhone relay health check failed", error: e, trace: s); + return false; + } finally { + relayHealthChecking.value = false; + } + } + + Future scheduleRelayHealthReminder( + int secondsUntilRenewal) async { + await _relayReminderLock.synchronized(() async { + await notif.cancelRelayCheckReminder(); + final currentState = state; + if (currentState == null) { + return; + } + try { + if (await getUserManagedIPhoneRelayDevice( + fromState: currentState) == + null) { + return; + } + if ((await api.getMyPhoneHandles( + state: currentState.client)) + .isEmpty) { + return; + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay reminder", + error: e, trace: s); + return; + } + if (!identical(state, currentState)) { + return; + } + + const warningLeadTime = Duration(minutes: 15); + final delaySeconds = + max(10, secondsUntilRenewal - warningLeadTime.inSeconds); + await notif.scheduleRelayCheckReminder( + DateTime.now().add(Duration(seconds: delaySeconds))); + }); + } + + Future cancelRelayHealthReminder() async { + await _relayReminderLock.synchronized( + () => notif.cancelRelayCheckReminder()); + } + Map attachments = {}; Future> doValidateTargets(List targets, String handle) async { @@ -3353,12 +3621,17 @@ class RustPushService extends GetxService { var state = push.field0; if (state is api.RegisterState_Registered) { notifiedFailed = false; + unawaited(scheduleRelayHealthReminder(state.nextS)); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-success"); } handleRegistered(); } + if (state is api.RegisterState_Registering) { + unawaited(cancelRelayHealthReminder()); + } if (state is api.RegisterState_Failed && !notifiedFailed) { + unawaited(cancelRelayHealthReminder()); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-failure"); } @@ -4877,6 +5150,25 @@ class RustPushService extends GetxService { initAppLinks(); initMixPanel(); await initFuture; + try { + await restoreRelayHealthState(); + } catch (e, s) { + Logger.warn("Failed to restore iPhone relay health", + error: e, trace: s); + await clearRelayHealthState(); + } + if (state != null) { + try { + final registrationState = + await api.getRegstate(state: state!.client); + if (registrationState is api.RegisterState_Registered) { + await scheduleRelayHealthReminder(registrationState.nextS); + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay health check", + error: e, trace: s); + } + } Timer(const Duration(seconds: 2), checkIncident); // pre-cache next FT link if (pushService.state != null) api.getFtLink(facetime: pushService.state!.ftClient, usage: "next"); @@ -4955,6 +5247,14 @@ class RustPushService extends GetxService { var thisState = state; state = null; + final relayHealthCheck = _relayHealthInFlight; + if (relayHealthCheck != null) { + await relayHealthCheck; + } + await cancelRelayHealthReminder(); + if (hw || logout) { + await clearRelayHealthState(); + } if (thisState == null) return; if (logout) { @@ -5036,6 +5336,7 @@ class RustPushService extends GetxService { @override void onClose() { + unawaited(cancelRelayHealthReminder()); if (state != null) disposeState(state!, true, false); super.onClose(); } diff --git a/test/services/rustpush/relay_health_test.dart b/test/services/rustpush/relay_health_test.dart new file mode 100644 index 0000000000..8a127a96a9 --- /dev/null +++ b/test/services/rustpush/relay_health_test.dart @@ -0,0 +1,84 @@ +import 'package:bluebubbles/services/rustpush/relay_health.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final checkedAt = DateTime.utc(2026, 8, 15, 12); + + test('a recent successful probe is healthy', () { + final snapshot = RelayHealthSnapshot( + reachable: true, + lastChecked: DateTime.utc(2026, 8, 15, 12), + ); + + expect( + snapshot.statusAt(checkedAt.add(const Duration(minutes: 5))), + RelayHealthStatus.healthy, + ); + }); + + test('an old successful probe is stale', () { + final snapshot = RelayHealthSnapshot( + reachable: true, + lastChecked: DateTime.utc(2026, 8, 15, 11), + ); + + expect(snapshot.statusAt(checkedAt), RelayHealthStatus.stale); + }); + + test('a failed probe is offline and retains the last success', () { + final snapshot = RelayHealthSnapshot( + reachable: true, + lastChecked: checkedAt, + lastSuccess: checkedAt, + ); + + final transition = snapshot.afterProbe( + isReachable: false, + checkedAt: checkedAt.add(const Duration(minutes: 1)), + ); + + expect( + transition.snapshot.statusAt(checkedAt.add(const Duration(minutes: 1))), + RelayHealthStatus.offline, + ); + expect(transition.snapshot.lastSuccess, checkedAt); + expect(transition.recovered, isFalse); + }); + + test('a successful probe after offline is recovered and healthy', () { + final offline = RelayHealthSnapshot( + reachable: false, + lastChecked: checkedAt, + ); + + final transition = offline.afterProbe( + isReachable: true, + checkedAt: checkedAt.add(const Duration(minutes: 2)), + ); + + expect(transition.recovered, isTrue); + expect( + transition.snapshot.statusAt(checkedAt.add(const Duration(minutes: 2))), + RelayHealthStatus.healthy, + ); + expect( + transition.snapshot.lastSuccess, + checkedAt.add(const Duration(minutes: 2)), + ); + }); + + test('only non-hosted Apple mobile relays are eligible', () { + expect( + isUserManagedIPhoneRelay(deviceName: "iPhone 15 Pro", hosted: false), + isTrue, + ); + expect( + isUserManagedIPhoneRelay(deviceName: "MacBookPro18,3", hosted: false), + isFalse, + ); + expect( + isUserManagedIPhoneRelay(deviceName: "iPhone 15 Pro", hosted: true), + isFalse, + ); + }); +}