mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
Update Chrome login and remove embedded browser
Combine Chrome Login v1 and Remove Embedded Browser.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
Executable
+25
@@ -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
|
||||
Generated
+2215
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user