diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 8922f12..c4c40c0 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -1,12 +1,15 @@ package com.remotedisplay.player import android.accessibilityservice.AccessibilityServiceInfo +import android.Manifest import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.widget.EditText import android.widget.FrameLayout import android.os.Handler import android.os.IBinder @@ -19,7 +22,9 @@ import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.widget.ImageView import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.media3.ui.PlayerView import com.remotedisplay.player.data.ContentCache import com.remotedisplay.player.data.ServerConfig @@ -66,6 +71,14 @@ class MainActivity : AppCompatActivity() { private var screenshotStreamRunnable: Runnable? = null private var playbackStarted = false + // Multi-tap BACK/ESC for hidden settings menu. + // Collect taps in a 2-second window; on expiry: 2 taps → settings, 3+ taps → exit. + private val backTapTimes = mutableListOf() + private var backTapRunnable: Runnable? = null + private val TAP_WINDOW_MS = 1800L + // Connection-failure auto-prompt threshold. + private var failureBannerShown = false + private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = service as WebSocketService.LocalBinder @@ -248,6 +261,16 @@ class MainActivity : AppCompatActivity() { updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() } updateChecker.startPeriodicCheck() + // Periodic connection-failure check so the "Stuck connecting?" banner appears + // without waiting for the next playlist update + val failureCheck = object : Runnable { + override fun run() { + checkConnectionFailureBanner() + handler.postDelayed(this, 30_000L) + } + } + handler.postDelayed(failureCheck, 15_000L) + } // Rotate the whole stage in software so portrait / flipped signage works even on @@ -783,10 +806,186 @@ class MainActivity : AppCompatActivity() { @Suppress("DEPRECATION") override fun onBackPressed() { - // Don't exit the app on back press - this is a kiosk/signage app + // Don't exit the app on back press - this is a kiosk/signage app. + // Multi-tap detection is handled in dispatchKeyEvent. Log.i("MainActivity", "Back press intercepted (kiosk mode)") } + // Multi-tap BACK/ESC detection — 2 taps → settings, 3+ taps → exit dialog. + // Catches hardware BACK, D-pad BACK (KEYCODE_BACK=4), and ESC (111). + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE -> { + handleBackTap() + return true // consume — never let the system handle it + } + } + } + return super.dispatchKeyEvent(event) + } + + private fun handleBackTap() { + val now = System.currentTimeMillis() + backTapTimes.add(now) + + // Trim taps older than the window + while (backTapTimes.isNotEmpty() && now - backTapTimes.first() > TAP_WINDOW_MS) { + backTapTimes.removeAt(0) + } + + // Cancel any pending evaluation and re-schedule + backTapRunnable?.let { handler.removeCallbacks(it) } + backTapRunnable = Runnable { + val count = backTapTimes.size + backTapTimes.clear() + when { + count >= 3 -> showExitDialog() + count == 2 -> showSettingsDialog() + // count == 1 → ignored (kiosk) + } + } + handler.postDelayed(backTapRunnable!!, TAP_WINDOW_MS) + } + + private fun showSettingsDialog() { + val serverUrl = config.serverUrl + val connected = wsService?.isConnected() == true + val version = try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "?" + } catch (_: Exception) { "?" } + + val items = arrayOf( + "${getString(R.string.settings_change_server)}\n ${if (serverUrl.isEmpty()) "—" else serverUrl}", + getString(R.string.settings_reconfigure), + getString(R.string.settings_permissions), + "${getString(R.string.settings_device_info)}\n ${getString(R.string.settings_info_device)}: ${config.deviceId.take(8)}… | v$version | ${if (connected) "●" else "○"}", + getString(R.string.settings_exit) + ) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.settings_title)) + .setItems(items) { _, which -> + when (which) { + 0 -> showChangeServerDialog(serverUrl) + 1 -> { + config.clearDeviceCredentials() + navigateToProvisioning(serverUrl) + } + 2 -> showPermissionsDialog() + 4 -> showExitDialog() + // 3 = info (read-only, dismiss) + } + } + .setOnCancelListener { /* dismissed, back to kiosk */ } + .show() + } + + private fun showChangeServerDialog(currentUrl: String) { + val input = EditText(this).apply { + setText(currentUrl) + inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI + hint = "https://screentinker.com" + setSingleLine() + } + val container = FrameLayout(this).apply { + val pad = (16 * resources.displayMetrics.density).toInt() + setPadding(pad, pad, pad, 0) + addView(input) + } + + AlertDialog.Builder(this) + .setTitle(getString(R.string.settings_change_server)) + .setView(container) + .setPositiveButton(getString(R.string.settings_save)) { _, _ -> + val url = input.text.toString().trim().trimEnd('/') + if (url.isNotEmpty() && url != currentUrl) { + config.clearDeviceCredentials() + navigateToProvisioning(url) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showPermissionsDialog() { + val accEnabled = isAccessibilityEnabled() + val notifyGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else true + + val lines = buildString { + appendLine("${getString(R.string.settings_perm_accessibility)}: ${if (accEnabled) "✓" else "✗"}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appendLine("${getString(R.string.settings_perm_notifications)}: ${if (notifyGranted) "✓" else "✗"}") + } + appendLine("") + appendLine(getString(R.string.settings_perm_hint)) + } + + AlertDialog.Builder(this) + .setTitle(getString(R.string.settings_permissions)) + .setMessage(lines) + .setPositiveButton(getString(R.string.settings_perm_open)) { _, _ -> + val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.parse("package:$packageName") + } + startActivity(intent) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showExitDialog() { + AlertDialog.Builder(this) + .setTitle(getString(R.string.settings_exit_title)) + .setMessage(getString(R.string.settings_exit_confirm)) + .setPositiveButton(getString(R.string.settings_exit_yes)) { _, _ -> + try { + wsService?.disconnect() + if (bound) { unbindService(connection); bound = false } + } catch (_: Exception) {} + finishAffinity() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + AlertDialog.Builder(this) + .setTitle(getString(R.string.settings_exit_title)) + .setMessage(getString(R.string.settings_exit_confirm)) + .setPositiveButton(getString(R.string.settings_exit_yes)) { _, _ -> + try { + wsService?.disconnect() + if (bound) { unbindService(connection); bound = false } + } catch (_: Exception) {} + finishAffinity() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun navigateToProvisioning(url: String? = null) { + try { wsService?.disconnect() } catch (_: Exception) {} + if (bound) { try { unbindService(connection) } catch (_: Exception) {}; bound = false } + val intent = Intent(this, ProvisioningActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + url?.let { putExtra("EXTRA_SERVER_URL", it) } + } + startActivity(intent) + finish() + } + + private fun checkConnectionFailureBanner() { + val failures = wsService?.consecutiveFailures ?: 0 + if (failures > 10 && !failureBannerShown && wsService?.isConnected() != true) { + failureBannerShown = true + showStatus("${getString(R.string.settings_connection_failed)}\n${getString(R.string.settings_connection_hint)}") + } + if (failures == 0) { + failureBannerShown = false + } + } + private fun isAccessibilityEnabled(): Boolean { val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java) diff --git a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt index ce80d19..3921c95 100644 --- a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt @@ -76,8 +76,12 @@ class ProvisioningActivity : AppCompatActivity() { pairingSection = findViewById(R.id.pairingSection) serverSection = findViewById(R.id.serverSection) - // Pre-fill if previously entered - if (config.serverUrl.isNotEmpty()) { + // Pre-fill if previously entered, OR if an external caller passed a URL + // (e.g. MainActivity settings → "Change server"). + val passedUrl = intent.getStringExtra("EXTRA_SERVER_URL")?.trimEnd('/') + if (!passedUrl.isNullOrEmpty()) { + serverUrlInput.setText(passedUrl) + } else if (config.serverUrl.isNotEmpty()) { serverUrlInput.setText(config.serverUrl) } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 950a2f8..879a320 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -121,7 +121,8 @@ class WebSocketService : Service() { fun connect(serverUrl: String? = null) { val url = serverUrl ?: config.serverUrl if (url.isEmpty()) { - Log.e("WebSocketService", "No server URL configured") + consecutiveFailures++ + Log.e("WebSocketService", "No server URL configured (${consecutiveFailures} consecutive)") return } if (!ConnectionGuard.shouldOpenNewSocket(socket != null, currentUrl == url, socketActive)) { @@ -153,6 +154,7 @@ class WebSocketService : Service() { socket = IO.socket(URI.create("$url/device"), options).apply { safeOn(Socket.EVENT_CONNECT) { Log.i("WebSocketService", "Connected to server") + consecutiveFailures = 0 register() } @@ -173,7 +175,8 @@ class WebSocketService : Service() { } safeOn(Socket.EVENT_CONNECT_ERROR) { args -> - Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}") + consecutiveFailures++ + Log.e("WebSocketService", "Connection error (${consecutiveFailures} consecutive): ${args.firstOrNull()}") } safeOn("device:registered") { args -> @@ -679,6 +682,11 @@ class WebSocketService : Service() { fun isConnected(): Boolean = socket?.connected() == true + // Consecutive connection failures (reset on any successful connect). + // Used by MainActivity to surface a "Stuck connecting?" prompt. + @Volatile var consecutiveFailures: Int = 0 + private set + override fun onDestroy() { wakeLock?.let { if (it.isHeld) it.release() } disconnect() diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b0a5222..4318a7a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,4 +4,25 @@ RemoteDisplay uses accessibility to enable remote power controls and system navigation. Nothing scheduled right now Waiting for content… + + + Settings + Change server URL + Reconfigure device (re-pair) + Device info + Device + Exit app + Save + Exit ScreenTinker? + The app will stop displaying content. You can reopen it later from the app launcher. + Exit + Can\'t reach the server + Press BACK twice for settings + + + Permissions + Accessibility + Notifications + Tap \"Open\" to manage permissions in system settings + Open settings diff --git a/docs/android-troubleshooting.md b/docs/android-troubleshooting.md index 590bcf8..f16f1dd 100644 --- a/docs/android-troubleshooting.md +++ b/docs/android-troubleshooting.md @@ -49,11 +49,22 @@ If the target server is up and on the **same LAN** as the device, the player ## Fix: re-point the player to a different server -The app only shows its **setup screen** when it is *not provisioned/paired* -(`MainActivity`: `if (!config.isProvisioned || !config.isPaired) -> ProvisioningActivity`). -So to change servers you must reset that state. Two ways: +Three ways to reconfigure the server URL, from easiest to most involved: -### A. On the phone, no tools (most reliable) +### A. In-app settings (APK v1.9.2+) — RECOMMENDED +1. **Press BACK (or ESC) twice quickly** on the device/remote — a Settings dialog opens. +2. Options available: + - **Change server URL** — enter new URL, clears pairing, returns to provisioning + - **Reconfigure device** — clears credentials, returns to provisioning screen + - **Permissions** — check Accessibility/Notification status, open system settings + - **Device info** — device ID, APK version, connection status + - **Exit app** — close the kiosk app (3× BACK also opens exit directly) +3. After changing server/reconfiguring, enter the pairing code from the dashboard to reconnect. + +If the app is stuck on "Connecting to server…" for more than a minute, it will +show a banner: **"Can't reach the server — Press BACK twice for settings."** + +### B. On the phone, no tools (most reliable) 1. **Settings → Apps → RemoteDisplay → Storage → Clear data.** This wipes the stale server URL and pairing. (Cached content is cleared too; it re-downloads after pairing — no harm.) @@ -67,7 +78,7 @@ So to change servers you must reset that state. Two ways: > remote power/navigation is also reset. Re-enable it if you need remote > reboot/screen control: Settings → Accessibility → RemoteDisplay → On. -### B. Via adb (if you have a working connection) +### C. Via adb (if you have a working connection) ```bash D= # Option 1: reset provisioning the same way "Clear data" does