[Service] New UI #1

Merged
shepard merged 1 commits from feature/portal_ui into master 2026-09-13 21:14:13 -07:00
45 changed files with 2726 additions and 205 deletions
Showing only changes of commit 1b1ca16fdd - Show all commits
@@ -118,6 +118,7 @@ public final class PortalClient: @unchecked Sendable {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
PortalClientIdentity.apply(to: &request)
let data: Data let data: Data
let response: URLResponse let response: URLResponse
@@ -190,6 +191,7 @@ public final class PortalClient: @unchecked Sendable {
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
PortalClientIdentity.apply(to: &request)
let payload: [String: String] = [ let payload: [String: String] = [
"pairingId": session.pairingId, "pairingId": session.pairingId,
@@ -447,6 +449,7 @@ public final class PortalClient: @unchecked Sendable {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
PortalClientIdentity.apply(to: &request)
let payload: [String: String] = [ let payload: [String: String] = [
"pairingId": session.pairingId, "pairingId": session.pairingId,
@@ -0,0 +1,58 @@
//
// PortalClientIdentity.swift
// PortalKit
//
// Identity headers sent on pairing (and available for other Portal requests).
//
import Foundation
import Darwin
/// Values for `X-Client-Name`, `X-Client-Version`, `X-Device-Model`, and `User-Agent`.
public enum PortalClientIdentity {
public static var clientName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return name
}
return "PortalCam"
}
public static var clientVersion: String {
if let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
!v.isEmpty {
return v
}
return PortalKitVersion.version
}
/// Hardware model identifier, e.g. `MacBookPro18,1`.
public static var deviceModel: String {
hwModel() ?? "Mac"
}
public static var userAgent: String {
"PortalCam/\(clientVersion) (macOS; \(deviceModel))"
}
/// Apply identity headers used by Portal TV pairing.
public static func apply(to request: inout URLRequest) {
request.setValue(clientName, forHTTPHeaderField: "X-Client-Name")
request.setValue(clientVersion, forHTTPHeaderField: "X-Client-Version")
request.setValue(deviceModel, forHTTPHeaderField: "X-Device-Model")
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
}
private static func hwModel() -> String? {
var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return nil }
var buf = [CChar](repeating: 0, count: size)
guard sysctlbyname("hw.model", &buf, &size, nil, 0) == 0 else { return nil }
let s = String(cString: buf).trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
}
+4 -1
View File
@@ -14,7 +14,10 @@
<uses-permission android:name="com.facebook.aloha.permission.GLOBAL_OWNER_SELECTED" /> <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.APP_FOUNDATION_SERVICE_LAUNCH" />
<uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" /> <uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" />
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true"> <uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true"
android:banner="@drawable/portal_banner">
<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_CONTROL_SERVICE" android:value="" />
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_METADATA_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"> <activity android:name=".MainActivity" android:screenOrientation="landscape" android:exported="true">
@@ -0,0 +1,56 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.portaltv.capability"
compileSdk = 35
defaultConfig {
applicationId = "com.portaltv.capability"
minSdk = 28
targetSdk = 28
versionCode = 1
versionName = "1.0"
}
sourceSets {
getByName("main") {
manifest.srcFile("../AndroidManifest.xml")
java.setSrcDirs(listOf("../src", "../smartcamera/src"))
res.setSrcDirs(listOf("../res"))
assets.setSrcDirs(emptyList<String>())
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
packaging {
resources.excludes += setOf("META-INF/INDEX.LIST", "META-INF/*.kotlin_module")
}
lint {
abortOnError = false
}
}
dependencies {
implementation("androidx.leanback:leanback:1.0.0")
implementation("androidx.fragment:fragment-ktx:1.8.5")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.core:core-ktx:1.13.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
@@ -0,0 +1 @@
../../../AndroidManifest.xml
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
+6 -3
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
./build-apk.sh echo "== Gradle assembleDebug =="
gradle :app:assembleDebug
APK="app/build/outputs/apk/debug/app-debug.apk"
cp -f "$APK" portal-capability-test.apk
echo "== Installing APK via ADB ==" echo "== Installing APK via ADB =="
adb install -r portal-capability-test.apk adb install -r portal-capability-test.apk
echo "== Starting MainActivity / PortalStreamingService ==" echo "== Starting MainActivity =="
adb shell am start -n com.portaltv.capability/.MainActivity adb shell am start -n com.portaltv.capability/.MainActivity
echo "== Done ==" echo "== Done =="
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FF2A3038" />
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_red_soft" />
<corners android:radius="10dp" />
<stroke android:width="2dp" android:color="@color/pc_red" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/pc_red_soft" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_blue" />
<corners android:radius="10dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/pc_blue" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FF3A414B" />
<corners android:radius="10dp" />
<stroke android:width="2dp" android:color="@color/pc_focus_outline" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FF3A414B" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/pc_card" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_card_inner_focus" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/pc_stroke" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/pc_card_inner" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/pc_stroke" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/pc_green" />
<size android:width="8dp" android:height="8dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/pc_blue" />
<size android:width="40dp" android:height="40dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#3322C55E" />
<size android:width="40dp" android:height="40dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#33FBBF24" />
<size android:width="40dp" android:height="40dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/pc_blue" />
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_mode_selected" />
<corners android:radius="12dp" />
<stroke android:width="2dp" android:color="@color/pc_focus_outline" />
</shape>
</item>
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_card_inner" />
<corners android:radius="12dp" />
<stroke android:width="2dp" android:color="@color/pc_focus_outline" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/pc_mode_selected" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/pc_stroke" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/pc_card_inner" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/pc_stroke" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FF102027" />
<size android:width="320dp" android:height="180dp" />
</shape>
Binary file not shown.
@@ -0,0 +1,451 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/pc_bg"
android:orientation="vertical"
android:padding="20dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="PortalCam"
android:textColor="@color/pc_text"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Camera Streaming"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/header_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
android:orientation="horizontal">
<FrameLayout
android:layout_width="16dp"
android:layout_height="match_parent">
<View
android:id="@+id/header_streaming_dot"
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_gravity="top|start"
android:layout_marginTop="4dp"
android:background="@drawable/pc_dot_green"
android:visibility="invisible" />
</FrameLayout>
<LinearLayout
android:id="@+id/header_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/header_streaming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Idle"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/header_ready"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ready"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginStart="28dp"
android:layout_marginEnd="28dp"
android:background="@color/pc_separator" />
</LinearLayout>
<TextView
android:id="@+id/header_clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dp"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/header_overflow"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="48dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/pc_text"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
<!-- Top cards row — height hugs mode list; card padding 14dp all sides. -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<!-- Mode -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:background="@drawable/pc_card"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="10dp"
android:text="Mode"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/modes_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
<!-- Mode Controls -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="12dp"
android:layout_weight="1.5"
android:background="@drawable/pc_card"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="10dp"
android:text="Mode Controls"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:id="@+id/controls_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/controls_empty_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/lucide"
android:includeFontPadding="false"
android:textColor="@color/pc_text_dim"
android:textSize="28sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:text="No mode parameters"
android:textColor="@color/pc_text_secondary"
android:textSize="15sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:text="This mode doesn't have any configurable settings."
android:textColor="@color/pc_text_dim"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/controls_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
<!-- Live Stats -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1.5"
android:background="@drawable/pc_card"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="10dp"
android:text="Live Stats"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<!-- Same rhythm as Mode: 52dp rows + 8dp gaps; row inset = 2× mode button L/R padding. -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="20dp"
android:paddingEnd="20dp">
<LinearLayout
android:id="@+id/stat_video_row"
android:layout_width="match_parent"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView android:id="@+id/stat_video_icon" android:layout_width="36dp" android:layout_height="36dp" android:fontFamily="@font/lucide" android:gravity="center" android:includeFontPadding="false" android:textColor="@color/pc_text_secondary" android:textSize="36sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingStart="12dp" android:text="Active video" android:textColor="@color/pc_text_secondary" android:textSize="18sp" />
<TextView android:id="@+id/stat_video" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="0" android:textColor="@color/pc_text_secondary" android:textSize="18sp" android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="8dp">
<include
layout="@layout/pc_separator_h"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="center_vertical" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView android:id="@+id/stat_audio_icon" android:layout_width="36dp" android:layout_height="36dp" android:fontFamily="@font/lucide" android:gravity="center" android:includeFontPadding="false" android:textColor="@color/pc_text_secondary" android:textSize="36sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingStart="12dp" android:text="Active audio" android:textColor="@color/pc_text_secondary" android:textSize="18sp" />
<TextView android:id="@+id/stat_audio" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="0" android:textColor="@color/pc_text_secondary" android:textSize="18sp" android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="8dp">
<include
layout="@layout/pc_separator_h"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="center_vertical" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView android:id="@+id/stat_video_bw_icon" android:layout_width="36dp" android:layout_height="36dp" android:fontFamily="@font/lucide" android:gravity="center" android:includeFontPadding="false" android:textColor="@color/pc_text_secondary" android:textSize="36sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingStart="12dp" android:text="Uplink video" android:textColor="@color/pc_text_secondary" android:textSize="18sp" />
<TextView android:id="@+id/stat_video_bw" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="0 bit/s" android:textColor="@color/pc_text_secondary" android:textSize="18sp" android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="8dp">
<include
layout="@layout/pc_separator_h"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="center_vertical" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView android:id="@+id/stat_audio_bw_icon" android:layout_width="36dp" android:layout_height="36dp" android:fontFamily="@font/lucide" android:gravity="center" android:includeFontPadding="false" android:textColor="@color/pc_text_secondary" android:textSize="36sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingStart="12dp" android:text="Uplink audio" android:textColor="@color/pc_text_secondary" android:textSize="18sp" />
<TextView android:id="@+id/stat_audio_bw" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="0 bit/s" android:textColor="@color/pc_text_secondary" android:textSize="18sp" android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<!-- Clients card -->
<LinearLayout
android:id="@+id/clients_host"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:layout_weight="1"
android:background="@drawable/pc_card"
android:orientation="vertical"
android:padding="14dp">
<LinearLayout
android:id="@+id/clients_pane"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingBottom="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Clients"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/clients_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/pc_badge"
android:paddingStart="8dp"
android:paddingTop="2dp"
android:paddingEnd="8dp"
android:paddingBottom="2dp"
android:text="0"
android:textColor="@color/pc_text"
android:textSize="12sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/btn_revoke_all"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/pc_btn_danger"
android:focusable="true"
android:gravity="center_vertical"
android:nextFocusRight="@id/btn_revoke_all"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingEnd="14dp">
<TextView
android:id="@+id/btn_revoke_all_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/lucide"
android:includeFontPadding="false"
android:textColor="@color/pc_red"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:text="Revoke all"
android:textColor="@color/pc_red"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<ScrollView
android:id="@+id/clients_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:focusable="false">
<LinearLayout
android:id="@+id/clients_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
<LinearLayout
android:id="@+id/client_detail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/detail_body"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:textColor="@color/pc_text"
android:textSize="16sp" />
<Button
android:id="@+id/btn_revoke_one"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:background="@drawable/pc_btn_danger"
android:focusable="true"
android:paddingStart="18dp"
android:paddingEnd="18dp"
android:text="Revoke client"
android:textColor="@color/pc_red" />
</LinearLayout>
<LinearLayout
android:id="@+id/log_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:text="Logs"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<ScrollView
android:id="@+id/log_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:focusable="true">
<TextView
android:id="@+id/log_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp" />
</ScrollView>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/guided_step_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fixed mode: D-pad cross (placement) + vertical zoom stack on the right. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="horizontal">
<!-- Zoom stack -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="28dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<Button
android:id="@+id/fixed_zoom_in"
android:layout_width="64dp"
android:layout_height="48dp"
android:layout_marginBottom="4dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:nextFocusDown="@+id/fixed_zoom_out"
android:nextFocusRight="@+id/fixed_center"
android:nextFocusUp="@+id/header_overflow"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="20sp" />
<TextView
android:id="@+id/fixed_zoom_value"
android:layout_width="64dp"
android:layout_height="48dp"
android:layout_marginBottom="4dp"
android:focusable="false"
android:gravity="center"
android:text="1.0×"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<Button
android:id="@+id/fixed_zoom_out"
android:layout_width="64dp"
android:layout_height="48dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:nextFocusUp="@+id/fixed_zoom_in"
android:nextFocusRight="@+id/fixed_center"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="20sp" />
</LinearLayout>
<!-- Direction cross -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<Button
android:id="@+id/fixed_up"
android:layout_width="56dp"
android:layout_height="48dp"
android:layout_marginBottom="4dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:nextFocusUp="@+id/header_overflow"
android:text="▲"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="16sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/fixed_left"
android:layout_width="56dp"
android:layout_height="48dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:nextFocusLeft="@+id/fixed_zoom_in"
android:text="◀"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="16sp" />
<Button
android:id="@+id/fixed_center"
android:layout_width="56dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:text="●"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="14sp" />
<Button
android:id="@+id/fixed_right"
android:layout_width="56dp"
android:layout_height="48dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:text="▶"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="16sp" />
</LinearLayout>
<Button
android:id="@+id/fixed_down"
android:layout_width="56dp"
android:layout_height="48dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:text="▼"
android:textAllCaps="false"
android:textColor="@color/pc_text"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pc_card_inner"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center_vertical"
android:nextFocusRight="@+id/client_revoke"
android:orientation="horizontal"
android:padding="12dp">
<!-- Left: client info (equal weight keeps Paired At centered) -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/client_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/pc_icon_circle_green"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/pc_green"
android:textSize="18sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/client_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/client_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/pc_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- Center: Paired At with separators on both sides -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="top"
android:orientation="horizontal">
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginEnd="28dp"
android:background="@color/pc_separator" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Paired At"
android:textColor="@color/pc_text_dim"
android:textSize="11sp" />
<TextView
android:id="@+id/client_paired_at"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/pc_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- Right: status + revoke (equal weight keeps center block centered) -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end|center_vertical"
android:orientation="horizontal"
android:paddingStart="28dp">
<LinearLayout
android:id="@+id/client_connected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_marginEnd="6dp"
android:background="@drawable/pc_dot_green" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:text="Connected"
android:textColor="@color/pc_green"
android:textSize="13sp" />
</LinearLayout>
<Button
android:id="@+id/client_revoke"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@drawable/pc_btn_danger"
android:focusable="true"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:text="Revoke"
android:textAllCaps="false"
android:textColor="@color/pc_red"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pc_card_inner"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<!-- Left: client info (equal weight keeps PIN centered) -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/pending_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/pc_icon_circle_yellow"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/pc_yellow"
android:textSize="18sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pairing request"
android:textColor="@color/pc_yellow"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/pending_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:textColor="@color/pc_text"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/pending_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/pc_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- Center: PIN with separators; fixed value-row height for timer alignment -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="top"
android:orientation="horizontal">
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginEnd="28dp"
android:background="@color/pc_separator" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="PIN"
android:textColor="@color/pc_text_dim"
android:textSize="11sp" />
<TextView
android:id="@+id/pending_pin"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:fontFamily="monospace"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:textColor="@color/pc_text"
android:textSize="22sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginStart="28dp"
android:background="@color/pc_separator" />
</LinearLayout>
<!-- Right: label tops with PIN; timer centered on PIN value row -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start"
android:orientation="vertical"
android:paddingStart="28dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Expires in"
android:textColor="@color/pc_text_dim"
android:textSize="11sp" />
<TextView
android:id="@+id/pending_expires"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:fontFamily="monospace"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:textColor="@color/pc_yellow"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_margin="4dp"
android:background="@drawable/pc_btn_secondary"
android:focusable="true"
android:textColor="@color/pc_text"
android:textSize="14sp" />
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pc_mode_item"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center_vertical"
android:minHeight="52dp"
android:orientation="horizontal"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/mode_icon"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@null"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/pc_text_secondary"
android:textSize="36sp" />
<TextView
android:id="@+id/mode_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:textColor="@color/pc_text_secondary"
android:textSize="18sp" />
<TextView
android:id="@+id/mode_check"
android:layout_width="36dp"
android:layout_height="36dp"
android:fontFamily="@font/lucide"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/pc_text"
android:textSize="18sp"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/lb_basic_card_bg_color"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Leanback.ImageCardView.Title" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="2dp"
android:textAppearance="@style/TextAppearance.Leanback.ImageCardView.Content" />
</LinearLayout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/stat_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:gravity="center"
android:text="●"
android:textColor="@color/pc_text_secondary"
android:textSize="12sp" />
<TextView
android:id="@+id/stat_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:textColor="@color/pc_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/stat_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/pc_text"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Reusable 1px horizontal separator (white @ 20%). Include with: <include layout="@layout/pc_separator_h" /> -->
<View xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/PcSeparatorHorizontal" />
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="pc_bg">#FF121418</color>
<color name="pc_card">#FF1C2025</color>
<color name="pc_card_inner">#FF252A31</color>
<color name="pc_card_inner_focus">#FF2E343C</color>
<color name="pc_stroke">#FF2A3038</color>
<color name="pc_blue">#FF2B7DE9</color>
<color name="pc_blue_soft">#332B7DE9</color>
<color name="pc_focus_outline">#FF8FC6F8</color>
<color name="pc_mode_selected">#FF1465B2</color>
<color name="pc_yellow">#FFFBBF24</color>
<color name="pc_red">#FFEF4444</color>
<color name="pc_red_soft">#33EF4444</color>
<color name="pc_green">#FF22C55E</color>
<color name="pc_green_soft">#3322C55E</color>
<color name="pc_text">#FFFFFFFF</color>
<color name="pc_text_secondary">#FF9CA3AF</color>
<color name="pc_text_dim">#FF6B7280</color>
<!-- Horizontal rule: 1px white @ 60% opacity -->
<color name="pc_separator">#33FFFFFF</color>
</resources>
+19 -1
View File
@@ -1 +1,19 @@
<resources><style name="AppTheme" parent="android:style/Theme.Material.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:colorAccent">#80cbc4</item></style></resources> <?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Leanback">
<item name="android:windowBackground">@color/pc_bg</item>
<item name="android:colorPrimary">@color/pc_blue</item>
<item name="android:colorAccent">@color/pc_blue</item>
<item name="defaultBrandColor">@color/pc_blue</item>
<item name="android:windowContentTransitions">true</item>
<item name="android:windowAllowEnterTransitionOverlap">true</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
<!-- 1px white @ 20% — use on a View, or via @layout/pc_separator_h -->
<style name="PcSeparatorHorizontal">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">1px</item>
<item name="android:background">@color/pc_separator</item>
</style>
</resources>
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PortalCapabilityTest"
include(":app")
@@ -0,0 +1,13 @@
package com.portaltv.capability
/**
* HTTP-facing auth check: validate a Bearer token against stored paired tokens.
* Implemented by [PortalAuthManager]; streaming handlers depend only on this interface.
*/
fun interface Authenticator {
/**
* @param authorizationHeader raw `Authorization` header value, or null
* @return the bearer token string if valid, else null
*/
fun authenticateBearer(authorizationHeader: String?): String?
}
@@ -0,0 +1,178 @@
package com.portaltv.capability
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.FragmentActivity
import androidx.leanback.app.GuidedStepSupportFragment
import androidx.leanback.transition.TransitionHelper
import androidx.leanback.widget.GuidanceStylist
import androidx.leanback.widget.GuidedAction
import androidx.leanback.R as LbR
/**
* Leanback guided confirmation for revoking one client or all clients.
*
* Options 1+3:
* 1) Custom entrance — separate left/right FadeAndShortSlide (stock packs both
* into one transition and the actions panel often skips).
* 3) setReorderingAllowed(true) + postponeEnterTransition until pre-draw.
*/
class ConfirmRevokeFragment : GuidedStepSupportFragment() {
override fun onProvideFragmentTransitions() {
if (uiStyle != UI_STYLE_ENTRANCE) {
super.onProvideFragmentTransitions()
return
}
val fade = TransitionHelper.createFadeTransition(
TransitionHelper.FADE_IN or TransitionHelper.FADE_OUT,
)
TransitionHelper.include(fade, LbR.id.guidedstep_background)
// Separate Visibility slides — one target side each.
val slideLeft = TransitionHelper.createFadeAndShortSlide(Gravity.START)
TransitionHelper.include(slideLeft, LbR.id.content_fragment)
val slideRight = TransitionHelper.createFadeAndShortSlide(Gravity.END)
TransitionHelper.include(slideRight, LbR.id.action_fragment_root)
TransitionHelper.include(slideRight, LbR.id.action_fragment)
TransitionHelper.include(slideRight, LbR.id.guidedactions_list)
val enter = TransitionHelper.createTransitionSet(false)
TransitionHelper.addTransition(enter, fade)
TransitionHelper.addTransition(enter, slideLeft)
TransitionHelper.addTransition(enter, slideRight)
enterTransition = enter
// Return is reverse of enter (two-part slide out) when finishing.
returnTransition = enter
sharedElementEnterTransition = null
val exit = TransitionHelper.createFadeAndShortSlide(Gravity.START)
TransitionHelper.exclude(exit, LbR.id.guidedstep_background, true)
exitTransition = exit
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postponeEnterTransition()
(view.parent as? View ?: view).doOnPreDraw {
startPostponedEnterTransition()
}
}
override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
val all = requireArguments().getBoolean(ARG_ALL)
val title = if (all) "Revoke all clients?" else "Revoke client?"
val description = if (all) {
"This removes every paired client. They will need to pair again."
} else {
val name = requireArguments().getString(ARG_NAME).orEmpty().ifBlank { "this client" }
"Remove $name? It will need to pair again."
}
val icon: Drawable? = null
return GuidanceStylist.Guidance(title, description, "PortalCam", icon)
}
override fun onCreateActions(actions: MutableList<GuidedAction>, savedInstanceState: Bundle?) {
actions.add(
GuidedAction.Builder(requireContext())
.id(ACTION_CONFIRM)
.title("Revoke")
.description(
if (requireArguments().getBoolean(ARG_ALL)) "Remove all paired clients"
else "Remove this client",
)
.build(),
)
actions.add(
GuidedAction.Builder(requireContext())
.id(ACTION_CANCEL)
.title("Cancel")
.build(),
)
}
override fun onGuidedActionClicked(action: GuidedAction) {
when (action.id) {
ACTION_CONFIRM -> {
performRevoke()
if (requireArguments().getBoolean(ARG_CLOSE_DETAIL)) {
parentFragmentManager.setFragmentResult(
RESULT_KEY,
Bundle().apply { putBoolean(RESULT_CLOSE_DETAIL, true) },
)
}
finishGuidedStepSupportFragments()
}
ACTION_CANCEL -> finishGuidedStepSupportFragments()
}
}
private fun performRevoke() {
val act = requireActivity()
val intent = Intent(act, PortalStreamingService::class.java)
if (requireArguments().getBoolean(ARG_ALL)) {
intent.action = "com.portaltv.capability.REVOKE_ALL"
} else {
intent.action = "com.portaltv.capability.REVOKE_CLIENT"
intent.putExtra("tokenHash", requireArguments().getString(ARG_HASH))
}
if (Build.VERSION.SDK_INT >= 26) act.startForegroundService(intent) else act.startService(intent)
}
companion object {
const val RESULT_KEY = "confirm_revoke"
const val RESULT_CLOSE_DETAIL = "close_detail"
private const val ARG_ALL = "all"
private const val ARG_HASH = "hash"
private const val ARG_NAME = "name"
private const val ARG_CLOSE_DETAIL = "close_detail"
private const val ACTION_CONFIRM = 1L
private const val ACTION_CANCEL = 2L
/** Must match Leanback's ENTRANCE back-stack name for finishGuidedStepSupportFragments. */
private const val STACK_ENTRANCE = "GuidedStepEntrance"
private const val TAG = "leanBackGuidedStepSupportFragment"
fun showRevokeAll(activity: FragmentActivity) {
show(activity, Bundle().apply { putBoolean(ARG_ALL, true) })
}
fun showRevokeClient(
activity: FragmentActivity,
tokenHash: String,
displayName: String,
closeDetail: Boolean = false,
) {
show(
activity,
Bundle().apply {
putBoolean(ARG_ALL, false)
putString(ARG_HASH, tokenHash)
putString(ARG_NAME, displayName)
putBoolean(ARG_CLOSE_DETAIL, closeDetail)
},
)
}
private fun show(activity: FragmentActivity, args: Bundle) {
val fragment = ConfirmRevokeFragment().apply {
arguments = args
uiStyle = UI_STYLE_ENTRANCE
}
// Option 3: reorderingAllowed so postponed enter captures start/end correctly.
activity.supportFragmentManager.beginTransaction()
.setReorderingAllowed(true)
.addToBackStack(STACK_ENTRANCE)
.replace(R.id.guided_step_overlay, fragment, TAG)
.commit()
}
}
}
@@ -0,0 +1,46 @@
package com.portaltv.capability
import android.content.Context
import android.graphics.Typeface
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
/** Lucide icon-font glyphs (codepoints from lucide-static `info.json`, same release as res/font/lucide.ttf). */
object LucideIcons {
const val CAMERA = "\uE064"
const val SPARKLES = "\uE412"
const val FOLDERS = "\uE33F"
const val USERS = "\uE1A4"
const val MAP_PIN = "\uE111"
const val LOCATE_FIXED = "\uE1DB"
const val SETTINGS = "\uE154"
const val SETTINGS_2 = "\uE245"
const val HOURGLASS = "\uE296"
const val MONITOR = "\uE11D"
const val LAPTOP = "\uE1CD"
const val VIDEO = "\uE1A5"
const val MIC = "\uE118"
const val UPLOAD = "\uE19E"
const val TRASH_2 = "\uE18E"
const val ELLIPSIS_VERTICAL = "\uE0B7"
const val CHECK = "\uE06C"
const val CIRCLE_CHECK = "\uE226"
const val ZOOM_IN = "\uE1B6"
const val ZOOM_OUT = "\uE1B7"
@Volatile private var typeface: Typeface? = null
fun typeface(context: Context): Typeface {
typeface?.let { return it }
val tf = ResourcesCompat.getFont(context, R.font.lucide)
?: error("Missing R.font.lucide")
typeface = tf
return tf
}
fun apply(view: TextView, glyph: String) {
view.typeface = typeface(view.context)
view.text = glyph
view.includeFontPadding = false
}
}
@@ -1,35 +1,39 @@
package com.portaltv.capability; package com.portaltv.capability;
import android.Manifest; import android.Manifest;
import android.app.*; import android.content.Intent;
import android.os.*;
import android.content.*;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.ImageFormat; import android.graphics.ImageFormat;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.hardware.camera2.*; import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap; import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.*; import android.media.*;
import android.os.*;
import android.util.Size; import android.util.Size;
import android.view.*; import android.view.*;
import android.widget.*; import android.widget.*;
import java.util.*; import androidx.fragment.app.FragmentActivity;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.concurrent.*; import java.util.*;
public class MainActivity extends Activity { /**
TextView authStatus; * Portal TV UI — dashboard mockup bound by [PortalCamUi].
LinearLayout authPanel; * Legacy local camera/HTTP helpers remain below for debugging; live path is PortalStreamingService.
@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); } */
public class MainActivity extends FragmentActivity {
@Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; 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 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(); } @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; PortalCamUi ui;
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();
float fx=0.5f, fy=0.5f, fs=1.0f;
final PortalSmartCamera.StateListener cameraStateListener=state->{ final PortalSmartCamera.StateListener cameraStateListener=state->{
final String mode="ModeSetting_"+state.getMode();
final org.json.JSONObject cfg=state.getConfig(); final org.json.JSONObject cfg=state.getConfig();
runOnUiThread(()->{ runOnUiThread(()->{
setCurrentMode(mode);
if("Fixed".equals(state.getMode())&&cfg!=null){ if("Fixed".equals(state.getMode())&&cfg!=null){
try{ try{
if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX"); if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX");
@@ -37,88 +41,55 @@ public class MainActivity extends Activity {
if(cfg.has("scale")) fs=(float)cfg.getDouble("scale"); if(cfg.has("scale")) fs=(float)cfg.getDouble("scale");
}catch(Exception e){p("state config parse: "+e);} }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 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;} } }; 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();
@Override
public void onCreate(Bundle b) {
super.onCreate(b);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
ui=new PortalCamUi(this, this::p);
ui.setFixedNudge(this::nudgeFixed);
ui.setFixedRecenter(this::recenterFixed);
ui.setOnSelectMode(this::setSmartMode);
ui.bind();
PortalAuthManager.start(this);
PortalSmartCamera.start(this); PortalSmartCamera.start(this);
PortalSmartCamera.addStateListener(cameraStateListener); 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); PortalSmartCamera.addStateListener(ui.getCameraListener());
PortalAuthManager.addSnapshotListener(ui.getAuthListener());
PortalStreamer.addSnapshotListener(ui.getStreamListener());
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(); 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(); 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); Intent service = new Intent(this, PortalStreamingService.class);
if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service); if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service);
preview=new ImageView(this);
} }
volatile boolean pipelineStarted; 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"); } 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 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;} } @Override
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; } public void onBackPressed(){
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 "?"; } if(ui!=null && ui.handleBack()) return;
// ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ---- super.onBackPressed();
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 p(String 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?">":"<"); } android.util.Log.d("PortalCap",x);
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":"^"); } runOnUiThread(()->{ if(ui!=null && ui.getLogText()!=null) ui.getLogText().append(String.format("%tT ",System.currentTimeMillis())+x+"\n"); });
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); } }); } public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
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"));} // ---- legacy inspect / local pipeline (unchanged behavior) ----
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 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){ void testCamera(final String id){
if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;} if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;}
@@ -127,7 +98,7 @@ public class MainActivity extends Activity {
try { try {
reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2); reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2);
startVideoEncoder(); startVideoEncoder();
frameBusy.set(false); frameCount=0; frameBusy.set(false);
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); 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(){ cm.openCamera(id,new CameraDevice.StateCallback(){
public void onOpened(CameraDevice c){ public void onOpened(CameraDevice c){
@@ -155,14 +126,29 @@ public class MainActivity extends Activity {
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.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); 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; 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 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 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 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; }
void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; }catch(Exception e){ p("ts mux: "+e); } } }).start(); }
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]; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) {} } } }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 setSmartMode(String mode){ if(mode.equals("Fixed")) PortalSmartCamera.setMode(mode,fx,fy,fs); else PortalSmartCamera.setMode(mode); } 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 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 recenterFixed(){ fx=0.5f; fy=0.5f; setSmartMode("Fixed"); }
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);} } @Override
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);} } protected void onDestroy(){
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);} } if(ui!=null){
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();} ui.destroy();
PortalSmartCamera.removeStateListener(ui.getCameraListener());
PortalAuthManager.removeSnapshotListener(ui.getAuthListener());
PortalStreamer.removeSnapshotListener(ui.getStreamListener());
}
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,317 @@
package com.portaltv.capability
import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* Owns pairing sessions and paired bearer tokens.
* HTTP handlers use [Authenticator] for Bearer checks; UI observes [Snapshot] via listeners.
*/
object PortalAuthManager : Authenticator {
private const val PREFS = "auth"
private const val KEY_TOKENS = "tokens"
private const val SWEEP_MS = 2_000L
data class PendingPairing(
val pairingId: String,
val pin: String,
val expiresAtMs: Long,
val clientName: String,
val userAgent: String,
val version: String,
val deviceModel: String,
val remoteHost: String,
)
data class PairedClient(
val tokenHash: String,
val displayName: String,
val userAgent: String,
val version: String,
val deviceModel: String,
val pairedAtMs: Long,
)
data class Snapshot(
val pending: List<PendingPairing> = emptyList(),
val clients: List<PairedClient> = emptyList(),
)
fun interface SnapshotListener {
fun onSnapshot(snapshot: Snapshot)
}
sealed class InitResult {
data class Ok(val session: PortalSrp.ActivePairing, val pending: PendingPairing) : InitResult()
}
sealed class VerifyOutcome {
data class Success(val M2: ByteArray, val token: String, val client: PairedClient) : VerifyOutcome()
data class Failed(val attemptsLeft: Int, val message: String, val httpStatus: Int = 401) : VerifyOutcome()
}
private data class SessionEntry(
val crypto: PortalSrp.ActivePairing,
val pending: PendingPairing,
)
private val random = SecureRandom()
private val sessions = ConcurrentHashMap<String, SessionEntry>()
private val listeners = CopyOnWriteArrayList<SnapshotListener>()
private val _snapshots = MutableSharedFlow<Snapshot>(replay = 1, extraBufferCapacity = 16)
val snapshots: SharedFlow<Snapshot> = _snapshots.asSharedFlow()
private val mainHandler = Handler(Looper.getMainLooper())
private var prefs: SharedPreferences? = null
@Volatile private var latest = Snapshot()
private var sweepStarted = false
private val sweepRunnable = object : Runnable {
override fun run() {
evictExpired()
mainHandler.postDelayed(this, SWEEP_MS)
}
}
@JvmStatic
fun start(context: Context) {
synchronized(this) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
latest = Snapshot(pending = emptyList(), clients = loadClientsLocked())
emit(latest)
}
if (!sweepStarted) {
sweepStarted = true
mainHandler.postDelayed(sweepRunnable, SWEEP_MS)
}
}
}
@JvmStatic
fun current(): Snapshot = latest
@JvmStatic
fun addSnapshotListener(listener: SnapshotListener) {
listeners.add(listener)
listener.onSnapshot(latest)
}
@JvmStatic
fun removeSnapshotListener(listener: SnapshotListener) {
listeners.remove(listener)
}
/** Begin a new pairing session (always a fresh PIN / pairingId). */
@JvmStatic
fun beginPairing(
clientName: String,
userAgent: String,
version: String,
deviceModel: String,
remoteHost: String,
): InitResult.Ok {
ensureStarted()
val pin = (100000 + random.nextInt(900000)).toString()
val crypto = PortalSrp.newPairing(pin)
val pending = PendingPairing(
pairingId = crypto.id,
pin = pin,
expiresAtMs = crypto.expiresAt,
clientName = clientName.ifBlank { "Unknown client" },
userAgent = userAgent,
version = version,
deviceModel = deviceModel,
remoteHost = remoteHost,
)
sessions[crypto.id] = SessionEntry(crypto, pending)
publish()
android.util.Log.i("PortalAuth", "pairing started id=${crypto.id} pin=$pin host=$remoteHost model=$deviceModel")
return InitResult.Ok(crypto, pending)
}
@JvmStatic
fun completePairing(
pairingId: String,
A: String,
M1: String,
tlsHash: ByteArray,
clientName: String,
userAgent: String,
version: String,
deviceModel: String,
): VerifyOutcome {
ensureStarted()
val entry = sessions[pairingId]
?: return VerifyOutcome.Failed(0, "No active pairing for id", 400)
if (System.currentTimeMillis() > entry.crypto.expiresAt) {
sessions.remove(pairingId)
publish()
return VerifyOutcome.Failed(0, "Pairing session expired", 400)
}
val res = PortalSrp.verifyClient(entry.crypto, A, M1, tlsHash)
return when (res) {
is PortalSrp.VerifyResult.Success -> {
sessions.remove(pairingId)
val name = clientName.ifBlank { entry.pending.clientName }
val ua = userAgent.ifBlank { entry.pending.userAgent }
val ver = version.ifBlank { entry.pending.version }
val model = deviceModel.ifBlank { entry.pending.deviceModel }
val client = storeToken(res.token, name, ua, ver, model)
publish()
android.util.Log.i("PortalAuth", "pairing ok client=$name model=$model hash=${client.tokenHash.take(8)}")
VerifyOutcome.Success(res.M2, res.token, client)
}
is PortalSrp.VerifyResult.Failed -> {
if (res.attemptsLeft <= 0) {
sessions.remove(pairingId)
publish()
}
VerifyOutcome.Failed(res.attemptsLeft, res.message, 401)
}
}
}
override fun authenticateBearer(authorizationHeader: String?): String? {
ensureStarted()
val a = authorizationHeader ?: return null
if (!a.startsWith("Bearer ")) return null
val token = a.substring(7).trim()
if (token.isEmpty()) return null
val h = hash(token)
val set = prefs?.getStringSet(KEY_TOKENS, emptySet()) ?: emptySet()
return if (set.contains(h)) token else null
}
@JvmStatic
fun cancelPairing(pairingId: String) {
ensureStarted()
if (sessions.remove(pairingId) != null) {
publish()
android.util.Log.i("PortalAuth", "cancelled pairing $pairingId")
}
}
@JvmStatic
fun revoke(tokenHash: String) {
ensureStarted()
val p = prefs ?: return
val set = (p.getStringSet(KEY_TOKENS, emptySet()) ?: emptySet()).toMutableSet()
if (!set.remove(tokenHash)) return
p.edit().putStringSet(KEY_TOKENS, set).remove("client.$tokenHash").apply()
publish()
android.util.Log.i("PortalAuth", "revoked ${tokenHash.take(8)}")
}
@JvmStatic
fun revokeAll() {
ensureStarted()
val p = prefs ?: return
val keys = p.all.keys.filter { it.startsWith("client.") }
val ed = p.edit().remove(KEY_TOKENS)
keys.forEach { ed.remove(it) }
ed.apply()
sessions.clear()
publish()
android.util.Log.i("PortalAuth", "revoked all clients")
}
private fun evictExpired() {
val now = System.currentTimeMillis()
var changed = false
val expired = sessions.filter { (_, e) -> now > e.crypto.expiresAt || e.crypto.attemptsLeft <= 0 }.keys
for (id in expired) {
if (sessions.remove(id) != null) changed = true
}
if (changed) {
android.util.Log.i("PortalAuth", "evicted ${expired.size} expired pairing(s)")
publish()
}
}
private fun storeToken(
token: String,
name: String,
ua: String,
version: String,
deviceModel: String,
): PairedClient {
val p = prefs ?: error("PortalAuthManager not started")
val h = hash(token)
val pairedAt = System.currentTimeMillis()
val set = (p.getStringSet(KEY_TOKENS, emptySet()) ?: emptySet()).toMutableSet()
set += h
// ua|name|version|pairedAt|deviceModel
val meta = listOf(
ua.ifBlank { "unknown" },
name,
version,
pairedAt.toString(),
deviceModel,
).joinToString("|")
p.edit().putStringSet(KEY_TOKENS, set).putString("client.$h", meta).apply()
// Drop legacy single-PIN key if present
p.edit().remove("pairingPin").apply()
return PairedClient(
tokenHash = h,
displayName = name.ifBlank { "Paired client" },
userAgent = ua,
version = version,
deviceModel = deviceModel,
pairedAtMs = pairedAt,
)
}
private fun loadClientsLocked(): List<PairedClient> {
val p = prefs ?: return emptyList()
val set = p.getStringSet(KEY_TOKENS, emptySet()) ?: emptySet()
return set.mapNotNull { h ->
val m = p.getString("client.$h", null) ?: return@mapNotNull null
val parts = m.split("|")
PairedClient(
tokenHash = h,
displayName = parts.getOrNull(1)?.ifBlank { null } ?: "Paired client",
userAgent = parts.getOrNull(0) ?: "unknown",
version = parts.getOrNull(2) ?: "",
pairedAtMs = parts.getOrNull(3)?.toLongOrNull() ?: 0L,
deviceModel = parts.getOrNull(4) ?: "",
)
}.sortedByDescending { it.pairedAtMs }
}
private fun publish() {
val pending = sessions.values.map { it.pending }.sortedBy { it.expiresAtMs }
val clients = synchronized(this) { loadClientsLocked() }
val next = Snapshot(pending = pending, clients = clients)
latest = next
emit(next)
}
private fun emit(s: Snapshot) {
_snapshots.tryEmit(s)
listeners.forEach { runCatching { it.onSnapshot(s) } }
}
private fun ensureStarted() {
check(prefs != null) { "PortalAuthManager.start(context) required" }
}
private fun hash(s: String): String =
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
/** SHA-256 hex of a raw bearer token (same id used in [PairedClient.tokenHash]). */
@JvmStatic
fun tokenHash(token: String): String = hash(token)
}
@@ -0,0 +1,536 @@
package com.portaltv.capability
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.fragment.app.FragmentActivity
import androidx.leanback.app.GuidedStepSupportFragment
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Binds the PortalCam dashboard mockup layout and keeps it event-driven.
*/
class PortalCamUi(
private val activity: FragmentActivity,
private val onLog: java.util.function.Consumer<String>,
) {
private val inflater = LayoutInflater.from(activity)
private val hUi = Handler(Looper.getMainLooper())
private lateinit var modesList: LinearLayout
private lateinit var controlsList: LinearLayout
private lateinit var controlsEmpty: View
private lateinit var clientsList: LinearLayout
private lateinit var clientsBadge: TextView
private lateinit var clientsPane: View
private lateinit var clientDetail: View
private lateinit var detailBody: TextView
private lateinit var logOverlay: View
private lateinit var logScroll: ScrollView
lateinit var logText: TextView
private set
private lateinit var headerClock: TextView
private lateinit var headerStreaming: TextView
private lateinit var headerStreamingDot: View
private lateinit var headerReady: TextView
private lateinit var statVideo: TextView
private lateinit var statAudio: TextView
private lateinit var statVideoBw: TextView
private lateinit var statAudioBw: TextView
private var currentMode: String? = null
private var controlsMode: String? = null
private var fixedCrop: Triple<Float, Float, Float> = Triple(0.5f, 0.5f, 1f)
private var fixedZoomValue: TextView? = null
private var detailTokenHash: String? = null
private var logExpanded = false
private var lastAuth = PortalAuthManager.current()
private var lastStream = PortalStreamer.current()
private val modeButtons = linkedMapOf<String, View>()
private val clockFmt = SimpleDateFormat("MMM d, yyyy\nh:mm a", Locale.US)
private val pairedAtFmt = SimpleDateFormat("MMM d, yyyy", Locale.US)
private val clockTicker = object : Runnable {
override fun run() {
headerClock.text = clockFmt.format(Date())
hUi.postDelayed(this, 30_000)
}
}
private val pendingTicker = object : Runnable {
override fun run() {
if (updatePendingCountdowns()) {
hUi.postDelayed(this, 1000)
}
}
}
val cameraListener = PortalSmartCamera.StateListener { state ->
activity.runOnUiThread {
currentMode = "ModeSetting_${state.mode}"
renderModes()
val cfg = state.config
if (state.mode == "Fixed" && cfg != null) {
applyFixedCropFromConfig(cfg)?.let { fixedCrop = it }
}
renderControls()
onLog.accept("Camera state -> ${state.mode} $cfg")
}
}
val authListener = PortalAuthManager.SnapshotListener { snap ->
activity.runOnUiThread {
lastAuth = snap
renderClients()
schedulePendingTicker()
}
}
val streamListener = PortalStreamer.SnapshotListener { snap ->
activity.runOnUiThread {
val prevConnected = lastStream.connectedTokenHashes
lastStream = snap
renderStats()
renderHeaderStatus()
if (snap.connectedTokenHashes != prevConnected) {
updateClientConnectionStatus()
}
}
}
fun bind() {
activity.setContentView(R.layout.activity_main)
modesList = activity.findViewById(R.id.modes_list)
controlsList = activity.findViewById(R.id.controls_list)
controlsEmpty = activity.findViewById(R.id.controls_empty)
clientsList = activity.findViewById(R.id.clients_list)
clientsBadge = activity.findViewById(R.id.clients_badge)
clientsPane = activity.findViewById(R.id.clients_pane)
clientDetail = activity.findViewById(R.id.client_detail)
detailBody = activity.findViewById(R.id.detail_body)
logOverlay = activity.findViewById(R.id.log_overlay)
logScroll = activity.findViewById(R.id.log_scroll)
logText = activity.findViewById(R.id.log_text)
headerClock = activity.findViewById(R.id.header_clock)
headerStreaming = activity.findViewById(R.id.header_streaming)
headerStreamingDot = activity.findViewById(R.id.header_streaming_dot)
headerReady = activity.findViewById(R.id.header_ready)
// Fixed text-column width = "Streaming" so Idle doesn't shift the separator (~8px after).
val statusText = activity.findViewById<View>(R.id.header_status_text)
statusText.minimumWidth = headerStreaming.paint.measureText("Streaming").toInt()
statVideo = activity.findViewById(R.id.stat_video)
statAudio = activity.findViewById(R.id.stat_audio)
statVideoBw = activity.findViewById(R.id.stat_video_bw)
statAudioBw = activity.findViewById(R.id.stat_audio_bw)
activity.findViewById<View>(R.id.header_overflow).setOnClickListener { toggleLog() }
activity.findViewById<View>(R.id.btn_revoke_all).setOnClickListener {
ConfirmRevokeFragment.showRevokeAll(activity)
}
activity.findViewById<Button>(R.id.btn_revoke_one).setOnClickListener {
val hash = detailTokenHash ?: return@setOnClickListener
val name = lastAuth.clients.firstOrNull { it.tokenHash == hash }?.let {
it.deviceModel.ifBlank { it.displayName }.ifBlank { "this client" }
} ?: "this client"
ConfirmRevokeFragment.showRevokeClient(activity, hash, name, closeDetail = true)
}
activity.supportFragmentManager.setFragmentResultListener(
ConfirmRevokeFragment.RESULT_KEY,
activity,
) { _, bundle ->
if (bundle.getBoolean(ConfirmRevokeFragment.RESULT_CLOSE_DETAIL)) showClientList()
}
LucideIcons.apply(activity.findViewById(R.id.header_overflow), LucideIcons.ELLIPSIS_VERTICAL)
LucideIcons.apply(activity.findViewById(R.id.controls_empty_icon), LucideIcons.SETTINGS_2)
LucideIcons.apply(activity.findViewById(R.id.stat_video_icon), LucideIcons.VIDEO)
LucideIcons.apply(activity.findViewById(R.id.stat_audio_icon), LucideIcons.MIC)
LucideIcons.apply(activity.findViewById(R.id.stat_video_bw_icon), LucideIcons.UPLOAD)
LucideIcons.apply(activity.findViewById(R.id.stat_audio_bw_icon), LucideIcons.UPLOAD)
LucideIcons.apply(activity.findViewById(R.id.btn_revoke_all_icon), LucideIcons.TRASH_2)
buildModes()
renderModes()
renderControls()
renderStats()
renderHeaderStatus()
renderClients()
hUi.post(clockTicker)
}
fun destroy() {
hUi.removeCallbacks(clockTicker)
hUi.removeCallbacks(pendingTicker)
}
fun handleBack(): Boolean {
val guided = GuidedStepSupportFragment.getCurrentGuidedStepSupportFragment(
activity.supportFragmentManager,
)
if (guided != null) {
guided.finishGuidedStepSupportFragments()
return true
}
if (logExpanded) {
toggleLog()
return true
}
if (clientDetail.visibility == View.VISIBLE) {
showClientList()
return true
}
return false
}
fun applyFixedCropFromConfig(cfg: org.json.JSONObject?): Triple<Float, Float, Float>? {
if (cfg == null) return null
return try {
Triple(
cfg.optDouble("centerX", 0.5).toFloat(),
cfg.optDouble("centerY", 0.5).toFloat(),
cfg.optDouble("scale", 1.0).toFloat(),
)
} catch (_: Exception) {
null
}
}
private fun buildModes() {
modesList.removeAllViews()
modeButtons.clear()
val icons = mapOf(
"DefaultAuto" to LucideIcons.SPARKLES,
"Desk" to LucideIcons.MONITOR,
"Meeting" to LucideIcons.USERS,
"Fixed" to LucideIcons.LOCATE_FIXED,
)
val modes = listOf("DefaultAuto", "Desk", "Meeting", "Fixed")
for ((index, mode) in modes.withIndex()) {
val row = inflater.inflate(R.layout.item_mode, modesList, false)
LucideIcons.apply(row.findViewById(R.id.mode_icon), icons.getValue(mode))
LucideIcons.apply(row.findViewById(R.id.mode_check), LucideIcons.CIRCLE_CHECK)
row.findViewById<TextView>(R.id.mode_title).text =
if (mode == "DefaultAuto") "Auto" else mode
row.setOnClickListener { setSmartMode(mode) }
val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// Gaps between buttons only; card paddingBottom (8dp) is the edge inset.
if (index < modes.lastIndex) lp.bottomMargin = dp(8)
modesList.addView(row, lp)
modeButtons[mode] = row
}
modeButtons["DefaultAuto"]?.nextFocusUpId = R.id.header_overflow
modeButtons["DefaultAuto"]?.let { auto ->
if (auto.id == View.NO_ID) auto.id = View.generateViewId()
val logs = activity.findViewById<View>(R.id.header_overflow)
logs.nextFocusLeftId = auto.id
logs.nextFocusDownId = auto.id
}
modeButtons["Fixed"]?.let { fixed ->
if (fixed.id == View.NO_ID) fixed.id = View.generateViewId()
activity.findViewById<View>(R.id.btn_revoke_all).nextFocusUpId = fixed.id
}
}
private fun renderModes() {
val short = currentMode?.removePrefix("ModeSetting_") ?: ""
for ((mode, row) in modeButtons) {
val selected = mode == short
row.isSelected = selected
row.findViewById<TextView>(R.id.mode_check).visibility =
if (selected) View.VISIBLE else View.GONE
row.findViewById<TextView>(R.id.mode_check).setTextColor(
activity.getColor(R.color.pc_text)
)
val icon = row.findViewById<TextView>(R.id.mode_icon)
icon.setBackgroundResource(0)
icon.setTextColor(
activity.getColor(if (selected) R.color.pc_text else R.color.pc_text_secondary)
)
row.findViewById<TextView>(R.id.mode_title).setTextColor(
activity.getColor(if (selected) R.color.pc_text else R.color.pc_text_secondary)
)
row.findViewById<TextView>(R.id.mode_title).paint.isFakeBoldText = selected
}
}
private fun renderControls() {
val short = currentMode?.removePrefix("ModeSetting_") ?: ""
// Keep focus: only rebuild the panel when the mode changes.
if (short == controlsMode) {
if (short == "Fixed") updateFixedZoomLabel()
return
}
controlsMode = short
controlsList.removeAllViews()
fixedZoomValue = null
when (short) {
"Fixed" -> {
controlsEmpty.visibility = View.GONE
controlsList.visibility = View.VISIBLE
val panel = inflater.inflate(R.layout.controls_fixed, controlsList, false)
fixedZoomValue = panel.findViewById(R.id.fixed_zoom_value)
updateFixedZoomLabel()
LucideIcons.apply(panel.findViewById(R.id.fixed_zoom_in), LucideIcons.ZOOM_IN)
LucideIcons.apply(panel.findViewById(R.id.fixed_zoom_out), LucideIcons.ZOOM_OUT)
panel.findViewById<View>(R.id.fixed_up).setOnClickListener {
nudgeFixed(0f, -0.05f, 1f)
}
panel.findViewById<View>(R.id.fixed_down).setOnClickListener {
nudgeFixed(0f, 0.05f, 1f)
}
panel.findViewById<View>(R.id.fixed_left).setOnClickListener {
nudgeFixed(-0.05f, 0f, 1f)
}
panel.findViewById<View>(R.id.fixed_right).setOnClickListener {
nudgeFixed(0.05f, 0f, 1f)
}
panel.findViewById<View>(R.id.fixed_center).setOnClickListener {
fixedRecenter?.run()
?: PortalSmartCamera.setMode("Fixed", 0.5f, 0.5f, fixedCrop.third)
}
panel.findViewById<View>(R.id.fixed_zoom_in).setOnClickListener {
nudgeFixed(0f, 0f, 0.85f)
}
panel.findViewById<View>(R.id.fixed_zoom_out).setOnClickListener {
nudgeFixed(0f, 0f, 1.1765f)
}
controlsList.addView(panel)
}
else -> {
controlsList.visibility = View.GONE
controlsEmpty.visibility = View.VISIBLE
}
}
}
private fun updateFixedZoomLabel() {
val scale = fixedCrop.third.coerceIn(0.1f, 1f)
val zoom = 1f / scale
fixedZoomValue?.text = String.format(Locale.US, "%.1f×", zoom)
}
private fun renderStats() {
statVideo.text = lastStream.activeVideo.toString()
statAudio.text = lastStream.activeAudio.toString()
statVideoBw.text = formatBps(lastStream.videoUplinkBps)
statAudioBw.text = formatBps(lastStream.audioUplinkBps)
}
private fun renderHeaderStatus() {
val streaming = lastStream.activeVideo > 0 || lastStream.activeAudio > 0
headerStreaming.text = if (streaming) "Streaming" else "Idle"
// INVISIBLE keeps the label from shifting when the dot is off.
headerStreamingDot.visibility = if (streaming) View.VISIBLE else View.INVISIBLE
headerReady.visibility = if (lastStream.httpReady) View.VISIBLE else View.INVISIBLE
}
private fun renderClients() {
clientsList.removeAllViews()
val pending = lastAuth.pending
val clients = lastAuth.clients
clientsBadge.text = clients.size.toString()
updateRevokeAllEnabled(clients.isNotEmpty())
for (pe in pending) {
val row = inflater.inflate(R.layout.item_client_pending, clientsList, false)
row.tag = pe.expiresAtMs
LucideIcons.apply(row.findViewById(R.id.pending_icon), LucideIcons.HOURGLASS)
row.findViewById<TextView>(R.id.pending_name).text =
pe.deviceModel.ifBlank { "Unknown device" }
row.findViewById<TextView>(R.id.pending_meta).text =
listOf(pe.clientName, pe.version)
.filter { it.isNotBlank() }
.joinToString(" · ")
row.findViewById<TextView>(R.id.pending_pin).text = pe.pin
row.findViewById<TextView>(R.id.pending_expires).text = formatCountdown(pe.expiresAtMs)
row.id = View.generateViewId()
clientsList.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(8)
})
}
if (pending.isEmpty() && clients.isEmpty()) {
val empty = TextView(activity).apply {
text = "No paired clients"
setTextColor(activity.getColor(R.color.pc_text_dim))
textSize = 14f
setPadding(dp(4), dp(12), dp(4), dp(12))
}
clientsList.addView(empty)
}
for (c in clients) {
val row = inflater.inflate(R.layout.item_client_paired, clientsList, false)
row.tag = c.tokenHash
LucideIcons.apply(row.findViewById(R.id.client_icon), LucideIcons.MONITOR)
row.findViewById<TextView>(R.id.client_name).text =
c.deviceModel.ifBlank { "Unknown device" }
row.findViewById<TextView>(R.id.client_meta).text =
listOf(c.displayName, c.version)
.filter { it.isNotBlank() }
.joinToString(" · ")
row.findViewById<TextView>(R.id.client_paired_at).text =
if (c.pairedAtMs > 0) pairedAtFmt.format(Date(c.pairedAtMs)) else ""
row.findViewById<View>(R.id.client_connected).visibility =
if (c.tokenHash in lastStream.connectedTokenHashes) View.VISIBLE else View.GONE
val revokeBtn = row.findViewById<Button>(R.id.client_revoke)
revokeBtn.setOnClickListener {
val label = c.deviceModel.ifBlank { c.displayName }.ifBlank { "this client" }
ConfirmRevokeFragment.showRevokeClient(activity, c.tokenHash, label)
}
row.setOnClickListener { revokeBtn.requestFocus() }
row.id = View.generateViewId()
clientsList.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(8)
})
}
wireClientRevokeFocus()
detailTokenHash?.let { hash ->
val still = clients.firstOrNull { it.tokenHash == hash }
if (still != null) showClientDetail(still) else showClientList()
}
}
/** Revoke: Left → its cell; Up/Down → adjacent cell; Right stays put. */
private fun wireClientRevokeFocus() {
val n = clientsList.childCount
val revokeAll = activity.findViewById<View>(R.id.btn_revoke_all)
revokeAll.nextFocusRightId = R.id.btn_revoke_all
val first = (0 until n).map { clientsList.getChildAt(it) }.firstOrNull { it.isFocusable }
val fixed = modeButtons["Fixed"]
if (first != null) {
first.nextFocusUpId = if (revokeAll.isFocusable) R.id.btn_revoke_all else (fixed?.id ?: View.NO_ID)
if (revokeAll.isFocusable) revokeAll.nextFocusDownId = first.id
fixed?.nextFocusDownId = first.id
} else {
fixed?.nextFocusDownId =
if (revokeAll.isFocusable) R.id.btn_revoke_all else (fixed.id)
}
for (i in 0 until n) {
val row = clientsList.getChildAt(i)
val revoke = row.findViewById<View>(R.id.client_revoke) ?: continue
if (revoke.id == View.NO_ID) revoke.id = View.generateViewId()
revoke.nextFocusLeftId = row.id
revoke.nextFocusRightId = revoke.id
if (i > 0) revoke.nextFocusUpId = clientsList.getChildAt(i - 1).id
if (i < n - 1) revoke.nextFocusDownId = clientsList.getChildAt(i + 1).id
}
}
private fun updateRevokeAllEnabled(enabled: Boolean) {
val revokeAll = activity.findViewById<View>(R.id.btn_revoke_all)
val hadFocus = revokeAll.isFocused
revokeAll.isEnabled = enabled
revokeAll.isFocusable = enabled
revokeAll.isClickable = enabled
revokeAll.alpha = if (enabled) 1f else 0.4f
if (!enabled && hadFocus) {
modeButtons["Fixed"]?.requestFocus()
?: activity.findViewById<View>(R.id.header_overflow).requestFocus()
}
}
private fun showClientDetail(c: PortalAuthManager.PairedClient) {
detailTokenHash = c.tokenHash
val sb = StringBuilder()
sb.append(c.displayName).append('\n')
if (c.deviceModel.isNotBlank()) sb.append("Model: ").append(c.deviceModel).append('\n')
if (c.version.isNotBlank()) sb.append("Version: ").append(c.version).append('\n')
sb.append("UA: ").append(c.userAgent).append('\n')
if (c.pairedAtMs > 0) sb.append(String.format("Paired: %tF %tT\n", c.pairedAtMs, c.pairedAtMs))
sb.append("Token: ").append(c.tokenHash.take(12)).append('…')
detailBody.text = sb.toString()
clientsPane.visibility = View.GONE
clientDetail.visibility = View.VISIBLE
activity.findViewById<View>(R.id.btn_revoke_one).requestFocus()
}
private fun showClientList() {
detailTokenHash = null
clientDetail.visibility = View.GONE
clientsPane.visibility = View.VISIBLE
}
private fun toggleLog() {
logExpanded = !logExpanded
logOverlay.visibility = if (logExpanded) View.VISIBLE else View.GONE
clientsPane.visibility = if (logExpanded || clientDetail.visibility == View.VISIBLE) View.GONE else View.VISIBLE
if (logExpanded) clientDetail.visibility = View.GONE
activity.findViewById<View>(R.id.header_overflow).requestFocus()
}
private fun schedulePendingTicker() {
hUi.removeCallbacks(pendingTicker)
if (lastAuth.pending.isNotEmpty()) hUi.post(pendingTicker)
}
/** Updates countdown labels in place; returns true if any pending rows remain. */
private fun updatePendingCountdowns(): Boolean {
var any = false
for (i in 0 until clientsList.childCount) {
val row = clientsList.getChildAt(i)
val expiresAt = row.tag as? Long ?: continue
val expiresView = row.findViewById<TextView>(R.id.pending_expires) ?: continue
expiresView.text = formatCountdown(expiresAt)
any = true
}
return any
}
/** Toggles Connected badges without rebuilding rows (preserves focus). */
private fun updateClientConnectionStatus() {
val connected = lastStream.connectedTokenHashes
for (i in 0 until clientsList.childCount) {
val row = clientsList.getChildAt(i)
val hash = row.tag as? String ?: continue
val badge = row.findViewById<View>(R.id.client_connected) ?: continue
badge.visibility = if (hash in connected) View.VISIBLE else View.GONE
}
}
private fun formatCountdown(expiresAtMs: Long): String {
val left = ((expiresAtMs - System.currentTimeMillis()) / 1000L).coerceAtLeast(0)
return String.format(Locale.US, "%02d:%02d", left / 60, left % 60)
}
/** Mode selection — activity supplies Fixed crop state. */
var onSelectMode: java.util.function.Consumer<String>? = null
/** Fixed crop nudge — MainActivity supplies crop math. */
var fixedNudge: FixedNudge? = null
/** Recenter Fixed crop (keep zoom). */
var fixedRecenter: Runnable? = null
fun interface FixedNudge {
fun nudge(dx: Float, dy: Float, sm: Float)
}
private fun setSmartMode(mode: String) {
onSelectMode?.accept(mode) ?: PortalSmartCamera.setMode(mode)
}
private fun nudgeFixed(dx: Float, dy: Float, sm: Float) {
fixedNudge?.nudge(dx, dy, sm) ?: PortalSmartCamera.setMode("Fixed")
}
private fun dp(v: Int): Int = (v * activity.resources.displayMetrics.density).toInt()
companion object {
fun formatBps(bps: Long): String = when {
bps <= 0L -> "0 bit/s"
bps < 1000L -> "$bps bit/s"
bps < 1_000_000L -> String.format(Locale.US, "%.1f kbit/s", bps / 1000.0)
else -> String.format(Locale.US, "%.2f Mbit/s", bps / 1_000_000.0)
}
}
}
@@ -0,0 +1,11 @@
package com.portaltv.capability
/** Row model for Leanback grids (modes, controls, clients, actions). */
data class PortalRow(
val id: String,
val title: String,
val subtitle: String = "",
val selected: Boolean = false,
val pending: Boolean = false,
val payload: Any? = null,
)
@@ -0,0 +1,35 @@
package com.portaltv.capability
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.leanback.widget.Presenter
/** Leanback Presenter using stock Leanback text/card colors. */
class PortalRowPresenter : Presenter() {
class VH(val root: LinearLayout, val title: TextView, val subtitle: TextView) : ViewHolder(root)
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.item_portal_row, parent, false) as LinearLayout
return VH(root, root.findViewById(R.id.title), root.findViewById(R.id.subtitle))
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any?) {
val row = item as PortalRow
val vh = viewHolder as VH
vh.title.text = row.title
if (row.subtitle.isEmpty()) {
vh.subtitle.visibility = android.view.View.GONE
} else {
vh.subtitle.visibility = android.view.View.VISIBLE
vh.subtitle.text = row.subtitle
}
// selected = current mode; Leanback FocusHighlight handles focus chrome
vh.root.isActivated = row.selected
vh.root.alpha = if (row.pending) 0.92f else 1f
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
}
@@ -0,0 +1,164 @@
package com.portaltv.capability
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Process-wide stream metrics for the TV UI: active media clients, live sessions, uplink bitrates.
* Auth / pairing lives in [PortalAuthManager].
*/
object PortalStreamer {
enum class SessionKind { VIDEO, AUDIO, STATUS }
data class Snapshot(
val activeVideo: Int = 0,
val activeAudio: Int = 0,
val videoUplinkBps: Long = 0,
val audioUplinkBps: Long = 0,
val httpReady: Boolean = false,
/** Token hashes with at least one live video / audio / SSE status session. */
val connectedTokenHashes: Set<String> = emptySet(),
)
fun interface SnapshotListener {
fun onSnapshot(snapshot: Snapshot)
}
private val listeners = CopyOnWriteArrayList<SnapshotListener>()
private val _snapshots = MutableSharedFlow<Snapshot>(replay = 1, extraBufferCapacity = 16)
val snapshots: SharedFlow<Snapshot> = _snapshots.asSharedFlow()
@Volatile private var latest = Snapshot()
private val videoUsers = AtomicInteger()
private val audioUsers = AtomicInteger()
private val videoBytes = AtomicLong()
private val audioBytes = AtomicLong()
private val sampleLock = Any()
private var sampleStartedAt = System.nanoTime()
private var sampleVideoBytes = 0L
private var sampleAudioBytes = 0L
/** tokenHash → (kind → refcount) for live sessions. */
private val sessions = ConcurrentHashMap<String, ConcurrentHashMap<SessionKind, AtomicInteger>>()
@JvmStatic
fun current(): Snapshot = latest
@JvmStatic
fun addSnapshotListener(listener: SnapshotListener) {
listeners.add(listener)
listener.onSnapshot(latest)
}
@JvmStatic
fun removeSnapshotListener(listener: SnapshotListener) {
listeners.remove(listener)
}
@JvmStatic
fun setHttpReady(ready: Boolean) {
if (latest.httpReady == ready) return
latest = latest.copy(httpReady = ready)
emit(latest)
}
@JvmStatic
fun setActiveVideo(count: Int) {
videoUsers.set(count.coerceAtLeast(0))
publish()
}
@JvmStatic
fun setActiveAudio(count: Int) {
audioUsers.set(count.coerceAtLeast(0))
publish()
}
/** Begin a live session for [tokenHash] (video stream, audio stream, or SSE status). */
@JvmStatic
fun acquireSession(tokenHash: String, kind: SessionKind) {
if (tokenHash.isBlank()) return
val kinds = sessions.getOrPut(tokenHash) { ConcurrentHashMap() }
kinds.getOrPut(kind) { AtomicInteger(0) }.incrementAndGet()
publish()
}
/** End a live session previously acquired for [tokenHash]. */
@JvmStatic
fun releaseSession(tokenHash: String, kind: SessionKind) {
if (tokenHash.isBlank()) return
val kinds = sessions[tokenHash] ?: return
val counter = kinds[kind] ?: return
val left = counter.decrementAndGet()
if (left <= 0) kinds.remove(kind, counter)
if (kinds.isEmpty()) sessions.remove(tokenHash, kinds)
publish()
}
/** Record bytes written on the video uplink. */
@JvmStatic
fun recordVideoBytes(n: Int) {
if (n <= 0) return
videoBytes.addAndGet(n.toLong())
synchronized(sampleLock) { sampleVideoBytes += n.toLong() }
}
/** Record bytes written on the audio uplink. */
@JvmStatic
fun recordAudioBytes(n: Int) {
if (n <= 0) return
audioBytes.addAndGet(n.toLong())
synchronized(sampleLock) { sampleAudioBytes += n.toLong() }
}
/**
* Sample uplink rates over the window since the last call (or start).
* Call ~1s from the streaming service.
*/
@JvmStatic
fun sampleBandwidth() {
val now = System.nanoTime()
val videoBps: Long
val audioBps: Long
synchronized(sampleLock) {
val elapsedNs = (now - sampleStartedAt).coerceAtLeast(1L)
val elapsedSec = elapsedNs / 1_000_000_000.0
videoBps = if (elapsedSec > 0) ((sampleVideoBytes * 8) / elapsedSec).toLong() else 0L
audioBps = if (elapsedSec > 0) ((sampleAudioBytes * 8) / elapsedSec).toLong() else 0L
sampleVideoBytes = 0
sampleAudioBytes = 0
sampleStartedAt = now
}
val next = latest.copy(videoUplinkBps = videoBps, audioUplinkBps = audioBps)
if (next == latest) return
latest = next
emit(next)
}
private fun connectedHashes(): Set<String> =
sessions.mapNotNull { (hash, kinds) ->
if (kinds.values.any { it.get() > 0 }) hash else null
}.toSet()
private fun publish() {
val next = latest.copy(
activeVideo = videoUsers.get(),
activeAudio = audioUsers.get(),
connectedTokenHashes = connectedHashes(),
)
if (next == latest) return
latest = next
emit(next)
}
private fun emit(s: Snapshot) {
_snapshots.tryEmit(s)
listeners.forEach { runCatching { it.onSnapshot(s) } }
}
}
@@ -10,9 +10,6 @@ import android.view.Surface
import java.net.* import java.net.*
import java.util.concurrent.* import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
import javax.net.ssl.SSLServerSocket import javax.net.ssl.SSLServerSocket
/** Foreground, UI-independent Portal raw media service over HTTPS. */ /** Foreground, UI-independent Portal raw media service over HTTPS. */
@@ -29,9 +26,7 @@ class PortalStreamingService : Service() {
private val audioLock = Any() private val audioLock = Any()
@Volatile private var audioLoopActive = false @Volatile private var audioLoopActive = false
private var audioThread: Thread? = null private var audioThread: Thread? = null
private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) } private val authenticator: Authenticator = PortalAuthManager
private val random = SecureRandom()
@Volatile private var pairing: PortalSrp.ActivePairing? = null
@Volatile private var recoveringCamera = false @Volatile private var recoveringCamera = false
/** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */ /** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */
@Volatile private var cameraGeneration = 0 @Volatile private var cameraGeneration = 0
@@ -41,20 +36,20 @@ class PortalStreamingService : Service() {
super.onCreate() super.onCreate()
android.util.Log.d("PortalService", "onCreate - initializing TLS") android.util.Log.d("PortalService", "onCreate - initializing TLS")
startForeground(42, notification()) startForeground(42, notification())
PortalAuthManager.start(this)
PortalSmartCamera.start(this) PortalSmartCamera.start(this)
try { try {
server = PortalTls.createServerSocket(this, PortalEndpoints.PORT) server = PortalTls.createServerSocket(this, PortalEndpoints.PORT)
android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}") android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}")
PortalStreamer.setHttpReady(true)
mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) } mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e) android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e)
PortalStreamer.setHttpReady(false)
} }
cameraHandler.post(object : Runnable { cameraHandler.post(object : Runnable {
override fun run() { override fun run() {
authPrefs.edit() PortalStreamer.sampleBandwidth()
.putInt("activeVideo", videoUsers.get())
.putInt("activeAudio", audioUsers.get())
.apply()
cameraHandler.postDelayed(this, 1000) cameraHandler.postDelayed(this, 1000)
} }
}) })
@@ -66,10 +61,16 @@ class PortalStreamingService : Service() {
video.clear(); audio.clear(); return START_STICKY video.clear(); audio.clear(); return START_STICKY
} }
if (i?.action == "com.portaltv.capability.REVOKE_ALL") { if (i?.action == "com.portaltv.capability.REVOKE_ALL") {
authPrefs.edit().clear().apply(); video.clear(); audio.clear() PortalAuthManager.revokeAll()
video.clear(); audio.clear()
android.util.Log.i("PortalService", "all clients revoked") android.util.Log.i("PortalService", "all clients revoked")
return START_STICKY return START_STICKY
} }
if (i?.action == "com.portaltv.capability.REVOKE_CLIENT") {
val hash = i.getStringExtra("tokenHash")
if (!hash.isNullOrBlank()) PortalAuthManager.revoke(hash)
return START_STICKY
}
if (videoUsers.get() > 0 && reader == null) startVideo() if (videoUsers.get() > 0 && reader == null) startVideo()
return START_STICKY return START_STICKY
} }
@@ -99,35 +100,8 @@ class PortalStreamingService : Service() {
} }
} }
private fun hash(s: String) = private fun authToken(headers: Map<String, String>): String? =
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8)) authenticator.authenticateBearer(headers["authorization"])
.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) { private fun handle(s: Socket) {
s.use { socket -> s.use { socket ->
@@ -161,7 +135,7 @@ class PortalStreamingService : Service() {
when { when {
// SRP-6a pairing endpoints // SRP-6a pairing endpoints
path.startsWith("/auth/srp/init") -> handleSrpInit(socket) path.startsWith("/auth/srp/init") -> handleSrpInit(socket, headers)
path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers) path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers)
// TLS Info endpoint (returns server cert SHA-256 for diagnostics) // TLS Info endpoint (returns server cert SHA-256 for diagnostics)
@@ -172,15 +146,18 @@ class PortalStreamingService : Service() {
// Media streams (require Bearer auth) // Media streams (require Bearer auth)
path.startsWith("/video.h264") -> { path.startsWith("/video.h264") -> {
if (authToken(headers) != null) stream(socket, video, videoUsers, "video/h264", true) val token = authToken(headers)
if (token != null) stream(socket, video, videoUsers, "video/h264", true, token)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
} }
path.startsWith("/audio.aac") -> { path.startsWith("/audio.aac") -> {
if (authToken(headers) != null) stream(socket, audio, audioUsers, "audio/aac", false) val token = authToken(headers)
if (token != null) stream(socket, audio, audioUsers, "audio/aac", false, token)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
} }
path.startsWith("/control") -> { path.startsWith("/control") -> {
if (authToken(headers) != null) control(socket, path) val token = authToken(headers)
if (token != null) control(socket, path, token)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
} }
else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json") else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json")
@@ -188,22 +165,33 @@ class PortalStreamingService : Service() {
} }
} }
private fun handleSrpInit(s: Socket) { private fun handleSrpInit(s: Socket, headers: Map<String, String>) {
val p = startSrpPairing() val remote = runCatching { s.inetAddress?.hostAddress }.getOrNull() ?: ""
val clientName = headers["x-client-name"] ?: ""
val version = headers["x-client-version"] ?: ""
val deviceModel = headers["x-device-model"] ?: ""
val userAgent = headers["user-agent"] ?: "unknown"
android.util.Log.i(
"PortalService",
"srp/init identity name=$clientName ver=$version model=$deviceModel ua=$userAgent host=$remote",
)
val init = PortalAuthManager.beginPairing(
clientName = clientName,
userAgent = userAgent,
version = version,
deviceModel = deviceModel,
remoteHost = remote,
)
val p = init.session
bringActivityToFront()
val saltHex = PortalSrp.bytesToHex(p.salt) val saltHex = PortalSrp.bytesToHex(p.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB)) val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB))
val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":120}" val expiresIn = ((p.expiresAt - System.currentTimeMillis()) / 1000L).coerceAtLeast(0)
val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":$expiresIn}"
reply(s, 200, json, "application/json") reply(s, 200, json, "application/json")
} }
private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map<String, String>) { 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('?', "") val queryParams = path.substringAfter('?', "")
.split('&') .split('&')
.mapNotNull { .mapNotNull {
@@ -213,7 +201,6 @@ class PortalStreamingService : Service() {
fun extractParam(key: String): String? { fun extractParam(key: String): String? {
queryParams[key]?.let { return it } queryParams[key]?.let { return it }
// Basic JSON search: "key":"value" or "key": "value"
val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"") val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"")
return pattern.find(body)?.groupValues?.getOrNull(1) return pattern.find(body)?.groupValues?.getOrNull(1)
} }
@@ -227,45 +214,25 @@ class PortalStreamingService : Service() {
return return
} }
if (pairingId != p.id) { when (val res = PortalAuthManager.completePairing(
reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json") pairingId = pairingId,
return A = A,
} M1 = M1,
tlsHash = PortalTls.certSha256,
val tlsHash = PortalTls.certSha256 clientName = headers["x-client-name"] ?: "",
val res = PortalSrp.verifyClient(p, A, M1, tlsHash) userAgent = headers["user-agent"] ?: "",
version = headers["x-client-version"] ?: "",
when (res) { deviceModel = headers["x-device-model"] ?: "",
is PortalSrp.VerifyResult.Success -> { )) {
val token = res.token is PortalAuthManager.VerifyOutcome.Success -> {
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 m2Hex = PortalSrp.bytesToHex(res.M2)
val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}" reply(s, 200, "{\"M2\":\"$m2Hex\",\"token\":\"${res.token}\"}", "application/json")
android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta")
reply(s, 200, json, "application/json")
} }
is PortalSrp.VerifyResult.Failed -> { is PortalAuthManager.VerifyOutcome.Failed -> {
android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}") 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( reply(
s, s,
401, res.httpStatus,
"{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}", "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}",
"application/json" "application/json"
) )
@@ -273,14 +240,18 @@ class PortalStreamingService : Service() {
} }
} }
private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean) { private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean, token: String) {
s.soTimeout = 0 // Don't timeout streaming connections s.soTimeout = 0 // Don't timeout streaming connections
val tokenHash = PortalAuthManager.tokenHash(token)
val kind = if (key) PortalStreamer.SessionKind.VIDEO else PortalStreamer.SessionKind.AUDIO
val o = s.getOutputStream() 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.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() o.flush()
val q = t.add(key) val q = t.add(key)
val active = n.incrementAndGet() val active = n.incrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active") if (key) PortalStreamer.setActiveVideo(active) else PortalStreamer.setActiveAudio(active)
PortalStreamer.acquireSession(tokenHash, kind)
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active hash=${tokenHash.take(8)}")
if (active == 1) { if (active == 1) {
if (t === video) startVideo() else startAudio() if (t === video) startVideo() else startAudio()
if (key) bringActivityToFront() if (key) bringActivityToFront()
@@ -293,13 +264,16 @@ class PortalStreamingService : Service() {
wait = false wait = false
o.write(p.data) o.write(p.data)
o.flush() o.flush()
if (key) PortalStreamer.recordVideoBytes(p.data.size) else PortalStreamer.recordAudioBytes(p.data.size)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}") android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}")
} finally { } finally {
t.remove(q) t.remove(q)
PortalStreamer.releaseSession(tokenHash, kind)
val left = n.decrementAndGet() val left = n.decrementAndGet()
if (key) PortalStreamer.setActiveVideo(left) else PortalStreamer.setActiveAudio(left)
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left") android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left")
if (left == 0) { if (left == 0) {
if (t === video) { if (t === video) {
@@ -323,7 +297,7 @@ class PortalStreamingService : Service() {
}.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) } }.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) }
} }
private fun control(s: Socket, p: String) { private fun control(s: Socket, p: String, token: String) {
val pathOnly = p.substringBefore('?') val pathOnly = p.substringBefore('?')
val q = p.substringAfter('?', "") val q = p.substringAfter('?', "")
.split('&') .split('&')
@@ -343,7 +317,7 @@ class PortalStreamingService : Service() {
when { when {
pathOnly == "/control/events" -> { pathOnly == "/control/events" -> {
controlEvents(s) controlEvents(s, token)
} }
pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> { pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> {
reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json") reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json")
@@ -406,9 +380,10 @@ class PortalStreamingService : Service() {
} }
} }
/** SSE: initial state, then `event: state` on each change. */ /** SSE: initial state, then `event: state` on each change. Live status subscription. */
private fun controlEvents(s: Socket) { private fun controlEvents(s: Socket, token: String) {
s.soTimeout = 0 s.soTimeout = 0
val tokenHash = PortalAuthManager.tokenHash(token)
val o = s.getOutputStream() val o = s.getOutputStream()
o.write( o.write(
("HTTP/1.1 200 OK\r\n" + ("HTTP/1.1 200 OK\r\n" +
@@ -432,7 +407,8 @@ class PortalStreamingService : Service() {
} }
} }
PortalSmartCamera.addStateListener(listener) PortalSmartCamera.addStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client connected") PortalStreamer.acquireSession(tokenHash, PortalStreamer.SessionKind.STATUS)
android.util.Log.i("PortalService", "SSE /control/events client connected hash=${tokenHash.take(8)}")
try { try {
// addStateListener already pushed latest; also write a comment keepalive loop. // addStateListener already pushed latest; also write a comment keepalive loop.
while (!s.isClosed) { while (!s.isClosed) {
@@ -448,6 +424,7 @@ class PortalStreamingService : Service() {
android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}") android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}")
} finally { } finally {
PortalSmartCamera.removeStateListener(listener) PortalSmartCamera.removeStateListener(listener)
PortalStreamer.releaseSession(tokenHash, PortalStreamer.SessionKind.STATUS)
android.util.Log.i("PortalService", "SSE /control/events client removed") android.util.Log.i("PortalService", "SSE /control/events client removed")
runCatching { s.close() } runCatching { s.close() }
} }
@@ -721,6 +698,7 @@ class PortalStreamingService : Service() {
mdns?.unregister() mdns?.unregister()
mdns = null mdns = null
server?.close() server?.close()
PortalStreamer.setHttpReady(false)
stopVideo() stopVideo()
stopAudio() stopAudio()
io.shutdownNow() io.shutdownNow()
@@ -1,14 +0,0 @@
/* 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;
}
}