diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 67b1bbd685..6c4921a05b 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -781,7 +781,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe token = quote(f"{record['slug']}:{record['session']}", safe='') if path.endswith('/qr.svg'): from openpilot.common.qrcode import _Qr, _capacity - raw = ('starpilot-remote://add?key=' + quote(token, safe='')).encode() + raw = ('starpilot-remote://add?key=' + token).encode() version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8) matrix = _Qr(version, raw).modules size = len(matrix) + 8 diff --git a/openpilot/starpilot/galaxy/tests/test_sentry_notifications_http.py b/openpilot/starpilot/galaxy/tests/test_sentry_notifications_http.py index 05a20bafbb..58fd50ae73 100644 --- a/openpilot/starpilot/galaxy/tests/test_sentry_notifications_http.py +++ b/openpilot/starpilot/galaxy/tests/test_sentry_notifications_http.py @@ -33,7 +33,7 @@ class NotificationHTTPTests(unittest.TestCase): return result try: cookie = request()[2]['Set-Cookie'].split(';', 1)[0] - code, result, _ = request({'action': 'appSubscribe', 'token': 'test-token', 'appId': 'link.firestar.galaxy', 'label': 'Phone'}, cookie) + code, result, _ = request({'action': 'appSubscribe', 'token': 'test-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}, cookie) self.assertEqual(code, 200) self.assertNotIn('test-token', json.dumps(result)) self.assertEqual(request({'action': 'test', 'target': 'app', 'id': 'missing'}, cookie)[0], 404) diff --git a/openpilot/starpilot/galaxy/web/js/companion.js b/openpilot/starpilot/galaxy/web/js/companion.js index 9cb1e1b417..1b33ded54c 100644 --- a/openpilot/starpilot/galaxy/web/js/companion.js +++ b/openpilot/starpilot/galaxy/web/js/companion.js @@ -1,6 +1,7 @@ import { requestJson } from './startup.js' import { GxNotice } from './notice.js' import { BluetoothNotice, bluetoothConnection } from './bluetooth-notice.js' +import { ANDROID_BETA_URL } from './install-app.js' export function newerCompanionVersion(available, installed) { const parts = version => typeof version === 'string' && /^\d+(?:\.\d+){1,3}$/.test(version) ? version.split('.').map(Number) : [] @@ -23,7 +24,7 @@ export const GalaxyCompanion = { components: { GxNotice, BluetoothNotice }, emits: ['status'], props: { mode: String, unauthorized: Function }, - data: () => ({ status: null, error: '', native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false, + data: () => ({ status: null, error: '', betaUrl: ANDROID_BETA_URL, native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false, bluetooth: bluetoothConnection(), nativeBytes: 0, busy: false, generation: 0, alive: false }), computed: { windowAutoStart() { return !!window.StarPilotApp?.setAutoStart } }, mounted() { this.start() }, @@ -87,7 +88,7 @@ export const GalaxyCompanion = {
Join the Android beta on Google Groups to get the app, then join TheGalaxy Wi-Fi.
- Join the Android beta + Join the Android betaStatus: {{ status.state }} {{ status.reason }} · {{ ((native ? nativeBytes : status.bytes) / 1048576).toFixed(1) }} MB today
{{ notifications.deliverySemantics }}
-Push not set up — the app will pick up real alerts by polling.
+Configure a push relay to deliver phone alerts.
+ +{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }} - +
{{ phone.lastResult }}
diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 23a4ec26a9..7fc2be6b9f 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -25,7 +25,7 @@ from openpilot.starpilot.sentry_mode import web_push CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush') APP_PUSH_RELAY_URL = '' -APP_IDS = ('link.firestar.companion', 'link.firestar.galaxy', 'link.galaxy.bluetooth.natehuby76') +APP_IDS = ('com.starpilot.galaxy', 'link.firestar.companion', 'link.galaxy.bluetooth.natehuby76') MAX_STATE_BYTES = 1024 * 1024 MAX_JOBS = 512 MAX_AGE = 86400 @@ -192,7 +192,8 @@ class NotificationOwner: type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or - (type(job['subscriptionId']) is not str or (job['subscriptionId'] and re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else r'[0-9a-f]{32}', job['subscriptionId']) is None)) or + (type(job['subscriptionId']) is not str or (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else + r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', job['subscriptionId']) is None)) or type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or @@ -237,7 +238,7 @@ class NotificationOwner: 'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'} result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} for sub in self.state['appSubscriptions']] result['removedAppSubscriptions'] = [{'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:] - result['appPushAvailable'] = bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) + result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) for name, cfg in self.state['channels'].items(): relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']] latest = max(relevant, key=lambda j: j['created'], default=None) @@ -281,7 +282,7 @@ class NotificationOwner: # Empty fields preserve configured credentials; explicit disable does not delete them. url = url or old['url'] token = payload['token'] or old['token'] - if payload['enabled'] and name not in ('webPush', 'appPush') and not url: + if payload['enabled'] and name != 'webPush' and not url: raise ValueError('Enter a notification URL') self.state['channels'][name] = {'enabled': payload['enabled'], 'url': url, 'token': token, 'generation': uuid.uuid4().hex, 'since': now} @@ -319,7 +320,8 @@ class NotificationOwner: elif action == 'appSubscribe': if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or - payload['appId'] not in APP_IDS or type(payload['label']) is not str or len(payload['label']) > 64): + type(payload['appId']) is not str or payload['appId'] not in APP_IDS or + type(payload['label']) is not str or len(payload['label']) > 64): raise ValueError('Invalid app subscription') current = self.state['appSubscriptions'] existing = next((sub for sub in current if sub['token'] == payload['token']), None) @@ -332,8 +334,7 @@ class NotificationOwner: else: raise ValueError('Remove a phone before adding another') cfg = self.state['channels']['appPush'] - cfg['enabled'] = True - cfg['url'] = cfg['url'] or APP_PUSH_RELAY_URL + cfg['enabled'] = bool(cfg['url'] or APP_PUSH_RELAY_URL) elif action == 'appUnsubscribe': if set(payload) not in ({'action', 'token'}, {'action', 'id'}): raise ValueError('Choose a phone subscription') diff --git a/openpilot/starpilot/sentry_mode/tests/test_notifications.py b/openpilot/starpilot/sentry_mode/tests/test_notifications.py index 406c018c4e..97aa9e6987 100644 --- a/openpilot/starpilot/sentry_mode/tests/test_notifications.py +++ b/openpilot/starpilot/sentry_mode/tests/test_notifications.py @@ -319,9 +319,11 @@ def test_app_subscription_dispatch_redaction_and_expired_removal(tmp_path): clock = [100.] owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 410) try: - snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'link.firestar.galaxy', 'label': 'Phone'}) + snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}) sub_id = snapshot['appSubscriptions'][0]['id'] assert len(sub_id) == 12 and 'private-fcm-token' not in json.dumps(snapshot) + assert not snapshot['appPushAvailable'] + owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''}) with pytest.raises(LookupError): owner.action({'action': 'test', 'target': 'app', 'id': 'missing'}) owner.action({'action': 'test', 'target': 'app', 'id': sub_id}) @@ -338,7 +340,8 @@ def test_app_success_dedupe_selfie_and_migration(tmp_path): events = [] root = tmp_path / 'notifications' owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 200) - owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'link.firestar.companion', 'label': 'Phone'}) + owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}) + owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''}) events.extend([{'eventId': 'a' * 32, 'kind': 'selfie', 'wallTimeNs': int(101e9)}, {'eventId': 'b' * 32, 'kind': 'warning', 'wallTimeNs': int(101e9)}]) clock[0] = 102. owner.tick(); owner.tick() @@ -356,6 +359,20 @@ def test_app_success_dedupe_selfie_and_migration(tmp_path): migrated.close() +def test_webhook_job_survives_restart_with_empty_subscription_id(tmp_path): + root = tmp_path / 'notifications' + owner = NotificationOwner(root, store()) + configure(owner, 'webhook') + owner.action({'action': 'test', 'channel': 'webhook'}) + owner.close() + reopened = NotificationOwner(root, store()) + try: + assert reopened.available + assert reopened.snapshot()['channels']['webhook']['pending'] == 1 + finally: + reopened.close() + + def test_ios_bundle_id_can_subscribe(tmp_path): owner = NotificationOwner(tmp_path / 'notifications', store(), transport=lambda *args: 200) try: