diff --git a/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift b/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift
index 15ee727..1bcc3a6 100644
--- a/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift
+++ b/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift
@@ -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,
diff --git a/mac/PortalKit/Sources/PortalKit/Client/PortalClientIdentity.swift b/mac/PortalKit/Sources/PortalKit/Client/PortalClientIdentity.swift
new file mode 100644
index 0000000..f3b5faa
--- /dev/null
+++ b/mac/PortalKit/Sources/PortalKit/Client/PortalClientIdentity.swift
@@ -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
+ }
+}
diff --git a/portal-capability-test/AndroidManifest.xml b/portal-capability-test/AndroidManifest.xml
index ff48f30..b7b03f4 100644
--- a/portal-capability-test/AndroidManifest.xml
+++ b/portal-capability-test/AndroidManifest.xml
@@ -14,7 +14,10 @@
-
+
+
+
diff --git a/portal-capability-test/app/build.gradle.kts b/portal-capability-test/app/build.gradle.kts
new file mode 100644
index 0000000..89fe4df
--- /dev/null
+++ b/portal-capability-test/app/build.gradle.kts
@@ -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())
+ }
+ }
+
+ 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")
+}
diff --git a/portal-capability-test/app/src/main/AndroidManifest.xml b/portal-capability-test/app/src/main/AndroidManifest.xml
new file mode 120000
index 0000000..4db074a
--- /dev/null
+++ b/portal-capability-test/app/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+../../../AndroidManifest.xml
\ No newline at end of file
diff --git a/portal-capability-test/build.gradle.kts b/portal-capability-test/build.gradle.kts
new file mode 100644
index 0000000..c7ad754
--- /dev/null
+++ b/portal-capability-test/build.gradle.kts
@@ -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
+}
diff --git a/portal-capability-test/deploy.sh b/portal-capability-test/deploy.sh
index 71436ca..ad96961 100755
--- a/portal-capability-test/deploy.sh
+++ b/portal-capability-test/deploy.sh
@@ -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 =="
diff --git a/portal-capability-test/gradle.properties b/portal-capability-test/gradle.properties
new file mode 100644
index 0000000..18825f9
--- /dev/null
+++ b/portal-capability-test/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/portal-capability-test/res/drawable/pc_badge.xml b/portal-capability-test/res/drawable/pc_badge.xml
new file mode 100644
index 0000000..a6ceea4
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_badge.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_btn_danger.xml b/portal-capability-test/res/drawable/pc_btn_danger.xml
new file mode 100644
index 0000000..0c25b3e
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_btn_danger.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_btn_primary.xml b/portal-capability-test/res/drawable/pc_btn_primary.xml
new file mode 100644
index 0000000..2a0e890
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_btn_primary.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_btn_secondary.xml b/portal-capability-test/res/drawable/pc_btn_secondary.xml
new file mode 100644
index 0000000..9084562
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_btn_secondary.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_card.xml b/portal-capability-test/res/drawable/pc_card.xml
new file mode 100644
index 0000000..9373022
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_card.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_card_inner.xml b/portal-capability-test/res/drawable/pc_card_inner.xml
new file mode 100644
index 0000000..469dc33
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_card_inner.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_dot_green.xml b/portal-capability-test/res/drawable/pc_dot_green.xml
new file mode 100644
index 0000000..71faa67
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_dot_green.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_icon_circle_blue.xml b/portal-capability-test/res/drawable/pc_icon_circle_blue.xml
new file mode 100644
index 0000000..563bf25
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_icon_circle_blue.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_icon_circle_green.xml b/portal-capability-test/res/drawable/pc_icon_circle_green.xml
new file mode 100644
index 0000000..49672a6
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_icon_circle_green.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_icon_circle_yellow.xml b/portal-capability-test/res/drawable/pc_icon_circle_yellow.xml
new file mode 100644
index 0000000..a7ad562
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_icon_circle_yellow.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_logo_bg.xml b/portal-capability-test/res/drawable/pc_logo_bg.xml
new file mode 100644
index 0000000..88f637e
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_logo_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/pc_mode_item.xml b/portal-capability-test/res/drawable/pc_mode_item.xml
new file mode 100644
index 0000000..6bffbb6
--- /dev/null
+++ b/portal-capability-test/res/drawable/pc_mode_item.xml
@@ -0,0 +1,31 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/drawable/portal_banner.xml b/portal-capability-test/res/drawable/portal_banner.xml
new file mode 100644
index 0000000..6d8f559
--- /dev/null
+++ b/portal-capability-test/res/drawable/portal_banner.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/portal-capability-test/res/font/lucide.ttf b/portal-capability-test/res/font/lucide.ttf
new file mode 100644
index 0000000..4adaca9
Binary files /dev/null and b/portal-capability-test/res/font/lucide.ttf differ
diff --git a/portal-capability-test/res/layout/activity_main.xml b/portal-capability-test/res/layout/activity_main.xml
new file mode 100644
index 0000000..c9926f5
--- /dev/null
+++ b/portal-capability-test/res/layout/activity_main.xml
@@ -0,0 +1,451 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/controls_fixed.xml b/portal-capability-test/res/layout/controls_fixed.xml
new file mode 100644
index 0000000..85d7258
--- /dev/null
+++ b/portal-capability-test/res/layout/controls_fixed.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/item_client_paired.xml b/portal-capability-test/res/layout/item_client_paired.xml
new file mode 100644
index 0000000..de9deb2
--- /dev/null
+++ b/portal-capability-test/res/layout/item_client_paired.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/item_client_pending.xml b/portal-capability-test/res/layout/item_client_pending.xml
new file mode 100644
index 0000000..a42dcb6
--- /dev/null
+++ b/portal-capability-test/res/layout/item_client_pending.xml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/item_control.xml b/portal-capability-test/res/layout/item_control.xml
new file mode 100644
index 0000000..a3b418b
--- /dev/null
+++ b/portal-capability-test/res/layout/item_control.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/portal-capability-test/res/layout/item_mode.xml b/portal-capability-test/res/layout/item_mode.xml
new file mode 100644
index 0000000..8a64bb7
--- /dev/null
+++ b/portal-capability-test/res/layout/item_mode.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/item_portal_row.xml b/portal-capability-test/res/layout/item_portal_row.xml
new file mode 100644
index 0000000..17b793b
--- /dev/null
+++ b/portal-capability-test/res/layout/item_portal_row.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/item_stat_row.xml b/portal-capability-test/res/layout/item_stat_row.xml
new file mode 100644
index 0000000..025d6ce
--- /dev/null
+++ b/portal-capability-test/res/layout/item_stat_row.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/portal-capability-test/res/layout/pc_separator_h.xml b/portal-capability-test/res/layout/pc_separator_h.xml
new file mode 100644
index 0000000..010f54b
--- /dev/null
+++ b/portal-capability-test/res/layout/pc_separator_h.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/portal-capability-test/res/values/colors.xml b/portal-capability-test/res/values/colors.xml
new file mode 100644
index 0000000..781463a
--- /dev/null
+++ b/portal-capability-test/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+ #FF121418
+ #FF1C2025
+ #FF252A31
+ #FF2E343C
+ #FF2A3038
+ #FF2B7DE9
+ #332B7DE9
+ #FF8FC6F8
+ #FF1465B2
+ #FFFBBF24
+ #FFEF4444
+ #33EF4444
+ #FF22C55E
+ #3322C55E
+ #FFFFFFFF
+ #FF9CA3AF
+ #FF6B7280
+
+ #33FFFFFF
+
diff --git a/portal-capability-test/res/values/styles.xml b/portal-capability-test/res/values/styles.xml
index 52b8980..db467de 100644
--- a/portal-capability-test/res/values/styles.xml
+++ b/portal-capability-test/res/values/styles.xml
@@ -1 +1,19 @@
-
+
+
+
+
+
+
+
diff --git a/portal-capability-test/settings.gradle.kts b/portal-capability-test/settings.gradle.kts
new file mode 100644
index 0000000..7c3816d
--- /dev/null
+++ b/portal-capability-test/settings.gradle.kts
@@ -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")
diff --git a/portal-capability-test/src/com/portaltv/capability/Authenticator.kt b/portal-capability-test/src/com/portaltv/capability/Authenticator.kt
new file mode 100644
index 0000000..9633342
--- /dev/null
+++ b/portal-capability-test/src/com/portaltv/capability/Authenticator.kt
@@ -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?
+}
diff --git a/portal-capability-test/src/com/portaltv/capability/ConfirmRevokeFragment.kt b/portal-capability-test/src/com/portaltv/capability/ConfirmRevokeFragment.kt
new file mode 100644
index 0000000..d23f982
--- /dev/null
+++ b/portal-capability-test/src/com/portaltv/capability/ConfirmRevokeFragment.kt
@@ -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, 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()
+ }
+ }
+}
diff --git a/portal-capability-test/src/com/portaltv/capability/LucideIcons.kt b/portal-capability-test/src/com/portaltv/capability/LucideIcons.kt
new file mode 100644
index 0000000..61562ef
--- /dev/null
+++ b/portal-capability-test/src/com/portaltv/capability/LucideIcons.kt
@@ -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
+ }
+}
diff --git a/portal-capability-test/src/com/portaltv/capability/MainActivity.java b/portal-capability-test/src/com/portaltv/capability/MainActivity.java
index b0f6837..6a5f514 100644
--- a/portal-capability-test/src/com/portaltv/capability/MainActivity.java
+++ b/portal-capability-test/src/com/portaltv/capability/MainActivity.java
@@ -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 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> 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 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>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List 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> 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 packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off>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>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="Portal webcam
/stream.ts (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)
video: /video.h264 (raw H.264 Annex B)
audio: /audio.aac (raw AAC ADTS)
ffplay http://"+deviceIp()+":5654/stream.ts
mpv http://"+deviceIp()+":5654/stream.ts
vlc http://"+deviceIp()+":5654/stream.ts
"; 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 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 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 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 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 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 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{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> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet>());
final java.util.concurrent.BlockingQueue 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> 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 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 t=new ArrayList(); 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();
+ }
}
diff --git a/portal-capability-test/src/com/portaltv/capability/PortalAuthManager.kt b/portal-capability-test/src/com/portaltv/capability/PortalAuthManager.kt
new file mode 100644
index 0000000..fe420af
--- /dev/null
+++ b/portal-capability-test/src/com/portaltv/capability/PortalAuthManager.kt
@@ -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 = emptyList(),
+ val clients: List = 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()
+ private val listeners = CopyOnWriteArrayList()
+ private val _snapshots = MutableSharedFlow(replay = 1, extraBufferCapacity = 16)
+ val snapshots: SharedFlow = _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 {
+ 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)
+}
diff --git a/portal-capability-test/src/com/portaltv/capability/PortalCamUi.kt b/portal-capability-test/src/com/portaltv/capability/PortalCamUi.kt
new file mode 100644
index 0000000..c85b2f0
--- /dev/null
+++ b/portal-capability-test/src/com/portaltv/capability/PortalCamUi.kt
@@ -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,
+) {
+ 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 = 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()
+
+ 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(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(R.id.header_overflow).setOnClickListener { toggleLog() }
+ activity.findViewById(R.id.btn_revoke_all).setOnClickListener {
+ ConfirmRevokeFragment.showRevokeAll(activity)
+ }
+ activity.findViewById