diff --git a/client/android/README.md b/client/android/README.md index d7cd950..a87a501 100644 --- a/client/android/README.md +++ b/client/android/README.md @@ -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. diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/CameraDiagnostics.kt b/client/android/app/src/main/java/cloud/betterportal/frame/CameraDiagnostics.kt new file mode 100644 index 0000000..1f9f3e2 --- /dev/null +++ b/client/android/app/src/main/java/cloud/betterportal/frame/CameraDiagnostics.kt @@ -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()) + val details = mutableListOf() + 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) + } +} diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/CameraTile.kt b/client/android/app/src/main/java/cloud/betterportal/frame/CameraTile.kt index e25a193..55f2a6e 100644 --- a/client/android/app/src/main/java/cloud/betterportal/frame/CameraTile.kt +++ b/client/android/app/src/main/java/cloud/betterportal/frame/CameraTile.kt @@ -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 @@ -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 } @@ -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 @@ -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) { @@ -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, @@ -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) { @@ -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) } diff --git a/client/android/app/src/test/java/cloud/betterportal/frame/CameraDiagnosticsTest.kt b/client/android/app/src/test/java/cloud/betterportal/frame/CameraDiagnosticsTest.kt new file mode 100644 index 0000000..b82cf91 --- /dev/null +++ b/client/android/app/src/test/java/cloud/betterportal/frame/CameraDiagnosticsTest.kt @@ -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))) + } +}