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