Update Chrome login and remove embedded browser

Combine Chrome Login v1 and Remove Embedded Browser.
This commit is contained in:
Danny
2026-10-10 12:57:55 -07:00
committed by dirwin31
parent b006d1ea1f
commit e3ce3aec8f
22 changed files with 3583 additions and 26 deletions
+3
View File
@@ -135,3 +135,6 @@ openpilot/selfdrive/modeld/models/*.unchunked
# Jetlink editable-install metadata.
/jetlink_repo/jetlink.egg-info/
# Local Google Play resolver build output (source and lockfile are versioned).
/tools/playlink/target/
@@ -8,11 +8,13 @@ from __future__ import annotations
import os
import tempfile
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from collections.abc import Callable
from typing import BinaryIO
from openpilot.starpilot.system.android_auto import apk_identity
from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
class SetupRejected(RuntimeError):
@@ -34,14 +36,17 @@ class AndroidAutoSetup:
install_ready: Callable[[], bool] = lambda: False,
service_ready: Callable[[], bool] = lambda: False,
set_enabled: Callable[[bool], None] | None = None,
enable_bluetooth: Callable[[tuple], None] | None = None):
enable_bluetooth: Callable[[tuple], None] | None = None, play_install=None):
self.parked, self.enabled, self.session_valid = parked, enabled, session_valid
self.bluetooth_enabled = bluetooth_enabled
self.install_ready, self.service_ready, self.set_enabled_value = install_ready, service_ready, set_enabled
self.enable_bluetooth_value = enable_bluetooth
self.play_install = play_install
self.job = import_job or apk_identity.ImportJob()
self.identity_status = identity_status
self.token_store = self.job.token_store if isinstance(self.job, apk_identity.ImportJob) else play_resolver.TokenStore()
self._upload_lock = threading.Lock()
self._refresh_after = 0.0
def _require_session(self, session: tuple) -> None:
if not session or not self.session_valid(session):
@@ -55,6 +60,8 @@ class AndroidAutoSetup:
'installReady': self.install_ready(), 'serviceReady': self.service_ready(),
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error') if key in ident},
'import': self.job.status(),
'googlePlay': {'available': play_resolver.BINARY.is_file(), 'remembered': self.token_store.exists(),
**({'package': self.play_install.status()} if self.play_install is not None else {})},
'maxUploadBytes': apk_identity.MAX_FILE_BYTES,
'wiredAvailable': False,
'steps': [
@@ -127,6 +134,65 @@ class AndroidAutoSetup:
path.unlink(missing_ok=True)
self._upload_lock.release()
def start_google_import(self, session: tuple, *, email: str, token: str, remember: bool = False) -> dict:
self._require_session(session)
if type(remember) is not bool:
raise SetupRejected('Remember must be a boolean')
play_resolver.validate_credentials(email, token)
if not self._upload_lock.acquire(blocking=False):
raise SetupRejected('Another Android Auto upload is running')
try:
self._require_session(session)
if self.job.busy():
raise SetupRejected('An Android Auto import is running')
self.job.start_google_import(email=email, save_token=remember,
**({'oauth_token': token} if token.startswith('oauth2_4/') else {'aas_token': token}),
enabled=lambda: self.session_valid(session))
self._refresh_after = time.monotonic() + 86400
finally:
self._upload_lock.release()
return self.status(session)
def forget_google(self, session: tuple) -> dict:
self._require_session(session)
with self._upload_lock:
self._require_session(session)
if self.job.busy():
raise SetupRejected('Wait for the current import before forgetting Google sign-in')
self.token_store.forget()
return self.status(session)
def maintain(self, now: float | None = None) -> None:
"""Called by Galaxy housekeeping; at most one refresh per day, only offroad.
Uses the same job and admission lock as manual imports. Explicitly saved
credentials authorize renewal without the user signing in again. No account secrets
or signed links enter the HTTP status or logs.
"""
now = time.monotonic() if now is None else now
if now < self._refresh_after or not self._upload_lock.acquire(blocking=False):
return
try:
self._refresh_after = now + 60
if self.job.busy() or not self.token_store.exists() or not self.enabled() or not self.parked():
return
ident = self.identity_status()
expires = ident.get('expires')
if not expires or (datetime.fromisoformat(expires) - datetime.now(UTC)).total_seconds() > 14 * 86400:
return
self._refresh_after = now + 86400
credentials = self.token_store.load()
try:
self.job.start_google_import(email=credentials['email'], aas_token=credentials['token'], save_token=True,
enabled=lambda: self.enabled() and self.parked() and self.token_store.exists())
finally:
credentials.clear()
except (OSError, ValueError, TypeError, RuntimeError):
# Malformed/revoked credentials must not crash Galaxy or cause retry storms.
self._refresh_after = now + 86400
finally:
self._upload_lock.release()
def remove(self, session: tuple, *, disconnect: Callable[[], None] | None = None) -> dict:
self._require_session(session)
with self._upload_lock:
@@ -136,5 +202,6 @@ class AndroidAutoSetup:
if disconnect is not None:
disconnect()
self._require_session(session)
self.token_store.forget()
apk_identity.remove_identity()
return self.status(session)
+11 -1
View File
@@ -57,6 +57,15 @@ class _LocalHTTPServer(ThreadingHTTPServer):
for _ in range(acquired):
self._slots.release()
def service_actions(self):
super().service_actions()
maintain = getattr(self, 'maintain_android_auto', None)
if maintain is not None:
try:
maintain()
except Exception:
pass # Optional housekeeping must never stop serve_forever.
def server_close(self, *, close_sources: bool = True):
if not close_sources:
# Managed shutdown can abandon a stuck daemon request. Its readers must
@@ -76,7 +85,8 @@ class _LocalHTTPServer(ThreadingHTTPServer):
for name in ('map_source', 'settings_source', 'vehicle_selection_source', 'model_source', 'plots_source',
'flm_source', 'bluetooth_authority', 'bluetooth_source', 'model_manager_source', 'model_authority', 'layout_authority', 'favorites_source',
'sound_authority', 'sound_source', 'software_operations_source', 'drive_stats_authority', 'drive_stats_source',
'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source'):
'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source',
'android_auto_setup_source', 'play_install_source'):
close = getattr(getattr(self, name, None), 'close', None)
if callable(close):
cleanup.callback(close)
+176
View File
@@ -0,0 +1,176 @@
"""Downloads the pinned Google Play resolver in the background after Galaxy setup, while parked."""
import fcntl
import hashlib
import os
from pathlib import Path
import platform
import shutil
import tempfile
import threading
import time
import urllib.request
from urllib.parse import urlsplit
from openpilot.starpilot.system.android_auto.play_package import PACKAGE, RUNTIME
class Paused(RuntimeError):
pass
def checked_url(url):
parsed = urlsplit(url)
if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port not in (None, 443)
or parsed.hostname not in ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')):
raise ValueError('Untrusted package URL')
return url
class ReleaseRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return super().redirect_request(req, fp, code, msg, headers, checked_url(newurl))
def download(asset, destination, check, progress):
digest = hashlib.sha256()
received = 0
opener = urllib.request.build_opener(ReleaseRedirect())
request = urllib.request.Request(checked_url(asset['url']), headers={'User-Agent': 'StarPilot-Galaxy-Setup'})
with opener.open(request, timeout=10) as response, destination.open('wb') as output:
if response.status != 200:
raise ValueError('Incomplete package response')
while True:
check()
block = response.read(64 * 1024)
if not block:
break
received += len(block)
if received > asset['bytes']:
raise ValueError('Package exceeds expected size')
output.write(block)
digest.update(block)
progress(received)
if received != asset['bytes'] or digest.hexdigest() != asset['sha256']:
raise ValueError('Package integrity check failed')
class PlayInstall:
def __init__(self, *, ready, parked, destination=RUNTIME, package=PACKAGE, enabled=None, fetch=download):
self.ready, self.parked, self.destination, self.package = ready, parked, destination, package
self.enabled = (Path('/AGNOS').is_file() and platform.machine() == 'aarch64') if enabled is None else enabled
self.fetch = fetch
self.lock = threading.Lock()
self.stop = threading.Event()
self.thread = None
self.retry_at = 0.0
self.failures = 0
self.state, self.percent = 'pending', 0
self.setup_seen = destination.parent / '.galaxy-setup-complete'
def mark_setup_complete(self):
if self.enabled and not self.setup_seen.exists():
self.setup_seen.parent.mkdir(parents=True, exist_ok=True)
self.setup_seen.touch(mode=0o600)
def installed(self):
try:
return ((self.destination / 'package-id').read_text().strip() == self.package['id'] and
(self.destination / 'playlink').is_file())
except OSError:
return False
def status(self):
with self.lock:
return {'state': self.state, 'percent': self.percent}
def maintain(self):
if not self.enabled or self.stop.is_set():
return
with self.lock:
if self.state == 'ready' or self.thread is not None and self.thread.is_alive():
return
if self.installed():
self.state, self.percent = 'ready', 100
return
if time.monotonic() < self.retry_at:
return
try:
setup_ready = self.setup_seen.exists() or self.ready()
parked = self.parked()
except Exception:
self._retry_later()
return
if not setup_ready:
return
if not parked:
self.state = 'paused'
return
self.state, self.percent = 'downloading', 0
self.thread = threading.Thread(target=self._run, name='galaxy-play-install', daemon=True)
self.thread.start()
def _retry_later(self):
# Early failures are usually the network still coming up after boot: 30 s, 1, 2, 4 min, then every 5 min.
self.state, self.retry_at = 'retrying', time.monotonic() + min(300, 30 * 2 ** self.failures)
self.failures += 1
def _run(self):
if platform.system() == 'Linux':
try:
os.setpriority(os.PRIO_PROCESS, threading.get_native_id(), 10)
except OSError:
pass
deadline = time.monotonic() + 1800
def check():
if self.stop.is_set() or not self.parked():
raise Paused()
if time.monotonic() > deadline:
raise TimeoutError()
resolver = self.package['resolver']
def progress(received):
with self.lock:
self.percent = min(99, received * 100 // resolver['bytes'])
try:
self.destination.parent.mkdir(parents=True, exist_ok=True)
with (self.destination.parent / '.install.lock').open('a') as lockfile:
fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
if not self.installed():
check()
# A power loss cannot run TemporaryDirectory cleanup. The exclusive
# lock proves no other installer still owns these staging directories.
for leftover in self.destination.parent.glob('.download-*'):
if leftover.is_dir() and not leftover.is_symlink():
shutil.rmtree(leftover)
# Room for the resolver and a margin.
if shutil.disk_usage(self.destination.parent).free < 64 * 1024**2:
raise OSError('Insufficient storage')
with tempfile.TemporaryDirectory(prefix='.download-', dir=self.destination.parent) as directory:
runtime = Path(directory) / 'runtime'
runtime.mkdir()
helper = runtime / 'playlink'
self.fetch(resolver, helper, check, progress)
check()
with self.lock:
self.state = 'installing'
with helper.open('rb') as executable:
header = executable.read(20)
if header[:5] != b'\x7fELF\x02' or int.from_bytes(header[18:20], 'little') != 183:
raise ValueError('Resolver is not ARM64')
helper.chmod(0o755)
(runtime / 'package-id').write_text(self.package['id'] + '\n')
check()
# Each package id installs to its own directory, so a running older version is never replaced.
runtime.rename(self.destination)
with self.lock:
self.state, self.percent, self.failures = 'ready', 100, 0
except Paused:
with self.lock:
self.state, self.retry_at = 'paused', time.monotonic() + 10
except Exception:
with self.lock:
self._retry_later()
def close(self):
self.stop.set()
if self.thread is not None:
self.thread.join(timeout=12)
+45
View File
@@ -149,6 +149,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
evidence_source.close()
raise
configuration_allowed = pairing_authority.configuration_allowed if pairing_authority is not None else parked
from openpilot.starpilot.galaxy.play_install import PlayInstall
play_install = PlayInstall(ready=lambda: access.status().status == AccessStatus.CONFIGURED_LOCAL or pairing.read() is not None,
parked=configuration_allowed)
def cloud_allowed():
from openpilot.common.params import Params
return bool(parked()) and bool(cloud_offroad() if cloud_offroad is not None else Params().get_bool('IsOffroad'))
@@ -332,6 +335,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
def enable_bluetooth(identity):
bluetooth_owner().request('power', enabled=True, session=identity, power_on_for_android_auto=True)
aa_setup_source = AndroidAutoSetup(parked=configuration_allowed, enabled=lambda: Params().get_bool('AndroidAutoEnabled'),
play_install=play_install,
session_valid=bluetooth_session_valid, bluetooth_enabled=bluetooth_ready,
install_ready=install_ready, service_ready=service_ready,
set_enabled=lambda value: Params().put_bool('AndroidAutoEnabled', value),
@@ -578,6 +582,10 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
return
if path == '/api/auth/session':
if self.direct_local():
try:
play_install.mark_setup_complete()
except OSError:
pass # Optional package provisioning must not prevent Galaxy access.
with session_lock:
token = self.session_token()
if not sessions.valid(token, local_generation):
@@ -1313,6 +1321,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
if path not in ('/api/connect/provider', '/api/auth/login', '/api/auth/logout', '/api/settings/preview', '/api/settings/confirm',
'/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
'/api/android-auto/google-import', '/api/android-auto/google-forget',
'/api/android-auto/layout', '/api/android-auto/enable', '/api/android-auto/control', '/api/android-auto/pairing',
'/api/android-auto/pairing/response', '/api/android-auto/pairing/cancel', '/api/android-auto/pairing/select',
'/api/ui/layout', '/api/ui/layout/preview', '/api/favorites/slots',
@@ -1942,6 +1951,33 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
aa_registry_source.revoke(identity)
self.respond(200, b'{"authenticated":false}', cookie='galaxy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0')
return
if path in ('/api/android-auto/google-import', '/api/android-auto/google-forget'):
from openpilot.starpilot.galaxy.android_auto_setup import SetupRejected
from openpilot.starpilot.system.android_auto.play_resolver import PlayError
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
forgetting = path.endswith('google-forget')
if not isinstance(payload, dict) or (forgetting and payload) or (not forgetting and
(set(payload) - {'email', 'token', 'remember'} or not {'email', 'token'} <= set(payload) or
type(payload.get('remember', False)) is not bool)):
self.json(400, {'error': 'Google account email, token and optional remember boolean required'})
return
try:
result = aa_setup_owner().forget_google(identity) if forgetting else aa_setup_owner().start_google_import(identity, **payload)
except PlayError as error:
self.json(400, {'error': str(error)})
except SetupRejected as error:
self.json(409, {'error': str(error)})
except (OSError, RuntimeError):
self.json(503, {'error': 'Google Play import is unavailable'})
else:
if self.settings_session() == identity:
self.json(200 if forgetting else 202, result)
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if path == '/api/android-auto/enable':
if type(payload) is not dict or set(payload) not in ({'enabled'}, {'enabled', 'enableBluetooth'}) or \
type(payload['enabled']) is not bool or type(payload.get('enableBluetooth', False)) is not bool or \
@@ -2494,6 +2530,15 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
server.software_operations_source = software_operations_source
if drive_stats_source is not None:
server.drive_stats_source = drive_stats_source
def maintain_android_auto():
play_install.maintain()
from openpilot.starpilot.system.android_auto.play_resolver import TOKEN_PATH
if aa_setup_source is not None or TOKEN_PATH.is_file():
maintain = getattr(aa_setup_owner(), 'maintain', None)
if callable(maintain):
maintain()
server.maintain_android_auto = maintain_android_auto
server.play_install_source = play_install
server.start_drive_history = lambda: statistics_owner().start()
return server
@@ -56,6 +56,7 @@ def api(tmp_path):
'message': 'Package status'},
bluetooth_enabled=lambda: state['bluetooth_enabled'], install_ready=lambda: state['install_ready'],
service_ready=lambda: True, set_enabled=set_enabled, enable_bluetooth=enable_bluetooth)
state['setup_owner'] = setup
class FakeClient:
def __init__(self):
self.calls = []
@@ -440,3 +441,32 @@ def test_outgoing_car_selection_requires_current_pairing_session_and_valid_addre
state['parked'] = True
assert request('POST', '/api/android-auto/pairing/cancel', '{}', cookie=cookie, content_type='application/json')[0] == 200
assert select() == 409
def test_google_import_is_authenticated_bounded_and_validated(api):
request, job, state, _ = api
job.start_google_import = Mock(side_effect=lambda **kwargs: setattr(job, 'running', True))
path = '/api/android-auto/google-import'
valid = {'email': 'a@example.com', 'token': 'oauth2_4/test', 'remember': False}
assert request('POST', path, json.dumps(valid), content_type='application/json')[0] == 401
_, _, headers = request('POST', '/api/auth/login', '{"password":"password123"}', content_type='application/json')
cookie = headers['Set-Cookie'].split(';', 1)[0]
for payload in (None, [], {}, {'email': 1, 'token': 'oauth2_4/test'}, {**valid, 'remember': 1},
{**valid, 'token': 'not-a-token'}, {**valid, 'url': 'http://elsewhere'}):
assert request('POST', path, json.dumps(payload), cookie=cookie, content_type='application/json')[0] == 400
assert request('POST', path, json.dumps({**valid, 'token': 'x' * 5000}), cookie=cookie, content_type='application/json')[0] == 413
state.update(parked=False, enabled=False)
code, status, _ = request('POST', path, json.dumps(valid), cookie=cookie, content_type='application/json')
assert code == 202 and status['import']['state'] == 'running'
assert 'oauth2_4/test' not in json.dumps(status)
assert request('POST', path, json.dumps(valid), cookie=cookie, content_type='application/json')[0] == 409
assert request('POST', '/api/android-auto/google-forget', '{}', cookie=cookie, content_type='application/json')[0] == 409
job.start_google_import.assert_called_once()
def test_on_device_browser_api_is_gone(api):
request, _, _, _ = api
_, _, headers = request('POST', '/api/auth/login', '{"password":"password123"}', content_type='application/json')
cookie = headers['Set-Cookie'].split(';', 1)[0]
start = {'operation': 'start', 'email': 'user@example.com', 'remember': False}
assert request('POST', '/api/android-auto/google-browser', json.dumps(start), cookie=cookie, content_type='application/json')[0] != 200
@@ -217,3 +217,58 @@ def test_session_loss_while_enabling_bluetooth_never_enables_android_auto(tmp_pa
with pytest.raises(SetupRejected, match='session expired'):
service.enable(session, True, enable_bluetooth=True)
setter.assert_not_called()
def google_setup(tmp_path):
from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
state = {'enabled': True, 'parked': True, 'session': ('galaxy', '1')}
store = play_resolver.TokenStore(tmp_path / 'aa_token')
job = apk_identity.ImportJob(work_dir=tmp_path / 'imports', token_store=store)
job.start_google_import = Mock()
service = AndroidAutoSetup(parked=lambda: state['parked'], enabled=lambda: state['enabled'],
session_valid=lambda session: session == state['session'], import_job=job,
identity_status=lambda: {'installed': True, 'expires': '2020-01-01T00:00:00+00:00'})
return service, job, state, store
def test_google_import_session_admission_and_exclusive_upload_lock(tmp_path):
service, job, state, _ = google_setup(tmp_path)
with pytest.raises(SetupRejected):
service.start_google_import(('old', '1'), email='a@example.com', token='oauth2_4/test')
with service._upload_lock, pytest.raises(SetupRejected):
service.start_google_import(state['session'], email='a@example.com', token='oauth2_4/test')
job.start_google_import.assert_not_called()
service.start_google_import(state['session'], email='a@example.com', token='oauth2_4/test', remember=True)
kwargs = job.start_google_import.call_args.kwargs
assert kwargs['save_token'] and kwargs['enabled']()
state['session'] = None
assert not kwargs['enabled']()
def test_refresh_requires_opt_in_offroad_and_throttles_failed_attempts(tmp_path):
service, job, state, store = google_setup(tmp_path)
service.maintain(0)
job.start_google_import.assert_not_called()
store.save('a@example.com', 'aas_et/test')
state['parked'] = False
service.maintain(60)
job.start_google_import.assert_not_called()
state['parked'] = True
service.maintain(120)
job.start_google_import.assert_called_once()
kwargs = job.start_google_import.call_args.kwargs
assert kwargs['aas_token'] == 'aas_et/test' and kwargs['enabled']()
service.maintain(180)
job.start_google_import.assert_called_once()
state['enabled'] = False
assert not kwargs['enabled']()
service.forget_google(state['session'])
assert not store.exists()
def test_refresh_skips_identity_not_due_for_renewal(tmp_path):
service, job, _, store = google_setup(tmp_path)
store.save('a@example.com', 'aas_et/test')
service.identity_status = lambda: {'installed': True, 'expires': '2099-01-01T00:00:00+00:00'}
service.maintain(0)
job.start_google_import.assert_not_called()
@@ -0,0 +1,146 @@
import hashlib
import io
import time
from unittest.mock import Mock
import pytest
from openpilot.starpilot.galaxy.play_install import Paused, PlayInstall, checked_url, download
def wait(job):
job.thread.join(timeout=3)
assert not job.thread.is_alive()
@pytest.fixture
def provision(tmp_path):
helper = b'\x7fELF\x02' + bytes(13) + (183).to_bytes(2, 'little')
contents = {'resolver': helper}
manifest = {'id': 'release-1', 'resolver': {'sha256': hashlib.sha256(helper).hexdigest(), 'bytes': len(helper), 'url': 'resolver'}}
state = {'ready': False, 'parked': True}
calls = []
def fetch(asset, destination, check, progress):
calls.append(asset['url'])
check()
destination.write_bytes(contents[asset['url']])
progress(asset['bytes'])
job = PlayInstall(ready=lambda: state['ready'], parked=lambda: state['parked'], destination=tmp_path / 'installed' / 'release-1',
package=manifest, enabled=True, fetch=fetch)
yield job, state, calls
job.close()
def test_waits_for_setup_then_installs_once(provision):
job, state, calls = provision
job.maintain()
assert job.thread is None and not calls
abandoned = job.destination.parent / '.download-interrupted'
abandoned.mkdir(parents=True)
(abandoned / 'partial').write_bytes(b'interrupted download')
state['ready'] = True
job.maintain()
wait(job)
assert job.status() == {'state': 'ready', 'percent': 100}
assert job.installed()
assert calls == ['resolver']
assert not list(job.destination.parent.glob('.download-*'))
job.maintain()
assert calls == ['resolver']
restarted = PlayInstall(ready=lambda: False, parked=lambda: False, destination=job.destination,
package=job.package, enabled=True, fetch=Mock(side_effect=AssertionError))
restarted.maintain()
assert restarted.status()['state'] == 'ready'
def test_local_setup_marker_survives_restart(provision):
job, _, calls = provision
job.mark_setup_complete()
job.maintain()
wait(job)
assert job.installed() and calls
def test_park_admission_and_cancellation_leave_no_partial_install(provision):
job, state, calls = provision
state.update(ready=True, parked=False)
job.maintain()
assert job.status()['state'] == 'paused' and not calls
state['parked'] = True
original = job.fetch
def move(asset, destination, check, progress):
original(asset, destination, check, progress)
state['parked'] = False
job.fetch = move
job.maintain()
wait(job)
assert job.status()['state'] == 'paused'
assert not job.destination.exists()
assert not list(job.destination.parent.glob('.download-*'))
def test_failed_download_backs_off_and_preserves_older_release(provision):
job, state, calls = provision
state['ready'] = True
older = job.destination.parent / 'previous'
older.mkdir(parents=True)
(older / 'keep').write_text('old runtime')
job.fetch = Mock(side_effect=OSError('signed-url-must-not-leak'))
job.maintain()
wait(job)
assert job.status()['state'] == 'retrying'
assert job.retry_at > time.monotonic()
job.maintain()
assert job.fetch.call_count == 1
assert (older / 'keep').read_text() == 'old runtime'
assert not job.destination.exists()
@pytest.mark.parametrize('url', ['http://github.com/file', 'https://github.com.evil.test/file',
'https://user:password@github.com/file', 'https://example.com/file'])
def test_rejects_untrusted_download_and_redirect_urls(url):
with pytest.raises(ValueError):
checked_url(url)
@pytest.mark.parametrize('body,size,digest', [(b'bad', 3, '0'*64), (b'oversize', 2, '0'*64), (b'short', 10, '0'*64)])
def test_download_integrity_failure(tmp_path, monkeypatch, body, size, digest):
response = io.BytesIO(body)
response.status = 200
monkeypatch.setattr('urllib.request.build_opener', lambda *_: Mock(open=Mock(return_value=response)))
with pytest.raises(ValueError):
download({'url': 'https://github.com/release/file', 'bytes': size, 'sha256': digest}, tmp_path / 'download', lambda: None, lambda _: None)
def test_download_checks_permission_before_consuming_body(tmp_path, monkeypatch):
response = io.BytesIO(b'content')
response.status = 200
monkeypatch.setattr('urllib.request.build_opener', lambda *_: Mock(open=Mock(return_value=response)))
def paused():
raise Paused()
with pytest.raises(Paused):
download({'url': 'https://github.com/release/file', 'bytes': 7, 'sha256': '0'*64}, tmp_path / 'download', paused, lambda _: None)
def test_retry_delay_starts_short_and_caps_at_five_minutes(provision):
job, _, _ = provision
delays = []
for _ in range(6):
before = time.monotonic()
job._retry_later()
delays.append(round(job.retry_at - before))
assert delays == [30, 60, 120, 240, 300, 300]
assert job.status()['state'] == 'retrying'
def test_rejects_resolver_that_is_not_arm64(provision):
job, state, _ = provision
state['ready'] = True
def fetch(asset, destination, check, progress):
destination.write_bytes(b'#!/bin/sh\n')
job.fetch = fetch
job.maintain()
wait(job)
assert job.status()['state'] == 'retrying'
assert not job.destination.exists()
@@ -292,9 +292,9 @@ console.log('Remote-capable binary uploader: progress, credentials, failure, tim
// renders an inert DOM node and hides every setup control in real browsers.
const { compile } = await import('../web/vendor/vue/vue.esm-browser.js')
const render = compile(AndroidAutoPage.template, { decodeEntities: value => value })
const vm = page(setup)
const vm = page({ ...setup, identity: { ...setup.identity, installed: false } })
vm.mode = 'local'
vm.installOpen = true // the package picker lives in the install sheet
vm.installOpen = true // the package picker lives in the install sheet, shown only before setup
const tree = render(vm, [])
const tags = []
function visit(node) {
@@ -305,6 +305,14 @@ function visit(node) {
visit(tree)
assert(!tags.includes('template'), 'setup cannot live inside an inert template element')
assert(tags.includes('input') && tags.includes('button'), 'setup renders package selection and actions')
// Once installed, Manage package shows status and removal only; setup needs removal first.
const managed = page({ ...setup, identity: { ...setup.identity, installed: true }, googlePlay: { available: true, remembered: true } })
managed.installOpen = true
const managedTags = []
const collect = (node) => { if (!node || typeof node !== 'object') return; if (typeof node.type === 'string') managedTags.push(node.type); if (Array.isArray(node.children)) node.children.forEach(collect) }
collect(render(managed, []))
assert(!managedTags.includes('input'), 'manage view offers no setup inputs')
assert(managedTags.includes('dl'), 'manage view shows status and renewal')
for (const [field, reason] of [['installReady', /display|encoder/],
['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) {
const blocked = page({ ...setup, [field]: false })
@@ -393,3 +401,60 @@ connectingPage.runtime = { running: true, state: 'connecting_bluetooth' }
assert.equal(connectingPage.projecting, false)
connectingPage.runtime.state = 'streaming'
assert.equal(connectingPage.projecting, true)
// Google import keeps session material out of page state after submission.
const { googleInstallChecks } = await import('../web/js/android-auto.js')
const googlePage = page({ ...setup, googlePlay: { available: true, remembered: false } })
googlePage.googleEmail = 'user@example.com'
googlePage.googleToken = 'oauth2_4/test-private'
googlePage.googleRemember = false
let googleCall
googlePage.feed = { async googleImport(...args) { googleCall = args; assert.equal(googlePage.googleToken, ''); return true } }
assert.equal(await googlePage.googleImport(), true)
assert.deepEqual(googleCall, ['user@example.com', 'oauth2_4/test-private', false])
googlePage.googleToken = 'another-private-value'
googlePage.closeInstall()
assert.equal(googlePage.googleToken, '')
assert.deepEqual(googleInstallChecks({ state: 'running', stage: 'resolving' }).map(c => c.status), ['done', 'active', 'pending', 'pending'])
assert.deepEqual(googleInstallChecks({ state: 'failed', stage: 'downloading' }).map(c => c.status), ['done', 'done', 'failed', 'pending'])
assert.match(googleInstallChecks({ state: 'running', stage: 'downloading', downloaded: 5, total: 10 })[2].label, /50%/)
assert(googleInstallChecks({ state: 'done', stage: 'done' }).every(c => c.status === 'done'))
assert.match(AndroidAutoPage.template, /Upload a file/)
assert.match(AndroidAutoPage.template, /Turn off automatic renewal/)
assert.match(AndroidAutoPage.template, /Remove Android Auto support/)
assert.match(AndroidAutoPage.template, /will just sit and spin/)
assert.equal(AndroidAutoPage.computed.renewLabel.call({ setup: { googlePlay: { remembered: true } } }), 'Auto-renew on')
assert.equal(AndroidAutoPage.computed.renewLabel.call({ setup: { googlePlay: { remembered: false } } }), 'Auto-renew off')
assert.match(AndroidAutoPage.template, /Manage package/)
const { cleanToken, tokenProblem } = await import('../web/js/android-auto.js')
assert.equal(cleanToken(' "oauth2_4/abc-DEF_123" '), 'oauth2_4/abc-DEF_123')
assert.equal(cleanToken('oauth_token=oauth2_4/abc;'), 'oauth2_4/abc')
assert.equal(cleanToken('oauth2_4%2Fabc'), 'oauth2_4/abc')
assert.equal(tokenProblem(''), '')
assert.equal(tokenProblem('oauth2_4/abc'), '')
assert.match(tokenProblem('SAPISID-value'), /oauth2_4\//)
assert.match(tokenProblem('oauth2_4/abc def'), /spaces/)
const cleaning = page({ ...setup, googlePlay: { available: true, remembered: false } })
Object.assign(cleaning, { googleEmail: 'user@example.com', googleToken: ' "oauth2_4/pasted" ', googleRemember: true })
let cleanedCall
cleaning.feed = { async googleImport(...args) { cleanedCall = args; return true } }
assert.equal(await cleaning.googleImport(), true)
assert.deepEqual(cleanedCall, ['user@example.com', 'oauth2_4/pasted', true])
// Auto-renew is on by default, and turning it on later always saves the renewal credential.
assert.equal(AndroidAutoPage.data().googleRemember, true)
const renewal = page({ ...setup, identity: { ...setup.identity, installed: true }, googlePlay: { available: true, remembered: false } })
Object.assign(renewal, { googleRemember: false, installMethod: 'manual', googleToken: 'stale' })
renewal.startRenewal()
assert.equal(renewal.renewing, true)
assert.equal(renewal.installMethod, 'google')
assert.equal(renewal.googleToken, '')
Object.assign(renewal, { googleEmail: 'user@example.com', googleToken: 'oauth2_4/renew', googleRemember: false })
let renewCall
renewal.feed = { async googleImport(...args) { renewCall = args; return true } }
assert.equal(await renewal.googleImport(), true)
assert.deepEqual(renewCall, ['user@example.com', 'oauth2_4/renew', true])
renewal.closeInstall()
assert.equal(renewal.renewing, false)
assert.match(AndroidAutoPage.template, /Turn on automatic renewal/)
assert.match(AndroidAutoPage.template, /Use a computer for this/)
console.log('Google Play import: credential clearing, progress, retry and manual fallback passed')
@@ -1455,6 +1455,59 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-checks .gx-aa-check--active::before { border-top-color: var(--primary); animation: gx-spin .9s linear infinite; }
.gx-aa-checks .gx-aa-check--failed { color: var(--error); }
.gx-aa-checks .gx-aa-check--failed::before { content: "✕"; border: 0; background: var(--error); color: var(--on-error); }
.gx-aa-google, .gx-aa-google__form { display: grid; gap: var(--sp-3); min-width: 0; }
.gx-aa-google__form label { display: grid; gap: var(--sp-1); }
.gx-aa-google__form .gx-field { width: 100%; min-width: 0; }
.gx-aa-google__form label:has(input[type="checkbox"]) { display: block; }
.gx-aa-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-2); }
.gx-aa-method { position: relative; display: flex; align-items: flex-start; gap: var(--sp-3); min-height: var(--touch-target); padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--outline); border-radius: var(--radius-sm); background: var(--surface-container); color: var(--on-surface); font: inherit;
text-align: left; cursor: pointer; transition: border-color var(--motion-fast), background var(--motion-fast); }
.gx-aa-method:hover:not(:disabled) { border-color: var(--primary); }
.gx-aa-method:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-aa-method[aria-checked="true"] { border-color: var(--primary); background: var(--primary-container); box-shadow: inset 0 0 0 1px var(--primary); }
.gx-aa-method:disabled { opacity: .5; cursor: default; }
.gx-aa-method > i { font-size: 1.25rem; line-height: 1.4; color: var(--primary); }
.gx-aa-method > span:not(.gx-aa-method__badge) { display: grid; gap: 2px; min-width: 0; }
.gx-aa-method small { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.35; }
.gx-aa-method__badge { position: absolute; top: calc(-1 * var(--sp-2)); right: var(--sp-3); padding: 1px var(--sp-2); border-radius: var(--radius-full);
background: var(--primary); color: var(--on-primary); font-size: var(--fs-xs); font-weight: var(--fw-bold); }
.gx-aa-google__email { display: grid; gap: var(--sp-1); width: 100%; font-size: var(--fs-sm); color: var(--on-surface-variant); }
.gx-aa-google__email.gx-field, .gx-aa-google__email .gx-field { width: 100%; min-width: 0; box-sizing: border-box; }
.gx-aa-google__remember { display: flex; align-items: flex-start; gap: var(--sp-2); cursor: pointer; }
.gx-aa-google__remember input { margin-top: 3px; accent-color: var(--primary); }
.gx-aa-google__remember span { display: grid; gap: 2px; }
.gx-aa-google__forget { justify-self: start; }
.gx-aa-prep { display: grid; gap: var(--sp-3); width: 100%; box-sizing: border-box; padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-sm);
background: var(--surface-container); border: 1px solid var(--outline); }
.gx-aa-prep progress { width: 100%; accent-color: var(--primary); }
.gx-aa-prep--retrying { border-color: var(--warning); }
.gx-aa-prep--retrying > strong > i { color: var(--warning); margin-right: var(--sp-2); }
.gx-aa-prep > strong { font-size: var(--fs-lg); }
.gx-aa-prep progress { height: 10px; }
.gx-aa-prep__percent { font-size: var(--fs-xl); font-weight: var(--fw-bold); color: var(--primary); }
.gx-aa-prep__percent small { margin-left: var(--sp-3); font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--text-muted); }
.gx-aa-prep p { line-height: 1.5; }
.gx-aa-trademark { display: block; font-size: var(--fs-xs); opacity: .8; }
.gx-aa-wiki ol.gx-aa-token__steps { margin-top: var(--sp-4); padding-left: 0; gap: var(--sp-4); }
.gx-aa-token__steps > li > p { margin: 0; color: var(--on-surface); line-height: 1.5; }
.gx-aa-token > p { line-height: 1.5; }
.gx-aa-token__steps small { color: var(--text-muted); line-height: 1.45; }
.gx-aa-token__steps .gx-aa-google__form { width: 100%; }
.gx-aa-token__how { margin: 0; padding-left: 1.2em; display: grid; gap: var(--sp-1); font-size: var(--fs-sm); }
.gx-aa-token code, .gx-aa-token kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: 1px 5px; border-radius: 6px;
background: var(--surface-container-high); border: 1px solid var(--outline); color: var(--on-surface); }
.gx-aa-token__browsers > button { min-height: 36px; padding: var(--sp-1) var(--sp-3); font-size: var(--fs-sm); }
.gx-aa-token__steps > li > p.gx-aa-token__warning, .gx-aa-token__warning { margin: 0; padding: var(--sp-3) var(--sp-4); border: 2px solid var(--error); border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); font-size: var(--fs-lg); font-weight: 800;
line-height: 1.35; text-transform: uppercase; letter-spacing: .01em; }
.gx-aa-manage { display: grid; gap: var(--sp-4); }
.gx-aa-manage__facts { display: grid; gap: var(--sp-3); margin: 0; }
.gx-aa-manage__facts > div { display: grid; gap: var(--sp-1); }
.gx-aa-manage__facts dt { font-size: var(--fs-sm); color: var(--text-muted); }
.gx-aa-manage__facts dd { margin: 0; line-height: 1.5; }
.gx-link { padding: 0; border: 0; background: none; color: var(--primary); font: inherit; font-weight: var(--fw-medium); text-decoration: underline; cursor: pointer; }
.gx-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
.gx-aa-sheet__done { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; padding-block: var(--sp-3); }
.gx-aa-sheet__done > i { font-size: 3.5rem; }
.gx-aa-sheet__done .gx-aa-checks { width: auto; text-align: left; }
+198 -16
View File
@@ -302,6 +302,30 @@ export class AndroidAutoFeed {
return true
}
async googleImport(email, token, remember) {
if (!this.active || this.busy || !this.setup || this.setup.import?.state === "running") return false
const result = await this.request("./api/android-auto/google-import", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, token, remember })
}, 12000)
token = ""
if (result === null) return false
if (validSetup(result)) this.setup = result
this.emit()
await this.refresh()
return true
}
async forgetGoogle() {
if (!this.active || this.busy || this.setup?.import?.state === "running") return false
const result = await this.request("./api/android-auto/google-forget", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}"
})
if (result === null) return false
await this.refresh()
return true
}
async removePackage() {
if (!this.active || this.busy || !this.setup || this.setup.import?.state === "running") return false
const result = await this.request("./api/android-auto/identity", { method: "DELETE" }, 30000)
@@ -377,7 +401,7 @@ export function importProblem(job, fileName = "", locale = undefined) {
case "UNSUPPORTED_VERSION":
return job.error || "This version stores its key differently. Choose another Android Auto release."
case "TOO_LARGE": return "This file is too large to be the Android Auto app."
case "CANCELLED": return "Setup stopped because Android Auto was turned off. Try again."
case "CANCELLED": return "Setup authorization changed. Sign in to Galaxy and try again."
default: return job.error || "Something went wrong. Try again."
}
}
@@ -399,6 +423,33 @@ export function installChecks(job, locale = undefined) {
})
}
export function googleInstallChecks(job) {
const stages = ["authenticating", "resolving", "downloading", "installing", "done"]
const at = stages.indexOf(job?.stage)
const percent = job?.total > 0 ? ` (${Math.min(100, Math.round(100 * job.downloaded / job.total))}%)` : ""
return ["Google Play Authentication", "Play Store Delivery Link", `Downloading APK${percent}`, "Extracting Certificates & Keys"]
.map((label, index) => ({
label, status: job?.state === "done" || index < at ? "done" :
index === at ? (job?.state === "failed" ? "failed" : "active") : "pending"
}))
}
const TOKEN_BROWSERS = [{ id: "chrome", name: "Chrome or Edge" }, { id: "firefox", name: "Firefox" }, { id: "safari", name: "Safari" }]
// Accepts what people actually copy: surrounding quotes or spaces, a "oauth_token=" prefix, or a URL-encoded value.
export function cleanToken(value) {
let token = String(value || "").trim().replace(/^oauth_token\s*[=:]\s*/i, "").replace(/^["']|["';]$/g, "").trim()
if (/^oauth2_4%2F/i.test(token)) { try { token = decodeURIComponent(token) } catch { /* keep as typed */ } }
return token
}
export function tokenProblem(value) {
if (!String(value || "").trim()) return ""
const token = cleanToken(value)
if (/^(oauth2_4|aas_et)\//.test(token)) return /\s/.test(token) ? "The value contains spaces. Copy only the Value cell." : ""
return "That doesn’t look right. Copy the Value of the row named oauth_token; it starts with oauth2_4/."
}
export const AndroidAutoPage = {
components: { GxState, GxIconButton, GxNotice, GxDialog, GalaxySettingRow },
props: {
@@ -408,6 +459,8 @@ export const AndroidAutoPage = {
data: () => ({
setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, removeOpen: false, installAttempted: false, installBaseline: null,
installMethod: "google", googleEmail: "", googleToken: "", googleRemember: true, tokenBrowser: "chrome", renewing: false,
tokenBrowsers: TOKEN_BROWSERS,
installFileName: "", apkmirror: APKMIRROR_URL, pairWhenReady: false, enableOpen: false
}),
mounted() {
@@ -416,7 +469,7 @@ export const AndroidAutoPage = {
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.begin()
},
beforeUnmount() { this.finishEnable(false); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
beforeUnmount() { this.googleToken = ""; this.finishEnable(false); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
watch: {
mode() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
localAccess() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
@@ -457,9 +510,26 @@ export const AndroidAutoPage = {
if (job.state === "done" && this.setup.identity.installed) return "done"
return job.state === "failed" ? "failed" : "choose"
},
checks() { return installChecks(this.setup?.import) },
tokenProblem() { return tokenProblem(this.googleToken) },
googlePrep() {
const pkg = this.setup?.googlePlay?.package
if (!pkg || pkg.state === "ready") return null
const label = {
downloading: "Downloading",
installing: "Installing",
pending: "Preparing",
paused: "Paused until the car is in Park",
retrying: "Waiting for an internet connection",
}[pkg.state] || "Preparing…"
const progress = ["downloading", "installing"].includes(pkg.state)
return { state: pkg.state, label, detail: progress ? "Google Play support · about 12 MB" : "", percent: pkg.percent || 0, progress }
},
checks() { return this.setup?.import?.source === "google" ? googleInstallChecks(this.setup.import) : installChecks(this.setup?.import) },
problem() { return importProblem(this.setup?.import, this.installFileName) },
hasPackage() { return !!(this.setup?.identity.installed || this.setup?.identity.expired || this.setup?.identity.error) },
onPhone() { return typeof navigator !== "undefined" && (navigator.userAgentData?.mobile ?? /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "")) },
galaxyAddress() { return typeof location !== "undefined" ? location.host : "this Galaxy address" },
renewLabel() { return this.setup?.googlePlay?.remembered ? "Auto-renew on" : "Auto-renew off" },
removeReason() {
if (!this.setup) return "Load setup status first."
if (this.setup.import?.state === "running") return "Wait for the current package check to finish."
@@ -544,9 +614,18 @@ export const AndroidAutoPage = {
return this.feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted, value })
},
openInstall() {
Object.assign(this, { installOpen: true, installAttempted: false, packageFile: null })
Object.assign(this, { installOpen: true, installAttempted: false, packageFile: null, renewing: false, googleRemember: true })
},
closeInstall() { this.installOpen = false; this.googleToken = ""; this.renewing = false },
// Auto-renew needs a fresh sign-in: the long-lived credential is only issued when a token is exchanged.
startRenewal() { Object.assign(this, { renewing: true, installMethod: "google", googleRemember: true, googleToken: "", installAttempted: false }) },
async googleImport() {
if (this.busy || !this.googleEmail.trim() || !this.googleToken.trim() || this.tokenProblem) return false
Object.assign(this, { installAttempted: true, installBaseline: this.setup.import?.started ?? null, installFileName: "" })
const token = cleanToken(this.googleToken)
this.googleToken = ""
return this.feed.googleImport(this.googleEmail.trim(), token, this.renewing || this.googleRemember)
},
closeInstall() { this.installOpen = false },
chooseAgain() { Object.assign(this, { installAttempted: false, packageFile: null }) },
choosePackage(event) { this.packageFile = event.target.files?.[0] || null },
async upload() {
@@ -590,12 +669,13 @@ export const AndroidAutoPage = {
<p v-if="!setup.installReady" class="gx-aa-pill gx-aa-pill--expired" role="status">Not available on this build — the Android Auto display and encoder are missing.</p>
<template v-else>
<div class="gx-aa-status">
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}<template v-if="hasPackage && !setup.enabled"> · Off</template></span>
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}<template v-if="setup.identity.installed"> · {{ renewLabel }}</template><template v-if="hasPackage && !setup.enabled"> · Off</template></span>
<button v-if="hasPackage" type="button" class="gx-icon-btn gx-recordings__danger" aria-label="Delete Package" :disabled="!!removeReason" :title="removeReason || 'Delete Package'" @click="openRemove"><i class="bi bi-trash3"></i></button>
<span v-if="setup.enabled && !setup.serviceReady" class="gx-note" role="status">Starting…</span>
<div class="gx-actions gx-aa-status__actions">
<button v-if="!setup.identity.installed" class="gx-btn" @click="openInstall">{{ setup.identity.expired || setup.identity.error ? 'Reinstall' : 'Install support' }}</button>
<button v-else-if="expiry.level === 'soon'" class="gx-btn" @click="openInstall">Update</button>
<button v-if="setup.identity.installed" class="gx-btn gx-btn--tonal" @click="openInstall">Manage package</button>
<button v-if="setup.identity.installed && !setup.enabled" class="gx-btn" @click="setEnabled(true)">Turn On</button>
<button v-if="setup.enabled" class="gx-btn gx-btn--tonal" @click="setEnabled(false)">Turn Off</button>
</div>
@@ -611,7 +691,7 @@ export const AndroidAutoPage = {
<li><strong>The file won’t pick or was rejected:</strong> don’t unzip it. Use the .apk, .xapk or .apkm file exactly as downloaded.</li>
<li><strong>Upload is slow or stops:</strong> stay on this page and keep the comma awake and on the same Wi-Fi until it finishes.</li>
</ul>
<p><strong>Why is this needed?</strong> Car screens only talk to devices that present Google’s Android Auto certificate. Your comma reads it from your own copy of the app, on the device. Nothing is sent anywhere, and no Android app is installed on the comma.</p>
<p><strong>Why is this needed?</strong> Car screens only talk to devices that present Google’s Android Auto certificate. Your comma reads it from your own copy of the app, on the device. Google Play sign-in contacts Google directly; your extracted identity stays on the comma. No Android app is installed on the comma.</p>
</details>
</div>
</li>
@@ -811,7 +891,7 @@ export const AndroidAutoPage = {
<p>Pair each car in step 2 with <strong>Pair another</strong>. All your paired cars appear in the list. Tap <strong>Use</strong> next to the one you’re driving. Disconnect first if Android Auto is running.</p>
</details>
<details class="gx-aa-wiki"><summary>Is my information private?</summary>
<p>Yes. The Android Auto file you install stays on your comma and isn’t sent anywhere. Connection logs also stay on the comma until you download them yourself. They leave out Wi‑Fi passwords, Bluetooth addresses, and your vehicle ID.</p>
<p>Yes. Your extracted Android Auto identity stays on your comma. Google Play import sends your sign-in to Google, and automatic updates keep a private refresh credential on the comma until you forget it. The downloaded package is deleted after import. Connection logs also stay on the comma until you download them yourself. They leave out Wi‑Fi passwords, Bluetooth addresses, and your vehicle ID.</p>
</details>
<h4>Still stuck?</h4>
@@ -838,11 +918,11 @@ export const AndroidAutoPage = {
</GxDialog>
<GxDialog v-if="removeOpen && setup" :inert="busy" labelledby="gx-aa-remove-title" describedby="gx-aa-remove-body" alert @close="closeRemove">
<div><h3 id="gx-aa-remove-title">Remove the package?</h3>
<p id="gx-aa-remove-body">Are you sure you want to remove the package? Android Auto will disconnect first if connected. The device will not connect to your car until you re-add one.</p></div>
<div><h3 id="gx-aa-remove-title">Remove Android Auto support?</h3>
<p id="gx-aa-remove-body">This deletes the Android Auto certificate and key from this comma and forgets your saved Google sign-in, which turns off automatic renewal. Android Auto will disconnect first if connected. Your comma won’t connect to your car until you set it up again.</p></div>
<div class="gx-settings__controls gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" @click="closeRemove">Cancel</button>
<button type="button" class="gx-btn gx-btn--danger" @click="removePackage">Confirm</button>
<button type="button" class="gx-btn gx-btn--danger" @click="removePackage">Remove</button>
</div>
</GxDialog>
@@ -851,15 +931,116 @@ export const AndroidAutoPage = {
<div v-if="installState === 'done'" class="gx-aa-sheet__done">
<i class="bi bi-check-circle-fill gx-aa-ok" aria-hidden="true"></i>
<h3 id="gx-aa-install-title">You’re all set</h3>
<p>{{ expiry.label }}</p>
<p>{{ expiry.label }} · {{ renewLabel }}</p>
<p v-if="!setup.enabled" class="gx-note">Turn on Android Auto in step 1 to connect to your car.</p>
<ul class="gx-aa-checks"><li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li></ul>
<button class="gx-btn gx-aa-cta" @click="closeInstall">Done</button>
</div>
<div v-else-if="setup.identity.installed && !installAttempted && !renewing" class="gx-aa-manage">
<div class="gx-aa-sheet__head"><h3 id="gx-aa-install-title">Android Auto Support</h3>
<p class="gx-note">Support is set up on this comma. To set it up again, remove it first.</p></div>
<dl class="gx-aa-manage__facts">
<div><dt>Status</dt><dd><span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level">{{ expiry.label }}</span></dd></div>
<div><dt>Automatic renewal</dt><dd>
<template v-if="setup.googlePlay?.remembered"><strong>On.</strong> Renews with your saved Google sign-in before it expires.</template>
<template v-else><strong>Off.</strong> When it expires, you’ll need to set it up again. Turning it on takes one sign-in on a computer.</template>
</dd></div>
</dl>
<div class="gx-actions">
<button v-if="setup.googlePlay?.remembered" type="button" class="gx-btn gx-btn--tonal" :disabled="busy || setup.import?.state === 'running'" @click="feed.forgetGoogle()">Turn off automatic renewal</button>
<button v-else type="button" class="gx-btn" :disabled="busy || setup.import?.state === 'running' || !setup.googlePlay?.available" @click="startRenewal">Turn on automatic renewal</button>
<button type="button" class="gx-btn gx-btn--danger" :disabled="!!removeReason" :title="removeReason || undefined" @click="closeInstall(); openRemove()"><i class="bi bi-trash3" aria-hidden="true"></i> Remove Android Auto support</button>
</div>
</div>
<template v-else>
<div class="gx-aa-sheet__head"><h3 id="gx-aa-install-title">Set Up Android Auto Support</h3>
<p class="gx-note">A quick one-time setup that enables wireless projection with your car.</p></div>
<ol class="gx-aa-sheet__steps">
<div v-if="renewing" class="gx-aa-sheet__head"><h3 id="gx-aa-install-title">Turn On Automatic Renewal</h3>
<p class="gx-note">Sign in once more on a computer. Your comma saves a private renewal credential and refreshes Android Auto now, then renews it by itself before it expires.</p>
<button type="button" class="gx-link" @click="renewing = false">Back</button></div>
<div v-else class="gx-aa-sheet__head"><h3 id="gx-aa-install-title">Set Up Android Auto Support</h3>
<p class="gx-note">One-time setup so your car accepts your comma. Pick how to get the Android Auto app.</p></div>
<GxNotice v-if="onPhone" tone="warn"><p><strong>Use a computer for this.</strong> Setup needs a desktop browser’s developer tools, which phones don’t have. On a computer, open <strong>{{ galaxyAddress }}</strong> and come back to this page.</p></GxNotice>
<div v-if="!renewing" class="gx-aa-methods" role="radiogroup" aria-label="Installation method">
<button type="button" role="radio" class="gx-aa-method" :aria-checked="installMethod === 'google'" :disabled="busy || setup.import?.state === 'running'" @click="installMethod = 'google'; chooseAgain()">
<i class="bi bi-cloud-arrow-down" aria-hidden="true"></i>
<span><strong>Download automatically</strong></span>
<span class="gx-aa-method__badge">Recommended</span>
</button>
<button type="button" role="radio" class="gx-aa-method" :aria-checked="installMethod === 'manual'" :disabled="busy || setup.import?.state === 'running'" @click="installMethod = 'manual'; googleToken = ''; chooseAgain()">
<i class="bi bi-file-earmark-arrow-up" aria-hidden="true"></i>
<span><strong>Upload a file</strong><small>Use an Android Auto APK you downloaded</small></span>
</button>
</div>
<div v-if="installMethod === 'google' && googlePrep" class="gx-aa-prep" :class="'gx-aa-prep--' + googlePrep.state" role="status" aria-live="polite">
<strong><i v-if="googlePrep.state === 'retrying'" class="bi bi-arrow-repeat" aria-hidden="true"></i>{{ googlePrep.label }}</strong>
<template v-if="googlePrep.progress">
<progress :value="googlePrep.percent" max="100"></progress>
<span class="gx-aa-prep__percent">{{ googlePrep.percent }}%<small v-if="googlePrep.detail">{{ googlePrep.detail }}</small></span>
</template>
<p>Leave your comma on and connected to the internet. This finishes in the background, so you can close this window.</p>
<p class="gx-note">Want to set up now? <button type="button" class="gx-link" @click="installMethod = 'manual'; googleToken = ''; chooseAgain()">Upload the Android Auto APK instead</button>.</p>
</div>
<div v-else-if="installMethod === 'google'" class="gx-aa-google gx-aa-token">
<p v-if="!renewing" class="gx-note">Sign in with your normal browser on a computer, copy one value Google gives you, and paste it here. Your comma then downloads Android Auto from Google Play. It takes about two minutes.</p>
<ol v-if="installState === 'choose'" class="gx-aa-sheet__steps gx-aa-token__steps">
<li>
<p><strong>Enter your Google email</strong></p>
<input class="gx-field gx-aa-google__email" type="email" maxlength="254" v-model="googleEmail" autocomplete="email" placeholder="you@gmail.com" aria-label="Google account email" :disabled="busy" />
<label v-if="!renewing" class="gx-aa-google__remember"><input type="checkbox" v-model="googleRemember" :disabled="busy" />
<span>Keep it up to date automatically<small>Renews before the certificate expires. Saves a private sign-in credential on this comma.</small></span></label>
</li>
<li>
<p><strong>On a computer, open Google’s sign-in page.</strong> Use Chrome, Edge, Firefox or Safari, not a phone.</p>
<a class="gx-btn gx-btn--tonal" href="https://accounts.google.com/EmbeddedSetup" target="_blank" rel="noopener noreferrer">Open sign-in page <i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
<small>Or type <code>accounts.google.com/EmbeddedSetup</code> into the address bar.</small>
</li>
<li>
<p><strong>Sign in with the same account</strong> and finish any verification. When asked, click <strong>I agree</strong>.</p>
<p class="gx-aa-token__warning" role="note">After you click “I agree” the page will just sit and spin. It will never finish. That’s normal. Don’t wait — go to step 4 and get the token anyway.</p>
</li>
<li>
<p><strong>Open that tab’s cookies.</strong></p>
<div class="gx-tabs gx-aa-token__browsers" role="group" aria-label="Your browser">
<button v-for="b in tokenBrowsers" :key="b.id" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="tokenBrowser === b.id" @click="tokenBrowser = b.id">{{ b.name }}</button>
</div>
<ul class="gx-aa-token__how">
<template v-if="tokenBrowser === 'safari'">
<li>First turn on developer tools: <strong>Safari → Settings → Advanced</strong>, then tick <strong>Show features for web developers</strong>.</li>
<li>Press <kbd>⌘</kbd> <kbd>⌥</kbd> <kbd>I</kbd> to open Web Inspector.</li>
<li>Click the <strong>Storage</strong> tab, then <strong>Cookies</strong> in the left column.</li>
</template>
<li v-if="tokenBrowser !== 'safari'">Press <kbd>F12</kbd> on Windows or Linux, or <kbd>⌘</kbd> <kbd>⌥</kbd> <kbd>I</kbd> on a Mac, to open developer tools.</li>
<template v-if="tokenBrowser === 'chrome'">
<li>Click the <strong>Application</strong> tab. If you can’t see it, click <strong>»</strong> to find it.</li>
<li>In the left column, expand <strong>Cookies</strong> and click <strong>https://accounts.google.com</strong>.</li>
</template>
<template v-if="tokenBrowser === 'firefox'">
<li>Click the <strong>Storage</strong> tab.</li>
<li>In the left column, expand <strong>Cookies</strong> and click <strong>https://accounts.google.com</strong>.</li>
</template>
</ul>
</li>
<li>
<p><strong>Copy the <code>oauth_token</code> value.</strong> Find the row named <code>oauth_token</code>, double-click its <strong>Value</strong> and copy it. It starts with <code>oauth2_4/</code>.</p>
</li>
<li>
<p><strong>Paste it here right away.</strong> It only works once and expires after a few minutes.</p>
<form @submit.prevent="googleImport" autocomplete="off" class="gx-aa-google__form">
<label>oauth_token value <input class="gx-field" type="password" v-model="googleToken" autocomplete="off" spellcheck="false" maxlength="3072" placeholder="oauth2_4/…" required /></label>
<small v-if="tokenProblem" class="gx-aa-bad" role="alert">{{ tokenProblem }}</small>
<button type="submit" class="gx-btn" :disabled="busy || !googleEmail.trim() || !googleToken.trim() || !!tokenProblem || !setup.googlePlay?.available">Download and Install</button>
<small v-if="!googleEmail.trim()" class="gx-note">Enter your Google email in step 1 first.</small>
</form>
</li>
</ol>
<p v-if="installState === 'uploading'" role="status">Starting Google Play import…</p>
<ul v-if="['checking', 'failed'].includes(installState)" class="gx-aa-checks" aria-live="polite">
<li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li>
</ul>
<GxNotice v-if="installState === 'failed'" tone="danger"><p>{{ problem }}</p><button class="gx-btn gx-btn--tonal" @click="chooseAgain">Try Again</button></GxNotice>
<p v-if="error && installState === 'choose' && installAttempted" class="gx-aa-bad" role="alert">{{ error }}</p>
<p v-if="setup.googlePlay?.available === false" class="gx-note">Google Play isn’t available on this device yet. Use <strong>Upload a file</strong> instead.</p>
</div>
<ol v-else class="gx-aa-sheet__steps">
<li :class="{ 'gx-aa-sheet__step--muted': installState !== 'choose' }">
<strong>Download Android Auto</strong>
<a class="gx-btn gx-btn--tonal" :href="apkmirror" target="_blank" rel="noopener noreferrer">Open APKMirror <i class="bi bi-box-arrow-up-right"></i></a>
@@ -898,7 +1079,8 @@ export const AndroidAutoPage = {
<p v-if="error && installState === 'choose' && installAttempted" class="gx-aa-bad" role="alert">{{ error }}</p>
</li>
</ol>
<small class="gx-note">Your package stays on this comma. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.</small>
<small class="gx-note gx-aa-trademark">Google, Google Play and Android Auto are trademarks of Google LLC. StarPilot is not affiliated with or endorsed by Google.</small>
<small v-if="installMethod !== 'google'" class="gx-note">The uploaded file is deleted after import. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.</small>
</template>
</GxDialog>
</section>`,
@@ -3,8 +3,8 @@
The car accepts a projection source only when it presents Google's Android Auto
phone certificate and proves it holds the matching key. Both are embedded in the
Android Auto app, so each user supplies their own APK (or XAPK/APKM bundle) and
the comma extracts the identity on device. Nothing is downloaded from or sent to
anyone except the link the user provides.
the comma extracts the identity on device. Packages can also be fetched directly
from Google Play using the user's own account; no project broker is involved.
Extraction needs no decompiler. The app's DEX files hold the phone certificate
and the Google Automotive Link root as PEM string constants, and the key as an
@@ -42,6 +42,7 @@ from datetime import UTC, datetime
from pathlib import Path
from openpilot.starpilot.system.android_auto import identity as identity_store
from openpilot.starpilot.system.android_auto import play_resolver
# SHA-256 of the DER Google Automotive Link root certificate (valid until 2044).
GOOGLE_ROOT_SHA256 = "49e52efc13ad2ed09f204c3b10698bd84bb7105f510558aa14b8119a5c4ad17f"
@@ -292,12 +293,17 @@ def identity_status(directory: Path | None = None) -> dict:
# ------------------------------------------------------------------ background import
def download(url: str, destination: Path, progress: Callable[[int, int], None] = lambda done, total: None) -> None:
def download(url: str, destination: Path, progress: Callable[[int, int], None] = lambda done, total: None,
*, google_play: bool = False) -> None:
if not url.lower().startswith(("https://", "http://")):
raise IdentityImportError("Enter an http(s) link to the APK, XAPK, or APKM", "DOWNLOAD")
request = urllib.request.Request(url, headers={"User-Agent": "StarPilot-AndroidAuto/1"})
try:
with urllib.request.urlopen(request, timeout=30) as response, open(destination, "wb") as handle:
opener = urllib.request.build_opener(play_resolver.GoogleRedirect(), urllib.request.ProxyHandler({})) if google_play else None
if google_play:
play_resolver.validate_url(url)
open_url = opener.open if opener else urllib.request.urlopen
with open_url(request, timeout=30) as response, open(destination, "wb") as handle:
total = int(response.headers.get("Content-Length") or 0)
if total > MAX_FILE_BYTES:
raise IdentityImportError("That file is too large to be the Android Auto app", "TOO_LARGE")
@@ -317,7 +323,9 @@ def download(url: str, destination: Path, progress: Callable[[int, int], None] =
class ImportJob:
"""One identity import at a time, in a background thread, with pollable status."""
def __init__(self, work_dir: Path | None = None, identity_dir: Path | None = None, root_sha256: str = GOOGLE_ROOT_SHA256):
def __init__(self, work_dir: Path | None = None, identity_dir: Path | None = None, root_sha256: str = GOOGLE_ROOT_SHA256,
token_store: play_resolver.TokenStore | None = None):
self.token_store = token_store or play_resolver.TokenStore()
self.work_dir = work_dir or IMPORT_DIR
self.identity_dir = identity_dir
self.root_sha256 = root_sha256
@@ -349,6 +357,74 @@ class ImportJob:
self.thread = threading.Thread(target=self._run, args=(path, url, enabled), name="android_auto_identity_import", daemon=True)
self.thread.start()
def start_google_import(self, *, email: str, oauth_token: str | None = None, aas_token: str | None = None,
save_token: bool = False, enabled=None) -> None:
if bool(oauth_token) == bool(aas_token) or type(save_token) is not bool:
raise play_resolver.PlayError("Supply one Google sign-in token")
token = oauth_token or aas_token
play_resolver.validate_credentials(email, token)
with self.lock:
if self.busy():
raise IdentityImportError("An import is already running")
self.state = {"state": "running", "stage": "authenticating", "source": "google",
"started": datetime.now(UTC).timestamp(), "downloaded": 0, "total": 0}
self.thread = threading.Thread(target=self._run_google, args=(email, token, save_token, enabled),
name="android_auto_google_import", daemon=True)
self.thread.start()
def _run_google(self, email: str, token: str, remember: bool, enabled) -> None:
source = None
result = None
def progress(stage=None, **values):
if enabled is not None and not enabled():
raise IdentityImportError("Import cancelled: authorization changed", "CANCELLED")
self._set(**({"stage": stage} if stage else {}), **values)
try:
progress("authenticating")
self.work_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix="google-import-", suffix=".apk", dir=self.work_dir)
os.close(fd)
source = Path(name)
result = play_resolver.resolve(email, token, remember=remember, progress=progress)
token = ""
progress("downloading")
download(result['url'], source, lambda done, total: progress(downloaded=done, total=total), google_play=True)
result.pop('url', None)
progress("installing")
files, metadata = extract_identity(source, root_sha256=self.root_sha256, progress=lambda _stage: progress())
metadata['source'] = 'Google Play (user account)'
progress()
install_identity(files, metadata, self.identity_dir)
progress()
if remember:
self.token_store.save(email, result['aas_token'])
else:
self.token_store.forget()
self._set(state="done", stage="done", finished=datetime.now(UTC).timestamp(), expires=metadata['expires'],
message=f"Identity installed; valid until {metadata['expires'][:10]}")
except IdentityImportError as error:
# Download exceptions may contain signed URLs. Only extraction errors are
# safe to display; never pass through network or unexpected error text.
message = 'Google Play download failed; try again' if error.code == 'DOWNLOAD' else str(error)
self._set(state="failed", error=message, code=error.code, finished=datetime.now(UTC).timestamp(),
**({"expires": error.expires} if error.expires else {}))
except play_resolver.PlayError as error:
self._set(state="failed", error=str(error), code="GOOGLE_PLAY", finished=datetime.now(UTC).timestamp())
except Exception:
self._set(state="failed", error="Google Play import failed; try again or use manual upload",
code="GOOGLE_PLAY", finished=datetime.now(UTC).timestamp())
finally:
token = ""
if result is not None:
result.clear()
if source is not None:
try:
source.unlink(missing_ok=True)
except OSError:
pass
def _run(self, path: Path | None, url: str, enabled=None) -> None:
def progress(**values):
if enabled is not None and not enabled():
@@ -0,0 +1,9 @@
{
"schema": 2,
"resolver": {
"sha256": "f009b9c5964c6d49e41de9eb9d010f197d032ba81f148942a8143ad813d00ac1",
"bytes": 12171208,
"url": "https://github.com/dirwin31/openpilot/releases/download/google-browser-584069b7672b/playlink-arm64"
},
"id": "f009b9c5964c6d49e41de9eb9d010f197d032ba81f148942a8143ad813d00ac1"
}
@@ -0,0 +1,6 @@
"""Pinned Google Play resolver release asset and the directory it installs to."""
import json
from pathlib import Path
PACKAGE = json.loads(Path(__file__).with_suffix('.json').read_text())
RUNTIME = Path('/data/starpilot/google-play') / PACKAGE['id']
@@ -0,0 +1,161 @@
"""Runs the playlink resolver and stores the opt-in Google refresh token.
Secrets go over pipes, never arguments, environment or logs. Only an AAS token
the user chose to remember is persisted.
"""
from __future__ import annotations
import json
import os
import re
import selectors
import time
import stat
import subprocess
import tempfile
import urllib.request
from pathlib import Path
from urllib.parse import urlsplit
from openpilot.starpilot.system.android_auto.play_package import RUNTIME
BINARY = RUNTIME / 'playlink'
TOKEN_PATH = Path('/data/starpilot/aa_token')
MAX_TOKEN = 3072
class PlayError(RuntimeError):
pass
def validate_credentials(email: str, token: str) -> None:
if not isinstance(email, str) or not email.isascii() or len(email) > 254 or not re.fullmatch(r'[^\s@]{1,200}@[^\s@]{1,120}', email):
raise PlayError('Enter the Google account email used to sign in')
if not isinstance(token, str) or len(token) > MAX_TOKEN or not re.fullmatch(r'(?:oauth2_4/|aas_et/)[A-Za-z0-9._~/+=-]+', token):
raise PlayError('Paste a valid Google oauth_token or AAS token')
def validate_url(url: str) -> None:
try:
parsed = urlsplit(url)
valid = (parsed.scheme == 'https' and parsed.hostname == 'play.googleapis.com' and parsed.port in (None, 443)
and not parsed.username and not parsed.password and parsed.path == '/download/by-token/download' and not parsed.fragment)
except (TypeError, ValueError):
valid = False
if not valid:
raise PlayError('Google Play returned an invalid download link')
class GoogleRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
parsed = urlsplit(newurl)
host = parsed.hostname or ''
if (parsed.scheme != 'https' or parsed.port not in (None, 443) or parsed.username or parsed.password or
not (host == 'play.googleapis.com' or host.endswith('.gvt1.com'))):
raise PlayError('Google Play redirected outside its download servers')
return super().redirect_request(req, fp, code, msg, headers, newurl)
def resolve(email: str, token: str, *, remember: bool = False, progress=lambda stage: None) -> dict:
validate_credentials(email, token)
progress('authenticating')
# Callers only see allowlisted stage names; all other resolver output stays private.
process = None
output = bytearray()
output_size = 0
deadline = time.monotonic() + 120
stage = 'authenticating'
try:
process = subprocess.Popen([str(BINARY)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
env={'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8'})
process.stdin.write(json.dumps({'email': email, 'token': token, 'remember': remember}).encode())
process.stdin.close()
token = ''
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
while True:
progress(stage) # also checks cancellation while Google is responding
if time.monotonic() >= deadline:
raise PlayError('Google Play timed out; try signing in again')
if not selector.select(timeout=0.5):
continue
chunk = os.read(process.stdout.fileno(), 4096)
if not chunk:
break
output.extend(chunk)
output_size += len(chunk)
if output_size > 32768:
raise PlayError('Google Play returned an invalid response')
marker = b'{"stage":"resolving"}\n'
if output.startswith(marker):
del output[:len(marker)]
stage = 'resolving'
process.wait(timeout=max(0.01, deadline - time.monotonic()))
if process.returncode != 0:
raise PlayError('Google Play sign-in or delivery failed; sign in again and ensure Play terms are accepted on your account')
except subprocess.TimeoutExpired:
raise PlayError('Google Play timed out; try signing in again') from None
except OSError:
raise PlayError('Google Play support is unavailable in this build; use manual upload') from None
finally:
token = ''
if process is not None:
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
if not process.stdin.closed:
process.stdin.close()
try:
value = json.loads(output)
if not isinstance(value, dict) or value.get('status') != 'ok':
raise ValueError
validate_url(value['url'])
if remember:
validate_credentials(email, value['aas_token'])
if not value['aas_token'].startswith('aas_et/'):
raise ValueError
except (KeyError, TypeError, ValueError, PlayError):
raise PlayError('Google Play returned an invalid response') from None
progress('resolving')
return {'url': value['url'], **({'aas_token': value['aas_token']} if remember else {})}
class TokenStore:
def __init__(self, path: Path = TOKEN_PATH):
self.path = path
def exists(self) -> bool:
return self.path.is_file()
def save(self, email: str, token: str) -> None:
validate_credentials(email, token)
if not token.startswith('aas_et/'):
raise PlayError('Only a refresh token can be remembered')
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix='.aa-token-', dir=self.path.parent)
try:
with os.fdopen(fd, 'w') as handle:
json.dump({'email': email, 'token': token}, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(name, self.path)
finally:
Path(name).unlink(missing_ok=True)
def load(self) -> dict:
fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd) as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_uid != os.getuid():
raise PlayError('Saved Google credentials must be private to the device user')
value = json.loads(handle.read(4097))
if not isinstance(value, dict):
raise PlayError('Saved Google credentials are invalid')
validate_credentials(value.get('email'), value.get('token'))
if not value['token'].startswith('aas_et/'):
raise PlayError('Saved Google credentials are invalid')
return value
def forget(self) -> None:
self.path.unlink(missing_ok=True)
@@ -0,0 +1,140 @@
"""No live Google account or APK required for resolver/import regression tests."""
import json
import os
import sys
from unittest.mock import Mock
import pytest
from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
EMAIL = 'user@example.com'
TOKEN = 'oauth2_4/test-secret'
AAS = 'aas_et/test-refresh'
URL = 'https://play.googleapis.com/download/by-token/download?token=private'
def fake_binary(tmp_path, monkeypatch, body):
binary = tmp_path / 'playlink'
binary.write_text(f'#!{sys.executable}\n' + body)
binary.chmod(0o700)
monkeypatch.setattr(play_resolver, 'BINARY', binary)
def test_pipe_protocol_private_arguments_and_stages(tmp_path, monkeypatch):
fake_binary(tmp_path, monkeypatch, f'''import sys, json, os
assert len(sys.argv) == 1
assert not any("oauth2_4/" in value for value in os.environ.values())
request = json.load(sys.stdin)
assert request == {{"email": {EMAIL!r}, "token": {TOKEN!r}, "remember": True}}
print('{{"stage":"resolving"}}', flush=True)
print(json.dumps({{"status": "ok", "url": {URL!r}, "aas_token": {AAS!r}}}))
''')
stages = []
result = play_resolver.resolve(EMAIL, TOKEN, remember=True, progress=stages.append)
assert result == {'url': URL, 'aas_token': AAS}
assert stages[0] == 'authenticating' and stages[-1] == 'resolving'
@pytest.mark.parametrize('body', [
'import sys; print("oauth2_4/private", file=sys.stderr); sys.exit(1)',
'print("oauth2_4/private")',
'print("x" * 40000)',
'print(\'{"status":"ok","url":"http://127.0.0.1/secret"}\')',
])
def test_failures_never_echo_child_output(tmp_path, monkeypatch, body):
fake_binary(tmp_path, monkeypatch, body)
with pytest.raises(play_resolver.PlayError) as error:
play_resolver.resolve(EMAIL, TOKEN)
assert 'private' not in str(error.value) and '127.0.0.1' not in str(error.value)
def test_cancellation_kills_child(tmp_path, monkeypatch):
pid_file = tmp_path / 'pid'
fake_binary(tmp_path, monkeypatch, f'import os, time\nopen({str(pid_file)!r}, "w").write(str(os.getpid()))\ntime.sleep(20)\n')
def progress(_stage):
if pid_file.exists():
raise RuntimeError('cancelled')
with pytest.raises(RuntimeError, match='cancelled'):
play_resolver.resolve(EMAIL, TOKEN, progress=progress)
with pytest.raises(ProcessLookupError):
os.kill(int(pid_file.read_text()), 0)
@pytest.mark.parametrize('url', ['https://play.googleapis.com.evil/download/by-token/download',
'http://play.googleapis.com/download/by-token/download',
'https://user@play.googleapis.com/download/by-token/download',
'https://play.googleapis.com:444/download/by-token/download',
'https://play.googleapis.com/other'])
def test_rejects_unexpected_download_hosts(url):
with pytest.raises(play_resolver.PlayError):
play_resolver.validate_url(url)
def test_token_store_permissions_and_forget(tmp_path):
store = play_resolver.TokenStore(tmp_path / 'private' / 'aa_token')
store.save(EMAIL, AAS)
assert store.path.stat().st_mode & 0o777 == 0o600
assert store.load() == {'email': EMAIL, 'token': AAS}
assert not list(store.path.parent.glob('.aa-token-*'))
store.path.chmod(0o644)
with pytest.raises(play_resolver.PlayError):
store.load()
store.forget()
store.forget()
assert not store.exists()
def test_token_store_rejects_symlink(tmp_path):
target = tmp_path / 'elsewhere'
target.write_text('{}')
path = tmp_path / 'aa_token'
path.symlink_to(target)
with pytest.raises(OSError):
play_resolver.TokenStore(path).load()
@pytest.mark.parametrize('remember', [False, True])
def test_google_import_installs_and_cleans_private_package(tmp_path, monkeypatch, remember):
store = play_resolver.TokenStore(tmp_path / 'aa_token')
job = apk_identity.ImportJob(work_dir=tmp_path / 'work', identity_dir=tmp_path / 'identity', token_store=store)
monkeypatch.setattr(play_resolver, 'resolve', Mock(return_value={'url': URL, 'aas_token': AAS}))
def download(url, destination, progress, **kwargs):
assert kwargs == {"google_play": True}
assert url == URL and destination.stat().st_mode & 0o077 == 0
destination.write_bytes(b'package')
progress(7, 7)
monkeypatch.setattr(apk_identity, 'download', download)
monkeypatch.setattr(apk_identity, 'extract_identity', Mock(return_value=({'key': b'private'}, {'expires': '2027-01-01T00:00:00+00:00'})))
install = Mock()
monkeypatch.setattr(apk_identity, 'install_identity', install)
job.start_google_import(email=EMAIL, oauth_token=TOKEN, save_token=remember)
job.thread.join(3)
assert job.status()['state'] == 'done'
install.assert_called_once()
assert not list(job.work_dir.iterdir())
assert store.exists() is remember
assert all(secret not in json.dumps(job.status()) for secret in (TOKEN, AAS, URL, EMAIL))
def test_download_exception_redacted_and_old_identity_preserved(tmp_path, monkeypatch):
job = apk_identity.ImportJob(work_dir=tmp_path / 'work', token_store=play_resolver.TokenStore(tmp_path / 'token'))
monkeypatch.setattr(play_resolver, 'resolve', Mock(return_value={'url': URL}))
monkeypatch.setattr(apk_identity, 'download', Mock(side_effect=apk_identity.IdentityImportError(URL, 'DOWNLOAD')))
install = Mock()
monkeypatch.setattr(apk_identity, 'install_identity', install)
job.start_google_import(email=EMAIL, oauth_token=TOKEN)
job.thread.join(3)
assert job.status()['state'] == 'failed' and URL not in json.dumps(job.status())
assert not list(job.work_dir.iterdir())
install.assert_not_called()
def test_revoked_session_never_resolves_or_installs(tmp_path, monkeypatch):
resolve = Mock()
monkeypatch.setattr(play_resolver, 'resolve', resolve)
job = apk_identity.ImportJob(work_dir=tmp_path / 'work')
job.start_google_import(email=EMAIL, oauth_token=TOKEN, enabled=lambda: False)
job.thread.join(3)
assert job.status()['code'] == 'CANCELLED'
resolve.assert_not_called()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Builds the static Linux ARM64 Google Play resolver (playlink) in Docker.
set -euo pipefail
cd "$(dirname "$0")/.."
scripts/laptop_device_build.sh doctor
image=starpilot-playlink-builder:rust1.93.1
docker build --platform linux/arm64 -f tools/laptop_device_build/Dockerfile.playlink -t "$image" .
mkdir -p .cache/playlink-registry .cache/playlink-target .cache/playlink
docker run --rm --platform linux/arm64 \
--mount "type=bind,src=$PWD,dst=/work" \
--mount "type=bind,src=$PWD/.cache/playlink-registry,dst=/opt/playlink-cargo/registry" \
-e CARGO_TARGET_DIR=/work/.cache/playlink-target \
-e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/work \
"$image" bash -euc '
test "$(uname -sm)" = "Linux aarch64"
capnp --version | grep -q "version 1.0.1$"
cargo build --locked --release --target aarch64-unknown-linux-gnu
binary=/work/.cache/playlink-target/aarch64-unknown-linux-gnu/release/playlink
file "$binary"
readelf -h "$binary" | grep -q AArch64
if readelf -l "$binary" | grep -q INTERP; then exit 1; fi
if readelf -d "$binary" | grep -q NEEDED; then exit 1; fi
install -m 755 "$binary" /work/.cache/playlink/playlink-arm64
'
printf 'Play resolver: %s/.cache/playlink/playlink-arm64\n' "$PWD"
@@ -0,0 +1,9 @@
FROM openpilot-local-arm64-builder:latest
USER root
ENV CARGO_HOME=/opt/playlink-cargo RUSTUP_HOME=/opt/playlink-rustup
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \
&& sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.93.1 \
&& rm /tmp/rustup-init.sh
ENV PATH=/opt/playlink-cargo/bin:$PATH
ENV RUSTFLAGS="-C target-feature=+crt-static"
WORKDIR /work/tools/playlink
+2215
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "starpilot-playlink"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "playlink"
path = "src/main.rs"
[dependencies]
gpapi = "=6.1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
openssl = { version = "0.10", features = ["vendored"] }
[profile.release]
strip = true
+65
View File
@@ -0,0 +1,65 @@
//! Reads a Google sign-in token on stdin and prints the Android Auto download link.
//! No account, APK, URL cache or credentials are bundled.
use gpapi::Gpapi;
use serde::Deserialize;
use serde_json::json;
use std::io::{self, IsTerminal, Read, Write};
use std::time::Duration;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
email: String,
token: String,
remember: bool,
}
async fn resolve(request: Request) -> Result<serde_json::Value, ()> {
let mut api = Gpapi::new("px_9a", &request.email);
if request.token.starts_with("oauth2_4/") {
api.request_aas_token(request.token).await.map_err(|_| ())?;
} else if request.token.starts_with("aas_et/") {
api.set_aas_token(request.token);
} else {
return Err(());
}
// Deliberately no ToS acceptance: the user accepts Play terms on their own account.
api.login().await.map_err(|_| ())?;
writeln!(io::stdout(), "{{\"stage\":\"resolving\"}}").map_err(|_| ())?;
io::stdout().flush().map_err(|_| ())?;
let (url, _, _, _) = api
.get_download_info("com.google.android.projection.gearhead", None)
.await
.map_err(|_| ())?;
let mut result = json!({"status": "ok", "url": url.ok_or(())?});
if request.remember {
result["aas_token"] = json!(api.get_aas_token().ok_or(())?);
}
Ok(result)
}
#[tokio::main]
async fn main() {
// Silence panics so errors never disclose responses, cookies or credentials.
std::panic::set_hook(Box::new(|_| {}));
if io::stdin().is_terminal() || io::stdout().is_terminal() {
std::process::exit(2);
}
let mut input = String::new();
if io::stdin().take(4097).read_to_string(&mut input).is_err() || input.len() > 4096 {
std::process::exit(2);
}
let request = match serde_json::from_str::<Request>(&input) {
Ok(request) => request,
Err(_) => std::process::exit(2),
};
drop(input);
match tokio::time::timeout(Duration::from_secs(110), resolve(request)).await {
Ok(Ok(result)) => {
if writeln!(io::stdout(), "{result}").is_err() {
std::process::exit(1);
}
}
_ => std::process::exit(1),
}
}
Generated
+1
View File
@@ -808,6 +808,7 @@ requires-dist = [
{ name = "requests" },
{ name = "ruff", marker = "extra == 'testing'" },
{ name = "scons" },
{ name = "sentry-sdk", specifier = "==2.38.0" },
{ name = "setproctitle" },
{ name = "sounddevice" },
{ name = "teleoprtc", marker = "extra == 'vendored'", editable = "teleoprtc_repo" },