Vier Beweise, die das Projekt tragen, als eigenständig lauffähige Skripte:
S1 Plasma Mobile in proot über genestetes kwin_wayland im X11-Backend.
Fällt selbsttätig auf startplasmamobile und dann auf XFCE zurück, damit
ein Fehlschlag eindeutig Plasma zuzuordnen ist und nicht der Umgebung.
S2 Mikrofon über PulseAudio mit OpenSL-Quelle. Misst den Pegel statt nur die
Dateigröße — der typische Fehlschlag ist eine formal korrekte WAV-Datei
voller Stille.
S3 Virtuelle PipeWire-Kamera per GStreamer-pipewiresink, ohne Kernel-Modul.
Setzt die Firefox-Einstellung vorab, damit der Test nicht an einem
vergessenen Häkchen scheitert.
S4 Intent-Handoff mit lauffähiger Mini-Bridge: xdg-open-Handler für geo:,
tel: und sms:, Token-Authentifizierung, feste Liste erlaubter Schemata.
provision/provision.sh setzt die Hintergrundbeschränkungen per ADB und benennt,
was ADB nicht kann und von Hand erledigt werden muss.
Dokumentiert sind auch die sieben verworfenen Wege — Waydroid, AVF, QEMU, SIP,
APK-Daten-Redirect, Fenster-Durchreichung und der Android-Server — mit Belegen,
damit sie nicht erneut durchdacht werden müssen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
169 lines
5.8 KiB
Python
Executable File
169 lines
5.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""HPOS Mini-Bridge — Prototyp des Intent-Handoffs für Beweis S4.
|
|
|
|
Läuft in Termux, lauscht auf 127.0.0.1 und übersetzt URIs vom Linux in
|
|
Android-Intents. Das ist die kleinste ehrliche Fassung dessen, was später die
|
|
Bridge-APK macht: sie speichert nichts, sie reicht durch.
|
|
|
|
Bewusst eng gehalten:
|
|
* nur 127.0.0.1, niemals extern erreichbar
|
|
* gemeinsames Token, damit nicht jede App auf dem Telefon Intents auslösen kann
|
|
* feste Liste erlaubter Schemata statt beliebiger Intents
|
|
|
|
Start: python spike/bridge-mini.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
import socketserver
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = int(os.environ.get("HPOS_BRIDGE_PORT", "8474"))
|
|
|
|
BASIS = os.path.dirname(os.path.abspath(__file__))
|
|
TOKEN_DATEI = os.path.join(BASIS, "out", "bridge-token")
|
|
|
|
# Paketname einer Begleit-App, die vor der Hauptanwendung starten soll — typisch
|
|
# eine App mit Overlay, das über der Navigation liegen muss. Leer = keine.
|
|
BEGLEIT_APP = os.environ.get("HPOS_BEGLEIT_APP", "").strip()
|
|
BEGLEIT_VORLAUF = float(os.environ.get("HPOS_BEGLEIT_VORLAUF", "2.5"))
|
|
|
|
# Was aus dem Linux kommen darf. Alles andere wird abgelehnt — eine offene
|
|
# Intent-Schnittstelle wäre ein Einfallstor für jede App auf dem Gerät.
|
|
ERLAUBTE_SCHEMATA = {"geo", "tel", "sms", "smsto", "mailto", "http", "https"}
|
|
|
|
|
|
def token_lesen() -> str:
|
|
"""Token holen, beim ersten Start erzeugen."""
|
|
os.makedirs(os.path.dirname(TOKEN_DATEI), exist_ok=True)
|
|
if not os.path.exists(TOKEN_DATEI):
|
|
with open(os.open(TOKEN_DATEI, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f:
|
|
f.write(os.urandom(16).hex())
|
|
with open(TOKEN_DATEI) as f:
|
|
return f.read().strip()
|
|
|
|
|
|
TOKEN = token_lesen()
|
|
|
|
|
|
def am(*args: str) -> tuple[int, str]:
|
|
"""`am` aufrufen und Ergebnis zurückgeben."""
|
|
befehl = ["am", *args]
|
|
print(f" → {' '.join(shlex.quote(a) for a in befehl)}", flush=True)
|
|
try:
|
|
p = subprocess.run(befehl, capture_output=True, text=True, timeout=20)
|
|
return p.returncode, (p.stdout + p.stderr).strip()
|
|
except FileNotFoundError:
|
|
return 127, "am nicht gefunden — Paket termux-tools fehlt"
|
|
except subprocess.TimeoutExpired:
|
|
return 124, "am hat nicht geantwortet"
|
|
|
|
|
|
def app_starten(paket: str) -> None:
|
|
am("start", "--user", "0", "-a", "android.intent.action.MAIN",
|
|
"-c", "android.intent.category.LAUNCHER", "-p", paket)
|
|
|
|
|
|
def navigation(ziel: str) -> tuple[int, str]:
|
|
"""Turn-by-turn direkt starten statt nur die Karte zu zeigen.
|
|
|
|
Vorher optional die Begleit-App, damit deren Overlay schon steht, wenn die
|
|
Navigation in den Vordergrund kommt — andersherum legt sich die Navigation
|
|
darüber und das Overlay ist verdeckt.
|
|
"""
|
|
if BEGLEIT_APP:
|
|
print(f" Begleit-App vorab: {BEGLEIT_APP}", flush=True)
|
|
app_starten(BEGLEIT_APP)
|
|
time.sleep(BEGLEIT_VORLAUF)
|
|
uri = "google.navigation:q=" + urllib.parse.quote(ziel) + "&mode=d"
|
|
return am("start", "--user", "0", "-a", "android.intent.action.VIEW", "-d", uri)
|
|
|
|
|
|
def behandeln(zeile: str) -> str:
|
|
"""Eine Anfrage verarbeiten. Format: <token>\\t<uri>"""
|
|
teile = zeile.split("\t", 1)
|
|
if len(teile) != 2:
|
|
return "FEHLER ungueltiges-format"
|
|
token, uri = teile[0].strip(), teile[1].strip()
|
|
|
|
if token != TOKEN:
|
|
print(" ! Anfrage mit falschem Token abgelehnt", flush=True)
|
|
return "FEHLER token"
|
|
if not uri:
|
|
return "FEHLER leere-uri"
|
|
|
|
schema = uri.split(":", 1)[0].lower()
|
|
|
|
# geo:-URIs mit Suchbegriff bedeuten in der Praxis fast immer "dahin navigieren",
|
|
# nicht "Karte anschauen". Deshalb wird daraus Turn-by-turn.
|
|
if schema == "geo":
|
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(uri).query).get("q", [""])[0]
|
|
if query:
|
|
rc, aus = navigation(query)
|
|
return f"OK navigation {rc} {aus}" if rc == 0 else f"FEHLER am {rc} {aus}"
|
|
|
|
if schema == "tel":
|
|
# DIAL statt CALL: die Nummer erscheint im Wähler, gewählt wird bewusst.
|
|
rc, aus = am("start", "--user", "0", "-a", "android.intent.action.DIAL", "-d", uri)
|
|
return f"OK dial {rc} {aus}" if rc == 0 else f"FEHLER am {rc} {aus}"
|
|
|
|
if schema in ERLAUBTE_SCHEMATA:
|
|
rc, aus = am("start", "--user", "0", "-a", "android.intent.action.VIEW", "-d", uri)
|
|
return f"OK view {rc} {aus}" if rc == 0 else f"FEHLER am {rc} {aus}"
|
|
|
|
print(f" ! Schema abgelehnt: {schema}", flush=True)
|
|
return f"FEHLER schema-nicht-erlaubt {schema}"
|
|
|
|
|
|
class Handler(socketserver.StreamRequestHandler):
|
|
timeout = 10
|
|
|
|
def handle(self) -> None:
|
|
try:
|
|
zeile = self.rfile.readline(4096).decode("utf-8", "replace")
|
|
except Exception as e:
|
|
print(f" ! Lesefehler: {e}", flush=True)
|
|
return
|
|
if not zeile:
|
|
return
|
|
antwort = behandeln(zeile)
|
|
print(f" ← {antwort}", flush=True)
|
|
try:
|
|
self.wfile.write((antwort + "\n").encode())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
|
|
def main() -> int:
|
|
print("HPOS Mini-Bridge (Prototyp für Beweis S4)")
|
|
print(f" Lauscht auf {HOST}:{PORT}")
|
|
print(f" Token-Datei {TOKEN_DATEI}")
|
|
print(f" Begleit-App {BEGLEIT_APP or '(keine)'}")
|
|
print(" Beenden mit Strg+C")
|
|
print()
|
|
try:
|
|
with Server((HOST, PORT), Handler) as srv:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nBeendet.")
|
|
except OSError as e:
|
|
print(f"Start fehlgeschlagen: {e}", file=sys.stderr)
|
|
print(f"Läuft schon etwas auf Port {PORT}?", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|