Skip to content
Draft
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
84 changes: 84 additions & 0 deletions lib/app/layouts/settings/pages/profile/profile_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,6 +64,32 @@ class _ProfilePanelState extends OptimizedState<ProfilePanel> with WidgetsBindin
Rxn<api.QuotaInfo> quotaInfo = Rxn(null);
Rxn<GoogleSignInCredentials> 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<void> 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"];
Expand Down Expand Up @@ -664,6 +691,63 @@ class _ProfilePanelState extends OptimizedState<ProfilePanel> 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>(
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,
Expand Down
52 changes: 43 additions & 9 deletions lib/app/layouts/setup/pages/rustpush/hw_inp.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,28 @@ class HwInpState extends OptimizedState<HwInp> {
}

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<void> handleBeeper(String code) async {
if (code == lastCheckedCode) return;
lastCheckedCode = code;
try {
relayHost = normalizeRelayHost(relayHost);
if (staging == null) {
FocusManager.instance.primaryFocus?.unfocus();
}
Expand All @@ -134,7 +150,8 @@ class HwInpState extends OptimizedState<HwInp> {
options: Options(
headers: {
// not a secret; burner account
"X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712",
"X-Beeper-Access-Token":
registrationRelayAccessToken,
"Authorization": "Bearer $code",
},
)
Expand All @@ -143,7 +160,10 @@ class HwInpState extends OptimizedState<HwInp> {
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(
Expand All @@ -152,7 +172,8 @@ class HwInpState extends OptimizedState<HwInp> {
options: Options(
headers: {
// not a secret; burner account
"X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712",
"X-Beeper-Access-Token":
registrationRelayAccessToken,
"Authorization": "Bearer $code",
},
)
Expand All @@ -172,6 +193,8 @@ class HwInpState extends OptimizedState<HwInp> {
usingBeeper = true;
}
showSnackbar("Fetching validation data", "Done");
await ss.prefs.setString(
"registration-relay-host", relayHost);
stagingNonInp = true;
select(parsed, true);
} catch (e) {
Expand Down Expand Up @@ -451,6 +474,8 @@ class HwInpState extends OptimizedState<HwInp> {
@override
void initState() {
super.initState();
relayHost = ss.prefs.getString("registration-relay-host") ??
registrationRelayHost;

subscription = pushService.client.purchasesUpdatedStream.listen((PurchasesResultWrapper details) {
handlePurchases(details);
Expand Down Expand Up @@ -785,10 +810,19 @@ class HwInpState extends OptimizedState<HwInp> {
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);
}
},
),
],
Expand Down Expand Up @@ -1087,4 +1121,4 @@ class HwInpState extends OptimizedState<HwInp> {
// Get.delete<SetupViewController>(force: true);
}

}
}
73 changes: 73 additions & 0 deletions lib/services/backend/notifications/notifications_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<LocalNotification>> notifications = {};
Expand Down Expand Up @@ -169,6 +171,8 @@ class NotificationsService extends GetxService {
@override
void onClose() {
countSub?.cancel();
relayReminderTimer?.cancel();
relayReminderTimer = null;
super.onClose();
}

Expand Down Expand Up @@ -1070,6 +1074,75 @@ class NotificationsService extends GetxService {
);
}

Future<void> cancelRelayCheckReminder() async {
relayReminderTimer?.cancel();
relayReminderTimer = null;

if (kIsDesktop) {
await relayToast?.close();
relayToast = null;
return;
}
if (!kIsWeb) {
await flnp.cancel(-7 - 50);
}
}

Future<void> 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<void> createSubscriptionFailed() async {
const title = "Your subscription is no longer active!";
const subtitle =
Expand Down
67 changes: 67 additions & 0 deletions lib/services/rustpush/relay_health.dart
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading