This commit is contained in:
2026-09-13 12:15:36 -07:00
commit e473d00f4f
104 changed files with 16080 additions and 0 deletions
@@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.portaltv.capability">
<uses-sdk android:minSdkVersion="28" android:targetSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_CONTROL" />
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_METADATA" />
<uses-permission android:name="com.facebook.aloha.permission.READ_PORTAL_PACKAGE_POLICY" />
<uses-permission android:name="com.facebook.aloha.permission.GLOBAL_OWNER_SELECTED" />
<uses-permission android:name="com.facebook.aloha.permission.APP_FOUNDATION_SERVICE_LAUNCH" />
<uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" />
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true">
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_CONTROL_SERVICE" android:value="" />
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_METADATA_SERVICE" android:value="" />
<activity android:name=".MainActivity" android:screenOrientation="landscape" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".PortalStreamingService" android:exported="false" android:stopWithTask="false" />
<receiver android:name=".PortalBootReceiver" android:enabled="true" android:exported="true">
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
</receiver>
</application>
</manifest>
+415
View File
@@ -0,0 +1,415 @@
# Portal capability test app
Source for the diagnostic APK used on the Portal TV. It checks camera enumeration,
displays frames from camera 0, samples the microphone, and exercises the Portal
Smart Camera services in `com.facebook.portal.aiservice`:
- the **external** control service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE`)
with the four mode requests `DefaultAuto`, `Desk`, `Meeting`, `Fixed`
- the **external** metadata service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE`)
for reading the current mode back and watching mode changes
## Files
- `src/com/portaltv/capability/MainActivity.java` — app source
- `smartcamera/src/com/portaltv/smartcamera/` — Kotlin client library (see below)
- `AndroidManifest.xml` — permissions and app declaration
- `build-apk.sh` — reproducible build (javac -> d8 -> aapt2 -> zipalign -> apksigner)
- `portal-capability-test.apk` — already-built debug APK
## Webcam streaming
## Authentication and Security Architecture
Communication between the macOS client (`PortalCam.app` / `CamExtension`) and the Portal TV service is secured using an authenticated, channel-bound transport protocol:
1. **Transport Layer**: HTTPS via `SSLServerSocket` on port 5654 with forward-secret cipher suites (`ECDHE-ECDSA-AES128-GCM-SHA256`).
2. **Device Identity**: Self-signed ECDSA NIST P-256 (`secp256r1`) certificate generated inside hardware-backed `AndroidKeyStore`.
3. **Pairing & Mutual Authentication**: RFC 5054 SRP-6a (2048-bit MODP group) with **cryptographic TLS channel binding**.
4. **Post-Pairing Enforcement**: Strict TLS certificate SHA-256 fingerprint pinning in macOS Keychain, combined with 256-bit Bearer token authentication.
```
Mac (PortalCam.app) Portal TV
| |
| 1. POST /auth/srp/init (HTTPS) |
|------------------------------------------------->| Generates 6-digit PIN, salt s,
| | derives v = g^x mod N, B = (kv + g^b) mod N
| 2. { pairingId, salt, B } |
|<-------------------------------------------------| Displays PIN on TV UI
Extracts server TLS cert DER, |
computes tls_hash = SHA256(cert_der) |
User enters 6-digit PIN |
Computes x, A = g^a mod N, u, S, K |
M1 = SHA256(A || B || K || s || tls_hash) |
| |
| 3. POST /auth/srp/verify { pairingId, A, M1 } |
|------------------------------------------------->| Computes u, S, K
| | Verifies M1 using Portal's tls_hash
| | (Rate limit: max 3 attempts)
| 4. { M2, token } | Computes M2 = SHA256(A || M1 || K || tls_hash)
|<-------------------------------------------------|
Verifies M2 using tls_hash |
Pins tls_hash & saves token in Keychain |
| |
|==================================================|
| Subsequent Media & Control (HTTPS) |
| Strict Pinning: cert_sha256 == pinned_hash |
| Authorization: Bearer <token> |
|==================================================|
```
### Cryptographic & Protocol Choices
#### 1. Server Certificate: ECDSA P-256 (`secp256r1`)
* **Why Elliptic Curve over RSA**:
* **Handshake Speed & CPU**: EC scalar point multiplication is dramatically lighter on the Portal's Snapdragon 835 ARM cores than 2048-bit modular exponentiation.
* **Certificate Size**: The ECDSA certificate is only **352 bytes** DER (vs ~1.5 KB for RSA-2048), keeping the TLS handshake packet well within a single TCP MTU.
* **Key Generation Time**: Key generation in `AndroidKeyStore` takes ~100 ms compared to 1.53 seconds for RSA.
* **Clean Platform Integration**: Android Keymaster implements `SHA256withECDSA` natively, avoiding raw digest (`NONEwithRSA`) driver inconsistencies present on Android 9.
* **Storage**: Stored under alias `portalcam_tls_ec_p256` in `AndroidKeyStore` with private key non-exportable and protected for signing.
#### 2. TLS Protocol Version & Cipher Suite
* **Protocol**: TLS 1.2 with opportunistic negotiation up to TLS 1.3 (`enabledProtocols = arrayOf("TLSv1.3", "TLSv1.2")`).
* *Android 9 Platform Context*: Conscrypt on Android 9 (API 28) enables TLS 1.3 for client sockets, while server-mode `SSLServerSocket` natively negotiates `TLSv1.2`.
* **Cipher Suite**: `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` providing authenticated encryption (AEAD) and Ephemeral Diffie-Hellman forward secrecy.
#### 3. Password-Authenticated Key Exchange: RFC 5054 SRP-6a
* **Group Parameters**: 2048-bit MODP prime group ($N$, generator $g = 2$, multiplier $k = \text{SHA256}(N \parallel g)$).
* **PIN Entropy**: 6-digit random decimal number ($10^6$ combinations).
* **Key Derivation**: $x = \text{SHA256}(s \parallel \text{PIN})$, with a 16-byte cryptographically secure random salt $s$.
* **Zero-Knowledge Property**: The PIN is never transmitted over the wire in cleartext, hashed, or encrypted form. Mutual authentication proves knowledge of the PIN without exposing it to eavesdroppers.
---
### Cryptographic Channel Binding & MITM Prevention
Standard self-signed TLS pairing typically suffers from a **Trust-on-First-Use (TOFU)** vulnerability: a client connecting to an untrusted self-signed certificate has no initial root of trust, making it susceptible to an active Man-in-the-Middle (MITM) proxy that injects its own TLS certificate.
Our implementation eliminates this vulnerability by cryptographically binding the transport layer into the SRP evidence proofs:
$$\mathbf{tls\_hash} = \text{SHA256}(\text{LeafCertificate}_{\text{DER}})$$
$$M_1 = \text{SHA256}(\text{pad256}(A) \parallel \text{pad256}(B) \parallel K \parallel s \parallel \mathbf{tls\_hash})$$
$$M_2 = \text{SHA256}(\text{pad256}(A) \parallel M_1 \parallel K \parallel \mathbf{tls\_hash})$$
#### Why an Active MITM Cannot Succeed:
1. Suppose an attacker places a rogue proxy between the Mac and the Portal TV.
2. The proxy terminates TLS to the Mac using a rogue certificate $C_{\text{rogue}}$, and opens a second TLS connection to the Portal TV using $C_{\text{portal}}$.
3. The Mac computes $\text{tls\_hash}_{\text{mac}} = \text{SHA256}(C_{\text{rogue}})$.
4. The Mac computes evidence $M_1$ incorporating $\text{tls\_hash}_{\text{mac}}$ and sends it to the server.
5. The Portal TV verifies $M_1$ using its own certificate hash: $\text{tls\_hash}_{\text{portal}} = \text{SHA256}(C_{\text{portal}})$.
6. Because $C_{\text{rogue}} \neq C_{\text{portal}}$, the hashes differ: $\text{tls\_hash}_{\text{mac}} \neq \text{tls\_hash}_{\text{portal}}$.
7. **Verification fails immediately** on the Portal TV. The server aborts the handshake, increments the failed attempt counter, and refuses to issue a bearer token.
8. The attacker cannot forge a valid $M_1$ or $M_2$ because computing either proof requires the shared session key $K$, which requires knowledge of the PIN displayed exclusively on the physical TV screen.
---
### Certificate Pinning & Key Storage
Once the client successfully verifies $M_2$:
1. **Keychain Storage**:
* The 256-bit bearer token is stored under `portalAuthToken`.
* The server certificate's SHA-256 fingerprint is stored under `portalPinnedCertSha256`.
* Stored in the macOS Data Protection Keychain with shared access group `ENT9X9U544.com.kovtash.portalcam` and synchronized to the App Group `UserDefaults` (`ENT9X9U544.com.kovtash.portalcam`) for sandboxed access by the CMIO camera extension.
2. **Strict Pinning Enforcement**:
* `PortalPinnedSessionDelegate` intercepts `URLAuthenticationChallenge` on all subsequent HTTPS sessions (`/video.h264`, `/audio.aac`, `/control/*`).
* It extracts the server leaf certificate via `SecTrustCopyCertificateChain`, computes its SHA-256 digest, and performs a constant-time comparison against `PortalAuth.pinnedCertSha256`.
* Any certificate change (e.g. Rogue CA injection, LAN redirect, proxy) causes `completionHandler(.cancelAuthenticationChallenge, nil)`, severing the connection immediately.
---
### Brute-Force Rate Limiting
* The Portal TV strictly limits failed pairing attempts to **a maximum of 3**.
* If 3 failed `verify` attempts occur, or if the 120-second expiration window elapses:
* The active pairing session, PIN, salt, and verifier are completely erased from memory.
* Future attempts are rejected with `400 / 401`.
* **Security Margin**: With $10^6$ possible 6-digit PINs and 3 maximum attempts, the probability of an attacker guessing the PIN within the lifetime of a pairing session is:
$$P(\text{guess}) = \frac{3}{10^6} = 0.0003\%$$
### Verified milestone
The foreground streaming service is installed and verified on Portal TV. It
survives a device reboot, starts its listener automatically, starts camera or
microphone capture on the first client, and tears capture down when the last
client disconnects. Returning the Portal app to the foreground restores the
service path for new clients.
The app runs an HTTPS server on port 5654 (starts on launch), encoding with the
Snapdragon 835 hardware codecs (H.264 via `OMX.qcom.video.encoder.avc`, AAC-LC
via MediaCodec from the mic tee). All streaming and control endpoints require an `Authorization: Bearer <token>` header obtained via the SRP pairing handshake:
- `https://<portal-ip>:5654/` — info page
- `https://<portal-ip>:5654/stream.ts`**MPEG-TS: H.264 + AAC, synced (recommended)**
- `https://<portal-ip>:5654/video.h264` — raw H.264 Annex B, 720p30 @ ~2.5 Mbps
- `https://<portal-ip>:5654/audio.aac` — AAC ADTS, 48 kHz mono @ 64 kbps
Camera control (Bearer auth). Success bodies are always current state
`{"mode":"<Name>","config":{…}}`; failures are
`{"error":"<code>","message":"<text>"}` with a non-2xx status:
| Path | Purpose |
|---|---|
| `GET /control/state` | Read current mode + config |
| `GET /control/mode?mode=DefaultAuto\|Desk\|Meeting\|Fixed` | Switch mode; returns new state |
| `GET /control/fixed?x=&y=&scale=` | Apply Fixed crop; returns new state |
| `GET /control/desk?tightness=` | Apply Desk framing tightness; returns new state |
`config` is mode-specific: empty for DefaultAuto/Meeting; Fixed includes
`centerX`/`centerY`/`scale`; Desk includes optional tuning fields such as
`framingTightness`.
The TS stream is muxed on-device (hand-rolled PAT/PMT/PES/TS with PCR and
encoder PTS; MediaMuxer has no TS support on API 28). SPS/PPS are re-sent
before every IDR, so players can join mid-stream. This is the endpoint that
behaves in players — raw elementary streams make them flaky:
```sh
curl -k -H "Authorization: Bearer <token>" https://<portal-ip>:5654/stream.ts | ffplay -
```
Watch directly with ffplay or mpv (accepting pinned cert or with bearer token):
```sh
curl -k -H "Authorization: Bearer <token>" https://<portal-ip>:5654/video.h264 | \
ffplay -framerate 30 -probesize 500k -analyzeduration 500ms -fflags nobuffer -flags low_delay -f h264 -
```
-f h264 http://<portal-ip>:5654/video.h264
ffplay -nodisp -probesize 50k -analyzeduration 200ms -f aac http://<portal-ip>:5654/audio.aac
```
Both at once, one window (live remux to MPEG-TS; wallclock timestamps keep the
muxer happy since raw H.264 carries no timestamps):
```sh
ffmpeg -use_wallclock_as_timestamps 1 -f h264 -i http://<portal-ip>:5654/video.h264 \
-use_wallclock_as_timestamps 1 -f aac -i http://<portal-ip>:5654/audio.aac \
-c copy -f mpegts - | ffplay -
```
Use as a virtual webcam/mic on the computer:
```sh
# Linux (v4l2loopback):
ffmpeg -f h264 -i http://<portal-ip>:5654/video.h264 -pix_fmt yuv420p -f v4l2 /dev/video2
ffmpeg -f aac -i http://<portal-ip>:5654/audio.aac -f pulse portal-mic
# macOS: add a Media Source in OBS pointing at /video.h264 (uncheck "local file"),
# then start OBS Virtual Camera.
```
Multiple clients are supported; a slow video client is dropped rather than
allowed to corrupt its H.264 stream. New video clients are sent cached SPS/PPS
and a fresh IDR is requested from the encoder.
(USB gadget / UVC webcam mode is not possible on this device: Android 9 has no
UVC function in UsbDeviceManager, the kernel exposes no configfs gadget, and
configuring it would require root on a locked bootloader.)
## Smart Camera Kotlin library (`smartcamera/`)
Drop-in client wrapping the raw binder protocol below. Requires only
`kotlinx-coroutines` and the two manifest permissions.
```kotlin
val camera = SmartCameraController(context, lifecycleScope)
camera.start() // binds both services, subscribes mode + crop
camera.state.collect { s -> // connection, current mode, live crop window
Log.i("cam", "${s.connection} mode=${s.mode} crop=${s.cropWindow}")
}
camera.desk.activate() // per-mode sub-controllers
camera.desk.activate(DeskModeController.Tuning(framingTightness = 0.8f))
camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f)) // ~2x zoom, left third
camera.fixed.pan(dx = 0.05f, dy = 0f)
camera.fixed.zoomBy(1.25f)
camera.auto.activate()
camera.meeting.isActive.collect { active -> ... }
camera.stop()
```
- `SmartCameraController` owns one control session (the service only honors the
top priority-queue connection), keeps death tokens alive, and rebinds on
service death. State comes from `subscribeModeChanges` / `subscribeFrameMetadata("crop")`.
- `ModeController` subclasses (`auto`, `desk`, `meeting`, `fixed`) expose
`isActive` (from the mode subscription) plus mode-specific params; `fixed`
also exposes `appliedCrop` from the crop subscription.
- Only DefaultAuto/Desk/Meeting/Fixed are whitelisted on the external API.
Build and install:
```sh
./build-apk.sh
adb install -r -g portal-capability-test.apk
```
## Reaching the private APIs
Reverse engineering `aiservice.apk` (see `jadx-ai/`) shows the external services
are gated by nothing more than a normal-level manifest permission:
### External services: normal-level manifest permission (SOLVED)
`SmartCameraControlService` / `SmartCameraMetadataService` are protected only by
`android:permission="com.facebook.portal.permission.SMART_CAMERA_CONTROL"` /
`..._METADATA`, both defined by `aiservice` with `protectionLevel="0x0"` (normal).
Any app installed after `aiservice` that declares the uses-permission is granted
it. This app declares them, and all four mode requests are accepted — verified on
device. No exploit needed. No runtime FbPermission check exists on the external
path (`SmartCameraControlService` never calls `SmartCameraIPCPermissionManager`).
Practical notes for the external control service (all learned on device):
- **One connection only.** Each `connect()` creates a `Connection` in a priority
queue; only the top connection is *enabled* and `setMode` on any other is
silently dropped (`Enabled: false; returning...` in logcat). Priority: the
camera-editor package always wins, otherwise the client with the foreground
process wins. Reuse a single session — the app caches its `IControlSession`.
- The crop/zoom applies to Portal's AIService pipeline (calls, photobooth),
not to the app's own raw Camera2 preview.
## Smart Camera external API reference
All discovered by decompiling `aiservice.apk` and verified on device.
Binder wire format notes: every call writes its interface token first;
`ModeSetting` parcels as `writeInt(1) + writeString(name) + writeBundle(params)`;
`MetadataBundle` parcels as a bare `Bundle`; replies are
`readException()` then `readInt()` flag (1 = payload follows).
### Services
| Service class | Bind action | Permission (normal level) |
|---|---|---|
| `SmartCameraControlService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_CONTROL` |
| `SmartCameraMetadataService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_METADATA` |
Both live in package `com.facebook.portal.aiservice` (set it explicitly on the
bind intent).
### Control: `ISmartCameraControlService`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `getVersion()` | — | int (1) |
| 2 | `connect(IBinder deathToken)` | any Binder (strong-ref it client-side!) | `ISmartCameraControlConnection` |
### Control: `ISmartCameraControlConnection`
| # | Method | Notes |
|---|---|---|
| 1 | `close()` | |
| 2 | `requestControls(IControlStateCallback)` | returns `IControlSession`; pass any Binder as the callback (only used to signal "control revoked") |
| 3/4 | `subscribeAvailabilityChanges` / `unsubscribe…` | deprecated; immediately reports available |
| 5 | `setMode(ModeSetting)` | deprecated direct path, same whitelist as sessions |
| 6 | `isSessionPropertySupported(String)` | always false |
| 7 | `setSessionProperties(...)` | throws (unimplemented) |
### Control: `IControlSession` (from `requestControls`)
| # | Method | Notes |
|---|---|---|
| 1 | `close()` | closes the whole connection |
| 3 | `setMode(ModeSetting)` | the call this app uses; whitelist enforced |
| 4 | `isSessionPropertySupported(String)` | always false |
| 5 | `setSessionProperties(...)` | delegates, then throws |
### Modes (`ModeSetting_<name>` + Bundle params)
Whitelist (all an external caller may set): **DefaultAuto, Desk, Meeting, Fixed**.
| Mode | Bundle params | Effect |
|---|---|---|
| `DefaultAuto` | — | normal auto tracking |
| `Meeting` | — | group framing; forces `PERFORMANCE_NO_TRACKER` resource profile |
| `Desk` | all optional floats: `additional_stable_framing_tightness` (zoom-like framing tightness), `tracking_response_delay_percentage`, `tracking_sensitivity_percentage`, `transition_speed_percentage` | desk framing; mobileconfig defaults when absent; same resource profile as Meeting |
| `Fixed` | **required**: `camera.relative_crop_center_x` / `_y` (float, normalized), `camera.relative_crop_scale` (float, window size vs full frame: 1.0 = full, 0.5 ≈ 2x zoom), `camera.relative_exposure_region` (RectF, nullable), `camera.relative_face_metering_regions` (ArrayList<RectF>, nullable) | static crop, no tracking; missing keys throw `Missing key` server-side; keep center within `[scale/2, 1-scale/2]` |
Internal-only modes (whitelist-blocked externally, see "gated" section):
`Spotlight` (`person_id` int — follow one tracked person; reads back as
`BasicSpotlight`), `SpotlightStoryTime` (`person_id` + optional
`target_rect_landscape/portrait` RectF + `target_regions` = `head`/`shoulders`
list), `FullWide`, `Storytime`, `NoOp`.
### Metadata: `ISmartCameraMetadataService`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `getVersion()` | — | int |
| 2 | `connect(IBinder deathToken)` | any Binder | `ISmartCameraMetadataConnection` |
### Metadata: `ISmartCameraMetadataConnection`
| # | Method | Args | Returns |
|---|---|---|---|
| 1 | `close()` | | |
| 2 | `getMode()` | — | current `ModeSetting` or null |
| 3 | `subscribeModeChanges(IModeListener)` | listener binder | current mode + push on every change |
| 4 | `unsubscribeModeChanges(...)` | | |
| 5 | `getFrameMetadata(List<String> topics)` | topic names | `MetadataBundle` snapshot |
| 6 | `subscribeFrameMetadata(IStreamingMetadataReceiver, List<String> topics, float rateHz)` | receiver + topics + rate | initial snapshot + push at rate |
| 7 | `unsubscribeFrameMetadata(...)` | | |
Callback binder stubs (implement `onTransact` code 1; skip token via
`readInt(); readString()`, then `readInt()` flag, then payload):
- `IModeListener` (`com.facebook.portal.smartcamera.external.metadata.IModeListener`):
payload = `ModeSetting`
- `IStreamingMetadataReceiver` (`com.facebook.portal.smartcamera.metadata.IStreamingMetadataReceiver`):
payload = `MetadataBundle`
External frame-metadata topics: `crop` (RectF — the live crop window; watch it
to *see* tracking happen), `frame_orientation`, `frame_rotation`,
`effective_device_rotation`, `full_fov_aspect_ratio`.
Unknown topics throw `IllegalArgumentException: Unknown frame metadata topic`.
### Person-selection modes are gated to Meta-signed apps
The internal-only modes listed in the mode table above are gated two ways:
1. The **external** control service hardcodes a whitelist in
`AidlConnection.setMode` (`ModeSetting.A02` = DefaultAuto/Desk/Fixed/
Meeting) applied to *every* caller; bytecode inspection confirms the
session path (`IControlSession.setMode`) delegates to that same checked
method, so there is no bypass. Other modes fail with
`IllegalArgumentException: Mode not supported`.
2. The **internal** control service (`SMART_CAMERA_INTERNAL_CONTROL_SERVICE`)
has *no* whitelist — its `setMode` accepts every mode — but every method
first calls `SmartCameraIPCPermissionManager
.enforceAccessSmartCameraControlPermission()`, which requires the
caller's package to declare the
`com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_*`
meta-data **and be signed with Meta's key**. A sideloaded app cannot
satisfy the signature check, so Spotlight/person-selection is
unreachable. (Valid `person_id`s would also require the internal
world-metadata topic `person_ids`.)
(Two apparent Spotlight successes observed during testing were not
reproducible under controlled conditions; `BasicSpotlight` is also what the
Portal's own auto-tracking reports as the current mode when it follows
someone, which likely explains the readings.)
**Tracked-people data is likewise internal-only.** The world-model topics —
`person_count`, `person_ids`, `person_biometric_ids`, `person_distance_ft`,
`person_alignment_ratio`, `is_hand_waving`, `is_arm_raised`,
`is_voice_activity_detected`, `update_time_ms` — are served by
`getWorldMetadata` / `subscribeWorldMetadata` (transacts 8/9), which exist only
on the internal metadata connection (signature-gated). The external binder stub
only dispatches transactions 1-7, so there is no transaction-number trick to
reach them.
### Internal services: out of scope
`SmartCameraInternalControlService`, `SmartCameraInternalMetadataService` and
`AiInternalControlService` enforce a runtime FbPermission check requiring Meta's
signing key, so they are not reachable from a sideloaded app and are not used
here.
## Notes
- The `camera_denied_package=com.portaltv.capability` flag seen in
`platform_state_service` output is set by Portal's privacy layer the first
time the app opens the camera without the privacy system expecting it; it does
not affect the Smart Camera service path.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Build portal-capability-test.apk from src/ with the plain SDK toolchain
# (javac -> d8 -> aapt2 -> zipalign -> apksigner). No Gradle.
set -euo pipefail
cd "$(dirname "$0")"
SDK="$HOME/Library/Android/sdk"
PLATFORM="$SDK/platforms/android-35/android.jar"
BT="$SDK/build-tools/37.0.0"
OUT=portal-capability-test.apk
rm -rf build/classes build/apk build/compiled-res.zip build/unaligned.apk build/aligned.apk
mkdir -p build/classes build/apk
echo "== kotlinc =="
KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc"
KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib"
KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar"
KOTLIN_STDLIB_BASE="$KOTLIN_LIB/kotlin-stdlib.jar"
COROUTINES_CORE="$KOTLIN_LIB/kotlinx-coroutines-core-jvm.jar"
# Android Main dispatcher (optional at compile; needed at runtime for Dispatchers.Main)
COROUTINES_ANDROID=$(find "$HOME/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-android" -name 'kotlinx-coroutines-android-*.jar' 2>/dev/null | sort -V | tail -1)
if [[ -z "${COROUTINES_ANDROID}" || ! -f "${COROUTINES_ANDROID}" ]]; then
echo "error: kotlinx-coroutines-android jar not found under ~/.gradle/caches" >&2
exit 1
fi
KT_CP="$PLATFORM:$COROUTINES_CORE:$COROUTINES_ANDROID"
"$KOTLIN" -cp "$KT_CP" -d build/kotlin.jar \
$(find src smartcamera/src -name '*.kt')
echo "== javac =="
javac --release 8 -classpath "$PLATFORM:build/kotlin.jar" \
-d build/classes $(find src -name '*.java')
echo "== d8 =="
"$BT/d8" --lib "$PLATFORM" --min-api 28 --output build/apk \
$(find build/classes -name '*.class') build/kotlin.jar \
"$KOTLIN_STDLIB" "$KOTLIN_STDLIB_BASE" "$COROUTINES_CORE" "$COROUTINES_ANDROID"
echo "== aapt2 compile+link =="
"$BT/aapt2" compile --dir res -o build/compiled-res.zip
"$BT/aapt2" link -o build/unaligned.apk \
-I "$PLATFORM" \
--manifest AndroidManifest.xml \
--java src \
build/compiled-res.zip
# aapt2 --java regenerates R.java under src/; we don't use R, ignore it.
# Repack dex into the apk.
cp build/unaligned.apk build/withdex.apk
( cd build/apk && zip -q -u ../withdex.apk *.dex )
echo "== zipalign + sign =="
"$BT/zipalign" -f 4 build/withdex.apk build/aligned.apk
"$BT/apksigner" sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--out "$OUT" build/aligned.apk
echo "built $OUT"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
./build-apk.sh
echo "== Installing APK via ADB =="
adb install -r portal-capability-test.apk
echo "== Starting MainActivity / PortalStreamingService =="
adb shell am start -n com.portaltv.capability/.MainActivity
echo "== Done =="
@@ -0,0 +1 @@
<resources><style name="AppTheme" parent="android:style/Theme.Material.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:colorAccent">#80cbc4</item></style></resources>
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Standalone test runner for PortalSrp and security mechanisms on Android/JVM
set -euo pipefail
cd "$(dirname "$0")"
# Locate kotlinc
if [ -x "/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" ]; then
KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc"
KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib"
elif command -v kotlinc >/dev/null 2>&1; then
KOTLIN="$(command -v kotlinc)"
KOTLIN_LIB="$(dirname "$KOTLIN")/../lib"
else
echo "Error: kotlinc compiler not found" >&2
exit 1
fi
KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib.jar"
KOTLIN_STDLIB_JDK8="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar"
BUILD_DIR="build/test-classes"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
echo "== [1/3] Compiling JVM Base64 adapter =="
javac -d "$BUILD_DIR" test/android/util/Base64.java
echo "== [2/3] Compiling PortalSrp, PortalSrpClient, and test suites with kotlinc =="
"$KOTLIN" \
-cp "$BUILD_DIR" \
-d "$BUILD_DIR" \
src/com/portaltv/capability/PortalSrp.kt \
src/com/portaltv/capability/PortalSrpClient.kt \
$(find test -name '*.kt')
echo "== [3/3] Running Portal SRP-6a Unit & Integration Test Suites =="
CP="$BUILD_DIR:$KOTLIN_STDLIB"
if [ -f "$KOTLIN_STDLIB_JDK8" ]; then
CP="$CP:$KOTLIN_STDLIB_JDK8"
fi
java -cp "$CP" com.portaltv.capability.test.TestRunnerKt "$@"
@@ -0,0 +1,117 @@
package com.portaltv.smartcamera
import android.graphics.RectF
import android.os.Bundle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Controls one Smart Camera mode and exposes whether it is currently active
* (tracked via the controller's mode-change subscription).
*
* Note: the external API whitelists DefaultAuto, Desk, Meeting and Fixed only;
* other modes (Spotlight, FullWide, Storytime) are rejected server-side.
*/
abstract class ModeController(protected val controller: SmartCameraController) {
/** Full AIDL mode name, e.g. "ModeSetting_Desk". */
abstract val modeName: String
/** True while the camera pipeline reports this mode as current. */
val isActive: Flow<Boolean> =
controller.state.map { it.mode == modeName }.distinctUntilChanged()
/** Switch the camera to this mode. Returns false if the call could not be delivered. */
abstract suspend fun activate(): Boolean
}
/** DefaultAuto: Portal's standard auto-framing/tracking. No parameters. */
class AutoModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_DefaultAuto"
override suspend fun activate(): Boolean = controller.setMode(modeName)
}
/** Meeting: static wide framing for conference calls. No parameters. */
class MeetingModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Meeting"
override suspend fun activate(): Boolean = controller.setMode(modeName)
}
/**
* Desk: close-up framing of a desk area with tracking.
* All tuning values are optional; null leaves the service default in place.
*/
class DeskModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Desk"
data class Tuning(
/** Zoom-like tightness of the stable framing. */
val framingTightness: Float? = null,
val trackingResponseDelayPct: Float? = null,
val trackingSensitivityPct: Float? = null,
val transitionSpeedPct: Float? = null,
)
/** Last tuning sent (or defaults). The service does not report these back. */
private val _tuning = MutableStateFlow(Tuning())
val tuning: StateFlow<Tuning> = _tuning.asStateFlow()
override suspend fun activate(): Boolean = activate(_tuning.value)
suspend fun activate(tuning: Tuning): Boolean {
_tuning.value = tuning
val b = Bundle()
tuning.framingTightness?.let { b.putFloat("additional_stable_framing_tightness", it) }
tuning.trackingResponseDelayPct?.let { b.putFloat("tracking_response_delay_percentage", it) }
tuning.trackingSensitivityPct?.let { b.putFloat("tracking_sensitivity_percentage", it) }
tuning.transitionSpeedPct?.let { b.putFloat("transition_speed_percentage", it) }
return controller.setMode(modeName, b)
}
}
/**
* Fixed: static crop of the sensor, no tracking.
*
* [crop] is the requested (clamped) crop; [appliedCrop] is the live crop window
* reported back by the camera pipeline (requires start(trackCrop = true)).
*/
class FixedModeController internal constructor(controller: SmartCameraController) :
ModeController(controller) {
override val modeName = "ModeSetting_Fixed"
private val _crop = MutableStateFlow(CropConfig())
val crop: StateFlow<CropConfig> = _crop.asStateFlow()
val appliedCrop: Flow<RectF?> =
controller.state.map { it.cropWindow }.distinctUntilChanged()
override suspend fun activate(): Boolean = setCrop(_crop.value)
suspend fun setCrop(config: CropConfig): Boolean {
val c = config.clamped()
_crop.value = c
val b = Bundle().apply {
putFloat("camera.relative_crop_center_x", c.centerX)
putFloat("camera.relative_crop_center_y", c.centerY)
putFloat("camera.relative_crop_scale", c.scale)
// Required keys; null = leave to the pipeline.
putParcelable("camera.relative_exposure_region", null)
putParcelableArrayList("camera.relative_face_metering_regions", null)
}
return controller.setMode(modeName, b)
}
/** Moves the crop center by [dx], [dy] (normalized, clamped to the frame). */
suspend fun pan(dx: Float, dy: Float): Boolean =
setCrop(_crop.value.let { it.copy(centerX = it.centerX + dx, centerY = it.centerY + dy) })
/** [factor] > 1 zooms in, < 1 zooms out. */
suspend fun zoomBy(factor: Float): Boolean =
setCrop(_crop.value.let { it.copy(scale = it.scale / factor) })
}
@@ -0,0 +1,65 @@
package com.portaltv.smartcamera
import android.graphics.RectF
/** Failure talking to the Portal Smart Camera service. */
class SmartCameraException(message: String, cause: Throwable? = null) : Exception(message, cause)
enum class ConnectionState { DISCONNECTED, CONNECTING, READY }
/**
* Snapshot of everything the controller tracks.
*
* @property mode full AIDL mode name, e.g. "ModeSetting_Desk". Updated live via
* subscribeModeChanges once the metadata service is connected.
* @property cropWindow live crop window (normalized coordinates) reported by the
* camera pipeline via the "crop" frame-metadata topic. Reflects auto-tracking
* and applied Fixed crops. Null until the first metadata frame arrives.
*/
data class CameraState(
val connection: ConnectionState = ConnectionState.DISCONNECTED,
val mode: String? = null,
val cropWindow: RectF? = null,
) {
val isReady: Boolean get() = connection == ConnectionState.READY
}
/**
* Event-driven control view: mode + locally owned Fixed/Desk params.
* Emitted whenever mode metadata, Fixed crop, or Desk tuning changes.
*/
data class ControlSnapshot(
val connection: ConnectionState = ConnectionState.DISCONNECTED,
/** Full AIDL name, e.g. `ModeSetting_Desk`, or null if unknown. */
val mode: String? = null,
val fixedCrop: CropConfig = CropConfig(),
val deskTuning: DeskModeController.Tuning = DeskModeController.Tuning(),
val appliedCrop: RectF? = null,
) {
/** Short mode name without `ModeSetting_` prefix. */
val shortMode: String?
get() = mode?.removePrefix("ModeSetting_")
}
/**
* Requested crop for Fixed mode. All values normalized to [0, 1].
*
* @property scale 1.0 = full frame, 0.5 ~= 2x zoom.
*/
data class CropConfig(
val centerX: Float = 0.5f,
val centerY: Float = 0.5f,
val scale: Float = 1.0f,
) {
/** Clamps scale to [MIN_SCALE, 1] and keeps the crop window inside the frame. */
fun clamped(): CropConfig {
val s = scale.coerceIn(MIN_SCALE, 1f)
return CropConfig(
centerX.coerceIn(s / 2f, 1f - s / 2f),
centerY.coerceIn(s / 2f, 1f - s / 2f),
s,
)
}
companion object { const val MIN_SCALE = 0.1f }
}
@@ -0,0 +1,389 @@
package com.portaltv.smartcamera
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.RectF
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import com.portaltv.smartcamera.internal.MetadataReceiverBinder
import com.portaltv.smartcamera.internal.ModeListenerBinder
import com.portaltv.smartcamera.internal.Rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Maintains the connection to Portal's Smart Camera services (control + metadata)
* and tracks camera state via subscriptions.
*
* The consuming app must declare these (normal-level) manifest permissions:
* - [PERMISSION_CONTROL]
* - [PERMISSION_METADATA]
*
* Usage:
* ```
* val camera = SmartCameraController(context, lifecycleScope)
* camera.start()
* camera.desk.activate()
* camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f))
* ```
*
* Implementation notes (verified on Portal TV / ripley):
* - Exactly ONE control session is kept: every connect() creates a Connection in
* the service's priority queue and only the top one is enabled; setMode on
* others is silently dropped.
* - Strong references to the death-token binders are kept for the lifetime of
* the controller; if they are GC'd the service kills the connection.
*/
class SmartCameraController(
private val context: Context,
private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "SmartCamera"
const val AISERVICE_PACKAGE = "com.facebook.portal.aiservice"
const val CONTROL_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE"
const val METADATA_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE"
const val PERMISSION_CONTROL = "com.facebook.portal.permission.SMART_CAMERA_CONTROL"
const val PERMISSION_METADATA = "com.facebook.portal.permission.SMART_CAMERA_METADATA"
const val TOPIC_CROP = "crop"
private const val TOKEN_CONTROL_SERVICE =
"com.facebook.portal.smartcamera.external.control.ISmartCameraControlService"
private const val TOKEN_CONTROL_CONNECTION =
"com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection"
private const val TOKEN_CONTROL_SESSION =
"com.facebook.portal.smartcamera.external.control.IControlSession"
private const val TOKEN_METADATA_SERVICE =
"com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService"
private const val TOKEN_METADATA_CONNECTION =
"com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"
private const val TX_CONNECT = 2
private const val TX_REQUEST_CONTROLS = 2
private const val TX_SET_MODE = 3
private const val TX_GET_MODE = 2
private const val TX_SUBSCRIBE_MODE = 3
private const val TX_SUBSCRIBE_FRAME_METADATA = 6
private const val REBIND_DELAY_MS = 1_000L
}
private val _state = MutableStateFlow(CameraState())
val state: StateFlow<CameraState> = _state.asStateFlow()
/** Per-mode sub-controllers. */
val auto = AutoModeController(this)
val desk = DeskModeController(this)
val meeting = MeetingModeController(this)
val fixed = FixedModeController(this)
private val _control = MutableStateFlow(ControlSnapshot())
/**
* Combined mode + Fixed crop + Desk tuning. Prefer this for UI / SSE:
* it updates on AIDL mode changes and on local param writes.
*/
val control: StateFlow<ControlSnapshot> = _control.asStateFlow()
private var controlJob: Job? = null
private val lock = Any()
@Volatile private var started = false
@Volatile private var trackCrop = true
@Volatile private var cropRateHz = 1f
@Volatile private var rebindScheduled = false
private var controlService: IBinder? = null
private var controlConnection: IBinder? = null
private var session: IBinder? = null
private var metaService: IBinder? = null
private var metaConnection: IBinder? = null
// Death tokens handed to the service. MUST stay strongly referenced.
private val controlToken = Binder()
private val metaToken = Binder()
private val sessionCallbackToken = Binder()
private val modeListener = ModeListenerBinder { mode ->
_state.update { it.copy(mode = mode) }
}
private val cropReceiver = MetadataReceiverBinder { b ->
@Suppress("DEPRECATION")
(b.get(TOPIC_CROP) as? RectF)?.let { c -> _state.update { s -> s.copy(cropWindow = c) } }
}
private val controlDeathRecipient = IBinder.DeathRecipient {
Log.w(TAG, "control service died")
invalidateControl()
scheduleRebind()
}
private val metaDeathRecipient = IBinder.DeathRecipient {
Log.w(TAG, "metadata service died")
invalidateMeta()
scheduleRebind()
}
private val controlServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
scope.launch(Dispatchers.IO) { setupControl(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
invalidateControl()
scheduleRebind()
}
}
private val metadataServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
scope.launch(Dispatchers.IO) { setupMetadata(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
invalidateMeta()
scheduleRebind()
}
}
/**
* Binds to both services and starts tracking state. Safe to call once;
* subsequent calls are ignored. Reconnects automatically if the service dies.
*
* @param trackCrop subscribe to the "crop" frame-metadata topic so
* [CameraState.cropWindow] stays up to date.
*/
fun start(trackCrop: Boolean = true, cropRateHz: Float = 1f) {
synchronized(lock) {
if (started) return
started = true
this.trackCrop = trackCrop
this.cropRateHz = cropRateHz
}
_state.update { it.copy(connection = ConnectionState.CONNECTING) }
controlJob?.cancel()
controlJob = scope.launch {
combine(_state, fixed.crop, desk.tuning) { st, crop, tuning ->
ControlSnapshot(
connection = st.connection,
mode = st.mode,
fixedCrop = crop,
deskTuning = tuning,
appliedCrop = st.cropWindow,
)
}
.distinctUntilChanged()
.collect { _control.value = it }
}
bind(CONTROL_ACTION, controlServiceConnection)
bind(METADATA_ACTION, metadataServiceConnection)
}
/** Unbinds everything and resets state. */
fun stop() {
controlJob?.cancel()
controlJob = null
synchronized(lock) {
started = false
controlService = null
controlConnection = null
session = null
metaService = null
metaConnection = null
}
runCatching { context.unbindService(controlServiceConnection) }
runCatching { context.unbindService(metadataServiceConnection) }
_state.value = CameraState()
_control.value = ControlSnapshot()
}
/** One-shot query of the current mode; also refreshes [state]. */
suspend fun refreshMode(): String? = withContext(Dispatchers.IO) {
val conn = synchronized(lock) { metaConnection } ?: return@withContext null
try {
val r = Rpc.call(conn, TX_GET_MODE, TOKEN_METADATA_CONNECTION)
val mode = if (r.readInt() != 0) r.readString() else null
r.recycle()
_state.update { it.copy(mode = mode) }
mode
} catch (t: Throwable) {
Log.w(TAG, "getMode failed", t)
null
}
}
/** Sends setMode, rebuilding the session once on failure. */
internal suspend fun setMode(name: String, params: Bundle = Bundle()): Boolean =
withContext(Dispatchers.IO) {
repeat(2) { attempt ->
val s = ensureSession()
if (s == null) {
if (attempt == 0) delay(REBIND_DELAY_MS) // binding may be in flight
return@repeat
}
try {
val r = Rpc.call(s, TX_SET_MODE, TOKEN_CONTROL_SESSION) {
writeInt(1)
writeString(name)
writeBundle(params)
}
r.recycle()
// Optimistic mode so control/UI update before AIDL callback.
_state.update { it.copy(mode = name) }
return@withContext true
} catch (t: Throwable) {
Log.w(TAG, "setMode($name) failed (attempt ${attempt + 1})", t)
synchronized(lock) { session = null }
}
}
false
}
// ---- connection management (all called on Dispatchers.IO) ----
private fun bind(action: String, conn: ServiceConnection) {
val intent = Intent(action).setPackage(AISERVICE_PACKAGE)
try {
if (!context.bindService(intent, conn, Context.BIND_AUTO_CREATE)) {
Log.w(TAG, "$action not found")
}
} catch (t: Throwable) {
Log.w(TAG, "bind $action failed", t)
}
}
private fun setupControl(service: IBinder) {
try {
service.linkToDeath(controlDeathRecipient, 0)
val sess = connectSession(service)
synchronized(lock) {
controlService = service
session = sess
}
_state.update { it.copy(connection = ConnectionState.READY) }
Log.i(TAG, "control session ready")
} catch (t: Throwable) {
Log.w(TAG, "control setup failed", t)
invalidateControl()
scheduleRebind()
}
}
private fun setupMetadata(service: IBinder) {
try {
service.linkToDeath(metaDeathRecipient, 0)
val connReply = Rpc.call(service, TX_CONNECT, TOKEN_METADATA_SERVICE) {
writeStrongBinder(metaToken)
}
val connection = connReply.readStrongBinder()
connReply.recycle()
if (connection == null) throw SmartCameraException("no metadata connection returned")
synchronized(lock) {
metaService = service
metaConnection = connection
}
subscribeMode(connection)
if (trackCrop) subscribeCrop(connection)
Log.i(TAG, "metadata connection ready")
} catch (t: Throwable) {
Log.w(TAG, "metadata setup failed", t)
invalidateMeta()
scheduleRebind()
}
}
private fun connectSession(service: IBinder): IBinder {
val connReply = Rpc.call(service, TX_CONNECT, TOKEN_CONTROL_SERVICE) {
writeStrongBinder(controlToken)
}
val connection = connReply.readStrongBinder()
connReply.recycle()
if (connection == null) throw SmartCameraException("no control connection returned")
val sessReply = Rpc.call(connection, TX_REQUEST_CONTROLS, TOKEN_CONTROL_CONNECTION) {
writeStrongBinder(sessionCallbackToken)
}
val sess = sessReply.readStrongBinder()
sessReply.recycle()
if (sess == null) throw SmartCameraException("no control session returned")
synchronized(lock) { controlConnection = connection }
return sess
}
private fun ensureSession(): IBinder? {
synchronized(lock) { session }?.let { return it }
val service = synchronized(lock) { controlService } ?: return null
return try {
connectSession(service).also { s -> synchronized(lock) { session = s } }
} catch (t: Throwable) {
Log.w(TAG, "session rebuild failed", t)
invalidateControl()
null
}
}
private fun subscribeMode(connection: IBinder) {
val r = Rpc.call(connection, TX_SUBSCRIBE_MODE, TOKEN_METADATA_CONNECTION) {
writeStrongBinder(modeListener)
}
val current = if (r.readInt() != 0) r.readString() else null
r.recycle()
_state.update { it.copy(mode = current) }
}
private fun subscribeCrop(connection: IBinder) {
val r = Rpc.call(connection, TX_SUBSCRIBE_FRAME_METADATA, TOKEN_METADATA_CONNECTION) {
writeStrongBinder(cropReceiver)
writeStringList(listOf(TOPIC_CROP))
writeFloat(cropRateHz)
}
if (r.readInt() != 0) {
@Suppress("DEPRECATION")
(r.readBundle(javaClass.classLoader)?.get(TOPIC_CROP) as? RectF)?.let { c ->
_state.update { s -> s.copy(cropWindow = c) }
}
}
r.recycle()
}
private fun invalidateControl() {
synchronized(lock) {
controlService = null
controlConnection = null
session = null
}
_state.update { it.copy(connection = if (started) ConnectionState.CONNECTING else ConnectionState.DISCONNECTED) }
}
private fun invalidateMeta() {
synchronized(lock) {
metaService = null
metaConnection = null
}
}
private fun scheduleRebind() {
if (!started || rebindScheduled) return
rebindScheduled = true
scope.launch {
delay(REBIND_DELAY_MS)
rebindScheduled = false
if (started) {
if (synchronized(lock) { controlService == null }) bind(CONTROL_ACTION, controlServiceConnection)
if (synchronized(lock) { metaService == null }) bind(METADATA_ACTION, metadataServiceConnection)
}
}
}
}
@@ -0,0 +1,75 @@
package com.portaltv.smartcamera.internal
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.os.Parcel
import android.util.Log
import com.portaltv.smartcamera.SmartCameraException
/** Raw AIDL helpers for the Smart Camera external interfaces (no generated stubs). */
internal object Rpc {
/**
* Runs a transact with interface [token] and returns the reply Parcel.
* The caller must recycle the reply. Throws on rejection or remote exception.
*/
fun call(binder: IBinder, code: Int, token: String, write: Parcel.() -> Unit = {}): Parcel {
val q = Parcel.obtain()
val r = Parcel.obtain()
try {
q.writeInterfaceToken(token)
q.write()
if (!binder.transact(code, q, r, 0)) {
r.recycle()
throw SmartCameraException("transact code=$code on $token rejected")
}
r.readException()
return r
} finally {
q.recycle()
}
}
}
/**
* Stub for IModeListener: onModeChanged(ModeSetting), where ModeSetting parcels
* as a plain String (the full "ModeSetting_<name>").
*/
internal class ModeListenerBinder(private val callback: (String?) -> Unit) : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (code != 1) {
return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false }
}
try {
data.readInt() // strict-mode header from writeInterfaceToken
data.readString() // interface descriptor
callback(if (data.readInt() != 0) data.readString() else null)
} catch (t: Throwable) {
Log.w("SmartCamera", "mode listener parse error", t)
}
return true
}
}
/**
* Stub for IStreamingMetadataReceiver: onMetadata(MetadataBundle), where
* MetadataBundle parcels as a Bundle keyed by topic name.
*/
internal class MetadataReceiverBinder(private val callback: (Bundle) -> Unit) : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (code != 1) {
return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false }
}
try {
data.readInt() // strict-mode header
data.readString() // interface descriptor
if (data.readInt() != 0) {
data.readBundle(javaClass.classLoader)?.let(callback)
}
} catch (t: Throwable) {
Log.w("SmartCamera", "metadata receiver parse error", t)
}
return true
}
}
@@ -0,0 +1,168 @@
package com.portaltv.capability;
import android.Manifest;
import android.app.*;
import android.os.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.graphics.BitmapFactory;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.*;
import android.util.Size;
import android.view.*;
import android.widget.*;
import java.util.*;
import java.nio.ByteBuffer;
import java.util.concurrent.*;
public class MainActivity extends Activity {
TextView authStatus;
LinearLayout authPanel;
@Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; refreshAuthUi(); Intent i=new Intent(this,PortalStreamingService.class); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); }
@Override protected void onPause(){ PortalStreamingService.activityVisible=false; super.onPause(); }
@Override protected void onStop(){ Intent i=new Intent(this,PortalStreamingService.class); i.setAction("com.portaltv.capability.CLOSE_CLIENTS"); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); super.onStop(); }
TextView log; CameraManager cm; HandlerThread ht; Handler h; CameraDevice cam; ImageReader reader; ImageView preview; IBinder control; IBinder session; IBinder controlToken; IBinder meta; IBinder metaConn; IBinder metaToken; byte[] frameBuf; final java.util.concurrent.atomic.AtomicBoolean frameBusy=new java.util.concurrent.atomic.AtomicBoolean(); int frameCount; float fx=0.5f, fy=0.5f, fs=1.0f;
int screenW,screenH,ctlW=120,logHeaderH=96; boolean controlsExpanded,logExpanded; LinearLayout controlsPanel,controlsContent,logPanel,subFixed,subDesk; TextView subNone; ScrollView logScroll; Button controlsToggle,logToggle; final java.util.Map<String,Button> modeButtons=new java.util.HashMap<>(); String currentMode;
final PortalSmartCamera.StateListener cameraStateListener=state->{
final String mode="ModeSetting_"+state.getMode();
final org.json.JSONObject cfg=state.getConfig();
runOnUiThread(()->{
setCurrentMode(mode);
if("Fixed".equals(state.getMode())&&cfg!=null){
try{
if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX");
if(cfg.has("centerY")) fy=(float)cfg.getDouble("centerY");
if(cfg.has("scale")) fs=(float)cfg.getDouble("scale");
}catch(Exception e){p("state config parse: "+e);}
}
p("Camera state -> "+state.getMode()+" "+cfg);
});
};
final Binder modeListener=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); final String m=data.readInt()!=0?data.readString():null; p("Mode changed (legacy) -> "+(m!=null?m:"(null)")); }catch(Exception e){p("Mode listener parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
final Binder metaReceiver=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); if(data.readInt()!=0){ Bundle b=data.readBundle(getClass().getClassLoader()); String s=""; for(String k:b.keySet()) s+=k+"="+b.get(k)+" "; p("meta: "+s); } }catch(Exception e){p("Meta receiver parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
public void onCreate(Bundle b) { super.onCreate(b); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); buildUi();
PortalSmartCamera.start(this);
PortalSmartCamera.addStateListener(cameraStateListener);
if (Build.VERSION.SDK_INT >= 23 && (checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED || checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)) requestPermissions(new String[]{Manifest.permission.CAMERA,Manifest.permission.RECORD_AUDIO},7);
else startAll();
cm=(CameraManager)getSystemService(CAMERA_SERVICE); ht=new HandlerThread("camera"); ht.start(); h=new Handler(ht.getLooper()); p("Running as uid="+android.os.Process.myUid()); inspect();
Intent service = new Intent(this, PortalStreamingService.class);
if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service);
}
volatile boolean pipelineStarted;
synchronized void ensurePipeline(){ if(pipelineStarted)return; pipelineStarted=true; new Thread(()->{ testCamera("0"); startMicLoop(); startAudioEncoder(); startTsMuxer(); }).start(); p("Media pipeline started on first client"); }
void startAll(){ p("Camera UI driven by PortalSmartCamera state events"); }
void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<VChunk>> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} }
void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue<byte[]> q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} }
byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; }
String deviceIp(){ try{ for(java.net.NetworkInterface ni:java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) for(java.net.InetAddress a:java.util.Collections.list(ni.getInetAddresses())) if(!a.isLoopbackAddress()&&a instanceof java.net.Inet4Address) return a.getHostAddress(); }catch(Exception e){} return "?"; }
// ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ----
static int crcMpeg(byte[] d,int off,int len){ int c=0xFFFFFFFF; for(int i=off;i<off+len;i++){ c^=(d[i]&0xFF)<<24; for(int b=0;b<8;b++) c=(c&0x80000000)!=0?(c<<1)^0x04C11DB7:c<<1; } return c; }
byte[] patPacket(){ byte[] s={0x00,(byte)0xB0,0x0D,0x00,0x01,(byte)0xC1,0x00,0x00,0x00,0x01,(byte)0xF0,0x00}; return tablePacket(0,s); }
byte[] pmtPacket(){ byte[] s={0x02,(byte)0xB0,0x17,0x00,0x01,(byte)0xC1,0x00,0x00,(byte)0xE1,0x01,(byte)0xF0,0x00,0x1B,(byte)0xE1,0x01,(byte)0xF0,0x00,0x0F,(byte)0xE1,0x02,(byte)0xF0,0x00}; return tablePacket(0x1000,s); }
byte[] tablePacket(int pid,byte[] sec){ int crc=crcMpeg(sec,0,sec.length); byte[] full=new byte[sec.length+5]; full[0]=0; System.arraycopy(sec,0,full,1,sec.length); int n=sec.length+1; full[n++]=(byte)(crc>>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List<byte[]> pk=packetize(pid,false,full,0); return pk.get(0); }
void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; writePes(0x102,0xE1,a,false); } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; if(vBase<0) vBase=v.pts; if(v.k){ tsBroadcast(patPacket()); tsBroadcast(pmtPacket()); if(vCsd!=null&&!hasSps(v.d)){ byte[] m=new byte[vCsd.length+v.d.length]; System.arraycopy(vCsd,0,m,0,vCsd.length); System.arraycopy(v.d,0,m,vCsd.length,v.d.length); v=new VChunk(m,true,v.pts); } } writePes(0x101,0xE0,v,true); }catch(Exception e){ p("ts mux: "+e); } } }).start(); }
static boolean hasSps(byte[] d){ for(int i=0;i+4<Math.min(d.length,64);i++){ if(d[i]==0&&d[i+1]==0&&d[i+2]==1&&(d[i+3]&0x1F)==7) return true; if(d[i]==0&&d[i+1]==0&&d[i+2]==0&&d[i+3]==1&&(d[i+4]&0x1F)==7) return true; } return false; }
void tsBroadcast(byte[] d){ synchronized(tsClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<byte[]>> it=tsClients.iterator(); while(it.hasNext()){ if(!it.next().offer(d)) it.remove(); } } }
void writePes(int pid,int sid,VChunk c,boolean isVideo){ if(tsClients.isEmpty()) return; long base=isVideo?vBase:aBase; if(base<0){ if(isVideo) vBase=c.pts; else aBase=c.pts; base=c.pts; } long pts=(c.pts-base)*9/100; java.io.ByteArrayOutputStream pes=new java.io.ByteArrayOutputStream(); pes.write(0); pes.write(0); pes.write(1); pes.write(sid); int pl=isVideo?0:c.d.length+8; pes.write(pl>>8); pes.write(pl); pes.write(0x80); pes.write(0x80); pes.write(5); pes.write((2<<4)|((int)((pts>>30)&7)<<1)|1); pes.write((int)(pts>>22)&0xFF); pes.write((int)(((pts>>15)&0x7F)<<1)|1); pes.write((int)(pts>>7)&0xFF); pes.write((int)((pts&0x7F)<<1)|1); pes.write(c.d,0,c.d.length); for(byte[] p:packetize(pid,isVideo,pes.toByteArray(),pts)) tsBroadcast(p); }
java.util.List<byte[]> packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List<byte[]> out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off<pes.length){ byte[] p=new byte[188]; p[0]=0x47; p[1]=(byte)((first?0x40:0)|((pid>>8)&0x1F)); p[2]=(byte)pid; boolean pcr=first&&isVideo; int room=184-(pcr?8:0); int remain=pes.length-off; int take=Math.min(remain,room); boolean stuff=take<room; int afc=(pcr||stuff)?3:1; int cc=pid==0x101?ccV++&15:pid==0x102?ccA++&15:pid==0?ccPAT++&15:ccPMT++&15; p[3]=(byte)((afc<<4)|cc); int pos=4; if(afc==3){ int afLen=183-take; p[pos++]= (byte)afLen; p[pos++]=(byte)(pcr?0x10:0); if(pcr){ long b=pcr90k; p[pos++]=(byte)(b>>25); p[pos++]=(byte)(b>>17); p[pos++]=(byte)(b>>9); p[pos++]=(byte)(b>>1); p[pos++]=(byte)((b<<7)|0x7E); p[pos++]=0; } while(pos<4+1+afLen) p[pos++]=(byte)0xFF; } System.arraycopy(pes,off,p,pos,take); off+=take; first=false; out.add(p); } return out; }
// ---- end TS muxer ----
void startWebcamServer(){ if(httpSock!=null)return; new Thread(()->{ try{ httpSock=new java.net.ServerSocket(5654); p("Webcam server: http://"+deviceIp()+":5654/ (video=/video.h264 audio=/audio.aac)"); while(true){ final java.net.Socket s=httpSock.accept(); new Thread(()->handleHttp(s)).start(); } }catch(Exception e){p("Webcam server failed: "+e);} }).start(); }
void handleHttp(java.net.Socket s){ try{ s.setTcpNoDelay(true); java.io.BufferedReader in=new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); String line=in.readLine(); if(line==null){s.close();return;} String path=line.split(" ")[1]; while((line=in.readLine())!=null&&!line.isEmpty()){} java.io.OutputStream out=s.getOutputStream();
if(path.equals("/")||path.startsWith("/index")){ String h="<html><body style='background:#111;color:#eee;font-family:monospace'><h3>Portal webcam</h3><b><a style='color:#8af' href='/stream.ts'>/stream.ts</a> (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)</b><br>video: <a style='color:#8af' href='/video.h264'>/video.h264</a> (raw H.264 Annex B)<br>audio: <a style='color:#8af' href='/audio.aac'>/audio.aac</a> (raw AAC ADTS)<br><br>ffplay http://"+deviceIp()+":5654/stream.ts<br>mpv http://"+deviceIp()+":5654/stream.ts<br>vlc http://"+deviceIp()+":5654/stream.ts<br></body></html>"; byte[] b=h.getBytes(); out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes()); out.write(b); out.flush(); s.close(); return; }
if(path.startsWith("/video.h264")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/h264\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); byte[] csd=vCsd; if(csd!=null){ out.write(csd); out.flush(); } if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<VChunk> q=new java.util.concurrent.ArrayBlockingQueue<>(3); vClients.add(q); boolean started=false; try{ while(true){ VChunk c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!vClients.contains(q)) break; continue; } if(!started){ if(!c.k) continue; started=true; } out.write(c.d); out.flush(); } }finally{ vClients.remove(q); } return; }
if(path.startsWith("/stream.ts")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(24); synchronized(tsClients){ if(tsClients.isEmpty()){ vBase=-1; aBase=-1; } tsClients.add(q); } try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!tsClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ tsClients.remove(q); } return; }
if(path.startsWith("/audio.aac")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: audio/aac\r\nCache-Control: no-store\r\n\r\n".getBytes()); out.flush(); java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(256); aClients.add(q); try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!aClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ aClients.remove(q); } return; }
if(path.startsWith("/control/mode")){ String mode=query(path,"mode"); if(mode==null||!mode.matches("DefaultAuto|Desk|Meeting|Fixed")){ reply(out,400,"mode must be DefaultAuto, Desk, Meeting, or Fixed"); return; } setSmartMode(mode); reply(out,200,"mode requested: "+mode); return; }
if(path.startsWith("/control/fixed")){ try{ fx=Float.parseFloat(query(path,"x")); fy=Float.parseFloat(query(path,"y")); fs=Float.parseFloat(query(path,"scale")); setSmartMode("Fixed"); reply(out,200,String.format("fixed requested: x=%.3f y=%.3f scale=%.3f",fx,fy,fs)); }catch(Exception e){ reply(out,400,"x, y, and scale are required numbers"); } return; }
out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes()); out.flush(); s.close();
}catch(Exception e){} try{ s.close(); }catch(Exception e){} }
String query(String path,String key){ int q=path.indexOf('?'); if(q<0)return null; for(String p:path.substring(q+1).split("&")){String[] kv=p.split("=",2);if(kv.length==2&&java.net.URLDecoder.decode(kv[0]).equals(key))return java.net.URLDecoder.decode(kv[1]);}return null; }
void reply(java.io.OutputStream out,int code,String body)throws java.io.IOException{byte[] b=body.getBytes();out.write(("HTTP/1.1 "+code+" OK\r\nContent-Type: text/plain\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes());out.write(b);out.flush();}
public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
void buildUi(){
screenW=getResources().getDisplayMetrics().widthPixels; screenH=getResources().getDisplayMetrics().heightPixels;
LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(0xff101010);
authStatus=new TextView(this); authStatus.setTextColor(0xffffffff); authStatus.setPadding(12,8,12,8); root.addView(authStatus,new LinearLayout.LayoutParams(-1,70));
authPanel=new LinearLayout(this); authPanel.setOrientation(LinearLayout.VERTICAL); root.addView(authPanel,new LinearLayout.LayoutParams(-1,110));
Button revokeAll=new Button(this); revokeAll.setText("Revoke all paired clients"); revokeAll.setOnClickListener(v->{ Intent x=new Intent(this,PortalStreamingService.class); x.setAction("com.portaltv.capability.REVOKE_ALL"); if(Build.VERSION.SDK_INT>=26) startForegroundService(x); else startService(x); }); root.addView(revokeAll,new LinearLayout.LayoutParams(-1,70));
LinearLayout main=new LinearLayout(this); main.setOrientation(LinearLayout.HORIZONTAL); root.addView(main,new LinearLayout.LayoutParams(-1,0,1));
preview=new ImageView(this); preview.setBackgroundColor(0xff202020); preview.setScaleType(ImageView.ScaleType.FIT_CENTER); main.addView(preview,new LinearLayout.LayoutParams(0,-1,1));
controlsPanel=new LinearLayout(this); controlsPanel.setOrientation(LinearLayout.VERTICAL); controlsPanel.setBackgroundColor(0xff303030); controlsPanel.setPadding(8,8,8,8); main.addView(controlsPanel,new LinearLayout.LayoutParams(ctlW,-1));
LinearLayout trow=new LinearLayout(this); controlsToggle=new Button(this); controlsToggle.setText("<"); focusFx(controlsToggle); trow.addView(controlsToggle,new LinearLayout.LayoutParams(-2,84)); controlsPanel.addView(trow); controlsToggle.setOnClickListener(v->toggleControls());
ScrollView cs=new ScrollView(this); controlsContent=new LinearLayout(this); controlsContent.setOrientation(LinearLayout.VERTICAL); controlsContent.setVisibility(View.GONE); cs.addView(controlsContent); controlsPanel.addView(cs,new LinearLayout.LayoutParams(-1,0,1));
TextView ml=new TextView(this); ml.setText("Mode"); ml.setTextColor(0xffffffff); controlsContent.addView(ml);
for(final String mode:new String[]{"DefaultAuto","Desk","Meeting","Fixed"}){ Button x=new Button(this); x.setText(mode); x.setTextColor(0xffffffff); x.setBackgroundColor(C_IDLE); x.setOnClickListener(v->setSmartMode(mode)); x.setOnFocusChangeListener((v,f)->styleModeButton((Button)v,mode)); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; controlsContent.addView(x,lp); modeButtons.put(mode,x); }
FrameLayout subHost=new FrameLayout(this); LinearLayout.LayoutParams slp=new LinearLayout.LayoutParams(-1,-2); slp.topMargin=12; controlsContent.addView(subHost,slp);
subFixed=new LinearLayout(this); subFixed.setOrientation(LinearLayout.VERTICAL); subFixed.setVisibility(View.GONE);
LinearLayout fr1=new LinearLayout(this); LinearLayout fr2=new LinearLayout(this);
String[][] pan={{"<","-0.05","0","1"},{">","0.05","0","1"},{"^","0","-0.05","1"},{"v","0","0.05","1"}}; for(final String[] t:pan){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr1.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
String[][] zm={{"Z+","0","0","0.85"},{"Z-","0","0","1.1765"}}; for(final String[] t:zm){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr2.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
subFixed.addView(fr1); subFixed.addView(fr2); subHost.addView(subFixed);
subDesk=new LinearLayout(this); subDesk.setOrientation(LinearLayout.VERTICAL); subDesk.setVisibility(View.GONE);
for(final float t:new float[]{0.0f,0.5f,1.0f}){ Button x=new Button(this); x.setText("tight "+t); x.setOnClickListener(v->{Bundle b=new Bundle(); b.putFloat("additional_stable_framing_tightness",t); sendMode("ModeSetting_Desk",b,"Desk tight="+t);}); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; subDesk.addView(x,lp); }
subHost.addView(subDesk);
subNone=new TextView(this); subNone.setText("No mode parameters"); subNone.setTextColor(0xffaaaaaa); subNone.setVisibility(View.GONE); subHost.addView(subNone);
logPanel=new LinearLayout(this); logPanel.setOrientation(LinearLayout.VERTICAL); logPanel.setBackgroundColor(0xff282828); root.addView(logPanel,new LinearLayout.LayoutParams(-1,logHeaderH));
LinearLayout hdr=new LinearLayout(this); hdr.setGravity(16); TextView lt=new TextView(this); lt.setText("Log"); lt.setTextColor(0xffffffff); hdr.addView(lt,new LinearLayout.LayoutParams(0,-2,1)); logToggle=new Button(this); logToggle.setText("^"); focusFx(logToggle); hdr.addView(logToggle,new LinearLayout.LayoutParams(-2,-2)); logToggle.setOnClickListener(v->toggleLog()); logPanel.addView(hdr);
log=new TextView(this); log.setTextSize(14); log.setTextColor(0xffeeeeee); logScroll=new ScrollView(this); logScroll.addView(log); logScroll.setVisibility(View.GONE); logPanel.addView(logScroll,new LinearLayout.LayoutParams(-1,0,1));
setContentView(root);
}
void refreshAuthUi(){ if(authPanel==null)return; android.content.SharedPreferences p=getSharedPreferences("auth",MODE_PRIVATE); int av=p.getInt("activeVideo",0), aa=p.getInt("activeAudio",0); authStatus.setText((p.getString("pairingPin","").isEmpty()?"HTTPS ready":"Pairing PIN: "+p.getString("pairingPin",""))+" Active video: "+av+" audio: "+aa); authPanel.removeAllViews(); java.util.Set<String> ts=p.getStringSet("tokens",java.util.Collections.emptySet()); for(String h:ts){ String m=p.getString("client."+h,"unknown"); Button b=new Button(this); b.setText("Revoke "+m.replace('|',' ')); b.setOnClickListener(v->{ java.util.Set<String> n=p.getStringSet("tokens",java.util.Collections.emptySet()); n=new java.util.HashSet<>(n); n.remove(h); p.edit().putStringSet("tokens",n).remove("client."+h).apply(); refreshAuthUi(); }); authPanel.addView(b,new LinearLayout.LayoutParams(-1,60)); } }
Button mkBtn(String t,View.OnClickListener l){ Button x=new Button(this); x.setText(t); x.setOnClickListener(l); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; x.setLayoutParams(lp); return x; }
void toggleControls(){ controlsExpanded=!controlsExpanded; android.view.ViewGroup.LayoutParams lp=controlsPanel.getLayoutParams(); lp.width=controlsExpanded?screenW/5:ctlW; controlsPanel.setLayoutParams(lp); controlsContent.setVisibility(controlsExpanded?View.VISIBLE:View.GONE); controlsToggle.setText(controlsExpanded?">":"<"); }
void toggleLog(){ logExpanded=!logExpanded; android.view.ViewGroup.LayoutParams lp=logPanel.getLayoutParams(); lp.height=logExpanded?screenH/3:logHeaderH; logPanel.setLayoutParams(lp); logScroll.setVisibility(logExpanded?View.VISIBLE:View.GONE); logToggle.setText(logExpanded?"v":"^"); }
static final int C_ACTIVE=0xff2e7d32, C_IDLE=0xff424242, C_FOCUS=0xffff9800;
void styleModeButton(Button b,String mode){ boolean a=("ModeSetting_"+mode).equals(currentMode); if(b.isFocused()){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackgroundColor(a?C_ACTIVE:C_IDLE); b.setTextColor(0xffffffff); } }
void focusFx(Button b){ final android.graphics.drawable.Drawable d=b.getBackground(); final android.content.res.ColorStateList tc=b.getTextColors(); b.setOnFocusChangeListener((v,f)->{ if(f){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackground(d); b.setTextColor(tc); } }); }
void setCurrentMode(String m){ currentMode=m; runOnUiThread(()->{ for(java.util.Map.Entry<String,Button> e:modeButtons.entrySet()) styleModeButton(e.getValue(),e.getKey()); subFixed.setVisibility("ModeSetting_Fixed".equals(currentMode)?View.VISIBLE:View.GONE); subDesk.setVisibility("ModeSetting_Desk".equals(currentMode)?View.VISIBLE:View.GONE); subNone.setVisibility(("ModeSetting_DefaultAuto".equals(currentMode)||"ModeSetting_Meeting".equals(currentMode))?View.VISIBLE:View.GONE); }); }
void p(String x){android.util.Log.d("PortalCap",x); runOnUiThread(()->log.append(String.format("%tT ",System.currentTimeMillis())+x+"\n"));}
void inspect(){ try{ for(String id:cm.getCameraIdList()){CameraCharacteristics c=cm.getCameraCharacteristics(id); StreamConfigurationMap map=c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); p("Camera "+id+": facing="+c.get(CameraCharacteristics.LENS_FACING)+", sensor="+c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)); if(map!=null){Size[] y=map.getOutputSizes(ImageFormat.YUV_420_888); if(y!=null){String z=""; for(Size q:y) if(q.getWidth()>=1280) z+=q+" "; p(" YUV outputs: "+z);} Size[] j=map.getOutputSizes(ImageFormat.JPEG); if(j!=null){String z=""; for(Size q:j) if(q.getWidth()>=1280) z+=q+" "; p(" JPEG outputs: "+z);}} }}catch(Exception e){p("Inspect error: "+e);}}
void testCamera(final String id){
if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;}
if(cam!=null){cam.close();cam=null;} if(reader!=null){reader.close();reader=null;}
p("Opening camera "+id+" at 3840x2160 YUV...");
try {
reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2);
startVideoEncoder();
frameBusy.set(false); frameCount=0;
reader.setOnImageAvailableListener(r->{Image im=r.acquireLatestImage(); if(im==null)return; if(frameBusy.getAndSet(true)){im.close();return;} ByteBuffer bb=im.getPlanes()[0].getBuffer(); int len=bb.remaining(); if(frameBuf==null||frameBuf.length<len)frameBuf=new byte[len]; bb.get(frameBuf,0,len); im.close(); android.graphics.Bitmap bmp=BitmapFactory.decodeByteArray(frameBuf,0,len); runOnUiThread(()->{android.graphics.drawable.Drawable old=preview.getDrawable(); preview.setImageBitmap(bmp); if(old instanceof android.graphics.drawable.BitmapDrawable)((android.graphics.drawable.BitmapDrawable)old).getBitmap().recycle(); frameBusy.set(false);});},h);
cm.openCamera(id,new CameraDevice.StateCallback(){
public void onOpened(CameraDevice c){
cam=c;
try {
CaptureRequest.Builder q=c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); Surface out=reader.getSurface(); q.addTarget(out); if(vencSurface!=null) q.addTarget(vencSurface);
c.createCaptureSession(vencSurface!=null?Arrays.asList(out,vencSurface):Collections.singletonList(out),new CameraCaptureSession.StateCallback(){
public void onConfigured(CameraCaptureSession s){try{s.setRepeatingRequest(q.build(),null,h);p("Camera "+id+" capture started");}catch(Exception e){p("Capture failed: "+e);}}
public void onConfigureFailed(CameraCaptureSession s){p("Camera "+id+" configuration rejected: "+s);}
},h);
} catch(Exception e){p("Camera "+id+" setup failed: "+e);}
}
public void onDisconnected(CameraDevice c){p("Camera "+id+" disconnected");c.close();}
public void onError(CameraDevice c,int e){p("Camera "+id+" error "+e);c.close();}
},h);
} catch(Exception e){p("Camera "+id+" open failed: "+e);}
}
volatile boolean micLoop; Thread micThread;
static class VChunk{ final byte[] d; final boolean k; final long pts; VChunk(byte[] d,boolean k,long pts){this.d=d;this.k=k;this.pts=pts;} }
final java.util.concurrent.BlockingQueue<VChunk> tsV=new java.util.concurrent.ArrayBlockingQueue<>(120);
final java.util.concurrent.BlockingQueue<VChunk> tsA=new java.util.concurrent.ArrayBlockingQueue<>(256);
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> tsClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
int ccV,ccA,ccPAT,ccPMT; long vBase=-1,aBase=-1;
final java.util.Set<java.util.concurrent.BlockingQueue<VChunk>> vClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<VChunk>>());
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
final java.util.concurrent.BlockingQueue<byte[]> pcmIn=new java.util.concurrent.ArrayBlockingQueue<>(128);
volatile byte[] vCsd; MediaCodec venc,aenc; Surface vencSurface; java.net.ServerSocket httpSock;
void startMicLoop(){ if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){p("Microphone permission not granted");return;} if(micThread!=null)return; micLoop=true; micThread=new Thread(()->{ int n=AudioRecord.getMinBufferSize(48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT); AudioRecord ar=null; try{ ar=new AudioRecord(MediaRecorder.AudioSource.DEFAULT,48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,n*2); ar.startRecording(); p("Mic capture started: actualRate="+ar.getSampleRate()+" minBuf="+n+" state="+ar.getState()); byte[] b=new byte[n]; long tWin=System.nanoTime(),bWin=0; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) p("pcmIn FULL, dropped "+got+"B"); bWin+=got; long now=System.nanoTime(); if(now-tWin>5e9){ p("mic rate: "+(bWin*1e9/(now-tWin))+" B/s (expect 96000), queue="+pcmIn.size()); tWin=now; bWin=0; } } } }catch(Exception e){p("Mic capture failed: "+e);} finally{ try{if(ar!=null){ar.stop();ar.release();}}catch(Exception e){} micThread=null; } }); micThread.start(); }
void sendMode(final String modeName,final Bundle b,final String label){ try{ if(modeName.equals("ModeSetting_Desk")&&b!=null&&b.containsKey("additional_stable_framing_tightness")){ PortalSmartCamera.setDeskTightness(b.getFloat("additional_stable_framing_tightness")); p(label+" sent"); return; } String shortName=modeName.startsWith("ModeSetting_")?modeName.substring("ModeSetting_".length()):modeName; if(shortName.equals("Fixed")) PortalSmartCamera.setMode(shortName,fx,fy,fs); else PortalSmartCamera.setMode(shortName); p(label+" sent"); }catch(Exception e){p(label+" failed: "+e);} }
void setSmartMode(String mode){ if(mode.equals("Fixed")) PortalSmartCamera.setMode(mode,fx,fy,fs); else PortalSmartCamera.setMode(mode); }
void nudgeFixed(float dx,float dy,float sm){ fs=Math.min(1,Math.max(0.1f,fs*sm)); fx=Math.min(1-fs/2,Math.max(fs/2,fx+dx)); fy=Math.min(1-fs/2,Math.max(fs/2,fy+dy)); setSmartMode("Fixed"); }
void ensureSession(final Runnable next){ if(control==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){control=b;p("Smart Camera service bound");ensureSession(next);}public void onServiceDisconnected(ComponentName n){control=null;session=null;}},BIND_AUTO_CREATE);p("Binding Smart Camera service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlService");controlToken=new Binder();q.writeStrongBinder(controlToken); if(!control.transact(2,q,r,0)){p("Smart Camera connect rejected");return;} r.readException(); IBinder connection=r.readStrongBinder();if(connection==null){p("No control connection returned");return;} Parcel cr=Parcel.obtain(),co=Parcel.obtain();cr.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection");cr.writeStrongBinder(new Binder());if(!connection.transact(2,cr,co,0)){p("requestControls rejected");return;}co.readException();session=co.readStrongBinder();if(session==null){p("No control session returned");return;} p("Control session established"); next.run(); }catch(Exception e){p("Control connect failed: "+e);} }
void ensureMeta(final Runnable next){ if(meta==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){meta=b;p("Metadata service bound");ensureMeta(next);}public void onServiceDisconnected(ComponentName n){meta=null;metaConn=null;}},BIND_AUTO_CREATE);p("Binding metadata service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService");metaToken=new Binder();q.writeStrongBinder(metaToken); if(!meta.transact(2,q,r,0)){p("Metadata connect rejected");return;} r.readException(); metaConn=r.readStrongBinder();if(metaConn==null){p("No metadata connection returned");return;} p("Metadata connection established"); next.run(); }catch(Exception e){p("Metadata connect failed: "+e);} }
void queryMode(){ if(metaConn==null){ensureMeta(()->queryMode());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"); if(!metaConn.transact(2,q,r,0)){p("getMode call failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Current mode: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("getMode failed: "+e);} }
void watchModes(){ if(metaConn==null){ensureMeta(()->watchModes());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(modeListener); if(!metaConn.transact(3,q,r,0)){p("subscribeModeChanges failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Watching modes; current: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("subscribeModeChanges failed: "+e);} }
void watchCrop(){ if(metaConn==null){ensureMeta(()->watchCrop());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(metaReceiver); ArrayList<String> t=new ArrayList<String>(); t.add("crop"); q.writeStringList(t); q.writeFloat(1.0f); if(!metaConn.transact(6,q,r,0)){p("subscribeFrameMetadata failed");return;} r.readException(); if(r.readInt()!=0){ Bundle b=r.readBundle(getClass().getClassLoader()); p("crop now: "+b.get("crop")); } p("Watching crop @1Hz"); }catch(Exception e){p("subscribeFrameMetadata failed: "+e);} }
protected void onDestroy(){ PortalSmartCamera.removeStateListener(cameraStateListener); micLoop=false;try{if(venc!=null){venc.stop();venc.release();}}catch(Exception e){} try{if(aenc!=null){aenc.stop();aenc.release();}}catch(Exception e){} try{if(httpSock!=null)httpSock.close();}catch(Exception e){} if(cam!=null)cam.close();if(reader!=null)reader.close();if(ht!=null)ht.quitSafely();super.onDestroy();}
}
@@ -0,0 +1,15 @@
package com.portaltv.capability
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
class PortalBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val service = Intent(context, PortalStreamingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(service) else context.startService(service)
}
}
}
@@ -0,0 +1,80 @@
package com.portaltv.capability
import android.content.Context
import android.os.Build
import android.provider.Settings
/** Resolves a human-readable Portal identity for mDNS / UI. */
object PortalDeviceIdentity {
data class Info(val name: String, val model: String) {
/** DNS-SD instance name shown in discovery UIs. */
val serviceName: String
get() {
val n = sanitize(name)
val m = sanitize(model)
return when {
n.isEmpty() && m.isEmpty() -> PortalEndpoints.MDNS_FALLBACK_NAME
n.isEmpty() -> m
m.isEmpty() || n.equals(m, ignoreCase = true) -> n
else -> "$n ($m)"
}
}
}
fun resolve(context: Context): Info {
val model = firstNonBlank(
Build.MODEL,
Build.PRODUCT,
"PortalTV",
)
val name = firstNonBlank(
settings(context, "bluetooth_name"),
settings(context, Settings.Global.DEVICE_NAME),
bluetoothAdapterName(),
model,
)
return Info(name = name, model = model)
}
private fun settings(context: Context, key: String): String? =
try {
Settings.Secure.getString(context.contentResolver, key)
?: Settings.Global.getString(context.contentResolver, key)
} catch (_: Exception) {
null
}
private fun bluetoothAdapterName(): String? =
try {
@Suppress("DEPRECATION")
android.bluetooth.BluetoothAdapter.getDefaultAdapter()?.name
} catch (_: Exception) {
null
}
private fun firstNonBlank(vararg values: String?): String =
values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty()
/** DNS-SD instance names: printable, ≤63 bytes, no dots (NsdManager quirk). */
fun sanitize(raw: String): String {
val cleaned = buildString(raw.length) {
for (ch in raw.trim()) {
when {
ch.isLetterOrDigit() || ch == ' ' || ch == '-' || ch == '_' || ch == '(' || ch == ')' ->
append(ch)
ch == '.' || ch == ',' || ch == ':' || ch == '/' ->
append(' ')
else -> Unit
}
}
}.replace(Regex("\\s+"), " ").trim()
if (cleaned.isEmpty()) return ""
val bytes = cleaned.toByteArray(Charsets.UTF_8)
if (bytes.size <= 63) return cleaned
var end = cleaned.length
while (end > 0 && cleaned.substring(0, end).toByteArray(Charsets.UTF_8).size > 63) {
end--
}
return cleaned.substring(0, end).trimEnd()
}
}
@@ -0,0 +1,13 @@
package com.portaltv.capability
/** Shared HTTPS / DNS-SD endpoints for PortalCam. */
object PortalEndpoints {
/** "TV" as ASCII little-endian nibble joke → decimal 5654. */
const val PORT = 5654
/** DNS-SD service type (trailing dot required by NsdManager). */
const val MDNS_TYPE = "_portalcam._tcp."
/** Fallback instance name when device identity is unavailable. */
const val MDNS_FALLBACK_NAME = "PortalCam"
}
@@ -0,0 +1,72 @@
package com.portaltv.capability
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/** Registers the Portal HTTPS endpoint on the LAN via DNS-SD / mDNS. */
class PortalMdns(context: Context) {
private val appContext = context.applicationContext
private val nsd = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager
@Volatile private var registered: NsdServiceInfo? = null
@Volatile private var registering = false
private val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) {
registered = info
registering = false
Log.i(TAG, "mDNS registered ${info.serviceName} ${info.serviceType}:${info.port}")
}
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
registering = false
Log.e(TAG, "mDNS registration failed code=$errorCode name=${info.serviceName}")
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
registered = null
Log.i(TAG, "mDNS unregistered ${info.serviceName}")
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS unregistration failed code=$errorCode")
}
}
fun register(port: Int = PortalEndpoints.PORT) {
if (registered != null || registering) return
registering = true
val identity = PortalDeviceIdentity.resolve(appContext)
val info = NsdServiceInfo().apply {
serviceName = identity.serviceName
serviceType = PortalEndpoints.MDNS_TYPE
setPort(port)
setAttribute("model", identity.model)
setAttribute("name", identity.name)
}
Log.i(TAG, "mDNS registering as \"${identity.serviceName}\" (name=${identity.name} model=${identity.model})")
try {
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (e: Exception) {
registering = false
Log.e(TAG, "mDNS registerService threw", e)
}
}
fun unregister() {
val info = registered
registered = null
registering = false
if (info == null) return
try {
nsd.unregisterService(listener)
} catch (e: Exception) {
Log.w(TAG, "mDNS unregister failed", e)
}
}
companion object {
private const val TAG = "PortalMdns"
}
}
@@ -0,0 +1,229 @@
package com.portaltv.capability
import android.content.Context
import com.portaltv.smartcamera.ControlSnapshot
import com.portaltv.smartcamera.CropConfig
import com.portaltv.smartcamera.DeskModeController
import com.portaltv.smartcamera.SmartCameraController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.util.concurrent.CopyOnWriteArrayList
/**
* Process-wide Smart Camera handle backed by [SmartCameraController].
* Shared by [PortalStreamingService] (HTTPS /control + SSE) and [MainActivity] (TV UI).
*
* State is event-driven from [SmartCameraController.control]; mutations ack only —
* observe [states] / [StateListener] for updates.
*/
object PortalSmartCamera {
private const val TIMEOUT_MS = 8_000L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Volatile private var camera: SmartCameraController? = null
private var collectJob: Job? = null
@Volatile private var latest: State = State("DefaultAuto", JSONObject())
private val _states = MutableSharedFlow<State>(replay = 1, extraBufferCapacity = 16)
/** Hot stream of camera state for SSE / coroutines. Replay=1 → new collectors get latest. */
val states: SharedFlow<State> = _states.asSharedFlow()
private val listeners = CopyOnWriteArrayList<StateListener>()
fun interface StateListener {
fun onState(state: State)
}
data class State(val mode: String, val config: JSONObject) {
fun toJson(): String = JSONObject()
.put("mode", mode)
.put("config", config)
.toString()
/** Content equality — [JSONObject] is identity-based by default. */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is State) return false
return mode == other.mode && config.toString() == other.config.toString()
}
override fun hashCode(): Int = 31 * mode.hashCode() + config.toString().hashCode()
}
sealed class Outcome {
data object Ack : Outcome() {
fun toJson(): String = JSONObject().put("ok", true).toString()
}
data class Err(val code: String, val message: String, val httpStatus: Int = 500) : Outcome() {
fun toJson(): String = JSONObject()
.put("error", code)
.put("message", message)
.toString()
}
}
@JvmStatic
fun start(context: Context) {
if (camera != null) return
synchronized(this) {
if (camera != null) return
val c = SmartCameraController(context.applicationContext, scope).also {
it.start(trackCrop = true)
}
camera = c
collectJob?.cancel()
collectJob = scope.launch {
c.control
.map { it.toPortalState() }
.distinctUntilChanged()
.collect { publish(it) }
}
}
}
@JvmStatic
fun currentState(): State = latest
/** Current state as JSON (one-shot; prefer SSE `/control/events`). */
@JvmStatic
fun stateJsonBlocking(): String = latest.toJson()
@JvmStatic
fun addStateListener(listener: StateListener) {
listeners.add(listener)
listener.onState(latest)
}
@JvmStatic
fun removeStateListener(listener: StateListener) {
listeners.remove(listener)
}
@JvmStatic
fun applyModeBlocking(mode: String): Outcome = mutateBlocking {
applyMode(mode, centerX = null, centerY = null, scale = null)
}
@JvmStatic
fun applyModeBlocking(
mode: String,
centerX: Float,
centerY: Float,
scale: Float,
): Outcome = mutateBlocking {
applyMode(mode, centerX, centerY, scale)
}
@JvmStatic
fun applyDeskTightnessBlocking(tightness: Float): Outcome = mutateBlocking {
applyDeskTightness(tightness)
}
/** Fire-and-forget for TV UI buttons. State arrives via [StateListener]. */
@JvmStatic
@JvmOverloads
fun setMode(mode: String, centerX: Float = 0.5f, centerY: Float = 0.5f, scale: Float = 1f) {
scope.launch {
runCatching {
if (mode.removePrefix("ModeSetting_") == "Fixed") {
applyMode(mode, centerX, centerY, scale)
} else {
applyMode(mode, null, null, null)
}
}.onFailure { android.util.Log.e("PortalSmartCamera", "setMode failed", it) }
}
}
@JvmStatic
fun setDeskTightness(tightness: Float) {
scope.launch {
runCatching { applyDeskTightness(tightness) }
.onFailure { android.util.Log.e("PortalSmartCamera", "setDeskTightness failed", it) }
}
}
private fun mutateBlocking(block: suspend () -> Unit): Outcome = runCatching {
runBlocking {
withTimeout(TIMEOUT_MS) { block() }
}
Outcome.Ack
}.getOrElse { e ->
Outcome.Err("set_mode_failed", e.message ?: e.toString())
}
private suspend fun applyMode(
mode: String,
centerX: Float?,
centerY: Float?,
scale: Float?,
) {
val c = camera ?: error("smart camera not started")
val short = mode.removePrefix("ModeSetting_")
val ok = when (short) {
"DefaultAuto" -> c.auto.activate()
"Desk" -> c.desk.activate(c.desk.tuning.value)
"Meeting" -> c.meeting.activate()
"Fixed" -> {
val crop = CropConfig(
centerX ?: c.fixed.crop.value.centerX,
centerY ?: c.fixed.crop.value.centerY,
scale ?: c.fixed.crop.value.scale,
).clamped()
c.fixed.setCrop(crop)
}
else -> error("mode must be DefaultAuto, Desk, Meeting, or Fixed")
}
if (!ok) error("setMode($short) was not accepted by Smart Camera")
}
private suspend fun applyDeskTightness(tightness: Float) {
val c = camera ?: error("smart camera not started")
val ok = c.desk.activate(DeskModeController.Tuning(framingTightness = tightness))
if (!ok) error("setMode(Desk) was not accepted by Smart Camera")
}
private fun publish(state: State) {
latest = state
_states.tryEmit(state)
for (l in listeners) {
runCatching { l.onState(state) }
.onFailure { android.util.Log.w("PortalSmartCamera", "listener failed", it) }
}
}
private fun ControlSnapshot.toPortalState(): State {
val short = shortMode?.takeIf { it in MODES } ?: latest.mode.takeIf { it in MODES } ?: "DefaultAuto"
val config = JSONObject()
when (short) {
"Fixed" -> {
config.putRounded("centerX", fixedCrop.centerX)
config.putRounded("centerY", fixedCrop.centerY)
config.putRounded("scale", fixedCrop.scale)
}
"Desk" -> {
deskTuning.framingTightness?.let { config.putRounded("framingTightness", it) }
deskTuning.trackingResponseDelayPct?.let { config.putRounded("trackingResponseDelayPct", it) }
deskTuning.trackingSensitivityPct?.let { config.putRounded("trackingSensitivityPct", it) }
deskTuning.transitionSpeedPct?.let { config.putRounded("transitionSpeedPct", it) }
}
}
return State(short, config)
}
private fun JSONObject.putRounded(key: String, value: Float) {
put(key, Math.round(value * 1000.0) / 1000.0)
}
private val MODES = setOf("DefaultAuto", "Desk", "Meeting", "Fixed")
}
@@ -0,0 +1,198 @@
package com.portaltv.capability
import android.util.Base64
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
object PortalSrp {
// RFC 5054 2048-bit prime
private const val N_HEX =
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" +
"020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" +
"4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" +
"98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" +
"9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" +
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" +
"3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF"
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
val k: BigInteger
init {
val nBytes = toPadded256(N)
val gBytes = toPadded256(g)
k = BigInteger(1, sha256(nBytes, gBytes))
}
private val random = SecureRandom()
data class ActivePairing(
val id: String,
val pin: String,
val salt: ByteArray,
val v: BigInteger,
val privB: BigInteger,
val pubB: BigInteger,
val expiresAt: Long,
var attemptsLeft: Int = 3
)
fun toPadded256(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(256)
if (raw.size > 256) {
System.arraycopy(raw, raw.size - 256, result, 0, 256)
} else {
System.arraycopy(raw, 0, result, 256 - raw.size, raw.size)
}
return result
}
fun sha256(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-256")
for (p in parts) md.update(p)
return md.digest()
}
fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
fun isValidPublicA(A: BigInteger): Boolean = A.mod(N) != BigInteger.ZERO
fun isValidPublicB(B: BigInteger): Boolean = B.mod(N) != BigInteger.ZERO
fun isValidScrambler(u: BigInteger): Boolean = u != BigInteger.ZERO
fun newPairing(pin: String): ActivePairing {
val id = Base64.encodeToString(
ByteArray(12).also { random.nextBytes(it) },
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
)
val salt = ByteArray(16).also { random.nextBytes(it) }
// x = SHA256(salt || PIN)
val xBytes = sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// v = g^x mod N
val v = g.modPow(x, N)
// b = random 256-bit BigInteger, ensuring B mod N != 0
var b: BigInteger
var B: BigInteger
do {
val bBytes = ByteArray(32).also { random.nextBytes(it) }
b = BigInteger(1, bBytes).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE)
val gb = g.modPow(b, N)
B = k.multiply(v).add(gb).mod(N)
} while (!isValidPublicB(B))
return ActivePairing(
id = id,
pin = pin,
salt = salt,
v = v,
privB = b,
pubB = B,
expiresAt = System.currentTimeMillis() + 120_000L, // 2 minutes
attemptsLeft = 3
)
}
sealed class VerifyResult {
data class Success(val M2: ByteArray, val token: String) : VerifyResult()
data class Failed(val attemptsLeft: Int, val message: String) : VerifyResult()
}
fun verifyClient(
pairing: ActivePairing,
A_hex: String,
M1_hex: String,
tlsHash: ByteArray
): VerifyResult {
if (System.currentTimeMillis() > pairing.expiresAt) {
return VerifyResult.Failed(0, "Pairing session expired")
}
if (pairing.attemptsLeft <= 0) {
return VerifyResult.Failed(0, "Too many failed attempts; pairing cancelled")
}
val A = try {
BigInteger(A_hex, 16)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid client public key format")
}
// A mod N != 0
if (!isValidPublicA(A)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid public key A")
}
val M1 = try {
hexToBytes(M1_hex)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid M1 format")
}
val A_bytes = toPadded256(A)
val B_bytes = toPadded256(pairing.pubB)
// u = SHA256(PAD(A) || PAD(B))
val uBytes = sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
if (!isValidScrambler(u)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Scrambler u is zero")
}
// Server computes S = (A * v^u mod N)^b mod N
val vu = pairing.v.modPow(u, N)
val S = A.multiply(vu).mod(N).modPow(pairing.privB, N)
val S_bytes = toPadded256(S)
// K = SHA256(PAD(S))
val K = sha256(S_bytes)
// Expected M1 = SHA256(PAD(A) || PAD(B) || K || salt || tlsHash)
val expectedM1 = sha256(A_bytes, B_bytes, K, pairing.salt, tlsHash)
if (!constantTimeEquals(M1, expectedM1)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Authentication failed (wrong PIN or MITM detected)")
}
// M2 = SHA256(PAD(A) || M1 || K || tlsHash)
val M2 = sha256(A_bytes, M1, K, tlsHash)
// Generate cryptographically secure bearer token
val tokenBytes = ByteArray(32).also { random.nextBytes(it) }
val token = Base64.encodeToString(tokenBytes, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
return VerifyResult.Success(M2 = M2, token = token)
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
@@ -0,0 +1,105 @@
package com.portaltv.capability
import java.math.BigInteger
import java.security.SecureRandom
/**
* SRP-6a client implementation matching RFC 5054 2048-bit MODP group
* with cryptographic TLS channel binding.
*
* Compatible with PortalCam client (PortalSrpClient.swift) and PortalSrp server.
*/
class PortalSrpClient(
customA: BigInteger? = null,
private val random: SecureRandom = SecureRandom()
) {
@get:JvmName("getPrivateA")
val a: BigInteger
@get:JvmName("getPublicA")
val A: BigInteger
var K: ByteArray? = null
private set
var M1: ByteArray? = null
private set
private var tlsCertHash: ByteArray? = null
init {
if (customA != null) {
a = customA
} else {
val aBytes = ByteArray(32).also { random.nextBytes(it) }
a = BigInteger(1, aBytes).mod(PortalSrp.N.subtract(BigInteger.valueOf(2))).add(BigInteger.ONE)
}
A = PortalSrp.g.modPow(a, PortalSrp.N)
}
val pubAHex: String
get() = PortalSrp.bytesToHex(PortalSrp.toPadded256(A))
/**
* Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
* Enforces safety checks: B mod N != 0 and u != 0.
*/
fun computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: ByteArray): String {
val salt = PortalSrp.hexToBytes(saltHex)
require(salt.isNotEmpty()) { "Invalid salt hex" }
val bBytes = PortalSrp.hexToBytes(pubBHex)
val B = BigInteger(1, bBytes)
// Safety check B % N != 0
require(PortalSrp.isValidPublicB(B)) { "Server public value B % N == 0" }
// u = SHA256(pad256(A) || pad256(B))
val uBytes = PortalSrp.sha256(PortalSrp.toPadded256(A), PortalSrp.toPadded256(B))
val u = BigInteger(1, uBytes)
require(PortalSrp.isValidScrambler(u)) { "Computed u == 0" }
// x = SHA256(salt || UTF8(pin))
val xBytes = PortalSrp.sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = PortalSrp.g.modPow(x, PortalSrp.N)
val kgx = PortalSrp.k.multiply(gx).mod(PortalSrp.N)
val base = B.subtract(kgx).mod(PortalSrp.N)
val exp = a.add(u.multiply(x))
val S = base.modPow(exp, PortalSrp.N)
// K = SHA256(pad256(S))
val sessionK = PortalSrp.sha256(PortalSrp.toPadded256(S))
this.K = sessionK
this.tlsCertHash = tlsCertSha256
// M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256)
val clientM1 = PortalSrp.sha256(
PortalSrp.toPadded256(A),
PortalSrp.toPadded256(B),
sessionK,
salt,
tlsCertSha256
)
this.M1 = clientM1
return PortalSrp.bytesToHex(clientM1)
}
/**
* Verify server's M2 response.
* Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256).
*/
fun verifyServerM2(serverM2Hex: String): Boolean {
val expectedM1 = M1 ?: throw IllegalStateException("Client state not initialized for verification")
val sessionK = K ?: throw IllegalStateException("Client state not initialized for verification")
val certHash = tlsCertHash ?: throw IllegalStateException("Client state not initialized for verification")
val serverM2 = try {
PortalSrp.hexToBytes(serverM2Hex)
} catch (e: Exception) {
return false
}
val expectedM2 = PortalSrp.sha256(PortalSrp.toPadded256(A), expectedM1, sessionK, certHash)
return PortalSrp.constantTimeEquals(expectedM2, serverM2)
}
}
@@ -0,0 +1,746 @@
package com.portaltv.capability
import android.app.*
import android.content.*
import android.graphics.SurfaceTexture
import android.hardware.camera2.*
import android.media.*
import android.os.*
import android.view.Surface
import java.net.*
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
import javax.net.ssl.SSLServerSocket
/** Foreground, UI-independent Portal raw media service over HTTPS. */
class PortalStreamingService : Service() {
companion object { @JvmField @Volatile var activityVisible = false }
private val video = Track(true); private val audio = Track(false)
private val videoUsers = AtomicInteger(); private val audioUsers = AtomicInteger()
private var camera: CameraDevice? = null; private var cameraSession: CameraCaptureSession? = null
private var reader: MediaCodec? = null; private var audioCodec: MediaCodec? = null
private var videoSurface: Surface? = null; private var mic: AudioRecord? = null
private var server: SSLServerSocket? = null; private val io = Executors.newCachedThreadPool()
private val cameraHandler = Handler(Looper.getMainLooper())
private val videoLock = Any()
private val audioLock = Any()
@Volatile private var audioLoopActive = false
private var audioThread: Thread? = null
private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) }
private val random = SecureRandom()
@Volatile private var pairing: PortalSrp.ActivePairing? = null
@Volatile private var recoveringCamera = false
/** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */
@Volatile private var cameraGeneration = 0
private var mdns: PortalMdns? = null
override fun onCreate() {
super.onCreate()
android.util.Log.d("PortalService", "onCreate - initializing TLS")
startForeground(42, notification())
PortalSmartCamera.start(this)
try {
server = PortalTls.createServerSocket(this, PortalEndpoints.PORT)
android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}")
mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) }
} catch (e: Exception) {
android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e)
}
cameraHandler.post(object : Runnable {
override fun run() {
authPrefs.edit()
.putInt("activeVideo", videoUsers.get())
.putInt("activeAudio", audioUsers.get())
.apply()
cameraHandler.postDelayed(this, 1000)
}
})
Thread { acceptLoop() }.start()
}
override fun onStartCommand(i: Intent?, flags: Int, id: Int): Int {
if (i?.action == "com.portaltv.capability.CLOSE_CLIENTS") {
video.clear(); audio.clear(); return START_STICKY
}
if (i?.action == "com.portaltv.capability.REVOKE_ALL") {
authPrefs.edit().clear().apply(); video.clear(); audio.clear()
android.util.Log.i("PortalService", "all clients revoked")
return START_STICKY
}
if (videoUsers.get() > 0 && reader == null) startVideo()
return START_STICKY
}
override fun onBind(i: Intent?): IBinder? = null
private fun notification(): Notification {
val ch = NotificationChannel("portal", "Portal camera", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
return Notification.Builder(this, "portal")
.setContentTitle("Portal camera streaming (HTTPS)")
.setSmallIcon(android.R.drawable.presence_video_online)
.build()
}
private fun acceptLoop() {
val listener = server ?: return
while (!listener.isClosed) {
runCatching {
val client = listener.accept()
io.submit { handle(client) }
}.onFailure {
if (!listener.isClosed) {
android.util.Log.w("PortalService", "accept failed", it)
}
}
}
}
private fun hash(s: String) =
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private fun startSrpPairing(): PortalSrp.ActivePairing {
val existing = pairing
if (existing != null && System.currentTimeMillis() < existing.expiresAt && existing.attemptsLeft > 0) {
android.util.Log.i("PortalService", "SRP pairing started with PIN: ${existing.pin}")
return existing
}
val pin = (100000 + random.nextInt(900000)).toString()
val active = PortalSrp.newPairing(pin)
pairing = active
authPrefs.edit().putString("pairingPin", pin).apply()
cameraHandler.post {
android.widget.Toast.makeText(this, "Pairing PIN: $pin", android.widget.Toast.LENGTH_LONG).show()
}
bringActivityToFront()
android.util.Log.i("PortalService", "SRP pairing started with PIN: $pin")
return active
}
private fun authToken(headers: Map<String, String>): String? {
val a = headers["authorization"] ?: return null
if (!a.startsWith("Bearer ")) return null
val token = a.substring(7)
val h = hash(token)
return if ((authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).contains(h)) token else null
}
private fun handle(s: Socket) {
s.use { socket ->
socket.soTimeout = 5000
val reader = socket.getInputStream().bufferedReader()
val line = reader.readLine() ?: return
val headers = mutableMapOf<String, String>()
while (true) {
val h = reader.readLine() ?: break
if (h.isEmpty()) break
val k = h.indexOf(':')
if (k > 0) headers[h.substring(0, k).lowercase()] = h.substring(k + 1).trim()
}
val parts = line.split(" ")
val method = parts.getOrNull(0) ?: "GET"
val path = parts.getOrNull(1) ?: return
// If request has Content-Length, read body
var body = ""
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
if (contentLength in 1..65536) {
val buf = CharArray(contentLength)
var readTotal = 0
while (readTotal < contentLength) {
val r = reader.read(buf, readTotal, contentLength - readTotal)
if (r < 0) break
readTotal += r
}
body = String(buf, 0, readTotal)
}
when {
// SRP-6a pairing endpoints
path.startsWith("/auth/srp/init") -> handleSrpInit(socket)
path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers)
// TLS Info endpoint (returns server cert SHA-256 for diagnostics)
path.startsWith("/auth/cert") -> {
val sha = PortalSrp.bytesToHex(PortalTls.certSha256)
reply(socket, 200, "{\"certSha256\":\"$sha\"}", "application/json")
}
// Media streams (require Bearer auth)
path.startsWith("/video.h264") -> {
if (authToken(headers) != null) stream(socket, video, videoUsers, "video/h264", true)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/audio.aac") -> {
if (authToken(headers) != null) stream(socket, audio, audioUsers, "audio/aac", false)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/control") -> {
if (authToken(headers) != null) control(socket, path)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json")
}
}
}
private fun handleSrpInit(s: Socket) {
val p = startSrpPairing()
val saltHex = PortalSrp.bytesToHex(p.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB))
val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":120}"
reply(s, 200, json, "application/json")
}
private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map<String, String>) {
val p = pairing
if (p == null) {
reply(s, 400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}", "application/json")
return
}
// Parse params from query string or JSON body
val queryParams = path.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun extractParam(key: String): String? {
queryParams[key]?.let { return it }
// Basic JSON search: "key":"value" or "key": "value"
val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"")
return pattern.find(body)?.groupValues?.getOrNull(1)
}
val pairingId = extractParam("pairingId")
val A = extractParam("A")
val M1 = extractParam("M1")
if (pairingId == null || A == null || M1 == null) {
reply(s, 400, "{\"error\":\"missing_parameters\",\"message\":\"pairingId, A, and M1 are required\"}", "application/json")
return
}
if (pairingId != p.id) {
reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json")
return
}
val tlsHash = PortalTls.certSha256
val res = PortalSrp.verifyClient(p, A, M1, tlsHash)
when (res) {
is PortalSrp.VerifyResult.Success -> {
val token = res.token
val h = hash(token)
val set = (authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).toMutableSet()
set += h
val clientMeta = (headers["user-agent"] ?: "unknown") + "|" +
(headers["x-client-name"] ?: "") + "|" +
(headers["x-client-version"] ?: "") + "|" +
System.currentTimeMillis()
authPrefs.edit()
.putStringSet("tokens", set)
.putString("client.$h", clientMeta)
.remove("pairingPin")
.apply()
pairing = null
val m2Hex = PortalSrp.bytesToHex(res.M2)
val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}"
android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta")
reply(s, 200, json, "application/json")
}
is PortalSrp.VerifyResult.Failed -> {
android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}")
if (res.attemptsLeft <= 0) {
pairing = null
authPrefs.edit().remove("pairingPin").apply()
}
reply(
s,
401,
"{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}",
"application/json"
)
}
}
}
private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean) {
s.soTimeout = 0 // Don't timeout streaming connections
val o = s.getOutputStream()
o.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray())
o.flush()
val q = t.add(key)
val active = n.incrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active")
if (active == 1) {
if (t === video) startVideo() else startAudio()
if (key) bringActivityToFront()
}
try {
var wait = key
while (!s.isClosed && t.contains(q)) {
val p = q.poll(1, TimeUnit.SECONDS) ?: continue
if (!wait || p.key) {
wait = false
o.write(p.data)
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}")
} finally {
t.remove(q)
val left = n.decrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left")
if (left == 0) {
if (t === video) {
android.util.Log.i("PortalService", "last video client gone; stopping camera")
stopVideo()
} else {
android.util.Log.i("PortalService", "last audio client gone; stopping microphone")
stopAudio()
}
}
}
}
private fun bringActivityToFront() {
if (activityVisible) return
runCatching {
startActivity(
Intent().setClassName(this, "com.portaltv.capability.MainActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
)
}.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) }
}
private fun control(s: Socket, p: String) {
val pathOnly = p.substringBefore('?')
val q = p.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun replyOutcome(outcome: PortalSmartCamera.Outcome) {
when (outcome) {
is PortalSmartCamera.Outcome.Ack ->
reply(s, 200, outcome.toJson(), "application/json")
is PortalSmartCamera.Outcome.Err ->
reply(s, outcome.httpStatus, outcome.toJson(), "application/json")
}
}
when {
pathOnly == "/control/events" -> {
controlEvents(s)
}
pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> {
reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json")
}
pathOnly.startsWith("/control/mode") -> {
val mode = q["mode"]
if (mode == null || mode !in setOf("DefaultAuto", "Desk", "Meeting", "Fixed")) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_mode",
"mode must be DefaultAuto, Desk, Meeting, or Fixed",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking(mode))
}
pathOnly.startsWith("/control/fixed") -> {
val x = q["x"]?.toFloatOrNull()
val y = q["y"]?.toFloatOrNull()
val scale = q["scale"]?.toFloatOrNull()
if (x == null || y == null || scale == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_fixed",
"x, y, and scale are required numbers",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking("Fixed", x, y, scale))
}
pathOnly.startsWith("/control/desk") -> {
val tightness = q["tightness"]?.toFloatOrNull()
if (tightness == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_desk",
"tightness is a required number",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyDeskTightnessBlocking(tightness))
}
else -> reply(
s, 404,
PortalSmartCamera.Outcome.Err("not_found", "unknown control path", 404).toJson(),
"application/json",
)
}
}
/** SSE: initial state, then `event: state` on each change. */
private fun controlEvents(s: Socket) {
s.soTimeout = 0
val o = s.getOutputStream()
o.write(
("HTTP/1.1 200 OK\r\n" +
"Content-Type: text/event-stream\r\n" +
"Cache-Control: no-store\r\n" +
"Connection: keep-alive\r\n\r\n").toByteArray(Charsets.UTF_8)
)
o.flush()
fun writeEvent(state: PortalSmartCamera.State) {
val payload = "event: state\ndata: ${state.toJson()}\n\n"
o.write(payload.toByteArray(Charsets.UTF_8))
o.flush()
}
val queue = LinkedBlockingQueue<PortalSmartCamera.State>(32)
val listener = PortalSmartCamera.StateListener { state ->
// Drop oldest if slow client; keep connection alive.
while (!queue.offer(state)) {
queue.poll()
}
}
PortalSmartCamera.addStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client connected")
try {
// addStateListener already pushed latest; also write a comment keepalive loop.
while (!s.isClosed) {
val next = queue.poll(15, TimeUnit.SECONDS)
if (next != null) {
writeEvent(next)
} else {
o.write(": keepalive\n\n".toByteArray(Charsets.UTF_8))
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}")
} finally {
PortalSmartCamera.removeStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client removed")
runCatching { s.close() }
}
}
private fun reply(s: Socket, c: Int, b: String, contentType: String = "text/plain") {
val reason = when (c) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
404 -> "Not Found"
else -> "Error"
}
val d = b.toByteArray(Charsets.UTF_8)
s.getOutputStream().write(
("HTTP/1.1 $c $reason\r\nContent-Type: $contentType\r\nContent-Length: ${d.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8) + d
)
}
private fun startVideo() {
synchronized(videoLock) {
if (reader != null && videoSurface != null) {
android.util.Log.d("PortalService", "startVideo skipped; encoder already running")
return
}
android.util.Log.d("PortalService", "startVideo")
try {
// Tear down any half-open previous pipeline before starting a new one.
stopVideoLocked()
val generation = ++cameraGeneration
val f = MediaFormat.createVideoFormat("video/avc", 1280, 720)
f.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
f.setInteger(MediaFormat.KEY_BIT_RATE, 2500000)
f.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
reader = MediaCodec.createEncoderByType("video/avc")
reader!!.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
videoSurface = reader!!.createInputSurface()
reader!!.start()
openCamera(generation, videoSurface!!)
drainVideo()
} catch (e: Exception) {
android.util.Log.e("PortalService", "startVideo failed", e)
stopVideoLocked()
}
}
}
private fun drainVideo() {
Thread {
val b = MediaCodec.BufferInfo()
var count = 0
while (reader != null) try {
val i = reader!!.dequeueOutputBuffer(b, 10000)
if (i >= 0) {
val x = reader!!.getOutputBuffer(i)
if (x != null && b.size > 0) {
val d = ByteArray(b.size)
x.position(b.offset)
x.get(d)
val key = (b.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0 || (b.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0
video.publish(d, key, b.presentationTimeUs)
if (++count % 30 == 0) android.util.Log.d("PortalService", "video packets=$count bytes=${d.size} key=$key")
}
reader!!.releaseOutputBuffer(i, false)
}
} catch (e: Exception) {
if (reader != null) android.util.Log.e("PortalService", "video drain stopped", e)
break
}
}.start()
}
private fun openCamera(generation: Int, surface: Surface) {
val cm = getSystemService(CameraManager::class.java)
val id = cm.cameraIdList.firstOrNull()
if (id == null) {
android.util.Log.e("PortalService", "no cameras available")
return
}
if (checkSelfPermission("android.permission.CAMERA") != 0) {
android.util.Log.e("PortalService", "camera permission denied")
return
}
android.util.Log.d("PortalService", "opening camera $id (gen=$generation)")
try {
cm.openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(c: CameraDevice) {
if (generation != cameraGeneration || surface !== videoSurface) {
android.util.Log.w("PortalService", "stale camera onOpened (gen=$generation); closing")
runCatching { c.close() }
return
}
android.util.Log.d("PortalService", "camera opened (gen=$generation)")
try {
camera = c
val q = c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD)
q.addTarget(surface)
c.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
if (generation != cameraGeneration) {
android.util.Log.w("PortalService", "stale capture onConfigured; closing session")
runCatching { s.close() }
return
}
android.util.Log.d("PortalService", "capture configured")
try {
cameraSession = s
s.setRepeatingRequest(q.build(), null, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "setRepeatingRequest failed", e)
recoverCamera()
}
}
override fun onConfigureFailed(s: CameraCaptureSession) {
android.util.Log.e("PortalService", "capture configure failed")
recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "camera onOpened setup failed", e)
runCatching { c.close() }
recoverCamera()
}
}
override fun onDisconnected(c: CameraDevice) {
android.util.Log.e("PortalService", "camera disconnected")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
override fun onError(c: CameraDevice, e: Int) {
android.util.Log.e("PortalService", "camera error $e")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "openCamera failed", e)
recoverCamera()
}
}
private fun recoverCamera() {
if (videoUsers.get() <= 0 || recoveringCamera) return
recoveringCamera = true
cameraHandler.postDelayed({
recoveringCamera = false
if (videoUsers.get() > 0) {
stopVideo()
startVideo()
}
}, 750)
}
private fun stopVideo() {
synchronized(videoLock) {
stopVideoLocked()
}
}
private fun stopVideoLocked() {
cameraGeneration++
runCatching { cameraSession?.close() }
runCatching { camera?.close() }
cameraSession = null
camera = null
runCatching { videoSurface?.release() }
videoSurface = null
runCatching {
reader?.stop()
reader?.release()
}
reader = null
}
private fun startAudio() {
synchronized(audioLock) {
stopAudioLocked()
try {
val f = MediaFormat.createAudioFormat("audio/mp4a-latm", 48000, 1)
f.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
f.setInteger(MediaFormat.KEY_BIT_RATE, 64000)
val codec = MediaCodec.createEncoderByType("audio/mp4a-latm")
codec.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
audioCodec = codec
val n = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val record = AudioRecord(
MediaRecorder.AudioSource.DEFAULT,
48000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
n * 2
)
record.startRecording()
mic = record
audioLoopActive = true
audioThread = Thread {
val pcm = ByteArray(n)
val info = MediaCodec.BufferInfo()
while (audioLoopActive) {
try {
val got = record.read(pcm, 0, pcm.size)
if (!audioLoopActive) break
if (got > 0) {
val i = codec.dequeueInputBuffer(10_000)
if (!audioLoopActive) break
if (i >= 0) {
val x = codec.getInputBuffer(i) ?: continue
x.clear()
x.put(pcm, 0, got)
codec.queueInputBuffer(i, 0, got, System.nanoTime() / 1000, 0)
}
}
val o = codec.dequeueOutputBuffer(info, 0)
if (!audioLoopActive) break
if (o >= 0) {
val x = codec.getOutputBuffer(o)
if (x != null && info.size > 0) {
val d = ByteArray(info.size)
x.position(info.offset)
x.get(d)
audio.publish(d, false, info.presentationTimeUs)
}
codec.releaseOutputBuffer(o, false)
}
} catch (e: Exception) {
if (audioLoopActive) {
android.util.Log.w("PortalService", "audio loop stopped", e)
}
break
}
}
}.also {
it.name = "portal-audio"
it.start()
}
} catch (e: Exception) {
android.util.Log.e("PortalService", "startAudio failed", e)
stopAudioLocked()
}
}
}
private fun stopAudio() {
synchronized(audioLock) {
stopAudioLocked()
}
}
private fun stopAudioLocked() {
audioLoopActive = false
runCatching { mic?.stop() } // unblock AudioRecord.read
val t = audioThread
audioThread = null
if (t != null && t !== Thread.currentThread()) {
runCatching { t.join(750) }
}
runCatching { mic?.release() }
mic = null
runCatching {
audioCodec?.stop()
audioCodec?.release()
}
audioCodec = null
}
override fun onDestroy() {
mdns?.unregister()
mdns = null
server?.close()
stopVideo()
stopAudio()
io.shutdownNow()
super.onDestroy()
}
class Track(val v: Boolean) {
data class P(val data: ByteArray, val key: Boolean, val pts: Long)
private val qs = CopyOnWriteArraySet<LinkedBlockingDeque<P>>()
@Volatile private var config: ByteArray? = null
fun add(k: Boolean) = LinkedBlockingDeque<P>(if (v) 3 else 256).also {
qs += it
config?.let { c -> if (v) it.offer(P(c, true, 0)) }
}
fun clear() { qs.clear() }
fun contains(q: LinkedBlockingDeque<P>) = qs.contains(q)
fun remove(q: LinkedBlockingDeque<P>) { qs -= q }
fun publish(d: ByteArray, k: Boolean, p: Long) {
if (v && k && d.size < 256) config = d
qs.forEach { if (!it.offer(P(d, k, p))) qs -= it }
}
}
}
@@ -0,0 +1,108 @@
package com.portaltv.capability
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.math.BigInteger
import java.net.Socket
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.Principal
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.util.Date
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLServerSocket
import javax.net.ssl.X509ExtendedKeyManager
import javax.security.auth.x500.X500Principal
object PortalTls {
private const val ALIAS = "portalcam_tls_ec_p256"
private const val KS_TYPE = "AndroidKeyStore"
@Volatile
var certDer: ByteArray = ByteArray(0)
private set
@Volatile
var certSha256: ByteArray = ByteArray(0)
private set
fun getOrCreateSslContext(context: Context): SSLContext {
val ks = KeyStore.getInstance(KS_TYPE).apply { load(null) }
if (!ks.containsAlias(ALIAS)) {
android.util.Log.i("PortalTls", "Generating new EC secp256r1 self-signed certificate in AndroidKeyStore")
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KS_TYPE)
val now = System.currentTimeMillis()
val notBefore = Date(now - 86400000L) // 1 day ago
val notAfter = Date(now + 10L * 365 * 86400000L) // 10 years
val spec = KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_SIGN
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setCertificateSubject(X500Principal("CN=PortalCam, O=PortalCam, C=US"))
.setCertificateSerialNumber(BigInteger.valueOf(now))
.setCertificateNotBefore(notBefore)
.setCertificateNotAfter(notAfter)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512
)
.build()
kpg.initialize(spec)
kpg.generateKeyPair()
}
val cert = ks.getCertificate(ALIAS) as X509Certificate
certDer = cert.encoded
certSha256 = MessageDigest.getInstance("SHA-256").digest(certDer)
android.util.Log.i("PortalTls", "Certificate SHA-256: ${certSha256.joinToString("") { "%02x".format(it) }}")
// Build KeyManager that retrieves key from AndroidKeyStore
val km = object : X509ExtendedKeyManager() {
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> = arrayOf(ALIAS)
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String = ALIAS
override fun chooseEngineServerAlias(keyType: String?, issuers: Array<out Principal>?, engine: SSLEngine?): String = ALIAS
override fun getCertificateChain(alias: String?): Array<X509Certificate>? = arrayOf(cert)
override fun getPrivateKey(alias: String?): PrivateKey? = ks.getKey(ALIAS, null) as? PrivateKey
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), null, SecureRandom())
return sslContext
}
fun createServerSocket(context: Context, port: Int): SSLServerSocket {
val sslCtx = getOrCreateSslContext(context)
val s = sslCtx.serverSocketFactory.createServerSocket(port) as SSLServerSocket
s.needClientAuth = false
s.wantClientAuth = false
val supported = s.supportedProtocols.toList()
android.util.Log.i("PortalTls", "Supported protocols: $supported")
val desired = listOf("TLSv1.3", "TLSv1.2").filter { it in supported }
if (desired.isNotEmpty()) {
s.enabledProtocols = desired.toTypedArray()
}
android.util.Log.i("PortalTls", "Enabled protocols: ${s.enabledProtocols.contentToString()}")
return s
}
}
@@ -0,0 +1,14 @@
/* AUTO-GENERATED FILE. DO NOT MODIFY.
*
* This class was automatically generated by the
* aapt tool from the resource data it found. It
* should not be modified by hand.
*/
package com.portaltv.capability;
public final class R {
public static final class style {
public static final int AppTheme=0x7f010000;
}
}
@@ -0,0 +1,52 @@
package android.util;
import java.nio.charset.StandardCharsets;
/**
* JVM test implementation of android.util.Base64 using java.util.Base64.
* Enables running Android SRP cryptographic tests on standard desktop JVM.
*/
public class Base64 {
public static final int DEFAULT = 0;
public static final int NO_PADDING = 1;
public static final int NO_WRAP = 2;
public static final int CRLF = 4;
public static final int URL_SAFE = 8;
public static final int NO_CLOSE = 16;
public static String encodeToString(byte[] input, int flags) {
return encodeToString(input, 0, input.length, flags);
}
public static String encodeToString(byte[] input, int offset, int len, int flags) {
byte[] slice;
if (offset == 0 && len == input.length) {
slice = input;
} else {
slice = new byte[len];
System.arraycopy(input, offset, slice, 0, len);
}
java.util.Base64.Encoder encoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlEncoder()
: java.util.Base64.getEncoder();
if ((flags & NO_PADDING) != 0) {
encoder = encoder.withoutPadding();
}
return encoder.encodeToString(slice);
}
public static byte[] encode(byte[] input, int flags) {
return encodeToString(input, flags).getBytes(StandardCharsets.UTF_8);
}
public static byte[] decode(String str, int flags) {
java.util.Base64.Decoder decoder = ((flags & URL_SAFE) != 0)
? java.util.Base64.getUrlDecoder()
: java.util.Base64.getDecoder();
return decoder.decode(str);
}
public static byte[] decode(byte[] input, int flags) {
return decode(new String(input, StandardCharsets.UTF_8), flags);
}
}
@@ -0,0 +1,171 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpChannelBindingTest : TestSuite("Cryptographic TLS Channel Binding Enforcement") {
init {
val random = SecureRandom()
fun fakeCertHash(identifier: String): ByteArray =
MessageDigest.getInstance("SHA-256").digest(identifier.toByteArray(Charsets.UTF_8))
test("Channel Binding: Legitimate TLS connection succeeds and produces valid M2") {
val pin = "718293"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val legitimateTlsCertHash = fakeCertHash("CN=PortalCam Server Cert V1")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = legitimateTlsCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = legitimateTlsCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Success, "Legitimate TLS channel binding must succeed")
val success = result as PortalSrp.VerifyResult.Success
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server M2 bound to same TLS cert")
}
test("Channel Binding: Active MITM Proxy attack REJECTED by server") {
// Threat Model:
// An active MITM proxy (e.g. mitmproxy, Charles, or rogue gateway) intercepts
// the HTTPS connection. The proxy creates a separate TLS connection to the client
// using a forged CA, and another TLS connection to the Portal server using the Portal's cert.
// Client sees Cert_MITM; Server sees Cert_Real.
val pin = "998877"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val realServerTlsCertHash = fakeCertHash("CN=PortalCam Real Hardware Cert")
val mitmProxyTlsCertHash = fakeCertHash("CN=mitmproxy Fake Interceptor Cert")
// Client computes M1 bound to the MITM's TLS certificate
val m1FromClientUnderMitm = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyTlsCertHash
)
// MITM forwards client's (A, M1) to real server
val serverResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1FromClientUnderMitm,
tlsHash = realServerTlsCertHash // Server uses its genuine TLS cert hash
)
// Server MUST reject because M1 is bound to MITM's cert, not the server's cert!
assertTrue(
serverResult is PortalSrp.VerifyResult.Failed,
"Server MUST reject M1 generated under an active MITM proxy"
)
val failed = serverResult as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Channel Binding: Single bit flip in TLS cert hash triggers authentication failure") {
val pin = "456123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val originalCertHash = fakeCertHash("CN=PortalCam Genuine Cert")
val tamperedCertHash = originalCertHash.clone().also {
it[0] = (it[0].toInt() xor 0x01).toByte() // Flip 1 bit
}
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tamperedCertHash
)
val result = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = originalCertHash
)
assertTrue(result is PortalSrp.VerifyResult.Failed, "1-bit altered TLS hash must fail")
val failed = result as PortalSrp.VerifyResult.Failed
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("Channel Binding: Client rejects server M2 if TLS channel binding is altered") {
val pin = "654987"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val clientCertHash = fakeCertHash("CN=Client Observed Cert")
val serverCertHash = fakeCertHash("CN=Client Observed Cert")
val attackerAlteredCertHash = fakeCertHash("CN=Attacker Injected Cert")
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = clientCertHash
)
val result = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, serverCertHash)
assertTrue(result is PortalSrp.VerifyResult.Success)
val success = result as PortalSrp.VerifyResult.Success
// Attacker tries to forge M2 with altered cert hash
val forgedM2 = PortalSrp.sha256(
PortalSrp.toPadded256(client.A),
client.M1!!,
client.K!!,
attackerAlteredCertHash
)
val accepted = client.verifyServerM2(PortalSrp.bytesToHex(forgedM2))
assertFalse(accepted, "Client must reject M2 bound to an altered TLS cert hash")
}
test("Channel Binding: Truncated or empty TLS cert hash fails verification") {
val pin = "123123"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val fullCertHash = fakeCertHash("CN=PortalCam Cert Full")
val emptyCertHash = ByteArray(0)
val truncatedCertHash = ByteArray(16) { fullCertHash[it] }
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = fullCertHash
)
// Server receives empty or truncated TLS hash
val resultEmpty = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, emptyCertHash)
assertTrue(resultEmpty is PortalSrp.VerifyResult.Failed)
val resultTruncated = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, truncatedCertHash)
assertTrue(resultTruncated is PortalSrp.VerifyResult.Failed)
}
}
}
@@ -0,0 +1,94 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
class PortalSrpConstantTimeTest : TestSuite("Constant-Time Comparison constantTimeEquals") {
init {
test("Constant-time: Empty byte arrays match") {
assertTrue(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(0)))
}
test("Constant-time: Single-byte matching and non-matching arrays") {
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x00)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7F)))
assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0xFF.toByte()), byteArrayOf(0xFF.toByte())))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x01)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7E)))
assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x80.toByte()), byteArrayOf(0x00)))
}
test("Constant-time: 32-byte SHA-256 matching and non-matching arrays") {
val a = ByteArray(32) { (it * 7).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 32-byte arrays must match")
// Differing at index 0
val diffFirst = a.clone().also { it[0] = (it[0].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffFirst), "Difference at first byte must not match")
// Differing at middle index 16
val diffMid = a.clone().also { it[16] = (it[16].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffMid), "Difference at middle byte must not match")
// Differing at last index 31
val diffLast = a.clone().also { it[31] = (it[31].toInt() xor 0x01).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diffLast), "Difference at last byte must not match")
}
test("Constant-time: 256-byte MODP key matching and non-matching arrays") {
val a = ByteArray(256) { (it xor 0x5A).toByte() }
val b = a.clone()
assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 256-byte arrays must match")
val diff = a.clone().also { it[128] = (it[128].toInt() xor 0x80).toByte() }
assertFalse(PortalSrp.constantTimeEquals(a, diff), "Differing 256-byte arrays must not match")
}
test("Constant-time: Different length byte arrays must be rejected") {
val base = ByteArray(32) { 0xAA.toByte() }
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(31) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(33) { 0xAA.toByte() }))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(1)))
assertFalse(PortalSrp.constantTimeEquals(ByteArray(1), ByteArray(0)))
// Shared prefix but different length
val prefix = byteArrayOf(1, 2, 3)
val longer = byteArrayOf(1, 2, 3, 4)
assertFalse(PortalSrp.constantTimeEquals(prefix, longer))
assertFalse(PortalSrp.constantTimeEquals(longer, prefix))
}
test("Constant-time: Negative signed byte value edge cases") {
// In Java, byte is signed (-128 to 127).
// Verify bitwise operations properly handle negative bytes (0x80..0xFF) without sign extension bugs.
val a = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
val b = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte())
assertTrue(PortalSrp.constantTimeEquals(a, b))
val c = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFD.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, c))
val d = byteArrayOf(0x00, 0xFF.toByte(), 0xFE.toByte())
assertFalse(PortalSrp.constantTimeEquals(a, d))
}
test("Constant-time: Single bit divergence test across all 256 bits of SHA-256 hash") {
val original = ByteArray(32) { 0x55.toByte() }
for (byteIdx in 0 until 32) {
for (bitIdx in 0 until 8) {
val mutated = original.clone()
mutated[byteIdx] = (mutated[byteIdx].toInt() xor (1 shl bitIdx)).toByte()
assertFalse(
PortalSrp.constantTimeEquals(original, mutated),
"Must detect 1-bit difference at byte $byteIdx, bit $bitIdx"
)
}
}
}
}
}
@@ -0,0 +1,151 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.security.MessageDigest
import java.security.SecureRandom
class PortalSrpIntegrationTest : TestSuite("Portal SRP-6a End-to-End & Protocol Integration") {
init {
val random = SecureRandom()
fun sha256Hex(s: String): String =
MessageDigest.getInstance("SHA-256")
.digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
test("E2E Integration: Full PortalStreamingService SRP Handshake & Bearer Auth Flow") {
// Emulates PortalStreamingService state machine in pure Kotlin/JVM
val authTokens = mutableSetOf<String>()
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val serverPin = "654321"
// 1. Client fetches server cert hash (GET /auth/cert)
val clientSeenCertSha256 = serverTlsCertSha256.clone()
// 2. Server initiates SRP pairing (POST /auth/srp/init)
val pairing = PortalSrp.newPairing(serverPin)
val saltHex = PortalSrp.bytesToHex(pairing.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
// 3. Client initializes SRP client and computes A, M1
val client = PortalSrpClient()
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = serverPin,
tlsCertSha256 = clientSeenCertSha256
)
// 4. Client sends verification request (POST /auth/srp/verify)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Verify client must succeed")
val success = verifyResult as PortalSrp.VerifyResult.Success
// Server records token hash
val tokenHash = sha256Hex(success.token)
authTokens.add(tokenHash)
// 5. Client verifies server M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
assertTrue(client.verifyServerM2(m2Hex), "Client must accept server evidence M2")
// 6. Client uses bearer token on protected endpoint (GET /video.h264)
fun checkAuth(header: String?): Boolean {
if (header == null || !header.startsWith("Bearer ")) return false
val token = header.substring(7)
return authTokens.contains(sha256Hex(token))
}
assertTrue(checkAuth("Bearer ${success.token}"), "Legitimate token must grant access")
Assert.assertFalse(checkAuth("Bearer bogus-token"), "Forged token must be rejected")
Assert.assertFalse(checkAuth(null), "Missing authorization must be rejected")
}
test("E2E Integration: MITM Proxy intercepted handshake rejected at HTTP layer") {
val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val mitmProxyCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val pin = "889900"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
// Client computes M1 bound to MITM proxy's certificate
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = mitmProxyCertSha256
)
// Proxy relays M1 to server; Server checks against its real TLS cert
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = serverTlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Failed)
val failed = verifyResult as PortalSrp.VerifyResult.Failed
assertEquals(2, failed.attemptsLeft)
assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message)
}
test("E2E Integration: Brute-force PIN attack exhausts 3 attempts and invalidates pairing") {
val correctPin = "987654"
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing(correctPin)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
// Attacker makes 3 wrong guesses
val badGuesses = listOf("000000", "111111", "222222")
for ((index, guess) in badGuesses.withIndex()) {
val currentPairing = activePairing
assertNotNull(currentPairing, "Pairing must exist for attempt ${index + 1}")
val attackerClient = PortalSrpClient()
val attackerM1 = attackerClient.computeM1(
saltHex = PortalSrp.bytesToHex(currentPairing!!.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(currentPairing.pubB)),
pin = guess,
tlsCertSha256 = tlsCertSha256
)
val result = PortalSrp.verifyClient(
pairing = currentPairing,
A_hex = attackerClient.pubAHex,
M1_hex = attackerM1,
tlsHash = tlsCertSha256
)
assertTrue(result is PortalSrp.VerifyResult.Failed)
val failed = result as PortalSrp.VerifyResult.Failed
val expectedRemaining = 2 - index
assertEquals(expectedRemaining, failed.attemptsLeft)
if (failed.attemptsLeft <= 0) {
activePairing = null // Emulate PortalStreamingService wipeout
}
}
// Session is wiped
Assert.assertNull(activePairing, "Pairing session must be wiped after 3 failed attempts")
// 4th attempt: Even with the correct PIN, request fails because session no longer exists
val legitimateClient = PortalSrpClient()
val hasSession = activePairing != null
Assert.assertFalse(hasSession, "Cannot authenticate against wiped pairing session")
}
}
}
@@ -0,0 +1,149 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertNotNull
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpMathTest : TestSuite("2048-bit RFC 5054 SRP-6a Group Parameters & Key Agreement") {
init {
test("RFC 5054 2048-bit Prime N bit-length and primality verification") {
assertEquals(2048, PortalSrp.N.bitLength(), "N must be exactly 2048 bits")
assertTrue(PortalSrp.N.testBit(0), "N must be odd")
assertTrue(PortalSrp.N.isProbablePrime(100), "N must pass Miller-Rabin primality check with certainty 100")
// Safe prime check: (N - 1) / 2 is also prime
val q = PortalSrp.N.subtract(BigInteger.ONE).divide(BigInteger.valueOf(2))
assertTrue(q.isProbablePrime(80), "Sophie Germain / safe prime (N-1)/2 must be probable prime")
}
test("RFC 5054 Generator g and Multiplier k verification") {
assertEquals(BigInteger.valueOf(2), PortalSrp.g, "Generator g must be 2")
val expectedKBytes = PortalSrp.sha256(
PortalSrp.toPadded256(PortalSrp.N),
PortalSrp.toPadded256(PortalSrp.g)
)
val expectedK = BigInteger(1, expectedKBytes)
assertEquals(expectedK, PortalSrp.k, "PortalSrp.k must equal SHA256(PAD256(N) || PAD256(g))")
}
test("SRP-6a 2048-bit mutual key agreement K_client == K_server, M1 and M2 verification") {
val random = SecureRandom()
val pin = "849201"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB))
val saltHex = PortalSrp.bytesToHex(pairing.salt)
// Client computes M1
val m1Hex = client.computeM1(
saltHex = saltHex,
pubBHex = bHex,
pin = pin,
tlsCertSha256 = tlsCertSha256
)
// Verify Client S and Server S mathematical agreement
val A_bytes = PortalSrp.toPadded256(client.A)
val B_bytes = PortalSrp.toPadded256(pairing.pubB)
val uBytes = PortalSrp.sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
val vu = pairing.v.modPow(u, PortalSrp.N)
val sServer = client.A.multiply(vu).mod(PortalSrp.N).modPow(pairing.privB, PortalSrp.N)
val kServer = PortalSrp.sha256(PortalSrp.toPadded256(sServer))
assertNotNull(client.K, "Client session key K must not be null")
assertArrayEquals(kServer, client.K!!, "Cryptographic key agreement failed: K_client != K_server")
// Server verifies M1
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Server must accept valid M1 from client")
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(success.token.isNotEmpty(), "Server must issue bearer token upon successful verification")
// Client verifies M2
val m2Hex = PortalSrp.bytesToHex(success.M2)
val m2Valid = client.verifyServerM2(m2Hex)
assertTrue(m2Valid, "Client must verify server's M2 successfully")
}
test("SRP-6a mathematical agreement across diverse PIN formats (numeric, alphanumeric, utf8)") {
val testPins = listOf(
"000000",
"999999",
"123456",
"PortalPass-2026!#$",
"Secure🔐Pässtöken-12345",
"a",
"SuperLongPINExceedingStandardLengthsForStressTestingTheSha256HashingPipeline1234567890"
)
val random = SecureRandom()
val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) }
for (pin in testPins) {
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient(random = random)
val m1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val verifyResult = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = m1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
verifyResult is PortalSrp.VerifyResult.Success,
"Pairing must succeed for PIN: '$pin'"
)
val success = verifyResult as PortalSrp.VerifyResult.Success
assertTrue(
client.verifyServerM2(PortalSrp.bytesToHex(success.M2)),
"M2 verification must succeed for PIN: '$pin'"
)
}
}
test("Uniqueness and entropy of pairing parameters across multiple sessions") {
val pin = "555123"
val sessionCount = 50
val ids = mutableSetOf<String>()
val salts = mutableSetOf<String>()
val pubBs = mutableSetOf<BigInteger>()
for (i in 0 until sessionCount) {
val pairing = PortalSrp.newPairing(pin)
ids.add(pairing.id)
salts.add(PortalSrp.bytesToHex(pairing.salt))
pubBs.add(pairing.pubB)
}
assertEquals(sessionCount, ids.size, "All pairing IDs must be unique (sufficient entropy)")
assertEquals(sessionCount, salts.size, "All pairing salts must be unique (sufficient entropy)")
assertEquals(sessionCount, pubBs.size, "All server public keys B must be unique")
}
}
}
@@ -0,0 +1,115 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertArrayEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpPaddingTest : TestSuite("BigInteger toPadded256 Edge Cases") {
init {
test("Padding: BigInteger.ZERO produces 256 zero bytes") {
val padded = PortalSrp.toPadded256(BigInteger.ZERO)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
val expected = ByteArray(256)
assertArrayEquals(expected, padded, "ZERO must produce all zero bytes")
}
test("Padding: BigInteger.ONE produces 255 zeros followed by 0x01") {
val padded = PortalSrp.toPadded256(BigInteger.ONE)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(1.toByte(), padded[255], "Last byte must be 1")
}
test("Padding: Small BigInteger value (g = 2) produces 255 leading zeros") {
val padded = PortalSrp.toPadded256(PortalSrp.g)
assertEquals(256, padded.size)
for (i in 0 until 255) {
assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i")
}
assertEquals(2.toByte(), padded[255], "Last byte must be 2")
}
test("Padding: 128-bit (16-byte) BigInteger produces 240 leading zeros followed by 16 bytes") {
val raw16 = ByteArray(16) { (it + 1).toByte() }
val bi = BigInteger(1, raw16)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
for (i in 0 until 240) {
assertEquals(0.toByte(), padded[i], "Must have 240 leading zeros")
}
for (i in 0 until 16) {
assertEquals((i + 1).toByte(), padded[240 + i])
}
}
test("Padding: Exact 256-byte positive BigInteger with MSB 0 (raw.size == 256)") {
// High byte 0x7F ensures sign bit is 0, so raw.size == 256
val raw256 = ByteArray(256) { it.toByte() }
raw256[0] = 0x7F.toByte()
val bi = BigInteger(1, raw256)
assertEquals(256, bi.toByteArray().size, "BigInteger.toByteArray() should be 256 bytes")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size)
assertArrayEquals(raw256, padded, "Exact 256-byte number must be preserved without distortion")
}
test("Padding: Exact 2048-bit BigInteger with MSB 1 (raw.size == 257 due to Java sign byte)") {
// When MSB is 1, Java BigInteger.toByteArray() adds a leading 0x00 sign byte (257 bytes total).
// toPadded256 must strip the 0x00 sign byte and return the 256 significant bytes.
val raw256 = ByteArray(256) { 0xFF.toByte() }
val bi = BigInteger(1, raw256) // 2^2048 - 1
assertEquals(257, bi.toByteArray().size, "toByteArray() must contain 257 bytes with sign prefix")
assertEquals(0.toByte(), bi.toByteArray()[0], "First byte of toByteArray() must be sign byte 0x00")
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be exactly 256 bytes")
assertArrayEquals(raw256, padded, "Sign byte 0x00 must be stripped and 256 0xFF bytes retained")
}
test("Padding: RFC 5054 2048-bit prime N padding verification") {
assertEquals(257, PortalSrp.N.toByteArray().size, "N.toByteArray() has 257 bytes due to MSB=1")
val paddedN = PortalSrp.toPadded256(PortalSrp.N)
assertEquals(256, paddedN.size, "Padded N must be 256 bytes")
assertEquals(0xFF.toByte(), paddedN[0], "First byte of padded N must be 0xFF")
assertEquals(0xFF.toByte(), paddedN[255], "Last byte of padded N must be 0xFF")
}
test("Padding: Negative BigInteger representation handling") {
// Negative numbers in Java BigInteger: verify no ArrayIndexOutOfBoundsException or crashing
val negOne = BigInteger.valueOf(-1)
val paddedNegOne = PortalSrp.toPadded256(negOne)
assertEquals(256, paddedNegOne.size, "Padded negative BigInteger must be 256 bytes")
val neg128 = BigInteger.valueOf(-128)
val paddedNeg128 = PortalSrp.toPadded256(neg128)
assertEquals(256, paddedNeg128.size)
val negN = PortalSrp.N.negate()
val paddedNegN = PortalSrp.toPadded256(negN)
assertEquals(256, paddedNegN.size)
}
test("Padding: Oversized BigInteger (> 256 bytes) extracts lowest 256 bytes") {
// 258-byte number (2064 bits)
val raw258 = ByteArray(258) { (it % 256).toByte() }
raw258[0] = 0x01.toByte()
val bi = BigInteger(1, raw258)
val padded = PortalSrp.toPadded256(bi)
assertEquals(256, padded.size, "Output must be clamped to 256 bytes")
// Verify it took the last 256 bytes
val expectedSuffix = ByteArray(256)
System.arraycopy(raw258, 2, expectedSuffix, 0, 256)
assertArrayEquals(expectedSuffix, padded, "Must extract lowest 256 bytes")
}
}
}
@@ -0,0 +1,196 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.SecureRandom
class PortalSrpRateLimitingTest : TestSuite("SRP-6a Rate Limiting & Session Invalidation") {
init {
test("Rate limiting: Exact 3-attempt lifecycle enforcement (3 -> 2 -> 1 -> 0 -> cancelled)") {
val pin = "123456"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x42 }
assertEquals(3, pairing.attemptsLeft, "New pairing must start with exactly 3 attempts left")
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0x99.toByte() })
// Attempt 1: Failed
val res1 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res1 is PortalSrp.VerifyResult.Failed, "Attempt 1 with bogus M1 must fail")
val fail1 = res1 as PortalSrp.VerifyResult.Failed
assertEquals(2, fail1.attemptsLeft, "Attempt 1 failure must leave 2 attempts")
assertEquals(2, pairing.attemptsLeft, "Pairing object state must show 2 attempts left")
// Attempt 2: Failed
val res2 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res2 is PortalSrp.VerifyResult.Failed, "Attempt 2 with bogus M1 must fail")
val fail2 = res2 as PortalSrp.VerifyResult.Failed
assertEquals(1, fail2.attemptsLeft, "Attempt 2 failure must leave 1 attempt")
assertEquals(1, pairing.attemptsLeft, "Pairing object state must show 1 attempt left")
// Attempt 3: Failed -> 0 attempts left
val res3 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res3 is PortalSrp.VerifyResult.Failed, "Attempt 3 with bogus M1 must fail")
val fail3 = res3 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail3.attemptsLeft, "Attempt 3 failure must leave 0 attempts")
assertEquals(0, pairing.attemptsLeft, "Pairing object state must show 0 attempts left")
// Subsequent Attempt 4 on exhausted session: Must be blocked immediately
val res4 = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = bogusM1,
tlsHash = tlsCertSha256
)
assertTrue(res4 is PortalSrp.VerifyResult.Failed, "Attempt on exhausted session must fail")
val fail4 = res4 as PortalSrp.VerifyResult.Failed
assertEquals(0, fail4.attemptsLeft)
assertEquals("Too many failed attempts; pairing cancelled", fail4.message)
// Even if correct credentials are now provided, exhausted pairing must remain cancelled
val validM1Hex = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val resValidOnExhausted = PortalSrp.verifyClient(
pairing = pairing,
A_hex = client.pubAHex,
M1_hex = validM1Hex,
tlsHash = tlsCertSha256
)
assertTrue(
resValidOnExhausted is PortalSrp.VerifyResult.Failed,
"Exhausted pairing must reject even valid credentials"
)
assertEquals(
"Too many failed attempts; pairing cancelled",
(resValidOnExhausted as PortalSrp.VerifyResult.Failed).message
)
}
test("Rate limiting: Service-level session wipeout on 0 attempts remaining") {
// Simulates PortalStreamingService session lifecycle:
// When res.attemptsLeft <= 0, pairing is wiped (pairing = null).
// A subsequent request detects pairing == null -> rejected (session not found).
var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing("654321")
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x11 }
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0xEE.toByte() })
fun serviceVerify(A: String, M1: String): Pair<Int, String> {
val p = activePairing ?: return Pair(400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}")
val res = PortalSrp.verifyClient(p, A, M1, tlsCertSha256)
return when (res) {
is PortalSrp.VerifyResult.Success -> {
activePairing = null
Pair(200, "{\"token\":\"${res.token}\"}")
}
is PortalSrp.VerifyResult.Failed -> {
if (res.attemptsLeft <= 0) {
activePairing = null // Session wiped!
}
Pair(401, "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft}}")
}
}
}
// Attempt 1: 401, 2 attempts left
val (status1, body1) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status1)
assertTrue(body1.contains("\"attemptsLeft\":2"))
assertTrue(activePairing != null, "Session must still exist after attempt 1")
// Attempt 2: 401, 1 attempt left
val (status2, body2) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status2)
assertTrue(body2.contains("\"attemptsLeft\":1"))
assertTrue(activePairing != null, "Session must still exist after attempt 2")
// Attempt 3: 401, 0 attempts left, session wiped
val (status3, body3) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(401, status3)
assertTrue(body3.contains("\"attemptsLeft\":0"))
assertTrue(activePairing == null, "Session must be wiped after 3 failed attempts")
// Attempt 4: 400 session not found / no active pairing
val (status4, body4) = serviceVerify(client.pubAHex, bogusM1)
assertEquals(400, status4, "Subsequent attempt after wipeout must return 400")
assertTrue(body4.contains("no_active_pairing"), "Must report session not found")
}
test("Rate limiting: Recovery on valid attempt after prior failed attempt") {
val pin = "345678"
val pairing = PortalSrp.newPairing(pin)
val client = PortalSrpClient()
val tlsCertSha256 = ByteArray(32) { 0x33 }
// Failed attempt 1
val bogusM1 = PortalSrp.bytesToHex(ByteArray(32))
val res1 = PortalSrp.verifyClient(pairing, client.pubAHex, bogusM1, tlsCertSha256)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals(2, (res1 as PortalSrp.VerifyResult.Failed).attemptsLeft)
// Successful attempt 2 with correct PIN and M1
val validM1 = client.computeM1(
saltHex = PortalSrp.bytesToHex(pairing.salt),
pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)),
pin = pin,
tlsCertSha256 = tlsCertSha256
)
val res2 = PortalSrp.verifyClient(pairing, client.pubAHex, validM1, tlsCertSha256)
assertTrue(res2 is PortalSrp.VerifyResult.Success, "Valid attempt 2 after 1 failure must succeed")
}
test("Rate limiting: Session expiration blocks verification") {
val pin = "112233"
val expiredPairing = PortalSrp.ActivePairing(
id = "expired-session-id",
pin = pin,
salt = ByteArray(16),
v = BigInteger.valueOf(3),
privB = BigInteger.valueOf(4),
pubB = BigInteger.valueOf(5),
expiresAt = System.currentTimeMillis() - 5000L, // Expired 5 seconds ago
attemptsLeft = 3
)
val client = PortalSrpClient()
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = expiredPairing,
A_hex = client.pubAHex,
M1_hex = PortalSrp.bytesToHex(ByteArray(32)),
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed)
val fail = res as PortalSrp.VerifyResult.Failed
assertEquals(0, fail.attemptsLeft)
assertEquals("Pairing session expired", fail.message)
}
}
}
@@ -0,0 +1,190 @@
package com.portaltv.capability.test
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
import java.security.MessageDigest
class PortalSrpRfc5054Test : TestSuite("RFC 5054 Appendix B Test Vectors") {
companion object {
private fun cleanHex(s: String) = s.replace("\\s+".toRegex(), "").lowercase()
// 1024-bit prime from RFC 5054 Appendix A
val N_HEX = cleanHex(
"""
EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C
9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4
8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29
7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A
FD5138FE 8376435B 9FC61D2F C0EB06E3
"""
)
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
const val I = "alice"
const val P = "password123"
val SALT_HEX = cleanHex("BEB25379 D1A8581E B5A72767 3A2441EE")
val K_EXPECTED = cleanHex("7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F")
val X_EXPECTED = cleanHex("94B7555A ABE9127C C58CCF49 93DB6CF8 4D16C124")
val V_EXPECTED = cleanHex(
"""
7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812
9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5
C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5
EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78
E955A5E2 9E7AB245 DB2BE315 E2099AFB
"""
)
val A_PRIV_HEX = cleanHex("60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393")
val B_PRIV_HEX = cleanHex("E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20")
val A_PUB_EXPECTED = cleanHex(
"""
61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4
4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC
8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44
BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA
B349EF5D 76988A36 72FAC47B 0769447B
"""
)
val B_PUB_EXPECTED = cleanHex(
"""
BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011
BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99
6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA
37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE
EB4012B7 D7665238 A8E3FB00 4B117B58
"""
)
val U_EXPECTED = cleanHex("CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019")
val S_EXPECTED = cleanHex(
"""
B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D
233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C
41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F
3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D
C346D7E4 74B29EDE 8A469FFE CA686E5A
"""
)
fun sha1(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-1")
for (p in parts) md.update(p)
return md.digest()
}
fun toPadded128(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(128)
if (raw.size > 128) {
System.arraycopy(raw, raw.size - 128, result, 0, 128)
} else {
System.arraycopy(raw, 0, result, 128 - raw.size, raw.size)
}
return result
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
init {
test("RFC 5054 - Multiplier k = H(PAD(N) || PAD(g)) verification") {
val nBytes = toPadded128(N)
val gBytes = toPadded128(g)
val kBytes = sha1(nBytes, gBytes)
val kHex = bytesToHex(kBytes)
assertEquals(K_EXPECTED, kHex, "Multiplier k must match RFC 5054 test vector")
}
test("RFC 5054 - Password hash x = H(s || H(I || ':' || P)) verification") {
val inner = sha1("$I:$P".toByteArray(Charsets.UTF_8))
val salt = hexToBytes(SALT_HEX)
val xBytes = sha1(salt, inner)
val xHex = bytesToHex(xBytes)
assertEquals(X_EXPECTED, xHex, "Password hash x must match RFC 5054 test vector")
}
test("RFC 5054 - Verifier v = g^x mod N verification") {
val x = BigInteger(X_EXPECTED, 16)
val v = g.modPow(x, N)
val vHex = bytesToHex(toPadded128(v))
assertEquals(V_EXPECTED, vHex, "Verifier v must match RFC 5054 test vector")
}
test("RFC 5054 - Client public key A = g^a mod N verification") {
val a = BigInteger(A_PRIV_HEX, 16)
val A = g.modPow(a, N)
val aHex = bytesToHex(toPadded128(A))
assertEquals(A_PUB_EXPECTED, aHex, "Public key A must match RFC 5054 test vector")
}
test("RFC 5054 - Server public key B = (k*v + g^b) mod N verification") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val b = BigInteger(B_PRIV_HEX, 16)
val gb = g.modPow(b, N)
val B = k.multiply(v).add(gb).mod(N)
val bHex = bytesToHex(toPadded128(B))
assertEquals(B_PUB_EXPECTED, bHex, "Public key B must match RFC 5054 test vector")
}
test("RFC 5054 - Scrambler u = H(PAD(A) || PAD(B)) verification") {
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val uBytes = sha1(toPadded128(A), toPadded128(B))
val uHex = bytesToHex(uBytes)
assertEquals(U_EXPECTED, uHex, "Scrambler u must match RFC 5054 test vector")
}
test("RFC 5054 - Premaster secret S client/server agreement and test vector match") {
val k = BigInteger(1, hexToBytes(K_EXPECTED))
val v = BigInteger(1, hexToBytes(V_EXPECTED))
val x = BigInteger(1, hexToBytes(X_EXPECTED))
val a = BigInteger(A_PRIV_HEX, 16)
val b = BigInteger(B_PRIV_HEX, 16)
val A = BigInteger(A_PUB_EXPECTED, 16)
val B = BigInteger(B_PUB_EXPECTED, 16)
val u = BigInteger(1, hexToBytes(U_EXPECTED))
// Client S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = g.modPow(x, N)
val kgx = k.multiply(gx).mod(N)
val clientBase = B.subtract(kgx).mod(N)
val clientExp = a.add(u.multiply(x))
val sClient = clientBase.modPow(clientExp, N)
// Server S = (A * v^u mod N) ^ b mod N
val vu = v.modPow(u, N)
val serverBase = A.multiply(vu).mod(N)
val sServer = serverBase.modPow(b, N)
val sClientHex = bytesToHex(toPadded128(sClient))
val sServerHex = bytesToHex(toPadded128(sServer))
assertEquals(S_EXPECTED, sClientHex, "Client S must match RFC 5054 premaster secret")
assertEquals(S_EXPECTED, sServerHex, "Server S must match RFC 5054 premaster secret")
assertEquals(sClient, sServer, "Client and Server premaster secret S must be identical")
}
}
}
@@ -0,0 +1,146 @@
package com.portaltv.capability.test
import com.portaltv.capability.PortalSrp
import com.portaltv.capability.PortalSrpClient
import com.portaltv.capability.test.Assert.assertEquals
import com.portaltv.capability.test.Assert.assertFailsWith
import com.portaltv.capability.test.Assert.assertFalse
import com.portaltv.capability.test.Assert.assertTrue
import java.math.BigInteger
class PortalSrpSafetyTest : TestSuite("SRP-6a Safety Checks (A mod N != 0, B mod N != 0, u != 0)") {
init {
test("Safety: Reject A mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicA(BigInteger.ZERO), "A = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N), "A = N must be rejected (A mod N == 0)")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(2))), "A = 2N must be rejected")
assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(99))), "A = 99N must be rejected")
assertTrue(PortalSrp.isValidPublicA(BigInteger.valueOf(2)), "A = 2 is valid")
assertTrue(PortalSrp.isValidPublicA(PortalSrp.N.subtract(BigInteger.ONE)), "A = N-1 is valid")
}
test("Safety: Server verifyClient rejects A = 0") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = "00",
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 0")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter")
}
test("Safety: Server verifyClient rejects A == N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = nHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
assertEquals(2, failed.attemptsLeft)
}
test("Safety: Server verifyClient rejects A == 2N (A mod N == 0)") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val twoNHex = PortalSrp.N.multiply(BigInteger.valueOf(2)).toString(16)
val res = PortalSrp.verifyClient(
pairing = pairing,
A_hex = twoNHex,
M1_hex = dummyM1,
tlsHash = dummyTls
)
assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 2N")
val failed = res as PortalSrp.VerifyResult.Failed
assertEquals("Invalid public key A", failed.message)
}
test("Safety: Server verifyClient rejects malformed or non-hex A") {
val pairing = PortalSrp.newPairing("123456")
val dummyM1 = PortalSrp.bytesToHex(ByteArray(32))
val dummyTls = ByteArray(32)
val res1 = PortalSrp.verifyClient(pairing, "not-valid-hex", dummyM1, dummyTls)
assertTrue(res1 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res1 as PortalSrp.VerifyResult.Failed).message)
val res2 = PortalSrp.verifyClient(pairing, "", dummyM1, dummyTls)
assertTrue(res2 is PortalSrp.VerifyResult.Failed)
assertEquals("Invalid client public key format", (res2 as PortalSrp.VerifyResult.Failed).message)
}
test("Safety: Reject B mod N == 0 in validator helper") {
assertFalse(PortalSrp.isValidPublicB(BigInteger.ZERO), "B = 0 must be rejected")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N), "B = N must be rejected (B mod N == 0)")
assertFalse(PortalSrp.isValidPublicB(PortalSrp.N.multiply(BigInteger.valueOf(3))), "B = 3N must be rejected")
assertTrue(PortalSrp.isValidPublicB(BigInteger.valueOf(2)), "B = 2 is valid")
assertTrue(PortalSrp.isValidPublicB(PortalSrp.N.subtract(BigInteger.ONE)), "B = N-1 is valid")
}
test("Safety: Client rejects server B == 0 (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = 0") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = "00",
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Client rejects server B == N (B mod N == 0)") {
val client = PortalSrpClient()
val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16))
val dummyTls = ByteArray(32)
val nHex = PortalSrp.N.toString(16)
val ex = assertFailsWith<IllegalArgumentException>("Client must reject B = N") {
client.computeM1(
saltHex = dummySaltHex,
pubBHex = nHex,
pin = "123456",
tlsCertSha256 = dummyTls
)
}
assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection")
}
test("Safety: Server newPairing always generates valid B mod N != 0") {
for (i in 0 until 20) {
val p = PortalSrp.newPairing("777888")
assertTrue(PortalSrp.isValidPublicB(p.pubB), "Server generated B must satisfy B mod N != 0")
}
}
test("Safety: Reject u == 0 in validator helper") {
assertFalse(PortalSrp.isValidScrambler(BigInteger.ZERO), "u = 0 must be rejected")
assertTrue(PortalSrp.isValidScrambler(BigInteger.ONE), "u = 1 is valid")
assertTrue(PortalSrp.isValidScrambler(BigInteger.valueOf(42)), "u = 42 is valid")
}
}
}
@@ -0,0 +1,65 @@
package com.portaltv.capability.test
class AssertionException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
object Assert {
fun assertTrue(condition: Boolean, message: String = "Expected condition to be true") {
if (!condition) throw AssertionException(message)
}
fun assertFalse(condition: Boolean, message: String = "Expected condition to be false") {
if (condition) throw AssertionException(message)
}
fun assertEquals(expected: Any?, actual: Any?, message: String = "") {
if (expected != actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected <$expected> but got <$actual>")
}
}
fun assertNotEquals(unexpected: Any?, actual: Any?, message: String = "") {
if (unexpected == actual) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected value to differ from <$unexpected>")
}
}
fun assertArrayEquals(expected: ByteArray, actual: ByteArray, message: String = "") {
if (!expected.contentEquals(actual)) {
val prefix = if (message.isNotEmpty()) "$message: " else ""
val expHex = expected.joinToString("") { "%02x".format(it) }
val actHex = actual.joinToString("") { "%02x".format(it) }
throw AssertionException("${prefix}Byte arrays do not match.\nExpected: $expHex\nActual: $actHex")
}
}
fun assertNotNull(actual: Any?, message: String = "Expected non-null value") {
if (actual == null) throw AssertionException(message)
}
fun assertNull(actual: Any?, message: String = "Expected null value") {
if (actual != null) throw AssertionException("$message (was: $actual)")
}
inline fun <reified T : Throwable> assertFailsWith(message: String = "", block: () -> Unit): T {
try {
block()
} catch (t: Throwable) {
if (t is T) return t
throw AssertionException("Expected exception ${T::class.java.simpleName} but caught ${t::class.java.simpleName}: ${t.message}", t)
}
val prefix = if (message.isNotEmpty()) "$message: " else ""
throw AssertionException("${prefix}Expected exception ${T::class.java.simpleName} was not thrown")
}
}
data class TestCase(val name: String, val block: () -> Unit)
abstract class TestSuite(val name: String) {
val cases = mutableListOf<TestCase>()
fun test(name: String, block: () -> Unit) {
cases += TestCase(name, block)
}
}
@@ -0,0 +1,80 @@
package com.portaltv.capability.test
fun main() {
val suites = listOf(
PortalSrpRfc5054Test(),
PortalSrpMathTest(),
PortalSrpSafetyTest(),
PortalSrpRateLimitingTest(),
PortalSrpChannelBindingTest(),
PortalSrpPaddingTest(),
PortalSrpConstantTimeTest(),
PortalSrpIntegrationTest()
)
val green = "\u001B[32m"
val red = "\u001B[31m"
val cyan = "\u001B[36m"
val yellow = "\u001B[33m"
val bold = "\u001B[1m"
val reset = "\u001B[0m"
println("$bold============================================================$reset")
println("$bold$cyan Portal SRP-6a & Android Security Test Suite Runner$reset")
println("$bold============================================================$reset")
var totalTests = 0
var passedTests = 0
var failedTests = 0
val failures = mutableListOf<Triple<String, String, Throwable>>()
val startTime = System.currentTimeMillis()
for (suite in suites) {
println("\n$bold$yellow=== Suite: ${suite.name} ===$reset")
for (case in suite.cases) {
totalTests++
val caseStart = System.currentTimeMillis()
try {
case.block()
val duration = System.currentTimeMillis() - caseStart
println(" $green[PASS]$reset ${case.name} (${duration}ms)")
passedTests++
} catch (t: Throwable) {
val duration = System.currentTimeMillis() - caseStart
println(" $red[FAIL]$reset ${case.name} (${duration}ms)")
println(" $red-> ${t.message}$reset")
failures += Triple(suite.name, case.name, t)
failedTests++
}
}
}
val totalDuration = System.currentTimeMillis() - startTime
println("\n$bold============================================================$reset")
println("$bold Test Execution Summary$reset")
println("$bold============================================================$reset")
println(" Total Suites: ${suites.size}")
println(" Total Tests: $totalTests")
println(" $green Passed: $passedTests$reset")
if (failedTests > 0) {
println(" $red Failed: $failedTests$reset")
} else {
println(" Failed: 0")
}
println(" Total Time: ${totalDuration}ms")
println("============================================================")
if (failures.isNotEmpty()) {
println("\n$bold$red--- FAILURE DETAILS ---$reset")
for ((suiteName, caseName, error) in failures) {
println("\n$red[$suiteName] $caseName:$reset")
error.printStackTrace(System.out)
}
System.exit(1)
} else {
println("\n$bold$green*** ALL $passedTests SRP-6a TESTS PASSED WITH 0 ERRORS ***$reset\n")
System.exit(0)
}
}