Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,16 @@ experimental until these checks pass on named devices.
## Google Play

See [publishing setup](../../docs/android-play-publishing.md) and the [store kit](play/README.md) for AAB delivery, testing/production tracks, languages and listing assets.

### Camera failure diagnostics

Retained app diagnostics include the camera ID, primary/fallback selection, TCP
transport, retry count and whether a first frame was rendered. Playback errors
include the Media3 code and up to eight nested exception types with their first
stack location. Known network exception categories and exact RTSP method/status
messages (for example `DESCRIBE 401`) are retained. Arbitrary exception messages,
stream URLs and camera labels are omitted to avoid exposing credentials or server
response contents. Startup failures, stalled/ended streams, reconnect delays and
recovery after retry are also recorded. Retry entries describe the next stream.
These diagnostics identify failure categories; device logcat may still be needed
for failures whose details cannot safely be included in retained logs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cloud.betterportal.frame

import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.Collections
import java.util.IdentityHashMap

/** Bounded, allowlisted details: never copy arbitrary exception messages or URLs. */
internal object CameraDiagnostics {
fun identifier(value: String): String =
value.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: "unknown"

fun causes(error: Throwable): String {
val seen = Collections.newSetFromMap(IdentityHashMap<Throwable, Boolean>())
val details = mutableListOf<String>()
var current: Throwable? = error
while (current != null && details.size < 8 && seen.add(current)) {
val cause = current
val category = when (cause) {
is UnknownHostException -> "dns_failure"
is SocketTimeoutException -> "socket_timeout"
is ConnectException -> "connection_failed"
is java.io.EOFException -> "unexpected_eof"
else -> null
}
// Media3 RTSP failures commonly use e.g. "DESCRIBE 401". Only retain
// the method and status when the entire message matches this grammar.
val status = cause.message?.let {
Regex("^(OPTIONS|DESCRIBE|SETUP|PLAY|PAUSE|TEARDOWN|GET_PARAMETER|SET_PARAMETER) ([1-5][0-9]{2})$")
.matchEntire(it)?.value
}
val site = cause.stackTrace.firstOrNull()?.let {
" at ${it.className}.${it.methodName}:${it.lineNumber}"
}.orEmpty()
details.add(cause.javaClass.simpleName +
(category?.let { "[$it]" } ?: "") +
(status?.let { "[$it]" } ?: "") + site)
current = cause.cause
}
if (current != null) details.add("[cause chain truncated]")
return details.joinToString(" <- ").take(4096)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import kotlin.random.Random
class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : ViewerTile(context) {
private val handler = Handler(Looper.getMainLooper())
private val camera = cell.getJSONObject("camera")
private val diagnosticId = CameraDiagnostics.identifier(camera.optString("id"))
private var uri = camera.optString("uri")
private val fallbackUri = camera.optString("fallbackUri").takeUnless { it.isBlank() || it == "null" }
private var player: ExoPlayer? = null
Expand Down Expand Up @@ -82,6 +83,7 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
val lastFrame = lastVideoFrameAt
val quietFor = SystemClock.elapsedRealtime() - if (lastFrame > 0L) lastFrame else connectionStartedAt
if (quietFor >= if (firstFrame) 15_000L else 20_000L) {
logDiagnostic("warn", "Playback stalled quietMs=$quietFor")
recover()
return
}
Expand Down Expand Up @@ -124,6 +126,7 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
lastPresentationTimeUs = Long.MIN_VALUE
val frameEpoch = ++frameGeneration
if (!uri.startsWith("rtsp://", ignoreCase = true)) {
logDiagnostic("warn", "Unsupported stream scheme")
spinner.visibility = View.GONE
errorMessage.text = "Camera requires a supported RTSP stream"
errorMessage.visibility = View.VISIBLE
Expand Down Expand Up @@ -162,6 +165,7 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
if (player !== next) return
firstFrame = true
showReady()
if (retries > 0) logDiagnostic("info", "Playback recovered")
retries = 0
}
override fun onPlaybackStateChanged(state: Int) {
Expand All @@ -170,11 +174,14 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
showConnecting()
} else if (state == Player.STATE_READY && firstFrame) {
showReady()
} else if (state == Player.STATE_ENDED) recover()
} else if (state == Player.STATE_ENDED) {
logDiagnostic("warn", "Stream ended")
recover()
}
}
override fun onPlayerError(error: PlaybackException) {
DiagnosticLogs.record("warn", "Camera playback failed (${error.errorCodeName})")
if (player !== next) return
logDiagnostic("warn", "Camera playback failed (${error.errorCodeName}, code=${error.errorCode}) causes=${CameraDiagnostics.causes(error)}")
val decoderFailure = error.errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODING_FAILED,
Expand All @@ -190,7 +197,15 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
next.prepare()
next.playWhenReady = true
handler.postDelayed(watchdog, 5_000)
} catch (_: Exception) { recover() }
} catch (error: Exception) {
logDiagnostic("warn", "Camera startup failed causes=${CameraDiagnostics.causes(error)}")
recover()
}
}

private fun logDiagnostic(level: String, detail: String) {
val stream = if (fallbackUri != null && uri == fallbackUri) "fallback" else "primary"
DiagnosticLogs.record(level, "Camera id=$diagnosticId stream=$stream transport=tcp retry=$retries firstFrame=$firstFrame: $detail")
}

private fun recover(message: String? = null) {
Expand All @@ -210,6 +225,7 @@ class CameraTile(context: Context, cell: JSONObject, onExpand: () -> Unit) : Vie
if (fallbackUri != null && uri != fallbackUri) uri = fallbackUri
retries = min(retries + 1, 6)
val delay = min(30_000L, 1_000L shl retries) + Random.nextLong(250, 1_000)
logDiagnostic("info", "Reconnect scheduled delayMs=$delay")
handler.postDelayed({ connect() }, delay)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cloud.betterportal.frame

import org.junit.Assert.*
import org.junit.Test
import java.io.IOException
import java.net.SocketTimeoutException

class CameraDiagnosticsTest {
@Test fun retainsNestedCauseAndNetworkCategoryWithoutSecrets() {
val error = IOException("rtsp://alice:secret@camera/live?token=private",
SocketTimeoutException("password=secret"))
val result = CameraDiagnostics.causes(error)
assertTrue(result.contains("IOException"))
assertTrue(result.contains("SocketTimeoutException[socket_timeout]"))
for (secret in listOf("alice", "secret", "private", "rtsp://", "password=")) {
assertFalse(result.contains(secret))
}
}

@Test fun retainsOnlyStrictRtspStatusMessages() {
assertTrue(CameraDiagnostics.causes(IOException("DESCRIBE 401")).contains("[DESCRIBE 401]"))
assertFalse(CameraDiagnostics.causes(IOException("DESCRIBE 401 token=secret")).contains("DESCRIBE"))
assertFalse(CameraDiagnostics.causes(IOException("Authorization: Basic abcdef")).contains("abcdef"))
}

@Test fun boundsCyclesAndDeepChains() {
val first = IOException("first")
val second = IOException("second", first)
first.initCause(second)
assertTrue(CameraDiagnostics.causes(first).endsWith("[cause chain truncated]"))
var deep: Throwable = IOException("leaf")
repeat(30) { deep = IOException("parent", deep) }
val result = CameraDiagnostics.causes(deep)
assertTrue(result.length <= 4096)
assertEquals(8, Regex("IOException").findAll(result).count())
}

@Test fun rejectsUnsafeIdentifiers() {
assertEquals("42", CameraDiagnostics.identifier("42"))
assertEquals("cam-abc_1", CameraDiagnostics.identifier("cam-abc_1"))
assertEquals("unknown", CameraDiagnostics.identifier("rtsp://user:pass@host"))
assertEquals("unknown", CameraDiagnostics.identifier("42\nforged log"))
assertEquals("unknown", CameraDiagnostics.identifier("x".repeat(65)))
}
}
Loading