diff --git a/.gitignore b/.gitignore index d102070961..a4716fd4b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/openpilot/starpilot/galaxy/android_auto_setup.py b/openpilot/starpilot/galaxy/android_auto_setup.py index 8441b4fddc..4a23290090 100644 --- a/openpilot/starpilot/galaxy/android_auto_setup.py +++ b/openpilot/starpilot/galaxy/android_auto_setup.py @@ -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) diff --git a/openpilot/starpilot/galaxy/http_server.py b/openpilot/starpilot/galaxy/http_server.py index 4209104b32..020f6866bd 100644 --- a/openpilot/starpilot/galaxy/http_server.py +++ b/openpilot/starpilot/galaxy/http_server.py @@ -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) diff --git a/openpilot/starpilot/galaxy/play_install.py b/openpilot/starpilot/galaxy/play_install.py new file mode 100644 index 0000000000..f3e2b8dcd9 --- /dev/null +++ b/openpilot/starpilot/galaxy/play_install.py @@ -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) diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index e73a46bf21..e236e5d69b 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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 diff --git a/openpilot/starpilot/galaxy/test_android_auto_http.py b/openpilot/starpilot/galaxy/test_android_auto_http.py index c687c29c8b..06cda1e1bb 100644 --- a/openpilot/starpilot/galaxy/test_android_auto_http.py +++ b/openpilot/starpilot/galaxy/test_android_auto_http.py @@ -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 diff --git a/openpilot/starpilot/galaxy/test_android_auto_setup.py b/openpilot/starpilot/galaxy/test_android_auto_setup.py index 0d7e0f5716..32a66b1957 100644 --- a/openpilot/starpilot/galaxy/test_android_auto_setup.py +++ b/openpilot/starpilot/galaxy/test_android_auto_setup.py @@ -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() diff --git a/openpilot/starpilot/galaxy/test_play_install.py b/openpilot/starpilot/galaxy/test_play_install.py new file mode 100644 index 0000000000..96a1fe0188 --- /dev/null +++ b/openpilot/starpilot/galaxy/test_play_install.py @@ -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() diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs index 3ccd8570ce..e4d77e3d0f 100644 --- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs +++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs @@ -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') diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index f2fd6dc13c..e4837e5af1 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -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; } diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js index 6433e4434a..1c99c169d7 100644 --- a/openpilot/starpilot/galaxy/web/js/android-auto.js +++ b/openpilot/starpilot/galaxy/web/js/android-auto.js @@ -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 = {

Not available on this build — the Android Auto display and encoder are missing.