Update app IDs and notification handling for Galaxy app

This commit is contained in:
Prabhaav Pillai
2026-10-27 02:45:44 -04:00
parent 0c5f83e690
commit d0badbe715
7 changed files with 53 additions and 24 deletions
+1 -1
View File
@@ -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
@@ -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)
@@ -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 = {
<p v-if="native"><button class="gx-btn gx-btn--tonal" @click="connectionOptions">Bluetooth, Galaxy Wi-Fi or LAN</button></p>
<BluetoothNotice v-if="bluetooth" feature="Media transfers and phone internet">Phone internet requires the Galaxy hotspot. Settings and small requests remain available.</BluetoothNotice>
<template v-if="!native"><p>Join the Android beta on Google Groups to get the app, then join TheGalaxy Wi-Fi.</p>
<a class="gx-btn" href="https://groups.google.com/g/galaxy-starpilot-android-beta" target="_blank" rel="noopener">Join the Android beta</a>
<a class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Join the Android beta</a>
</template>
<template v-if="status && mode === 'local'"><p>Status: <strong>{{ status.state }}</strong> {{ status.reason }} · {{ ((native ? nativeBytes : status.bytes) / 1048576).toFixed(1) }} MB today</p>
</template><GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
+9 -4
View File
@@ -1,7 +1,7 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { InstallApp, isIosDevice } from "./install-app.js"
import { ANDROID_BETA_URL, InstallApp, isIosDevice } from "./install-app.js"
import { GalaxyCompanion, newerCompanionVersion } from "./companion.js"
import { GalaxyHotspot } from "./hotspot.js"
import { LocalAccess } from "./local-access.js"
@@ -15,7 +15,7 @@ export const GalaxyPage = {
components: { GxState, GxNotice, BluetoothNotice, InstallApp, GalaxyHotspot, GalaxyCompanion, LocalAccess },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
appKey: '', keyVisible: false, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
appKey: '', keyVisible: false, betaUrl: ANDROID_BETA_URL, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
bluetooth: bluetoothConnection(), nativeVersion: window.StarPilotApp?.version?.() || '', dismissedUpdateVersion: dismissedUpdate(),
ios: isIosDevice() }),
computed: {
@@ -33,7 +33,8 @@ export const GalaxyPage = {
mounted() { this.load() },
methods: {
async showAppKey() {
try { const data = await this.request('./api/galaxy/session'); this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey }
const url = this.url
try { const data = await this.request('./api/galaxy/session'); if (this.url !== url || !this.paired) return; this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey }
catch (error) { this.error = error.message }
},
async copyAppKey() {
@@ -73,6 +74,8 @@ export const GalaxyPage = {
const data = await this.request("./api/galaxy/pair", { password: this.password })
this.paired = data.paired
this.url = data.url
this.appKey = ''
this.keyVisible = false
this.password = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
@@ -85,6 +88,8 @@ export const GalaxyPage = {
await this.request("./api/galaxy/unpair", {})
this.paired = false
this.url = ""
this.appKey = ''
this.keyVisible = false
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
@@ -100,7 +105,7 @@ export const GalaxyPage = {
<GxNotice v-else-if="showAppUpdate && !ios" tone="warn" title="Android app update available">
Version {{ availableAppVersion }} is available. Installed: {{ installedAppVersion }}.
<template #actions><button v-if="nativeInstallerAvailable" type="button" class="gx-btn" @click="installAppUpdate">Update app</button>
<a v-else class="gx-btn" href="https://groups.google.com/g/galaxy-starpilot-android-beta" target="_blank" rel="noopener">Update app</a>
<a v-else class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Update app</a>
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button></template>
</GxNotice>
<GxState v-if="mode !== 'local'">Connect to your comma to set up remote access.</GxState>
@@ -61,7 +61,7 @@ export const SentryNotifications = {
components: { GxNotice },
name: "SentryNotifications",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, channels, labels, notifications: null, notificationBusy: false, notificationError: "",
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "",
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
@@ -83,6 +83,9 @@ export const SentryNotifications = {
removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) },
simulate() { return this.client.run({ action: 'simulate' }) },
canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' },
async saveRelay() {
if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = ''
},
start() {
clearInterval(this.poll)
this.refreshPush()
@@ -132,13 +135,15 @@ export const SentryNotifications = {
<GxNotice tone="danger" v-if="notifications?.queueFull">The notification queue is full. Some events could not be queued.</GxNotice>
<template v-if="notifications">
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
<section v-if="nativePush || notifications.appSubscriptions?.length" class="gx-card gx-panel gx-stack">
<h3>This phone (Galaxy app)</h3>
<button v-if="nativePush" class="gx-btn" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
<button v-if="pushState.registered && pushState.id" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
<p v-if="!notifications.appPushAvailable" class="gx-note">Push not set up — the app will pick up real alerts by polling.</p>
<section class="gx-card gx-panel gx-stack">
<h3>Galaxy app phones</h3>
<label>HTTPS push relay URL <input v-model="relayUrl" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
<button type="button" class="gx-btn gx-btn--tonal" @click="saveRelay">Save push relay</button>
<p v-if="!notifications.appPushAvailable" class="gx-note">Configure a push relay to deliver phone alerts.</p>
<button v-if="nativePush" type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
<button v-if="notifications.appPushAvailable && pushState.registered && pushState.id" type="button" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
<p v-for="phone in (notifications.appSubscriptions || [])" :key="phone.id">{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }}
<button class="gx-btn" @click="testPhone(phone.id)">Test</button><button class="gx-btn" @click="removePhone(phone.id)">Remove</button>
<button type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="testPhone(phone.id)">Test</button><button type="button" class="gx-btn" @click="removePhone(phone.id)">Remove</button>
</p>
<p v-for="phone in (notifications.removedAppSubscriptions || [])" :key="phone.id">{{ phone.lastResult }}</p>
<button v-if="canSimulate()" class="gx-btn" @click="simulate">Simulate event</button>
@@ -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')
@@ -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: