Files
punktfunk/clients/android/app/src/main/AndroidManifest.xml
T
enricobuehler b63f9f4ac0 feat(client/android): punktfunk:// links open a stream
Android was the last client with no URL door at all: no VIEW intent filter,
no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream
Deck macro or a wiki link can open a stream on a host this device already
trusts.

The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json`
is the cross-language contract, and the Kotlin suite runs the same 44 cases
the Rust one does — including every refusal code — so three parsers cannot
drift into three different security postures. It is resolved from the shared
path rather than copied, because a copy would be a fourth contract free to go
stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a
refusal rather than a U+FFFD that survives into a log line or a filename.

The routing lives in `ConnectScreen` because that is what owns the connect
path — trust decisions, the local-network grant, wake-and-retry — and a link
must go THROUGH all of it, never around it. The rules are absolute and each
one is a branch you can point at: a known, pinned host does exactly what
tapping its card does; an unknown or never-pinned one gets the confirmation
sheet, from which the normal pairing flow proceeds under the user's eyes; an
`fp=` that contradicts the stored pin is a hard refusal; an ambiguous host
name or a profile this device doesn't have refuses with a notice naming what
failed, because a "Work" shortcut streaming with the wrong settings is worse
than an error; a link arriving mid-stream never preempts it (pointing at the
host already being streamed is a no-op — the intent has already brought the
app forward, which is what focusing it means). `wake` and `browse` parse, and
are refused with a notice rather than silently connecting.

`launch=` and `profile=` ride the whole path, including through a trust
decision: a link to a host that still needs pairing keeps its game and its
profile across the confirmation instead of quietly landing on a plain desktop
session.

`launchMode` stays `standard` and the `configChanges` set is untouched — its
`keyboard` entry is what keeps an SC2 claim from killing a running stream.
The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what
that launch mode requires.
2026-07-29 12:17:18 +02:00

111 lines
7.6 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- mDNS discovery of _punktfunk._udp on the LAN (native mdns-sd browse). Requested
opportunistically — raw multicast reception needs only the MulticastLock, not this. -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- HostDiscovery holds a MulticastLock while the native mDNS browse runs — raw multicast
reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37, our targetSdk) for ALL local-network traffic incl. the
QUIC socket — a RUNTIME permission (NEARBY_DEVICES group): ConnectScreen requests it on
entry and gates every dial/wake on the grant. Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<!-- Neutralize Play's IMPLIED hard requirements, which filtered real TVs as "not compatible"
(reported on a Philips OLED707): RECORD_AUDIO implies android.hardware.microphone and the
Wi-Fi state permissions imply android.hardware.wifi, both required=true unless declared
otherwise. Some TVs declare no microphone (mic uplink is optional and runtime-gated) and
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
direct-BLE pad — both optional (the feature quietly disengages without them). -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
scheduler treatment games get — which, together with the ADPF hints in the native decode
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
<application
android:allowBackup="false"
android:appCategory="game"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PunktfunkAndroid">
<!-- Game Mode config (Android 13+): declare we support Performance mode and opt OUT of the
OEM interventions that would fight the negotiated stream — resolution downscaling and
FPS overrides. A game-streaming client renders exactly the host's mode; a platform
downscale/FPS-cap corrupts that. Ignored below API 33. -->
<meta-data
android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" />
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
Android RECREATES the activity, disposing StreamScreen and killing the stream the
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
interfaces at session end brings the devices back — same flip, same need. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
android:theme="@style/Theme.PunktfunkAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
shortcut or a wiki page opens a stream on a host this device already trusts. The
URL carries only REFERENCES to things that exist here (a host record, a settings
profile, a library id) — never resolution/bitrate/codec values, and never a
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
browser hand it over (behind its own "Open Punktfunk?" prompt).
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
stream, and neither has anything to gain from this filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="punktfunk" />
</intent-filter>
</activity>
</application>
</manifest>