[Service] New UI
This commit is contained in:
@@ -118,6 +118,7 @@ public final class PortalClient: @unchecked Sendable {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
PortalClientIdentity.apply(to: &request)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
@@ -190,6 +191,7 @@ public final class PortalClient: @unchecked Sendable {
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
PortalClientIdentity.apply(to: &request)
|
||||
|
||||
let payload: [String: String] = [
|
||||
"pairingId": session.pairingId,
|
||||
@@ -447,6 +449,7 @@ public final class PortalClient: @unchecked Sendable {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
PortalClientIdentity.apply(to: &request)
|
||||
|
||||
let payload: [String: String] = [
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@
|
||||
<uses-permission android:name="com.facebook.aloha.permission.GLOBAL_OWNER_SELECTED" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.APP_FOUNDATION_SERVICE_LAUNCH" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" />
|
||||
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true">
|
||||
<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_METADATA_SERVICE" android:value="" />
|
||||
<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
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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 =="
|
||||
adb install -r portal-capability-test.apk
|
||||
|
||||
echo "== Starting MainActivity / PortalStreamingService =="
|
||||
echo "== Starting MainActivity =="
|
||||
adb shell am start -n com.portaltv.capability/.MainActivity
|
||||
|
||||
echo "== Done =="
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.*;
|
||||
import android.os.*;
|
||||
import android.content.*;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.ImageFormat;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.hardware.camera2.*;
|
||||
import android.hardware.camera2.params.StreamConfigurationMap;
|
||||
import android.media.*;
|
||||
import android.os.*;
|
||||
import android.util.Size;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
import java.util.*;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.*;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
TextView authStatus;
|
||||
LinearLayout authPanel;
|
||||
@Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; refreshAuthUi(); Intent i=new Intent(this,PortalStreamingService.class); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); }
|
||||
/**
|
||||
* Portal TV UI — dashboard mockup bound by [PortalCamUi].
|
||||
* Legacy local camera/HTTP helpers remain below for debugging; live path is PortalStreamingService.
|
||||
*/
|
||||
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 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 String mode="ModeSetting_"+state.getMode();
|
||||
final org.json.JSONObject cfg=state.getConfig();
|
||||
runOnUiThread(()->{
|
||||
setCurrentMode(mode);
|
||||
if("Fixed".equals(state.getMode())&&cfg!=null){
|
||||
try{
|
||||
if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX");
|
||||
@@ -37,88 +41,55 @@ public class MainActivity extends Activity {
|
||||
if(cfg.has("scale")) fs=(float)cfg.getDouble("scale");
|
||||
}catch(Exception e){p("state config parse: "+e);}
|
||||
}
|
||||
p("Camera state -> "+state.getMode()+" "+cfg);
|
||||
});
|
||||
};
|
||||
|
||||
final Binder modeListener=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); final String m=data.readInt()!=0?data.readString():null; p("Mode changed (legacy) -> "+(m!=null?m:"(null)")); }catch(Exception e){p("Mode listener parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
|
||||
final Binder metaReceiver=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); if(data.readInt()!=0){ Bundle b=data.readBundle(getClass().getClassLoader()); String s=""; for(String k:b.keySet()) s+=k+"="+b.get(k)+" "; p("meta: "+s); } }catch(Exception e){p("Meta receiver parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
|
||||
public void onCreate(Bundle b) { super.onCreate(b); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); buildUi();
|
||||
|
||||
@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.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();
|
||||
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);
|
||||
if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service);
|
||||
preview=new ImageView(this);
|
||||
}
|
||||
|
||||
volatile boolean pipelineStarted;
|
||||
synchronized void ensurePipeline(){ if(pipelineStarted)return; pipelineStarted=true; new Thread(()->{ testCamera("0"); startMicLoop(); startAudioEncoder(); startTsMuxer(); }).start(); p("Media pipeline started on first client"); }
|
||||
void startAll(){ p("Camera UI driven by PortalSmartCamera state events"); }
|
||||
void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<VChunk>> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} }
|
||||
void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue<byte[]> q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} }
|
||||
byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; }
|
||||
String deviceIp(){ try{ for(java.net.NetworkInterface ni:java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) for(java.net.InetAddress a:java.util.Collections.list(ni.getInetAddresses())) if(!a.isLoopbackAddress()&&a instanceof java.net.Inet4Address) return a.getHostAddress(); }catch(Exception e){} return "?"; }
|
||||
// ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ----
|
||||
static int crcMpeg(byte[] d,int off,int len){ int c=0xFFFFFFFF; for(int i=off;i<off+len;i++){ c^=(d[i]&0xFF)<<24; for(int b=0;b<8;b++) c=(c&0x80000000)!=0?(c<<1)^0x04C11DB7:c<<1; } return c; }
|
||||
byte[] patPacket(){ byte[] s={0x00,(byte)0xB0,0x0D,0x00,0x01,(byte)0xC1,0x00,0x00,0x00,0x01,(byte)0xF0,0x00}; return tablePacket(0,s); }
|
||||
byte[] pmtPacket(){ byte[] s={0x02,(byte)0xB0,0x17,0x00,0x01,(byte)0xC1,0x00,0x00,(byte)0xE1,0x01,(byte)0xF0,0x00,0x1B,(byte)0xE1,0x01,(byte)0xF0,0x00,0x0F,(byte)0xE1,0x02,(byte)0xF0,0x00}; return tablePacket(0x1000,s); }
|
||||
byte[] tablePacket(int pid,byte[] sec){ int crc=crcMpeg(sec,0,sec.length); byte[] full=new byte[sec.length+5]; full[0]=0; System.arraycopy(sec,0,full,1,sec.length); int n=sec.length+1; full[n++]=(byte)(crc>>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List<byte[]> pk=packetize(pid,false,full,0); return pk.get(0); }
|
||||
void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; writePes(0x102,0xE1,a,false); } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; if(vBase<0) vBase=v.pts; if(v.k){ tsBroadcast(patPacket()); tsBroadcast(pmtPacket()); if(vCsd!=null&&!hasSps(v.d)){ byte[] m=new byte[vCsd.length+v.d.length]; System.arraycopy(vCsd,0,m,0,vCsd.length); System.arraycopy(v.d,0,m,vCsd.length,v.d.length); v=new VChunk(m,true,v.pts); } } writePes(0x101,0xE0,v,true); }catch(Exception e){ p("ts mux: "+e); } } }).start(); }
|
||||
static boolean hasSps(byte[] d){ for(int i=0;i+4<Math.min(d.length,64);i++){ if(d[i]==0&&d[i+1]==0&&d[i+2]==1&&(d[i+3]&0x1F)==7) return true; if(d[i]==0&&d[i+1]==0&&d[i+2]==0&&d[i+3]==1&&(d[i+4]&0x1F)==7) return true; } return false; }
|
||||
void tsBroadcast(byte[] d){ synchronized(tsClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<byte[]>> it=tsClients.iterator(); while(it.hasNext()){ if(!it.next().offer(d)) it.remove(); } } }
|
||||
void writePes(int pid,int sid,VChunk c,boolean isVideo){ if(tsClients.isEmpty()) return; long base=isVideo?vBase:aBase; if(base<0){ if(isVideo) vBase=c.pts; else aBase=c.pts; base=c.pts; } long pts=(c.pts-base)*9/100; java.io.ByteArrayOutputStream pes=new java.io.ByteArrayOutputStream(); pes.write(0); pes.write(0); pes.write(1); pes.write(sid); int pl=isVideo?0:c.d.length+8; pes.write(pl>>8); pes.write(pl); pes.write(0x80); pes.write(0x80); pes.write(5); pes.write((2<<4)|((int)((pts>>30)&7)<<1)|1); pes.write((int)(pts>>22)&0xFF); pes.write((int)(((pts>>15)&0x7F)<<1)|1); pes.write((int)(pts>>7)&0xFF); pes.write((int)((pts&0x7F)<<1)|1); pes.write(c.d,0,c.d.length); for(byte[] p:packetize(pid,isVideo,pes.toByteArray(),pts)) tsBroadcast(p); }
|
||||
java.util.List<byte[]> packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List<byte[]> out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off<pes.length){ byte[] p=new byte[188]; p[0]=0x47; p[1]=(byte)((first?0x40:0)|((pid>>8)&0x1F)); p[2]=(byte)pid; boolean pcr=first&&isVideo; int room=184-(pcr?8:0); int remain=pes.length-off; int take=Math.min(remain,room); boolean stuff=take<room; int afc=(pcr||stuff)?3:1; int cc=pid==0x101?ccV++&15:pid==0x102?ccA++&15:pid==0?ccPAT++&15:ccPMT++&15; p[3]=(byte)((afc<<4)|cc); int pos=4; if(afc==3){ int afLen=183-take; p[pos++]= (byte)afLen; p[pos++]=(byte)(pcr?0x10:0); if(pcr){ long b=pcr90k; p[pos++]=(byte)(b>>25); p[pos++]=(byte)(b>>17); p[pos++]=(byte)(b>>9); p[pos++]=(byte)(b>>1); p[pos++]=(byte)((b<<7)|0x7E); p[pos++]=0; } while(pos<4+1+afLen) p[pos++]=(byte)0xFF; } System.arraycopy(pes,off,p,pos,take); off+=take; first=false; out.add(p); } return out; }
|
||||
// ---- end TS muxer ----
|
||||
void startWebcamServer(){ if(httpSock!=null)return; new Thread(()->{ try{ httpSock=new java.net.ServerSocket(5654); p("Webcam server: http://"+deviceIp()+":5654/ (video=/video.h264 audio=/audio.aac)"); while(true){ final java.net.Socket s=httpSock.accept(); new Thread(()->handleHttp(s)).start(); } }catch(Exception e){p("Webcam server failed: "+e);} }).start(); }
|
||||
void handleHttp(java.net.Socket s){ try{ s.setTcpNoDelay(true); java.io.BufferedReader in=new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); String line=in.readLine(); if(line==null){s.close();return;} String path=line.split(" ")[1]; while((line=in.readLine())!=null&&!line.isEmpty()){} java.io.OutputStream out=s.getOutputStream();
|
||||
if(path.equals("/")||path.startsWith("/index")){ String h="<html><body style='background:#111;color:#eee;font-family:monospace'><h3>Portal webcam</h3><b><a style='color:#8af' href='/stream.ts'>/stream.ts</a> (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)</b><br>video: <a style='color:#8af' href='/video.h264'>/video.h264</a> (raw H.264 Annex B)<br>audio: <a style='color:#8af' href='/audio.aac'>/audio.aac</a> (raw AAC ADTS)<br><br>ffplay http://"+deviceIp()+":5654/stream.ts<br>mpv http://"+deviceIp()+":5654/stream.ts<br>vlc http://"+deviceIp()+":5654/stream.ts<br></body></html>"; byte[] b=h.getBytes(); out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes()); out.write(b); out.flush(); s.close(); return; }
|
||||
if(path.startsWith("/video.h264")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/h264\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); byte[] csd=vCsd; if(csd!=null){ out.write(csd); out.flush(); } if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<VChunk> q=new java.util.concurrent.ArrayBlockingQueue<>(3); vClients.add(q); boolean started=false; try{ while(true){ VChunk c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!vClients.contains(q)) break; continue; } if(!started){ if(!c.k) continue; started=true; } out.write(c.d); out.flush(); } }finally{ vClients.remove(q); } return; }
|
||||
if(path.startsWith("/stream.ts")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(24); synchronized(tsClients){ if(tsClients.isEmpty()){ vBase=-1; aBase=-1; } tsClients.add(q); } try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!tsClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ tsClients.remove(q); } return; }
|
||||
if(path.startsWith("/audio.aac")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: audio/aac\r\nCache-Control: no-store\r\n\r\n".getBytes()); out.flush(); java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(256); aClients.add(q); try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!aClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ aClients.remove(q); } return; }
|
||||
if(path.startsWith("/control/mode")){ String mode=query(path,"mode"); if(mode==null||!mode.matches("DefaultAuto|Desk|Meeting|Fixed")){ reply(out,400,"mode must be DefaultAuto, Desk, Meeting, or Fixed"); return; } setSmartMode(mode); reply(out,200,"mode requested: "+mode); return; }
|
||||
if(path.startsWith("/control/fixed")){ try{ fx=Float.parseFloat(query(path,"x")); fy=Float.parseFloat(query(path,"y")); fs=Float.parseFloat(query(path,"scale")); setSmartMode("Fixed"); reply(out,200,String.format("fixed requested: x=%.3f y=%.3f scale=%.3f",fx,fy,fs)); }catch(Exception e){ reply(out,400,"x, y, and scale are required numbers"); } return; }
|
||||
out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes()); out.flush(); s.close();
|
||||
}catch(Exception e){} try{ s.close(); }catch(Exception e){} }
|
||||
String query(String path,String key){ int q=path.indexOf('?'); if(q<0)return null; for(String p:path.substring(q+1).split("&")){String[] kv=p.split("=",2);if(kv.length==2&&java.net.URLDecoder.decode(kv[0]).equals(key))return java.net.URLDecoder.decode(kv[1]);}return null; }
|
||||
void reply(java.io.OutputStream out,int code,String body)throws java.io.IOException{byte[] b=body.getBytes();out.write(("HTTP/1.1 "+code+" OK\r\nContent-Type: text/plain\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes());out.write(b);out.flush();}
|
||||
public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
|
||||
void buildUi(){
|
||||
screenW=getResources().getDisplayMetrics().widthPixels; screenH=getResources().getDisplayMetrics().heightPixels;
|
||||
LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(0xff101010);
|
||||
authStatus=new TextView(this); authStatus.setTextColor(0xffffffff); authStatus.setPadding(12,8,12,8); root.addView(authStatus,new LinearLayout.LayoutParams(-1,70));
|
||||
authPanel=new LinearLayout(this); authPanel.setOrientation(LinearLayout.VERTICAL); root.addView(authPanel,new LinearLayout.LayoutParams(-1,110));
|
||||
Button revokeAll=new Button(this); revokeAll.setText("Revoke all paired clients"); revokeAll.setOnClickListener(v->{ Intent x=new Intent(this,PortalStreamingService.class); x.setAction("com.portaltv.capability.REVOKE_ALL"); if(Build.VERSION.SDK_INT>=26) startForegroundService(x); else startService(x); }); root.addView(revokeAll,new LinearLayout.LayoutParams(-1,70));
|
||||
LinearLayout main=new LinearLayout(this); main.setOrientation(LinearLayout.HORIZONTAL); root.addView(main,new LinearLayout.LayoutParams(-1,0,1));
|
||||
preview=new ImageView(this); preview.setBackgroundColor(0xff202020); preview.setScaleType(ImageView.ScaleType.FIT_CENTER); main.addView(preview,new LinearLayout.LayoutParams(0,-1,1));
|
||||
controlsPanel=new LinearLayout(this); controlsPanel.setOrientation(LinearLayout.VERTICAL); controlsPanel.setBackgroundColor(0xff303030); controlsPanel.setPadding(8,8,8,8); main.addView(controlsPanel,new LinearLayout.LayoutParams(ctlW,-1));
|
||||
LinearLayout trow=new LinearLayout(this); controlsToggle=new Button(this); controlsToggle.setText("<"); focusFx(controlsToggle); trow.addView(controlsToggle,new LinearLayout.LayoutParams(-2,84)); controlsPanel.addView(trow); controlsToggle.setOnClickListener(v->toggleControls());
|
||||
ScrollView cs=new ScrollView(this); controlsContent=new LinearLayout(this); controlsContent.setOrientation(LinearLayout.VERTICAL); controlsContent.setVisibility(View.GONE); cs.addView(controlsContent); controlsPanel.addView(cs,new LinearLayout.LayoutParams(-1,0,1));
|
||||
TextView ml=new TextView(this); ml.setText("Mode"); ml.setTextColor(0xffffffff); controlsContent.addView(ml);
|
||||
for(final String mode:new String[]{"DefaultAuto","Desk","Meeting","Fixed"}){ Button x=new Button(this); x.setText(mode); x.setTextColor(0xffffffff); x.setBackgroundColor(C_IDLE); x.setOnClickListener(v->setSmartMode(mode)); x.setOnFocusChangeListener((v,f)->styleModeButton((Button)v,mode)); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; controlsContent.addView(x,lp); modeButtons.put(mode,x); }
|
||||
FrameLayout subHost=new FrameLayout(this); LinearLayout.LayoutParams slp=new LinearLayout.LayoutParams(-1,-2); slp.topMargin=12; controlsContent.addView(subHost,slp);
|
||||
subFixed=new LinearLayout(this); subFixed.setOrientation(LinearLayout.VERTICAL); subFixed.setVisibility(View.GONE);
|
||||
LinearLayout fr1=new LinearLayout(this); LinearLayout fr2=new LinearLayout(this);
|
||||
String[][] pan={{"<","-0.05","0","1"},{">","0.05","0","1"},{"^","0","-0.05","1"},{"v","0","0.05","1"}}; for(final String[] t:pan){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr1.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
|
||||
String[][] zm={{"Z+","0","0","0.85"},{"Z-","0","0","1.1765"}}; for(final String[] t:zm){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr2.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
|
||||
subFixed.addView(fr1); subFixed.addView(fr2); subHost.addView(subFixed);
|
||||
subDesk=new LinearLayout(this); subDesk.setOrientation(LinearLayout.VERTICAL); subDesk.setVisibility(View.GONE);
|
||||
for(final float t:new float[]{0.0f,0.5f,1.0f}){ Button x=new Button(this); x.setText("tight "+t); x.setOnClickListener(v->{Bundle b=new Bundle(); b.putFloat("additional_stable_framing_tightness",t); sendMode("ModeSetting_Desk",b,"Desk tight="+t);}); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; subDesk.addView(x,lp); }
|
||||
subHost.addView(subDesk);
|
||||
subNone=new TextView(this); subNone.setText("No mode parameters"); subNone.setTextColor(0xffaaaaaa); subNone.setVisibility(View.GONE); subHost.addView(subNone);
|
||||
logPanel=new LinearLayout(this); logPanel.setOrientation(LinearLayout.VERTICAL); logPanel.setBackgroundColor(0xff282828); root.addView(logPanel,new LinearLayout.LayoutParams(-1,logHeaderH));
|
||||
LinearLayout hdr=new LinearLayout(this); hdr.setGravity(16); TextView lt=new TextView(this); lt.setText("Log"); lt.setTextColor(0xffffffff); hdr.addView(lt,new LinearLayout.LayoutParams(0,-2,1)); logToggle=new Button(this); logToggle.setText("^"); focusFx(logToggle); hdr.addView(logToggle,new LinearLayout.LayoutParams(-2,-2)); logToggle.setOnClickListener(v->toggleLog()); logPanel.addView(hdr);
|
||||
log=new TextView(this); log.setTextSize(14); log.setTextColor(0xffeeeeee); logScroll=new ScrollView(this); logScroll.addView(log); logScroll.setVisibility(View.GONE); logPanel.addView(logScroll,new LinearLayout.LayoutParams(-1,0,1));
|
||||
setContentView(root);
|
||||
|
||||
@Override
|
||||
public void onBackPressed(){
|
||||
if(ui!=null && ui.handleBack()) return;
|
||||
super.onBackPressed();
|
||||
}
|
||||
void refreshAuthUi(){ if(authPanel==null)return; android.content.SharedPreferences p=getSharedPreferences("auth",MODE_PRIVATE); int av=p.getInt("activeVideo",0), aa=p.getInt("activeAudio",0); authStatus.setText((p.getString("pairingPin","").isEmpty()?"HTTPS ready":"Pairing PIN: "+p.getString("pairingPin",""))+" Active video: "+av+" audio: "+aa); authPanel.removeAllViews(); java.util.Set<String> ts=p.getStringSet("tokens",java.util.Collections.emptySet()); for(String h:ts){ String m=p.getString("client."+h,"unknown"); Button b=new Button(this); b.setText("Revoke "+m.replace('|',' ')); b.setOnClickListener(v->{ java.util.Set<String> n=p.getStringSet("tokens",java.util.Collections.emptySet()); n=new java.util.HashSet<>(n); n.remove(h); p.edit().putStringSet("tokens",n).remove("client."+h).apply(); refreshAuthUi(); }); authPanel.addView(b,new LinearLayout.LayoutParams(-1,60)); } }
|
||||
Button mkBtn(String t,View.OnClickListener l){ Button x=new Button(this); x.setText(t); x.setOnClickListener(l); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; x.setLayoutParams(lp); return x; }
|
||||
void toggleControls(){ controlsExpanded=!controlsExpanded; android.view.ViewGroup.LayoutParams lp=controlsPanel.getLayoutParams(); lp.width=controlsExpanded?screenW/5:ctlW; controlsPanel.setLayoutParams(lp); controlsContent.setVisibility(controlsExpanded?View.VISIBLE:View.GONE); controlsToggle.setText(controlsExpanded?">":"<"); }
|
||||
void toggleLog(){ logExpanded=!logExpanded; android.view.ViewGroup.LayoutParams lp=logPanel.getLayoutParams(); lp.height=logExpanded?screenH/3:logHeaderH; logPanel.setLayoutParams(lp); logScroll.setVisibility(logExpanded?View.VISIBLE:View.GONE); logToggle.setText(logExpanded?"v":"^"); }
|
||||
static final int C_ACTIVE=0xff2e7d32, C_IDLE=0xff424242, C_FOCUS=0xffff9800;
|
||||
void styleModeButton(Button b,String mode){ boolean a=("ModeSetting_"+mode).equals(currentMode); if(b.isFocused()){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackgroundColor(a?C_ACTIVE:C_IDLE); b.setTextColor(0xffffffff); } }
|
||||
void focusFx(Button b){ final android.graphics.drawable.Drawable d=b.getBackground(); final android.content.res.ColorStateList tc=b.getTextColors(); b.setOnFocusChangeListener((v,f)->{ if(f){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackground(d); b.setTextColor(tc); } }); }
|
||||
void setCurrentMode(String m){ currentMode=m; runOnUiThread(()->{ for(java.util.Map.Entry<String,Button> e:modeButtons.entrySet()) styleModeButton(e.getValue(),e.getKey()); subFixed.setVisibility("ModeSetting_Fixed".equals(currentMode)?View.VISIBLE:View.GONE); subDesk.setVisibility("ModeSetting_Desk".equals(currentMode)?View.VISIBLE:View.GONE); subNone.setVisibility(("ModeSetting_DefaultAuto".equals(currentMode)||"ModeSetting_Meeting".equals(currentMode))?View.VISIBLE:View.GONE); }); }
|
||||
void p(String x){android.util.Log.d("PortalCap",x); runOnUiThread(()->log.append(String.format("%tT ",System.currentTimeMillis())+x+"\n"));}
|
||||
|
||||
void p(String x){
|
||||
android.util.Log.d("PortalCap",x);
|
||||
runOnUiThread(()->{ if(ui!=null && ui.getLogText()!=null) ui.getLogText().append(String.format("%tT ",System.currentTimeMillis())+x+"\n"); });
|
||||
}
|
||||
|
||||
public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
|
||||
|
||||
// ---- 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 testCamera(final String id){
|
||||
if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;}
|
||||
@@ -127,7 +98,7 @@ public class MainActivity extends Activity {
|
||||
try {
|
||||
reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2);
|
||||
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);
|
||||
cm.openCamera(id,new CameraDevice.StateCallback(){
|
||||
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.concurrent.BlockingQueue<byte[]> pcmIn=new java.util.concurrent.ArrayBlockingQueue<>(128);
|
||||
volatile byte[] vCsd; MediaCodec venc,aenc; Surface vencSurface; java.net.ServerSocket httpSock;
|
||||
void startMicLoop(){ if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){p("Microphone permission not granted");return;} if(micThread!=null)return; micLoop=true; micThread=new Thread(()->{ int n=AudioRecord.getMinBufferSize(48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT); AudioRecord ar=null; try{ ar=new AudioRecord(MediaRecorder.AudioSource.DEFAULT,48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,n*2); ar.startRecording(); p("Mic capture started: actualRate="+ar.getSampleRate()+" minBuf="+n+" state="+ar.getState()); byte[] b=new byte[n]; long tWin=System.nanoTime(),bWin=0; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) p("pcmIn FULL, dropped "+got+"B"); bWin+=got; long now=System.nanoTime(); if(now-tWin>5e9){ p("mic rate: "+(bWin*1e9/(now-tWin))+" B/s (expect 96000), queue="+pcmIn.size()); tWin=now; bWin=0; } } } }catch(Exception e){p("Mic capture failed: "+e);} finally{ try{if(ar!=null){ar.stop();ar.release();}}catch(Exception e){} micThread=null; } }); micThread.start(); }
|
||||
void sendMode(final String modeName,final Bundle b,final String label){ try{ if(modeName.equals("ModeSetting_Desk")&&b!=null&&b.containsKey("additional_stable_framing_tightness")){ PortalSmartCamera.setDeskTightness(b.getFloat("additional_stable_framing_tightness")); p(label+" sent"); return; } String shortName=modeName.startsWith("ModeSetting_")?modeName.substring("ModeSetting_".length()):modeName; if(shortName.equals("Fixed")) PortalSmartCamera.setMode(shortName,fx,fy,fs); else PortalSmartCamera.setMode(shortName); p(label+" sent"); }catch(Exception e){p(label+" failed: "+e);} }
|
||||
void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<VChunk>> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} }
|
||||
void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue<byte[]> q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} }
|
||||
byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; }
|
||||
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 nudgeFixed(float dx,float dy,float sm){ fs=Math.min(1,Math.max(0.1f,fs*sm)); fx=Math.min(1-fs/2,Math.max(fs/2,fx+dx)); fy=Math.min(1-fs/2,Math.max(fs/2,fy+dy)); setSmartMode("Fixed"); }
|
||||
void ensureSession(final Runnable next){ if(control==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){control=b;p("Smart Camera service bound");ensureSession(next);}public void onServiceDisconnected(ComponentName n){control=null;session=null;}},BIND_AUTO_CREATE);p("Binding Smart Camera service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlService");controlToken=new Binder();q.writeStrongBinder(controlToken); if(!control.transact(2,q,r,0)){p("Smart Camera connect rejected");return;} r.readException(); IBinder connection=r.readStrongBinder();if(connection==null){p("No control connection returned");return;} Parcel cr=Parcel.obtain(),co=Parcel.obtain();cr.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection");cr.writeStrongBinder(new Binder());if(!connection.transact(2,cr,co,0)){p("requestControls rejected");return;}co.readException();session=co.readStrongBinder();if(session==null){p("No control session returned");return;} p("Control session established"); next.run(); }catch(Exception e){p("Control connect failed: "+e);} }
|
||||
void ensureMeta(final Runnable next){ if(meta==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){meta=b;p("Metadata service bound");ensureMeta(next);}public void onServiceDisconnected(ComponentName n){meta=null;metaConn=null;}},BIND_AUTO_CREATE);p("Binding metadata service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService");metaToken=new Binder();q.writeStrongBinder(metaToken); if(!meta.transact(2,q,r,0)){p("Metadata connect rejected");return;} r.readException(); metaConn=r.readStrongBinder();if(metaConn==null){p("No metadata connection returned");return;} p("Metadata connection established"); next.run(); }catch(Exception e){p("Metadata connect failed: "+e);} }
|
||||
void queryMode(){ if(metaConn==null){ensureMeta(()->queryMode());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"); if(!metaConn.transact(2,q,r,0)){p("getMode call failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Current mode: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("getMode failed: "+e);} }
|
||||
void watchModes(){ if(metaConn==null){ensureMeta(()->watchModes());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(modeListener); if(!metaConn.transact(3,q,r,0)){p("subscribeModeChanges failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Watching modes; current: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("subscribeModeChanges failed: "+e);} }
|
||||
void watchCrop(){ if(metaConn==null){ensureMeta(()->watchCrop());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(metaReceiver); ArrayList<String> t=new ArrayList<String>(); t.add("crop"); q.writeStringList(t); q.writeFloat(1.0f); if(!metaConn.transact(6,q,r,0)){p("subscribeFrameMetadata failed");return;} r.readException(); if(r.readInt()!=0){ Bundle b=r.readBundle(getClass().getClassLoader()); p("crop now: "+b.get("crop")); } p("Watching crop @1Hz"); }catch(Exception e){p("subscribeFrameMetadata failed: "+e);} }
|
||||
protected void onDestroy(){ PortalSmartCamera.removeStateListener(cameraStateListener); micLoop=false;try{if(venc!=null){venc.stop();venc.release();}}catch(Exception e){} try{if(aenc!=null){aenc.stop();aenc.release();}}catch(Exception e){} try{if(httpSock!=null)httpSock.close();}catch(Exception e){} if(cam!=null)cam.close();if(reader!=null)reader.close();if(ht!=null)ht.quitSafely();super.onDestroy();}
|
||||
void recenterFixed(){ fx=0.5f; fy=0.5f; setSmartMode("Fixed"); }
|
||||
|
||||
@Override
|
||||
protected void onDestroy(){
|
||||
if(ui!=null){
|
||||
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.util.concurrent.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import android.util.Base64
|
||||
import javax.net.ssl.SSLServerSocket
|
||||
|
||||
/** Foreground, UI-independent Portal raw media service over HTTPS. */
|
||||
@@ -29,9 +26,7 @@ class PortalStreamingService : Service() {
|
||||
private val audioLock = Any()
|
||||
@Volatile private var audioLoopActive = false
|
||||
private var audioThread: Thread? = null
|
||||
private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) }
|
||||
private val random = SecureRandom()
|
||||
@Volatile private var pairing: PortalSrp.ActivePairing? = null
|
||||
private val authenticator: Authenticator = PortalAuthManager
|
||||
@Volatile private var recoveringCamera = false
|
||||
/** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */
|
||||
@Volatile private var cameraGeneration = 0
|
||||
@@ -41,20 +36,20 @@ class PortalStreamingService : Service() {
|
||||
super.onCreate()
|
||||
android.util.Log.d("PortalService", "onCreate - initializing TLS")
|
||||
startForeground(42, notification())
|
||||
PortalAuthManager.start(this)
|
||||
PortalSmartCamera.start(this)
|
||||
try {
|
||||
server = PortalTls.createServerSocket(this, 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) }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e)
|
||||
PortalStreamer.setHttpReady(false)
|
||||
}
|
||||
cameraHandler.post(object : Runnable {
|
||||
override fun run() {
|
||||
authPrefs.edit()
|
||||
.putInt("activeVideo", videoUsers.get())
|
||||
.putInt("activeAudio", audioUsers.get())
|
||||
.apply()
|
||||
PortalStreamer.sampleBandwidth()
|
||||
cameraHandler.postDelayed(this, 1000)
|
||||
}
|
||||
})
|
||||
@@ -66,10 +61,16 @@ class PortalStreamingService : Service() {
|
||||
video.clear(); audio.clear(); return START_STICKY
|
||||
}
|
||||
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")
|
||||
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()
|
||||
return START_STICKY
|
||||
}
|
||||
@@ -99,35 +100,8 @@ class PortalStreamingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hash(s: String) =
|
||||
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun startSrpPairing(): PortalSrp.ActivePairing {
|
||||
val existing = pairing
|
||||
if (existing != null && System.currentTimeMillis() < existing.expiresAt && existing.attemptsLeft > 0) {
|
||||
android.util.Log.i("PortalService", "SRP pairing started with PIN: ${existing.pin}")
|
||||
return existing
|
||||
}
|
||||
val pin = (100000 + random.nextInt(900000)).toString()
|
||||
val active = PortalSrp.newPairing(pin)
|
||||
pairing = active
|
||||
authPrefs.edit().putString("pairingPin", pin).apply()
|
||||
cameraHandler.post {
|
||||
android.widget.Toast.makeText(this, "Pairing PIN: $pin", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
bringActivityToFront()
|
||||
android.util.Log.i("PortalService", "SRP pairing started with PIN: $pin")
|
||||
return active
|
||||
}
|
||||
|
||||
private fun authToken(headers: Map<String, String>): String? {
|
||||
val a = headers["authorization"] ?: return null
|
||||
if (!a.startsWith("Bearer ")) return null
|
||||
val token = a.substring(7)
|
||||
val h = hash(token)
|
||||
return if ((authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).contains(h)) token else null
|
||||
}
|
||||
private fun authToken(headers: Map<String, String>): String? =
|
||||
authenticator.authenticateBearer(headers["authorization"])
|
||||
|
||||
private fun handle(s: Socket) {
|
||||
s.use { socket ->
|
||||
@@ -161,7 +135,7 @@ class PortalStreamingService : Service() {
|
||||
|
||||
when {
|
||||
// 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)
|
||||
|
||||
// TLS Info endpoint (returns server cert SHA-256 for diagnostics)
|
||||
@@ -172,15 +146,18 @@ class PortalStreamingService : Service() {
|
||||
|
||||
// Media streams (require Bearer auth)
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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, 404, "{\"error\":\"not found\"}", "application/json")
|
||||
@@ -188,22 +165,33 @@ class PortalStreamingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSrpInit(s: Socket) {
|
||||
val p = startSrpPairing()
|
||||
private fun handleSrpInit(s: Socket, headers: Map<String, String>) {
|
||||
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 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")
|
||||
}
|
||||
|
||||
private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map<String, String>) {
|
||||
val p = pairing
|
||||
if (p == null) {
|
||||
reply(s, 400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}", "application/json")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse params from query string or JSON body
|
||||
val queryParams = path.substringAfter('?', "")
|
||||
.split('&')
|
||||
.mapNotNull {
|
||||
@@ -213,7 +201,6 @@ class PortalStreamingService : Service() {
|
||||
|
||||
fun extractParam(key: String): String? {
|
||||
queryParams[key]?.let { return it }
|
||||
// Basic JSON search: "key":"value" or "key": "value"
|
||||
val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"")
|
||||
return pattern.find(body)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
@@ -227,45 +214,25 @@ class PortalStreamingService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
if (pairingId != p.id) {
|
||||
reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json")
|
||||
return
|
||||
}
|
||||
|
||||
val tlsHash = PortalTls.certSha256
|
||||
val res = PortalSrp.verifyClient(p, A, M1, tlsHash)
|
||||
|
||||
when (res) {
|
||||
is PortalSrp.VerifyResult.Success -> {
|
||||
val token = res.token
|
||||
val h = hash(token)
|
||||
val set = (authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).toMutableSet()
|
||||
set += h
|
||||
val clientMeta = (headers["user-agent"] ?: "unknown") + "|" +
|
||||
(headers["x-client-name"] ?: "") + "|" +
|
||||
(headers["x-client-version"] ?: "") + "|" +
|
||||
System.currentTimeMillis()
|
||||
authPrefs.edit()
|
||||
.putStringSet("tokens", set)
|
||||
.putString("client.$h", clientMeta)
|
||||
.remove("pairingPin")
|
||||
.apply()
|
||||
pairing = null
|
||||
|
||||
when (val res = PortalAuthManager.completePairing(
|
||||
pairingId = pairingId,
|
||||
A = A,
|
||||
M1 = M1,
|
||||
tlsHash = PortalTls.certSha256,
|
||||
clientName = headers["x-client-name"] ?: "",
|
||||
userAgent = headers["user-agent"] ?: "",
|
||||
version = headers["x-client-version"] ?: "",
|
||||
deviceModel = headers["x-device-model"] ?: "",
|
||||
)) {
|
||||
is PortalAuthManager.VerifyOutcome.Success -> {
|
||||
val m2Hex = PortalSrp.bytesToHex(res.M2)
|
||||
val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}"
|
||||
android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta")
|
||||
reply(s, 200, json, "application/json")
|
||||
reply(s, 200, "{\"M2\":\"$m2Hex\",\"token\":\"${res.token}\"}", "application/json")
|
||||
}
|
||||
is PortalSrp.VerifyResult.Failed -> {
|
||||
is PortalAuthManager.VerifyOutcome.Failed -> {
|
||||
android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}")
|
||||
if (res.attemptsLeft <= 0) {
|
||||
pairing = null
|
||||
authPrefs.edit().remove("pairingPin").apply()
|
||||
}
|
||||
reply(
|
||||
s,
|
||||
401,
|
||||
res.httpStatus,
|
||||
"{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}",
|
||||
"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
|
||||
val tokenHash = PortalAuthManager.tokenHash(token)
|
||||
val kind = if (key) PortalStreamer.SessionKind.VIDEO else PortalStreamer.SessionKind.AUDIO
|
||||
val o = s.getOutputStream()
|
||||
o.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray())
|
||||
o.flush()
|
||||
val q = t.add(key)
|
||||
val active = n.incrementAndGet()
|
||||
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active")
|
||||
if (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 (t === video) startVideo() else startAudio()
|
||||
if (key) bringActivityToFront()
|
||||
@@ -293,13 +264,16 @@ class PortalStreamingService : Service() {
|
||||
wait = false
|
||||
o.write(p.data)
|
||||
o.flush()
|
||||
if (key) PortalStreamer.recordVideoBytes(p.data.size) else PortalStreamer.recordAudioBytes(p.data.size)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
t.remove(q)
|
||||
PortalStreamer.releaseSession(tokenHash, kind)
|
||||
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")
|
||||
if (left == 0) {
|
||||
if (t === video) {
|
||||
@@ -323,7 +297,7 @@ class PortalStreamingService : Service() {
|
||||
}.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 q = p.substringAfter('?', "")
|
||||
.split('&')
|
||||
@@ -343,7 +317,7 @@ class PortalStreamingService : Service() {
|
||||
|
||||
when {
|
||||
pathOnly == "/control/events" -> {
|
||||
controlEvents(s)
|
||||
controlEvents(s, token)
|
||||
}
|
||||
pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> {
|
||||
reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json")
|
||||
@@ -406,9 +380,10 @@ class PortalStreamingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE: initial state, then `event: state` on each change. */
|
||||
private fun controlEvents(s: Socket) {
|
||||
/** SSE: initial state, then `event: state` on each change. Live status subscription. */
|
||||
private fun controlEvents(s: Socket, token: String) {
|
||||
s.soTimeout = 0
|
||||
val tokenHash = PortalAuthManager.tokenHash(token)
|
||||
val o = s.getOutputStream()
|
||||
o.write(
|
||||
("HTTP/1.1 200 OK\r\n" +
|
||||
@@ -432,7 +407,8 @@ class PortalStreamingService : Service() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// addStateListener already pushed latest; also write a comment keepalive loop.
|
||||
while (!s.isClosed) {
|
||||
@@ -448,6 +424,7 @@ class PortalStreamingService : Service() {
|
||||
android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
PortalSmartCamera.removeStateListener(listener)
|
||||
PortalStreamer.releaseSession(tokenHash, PortalStreamer.SessionKind.STATUS)
|
||||
android.util.Log.i("PortalService", "SSE /control/events client removed")
|
||||
runCatching { s.close() }
|
||||
}
|
||||
@@ -721,6 +698,7 @@ class PortalStreamingService : Service() {
|
||||
mdns?.unregister()
|
||||
mdns = null
|
||||
server?.close()
|
||||
PortalStreamer.setHttpReady(false)
|
||||
stopVideo()
|
||||
stopAudio()
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user