Fix Android app crash on WebSocket connection loss

Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.

Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-28 10:13:26 -05:00
parent cd6e39a4a7
commit 8866e305f0

View file

@ -65,6 +65,20 @@ class WebSocketService : Service() {
return START_STICKY return START_STICKY
} }
// Wrap every Socket.IO listener body in try/catch. A malformed payload from the server
// (or a transient state error during disconnect) used to surface as an unhandled
// exception on the Socket.IO IO thread and crash the whole app.
private fun Socket.safeOn(event: String, handler: (Array<Any?>) -> Unit): Socket {
return on(event) { args ->
try {
@Suppress("UNCHECKED_CAST")
handler(args as Array<Any?>)
} catch (e: Throwable) {
Log.e("WebSocketService", "Listener for '$event' failed: ${e.message}", e)
}
}
}
fun connect(serverUrl: String? = null) { fun connect(serverUrl: String? = null) {
val url = serverUrl ?: config.serverUrl val url = serverUrl ?: config.serverUrl
if (url.isEmpty()) { if (url.isEmpty()) {
@ -79,189 +93,206 @@ class WebSocketService : Service() {
forceNew = true forceNew = true
reconnection = true reconnection = true
reconnectionAttempts = Integer.MAX_VALUE reconnectionAttempts = Integer.MAX_VALUE
reconnectionDelay = 2000 // Exponential backoff: starts at 1s, doubles each attempt, capped at 60s,
reconnectionDelayMax = 10000 // ±50% jitter so a fleet doesn't reconnect in lockstep after a server blip.
reconnectionDelay = 1000
reconnectionDelayMax = 60_000
randomizationFactor = 0.5
timeout = 20000 timeout = 20000
} }
socket = IO.socket(URI.create("$url/device"), options).apply { socket = IO.socket(URI.create("$url/device"), options).apply {
on(Socket.EVENT_CONNECT) { safeOn(Socket.EVENT_CONNECT) {
Log.i("WebSocketService", "Connected to server") Log.i("WebSocketService", "Connected to server")
register() register()
} }
on(Socket.EVENT_DISCONNECT) { safeOn(Socket.EVENT_DISCONNECT) { args ->
Log.w("WebSocketService", "Disconnected from server") val reason = args.firstOrNull()?.toString() ?: "unknown"
Log.w("WebSocketService", "Disconnected from server: $reason")
// Stop heartbeat while disconnected; player keeps showing cached content.
// Socket.IO will reconnect automatically per the options above.
stopHeartbeat()
} }
on(Socket.EVENT_CONNECT_ERROR) { args -> safeOn(Socket.EVENT_CONNECT_ERROR) { args ->
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}") Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
} }
on("device:registered") { args -> safeOn("device:registered") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val newDeviceId = data.getString("device_id") val newDeviceId = data.optString("device_id", "")
if (newDeviceId.isEmpty()) {
Log.w("WebSocketService", "device:registered missing device_id")
return@safeOn
}
config.deviceId = newDeviceId config.deviceId = newDeviceId
// Persist device_token (issued on first register, or refreshed on reconnect) // Persist device_token (issued on first register, or refreshed on reconnect)
if (data.has("device_token")) { if (data.has("device_token")) {
config.deviceToken = data.getString("device_token") config.deviceToken = data.optString("device_token", "")
} }
Log.i("WebSocketService", "Registered as: $newDeviceId") Log.i("WebSocketService", "Registered as: $newDeviceId")
handler.post { onRegistered?.invoke(newDeviceId) } handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } }
startHeartbeat() startHeartbeat()
} }
on("device:unpaired") { safeOn("device:unpaired") {
Log.w("WebSocketService", "Device not found on server - clearing credentials") Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:auth-error") { args -> safeOn("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed" val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair") Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:paired") { args -> safeOn("device:paired") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val id = data.getString("device_id") val id = data.optString("device_id", "")
val name = data.optString("name", "Display") val name = data.optString("name", "Display")
config.setPaired(true) config.setPaired(true)
config.deviceName = name config.deviceName = name
Log.i("WebSocketService", "Paired as: $name") Log.i("WebSocketService", "Paired as: $name")
handler.post { onPaired?.invoke(id, name) } handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
} }
on("device:playlist-update") { args -> safeOn("device:playlist-update") { args ->
Log.i("WebSocketService", "Playlist raw args: ${args.size} items, type=${args[0]?.javaClass?.name}, data=${args[0]}") val data = args.firstOrNull() as? JSONObject ?: run {
val data = args[0] as JSONObject Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
Log.i("WebSocketService", "Playlist update received, keys=${data.keys().asSequence().toList()}, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}") return@safeOn
handler.post { onPlaylistUpdate?.invoke(data) } }
Log.i("WebSocketService", "Playlist update received, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
handler.post { try { onPlaylistUpdate?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPlaylistUpdate cb: ${e.message}") } }
} }
on("device:content-delete") { args -> safeOn("device:content-delete") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val contentId = data.getString("content_id") val contentId = data.optString("content_id", "")
handler.post { onContentDelete?.invoke(contentId) } if (contentId.isNotEmpty()) {
} handler.post { try { onContentDelete?.invoke(contentId) } catch (e: Throwable) { Log.e("WebSocketService", "onContentDelete cb: ${e.message}") } }
on("device:screenshot-request") {
captureAndSendScreenshot()
handler.post { onScreenshotRequest?.invoke() }
}
on("device:remote-start") {
startScreenshotStream()
handler.post { onRemoteStart?.invoke() }
}
on("device:remote-stop") {
stopScreenshotStream()
handler.post { onRemoteStop?.invoke() }
}
on("device:remote-touch") { args ->
val data = args[0] as JSONObject
val x = data.getDouble("x").toFloat()
val y = data.getDouble("y").toFloat()
val action = data.optString("action", "tap")
// Use AccessibilityService for system-wide touch (works on dialogs too)
val svc = PowerAccessibilityService.instance
if (svc != null && action == "tap") {
handler.post { svc.injectTap(x, y) }
} else {
handler.post { onRemoteTouch?.invoke(x, y, action) }
} }
} }
on("device:remote-key") { args -> safeOn("device:screenshot-request") {
val data = args[0] as JSONObject captureAndSendScreenshot()
val keycode = data.getString("keycode") handler.post { try { onScreenshotRequest?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onScreenshotRequest cb: ${e.message}") } }
// Always inject via shell (works even when app not in foreground)
injectKey(keycode)
handler.post { onRemoteKey?.invoke(keycode) }
} }
on("device:command") { args -> safeOn("device:remote-start") {
val data = args[0] as JSONObject startScreenshotStream()
val type = data.getString("type") handler.post { try { onRemoteStart?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStart cb: ${e.message}") } }
}
safeOn("device:remote-stop") {
stopScreenshotStream()
handler.post { try { onRemoteStop?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStop cb: ${e.message}") } }
}
safeOn("device:remote-touch") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val x = data.optDouble("x", 0.0).toFloat()
val y = data.optDouble("y", 0.0).toFloat()
val action = data.optString("action", "tap")
val svc = PowerAccessibilityService.instance
if (svc != null && action == "tap") {
handler.post { try { svc.injectTap(x, y) } catch (e: Throwable) { Log.e("WebSocketService", "injectTap: ${e.message}") } }
} else {
handler.post { try { onRemoteTouch?.invoke(x, y, action) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteTouch cb: ${e.message}") } }
}
}
safeOn("device:remote-key") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val keycode = data.optString("keycode", "")
if (keycode.isEmpty()) return@safeOn
injectKey(keycode)
handler.post { try { onRemoteKey?.invoke(keycode) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteKey cb: ${e.message}") } }
}
safeOn("device:command") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val type = data.optString("type", "")
if (type.isEmpty()) return@safeOn
val payload = data.optJSONObject("payload") val payload = data.optJSONObject("payload")
Log.i("WebSocketService", "Command received: $type") Log.i("WebSocketService", "Command received: $type")
// Handle system commands directly in the service
when (type) { when (type) {
"launch" -> { "launch" -> {
handler.post { handler.post {
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply { try {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent) }
Log.i("WebSocketService", "Launched MainActivity from service") startActivity(intent)
Log.i("WebSocketService", "Launched MainActivity from service")
} catch (e: Throwable) { Log.e("WebSocketService", "launch cmd: ${e.message}") }
} }
} }
"settings" -> { "settings" -> {
handler.post { handler.post {
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply { try {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply {
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent) }
Log.i("WebSocketService", "Opened system settings") startActivity(intent)
} catch (e: Throwable) { Log.e("WebSocketService", "settings cmd: ${e.message}") }
} }
} }
"enable_system_capture" -> { "enable_system_capture" -> {
// Trigger MediaProjection permission request on device
handler.post { handler.post {
com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService) try {
Log.i("WebSocketService", "Requesting system capture permission") com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService)
} catch (e: Throwable) { Log.e("WebSocketService", "enable_system_capture: ${e.message}") }
} }
} }
"screen_off" -> { "screen_off" -> {
val a11y = PowerAccessibilityService.instance val a11y = PowerAccessibilityService.instance
if (a11y != null) { if (a11y != null) {
handler.post { a11y.lockScreen() } handler.post { try { a11y.lockScreen() } catch (e: Throwable) { Log.e("WebSocketService", "lockScreen: ${e.message}") } }
} else { } else {
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start() Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start()
} }
} }
"screen_on" -> { "screen_on" -> {
// WAKEUP keyevent works from shell on most devices
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start() Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start()
} }
else -> handler.post { onCommand?.invoke(type, payload) } else -> handler.post { try { onCommand?.invoke(type, payload) } catch (e: Throwable) { Log.e("WebSocketService", "onCommand cb: ${e.message}") } }
} }
} }
connect() connect()
} }
} catch (e: Exception) { } catch (e: Throwable) {
Log.e("WebSocketService", "Socket setup error: ${e.message}") Log.e("WebSocketService", "Socket setup error: ${e.message}", e)
} }
} }
private fun register() { private fun register() {
val data = JSONObject().apply { try {
if (config.isProvisioned && config.isPaired) { val data = JSONObject().apply {
put("device_id", config.deviceId) if (config.isProvisioned && config.isPaired) {
// Send device_token for authentication (may be empty for legacy devices) put("device_id", config.deviceId)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
put("device_token", token) put("device_token", token)
}
} else {
val pairingCode = (100000..999999).random().toString()
put("pairing_code", pairingCode)
config.deviceId = ""
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putString("pairing_code", pairingCode).apply()
} }
} else { try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
// Generate a pairing code if we don't have one try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
val pairingCode = (100000..999999).random().toString()
put("pairing_code", pairingCode)
config.deviceId = "" // Will be set on registered event
// Store pairing code temporarily
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putString("pairing_code", pairingCode).apply()
} }
put("device_info", deviceInfo.getDeviceInfo()) socket?.emit("device:register", data)
put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) {
Log.e("WebSocketService", "register failed: ${e.message}", e)
} }
socket?.emit("device:register", data)
} }
fun getPairingCode(): String { fun getPairingCode(): String {
@ -291,16 +322,17 @@ class WebSocketService : Service() {
fun requestPlaylistRefresh() { fun requestPlaylistRefresh() {
if (socket?.connected() != true || config.deviceId.isEmpty()) return if (socket?.connected() != true || config.deviceId.isEmpty()) return
Log.i("WebSocketService", "Requesting playlist refresh") Log.i("WebSocketService", "Requesting playlist refresh")
// Re-register triggers the server to send current playlist try {
val data = org.json.JSONObject().apply { val data = org.json.JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) put("device_token", token)
put("device_token", token) try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
} }
put("device_info", deviceInfo.getDeviceInfo()) socket?.emit("device:register", data)
} catch (e: Throwable) {
Log.e("WebSocketService", "requestPlaylistRefresh failed: ${e.message}")
} }
socket?.emit("device:register", data)
} }
private fun stopHeartbeat() { private fun stopHeartbeat() {
@ -310,11 +342,15 @@ class WebSocketService : Service() {
private fun sendHeartbeat() { private fun sendHeartbeat() {
if (socket?.connected() != true) return if (socket?.connected() != true) return
val data = JSONObject().apply { try {
put("device_id", config.deviceId) val data = JSONObject().apply {
put("telemetry", deviceInfo.getTelemetry()) put("device_id", config.deviceId)
try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") }
}
socket?.emit("device:heartbeat", data)
} catch (e: Throwable) {
Log.e("WebSocketService", "sendHeartbeat failed: ${e.message}")
} }
socket?.emit("device:heartbeat", data)
} }
// Screenshot streaming from the service (works even when activity is paused) // Screenshot streaming from the service (works even when activity is paused)
@ -381,11 +417,13 @@ class WebSocketService : Service() {
fun sendScreenshot(imageBase64: String) { fun sendScreenshot(imageBase64: String) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
val data = JSONObject().apply { try {
put("device_id", config.deviceId) val data = JSONObject().apply {
put("image_b64", imageBase64) put("device_id", config.deviceId)
} put("image_b64", imageBase64)
socket?.emit("device:screenshot", data) }
socket?.emit("device:screenshot", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendScreenshot: ${e.message}") }
} }
private fun injectKey(keycode: String) { private fun injectKey(keycode: String) {
@ -440,28 +478,32 @@ class WebSocketService : Service() {
fun sendContentAck(contentId: String, status: String) { fun sendContentAck(contentId: String, status: String) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
val data = JSONObject().apply { try {
put("device_id", config.deviceId) val data = JSONObject().apply {
put("content_id", contentId) put("device_id", config.deviceId)
put("status", status) put("content_id", contentId)
} put("status", status)
socket?.emit("device:content-ack", data) }
socket?.emit("device:content-ack", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
} }
fun sendPlaybackState(contentId: String, positionSec: Float) { fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
val data = JSONObject().apply { try {
put("device_id", config.deviceId) val data = JSONObject().apply {
put("current_content_id", contentId) put("device_id", config.deviceId)
put("position_sec", positionSec) put("current_content_id", contentId)
} put("position_sec", positionSec)
socket?.emit("device:playback-state", data) }
socket?.emit("device:playback-state", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlaybackState: ${e.message}") }
} }
fun disconnect() { fun disconnect() {
stopHeartbeat() stopHeartbeat()
socket?.disconnect() try { socket?.disconnect() } catch (e: Throwable) { Log.w("WebSocketService", "disconnect: ${e.message}") }
socket?.off() try { socket?.off() } catch (e: Throwable) { Log.w("WebSocketService", "off: ${e.message}") }
socket = null socket = null
} }