feat(android): hidden settings menu with multi-tap BACK/ESC detection

Add an in-app settings menu reachable via 2× BACK (or ESC) taps,
with a 1.8s window — Android TV and touch devices.

- 2 taps: settings dialog (change server, re-pair, permissions, exit)
- 3 taps: exit dialog directly (skip menu)
- Auto-banner after 10+ consecutive connection failures

Settings options:
- Change server URL (pre-fills ProvisioningActivity)
- Reconfigure device (clear credentials → re-pair)
- Permissions (Accessibility + Notifications status → system settings)
- Device info (ID, APK version, connection status)
- Exit app (finishAffinity)

Also adds EXTRA_SERVER_URL to ProvisioningActivity and a
consecutiveFailures counter to WebSocketService.
This commit is contained in:
BlazzzPlay 2026-07-07 16:57:49 -04:00
parent be01674d35
commit 69be6e804e
5 changed files with 253 additions and 10 deletions

View file

@ -1,12 +1,15 @@
package com.remotedisplay.player package com.remotedisplay.player
import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfo
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -19,7 +22,9 @@ import android.view.WindowManager
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.remotedisplay.player.data.ContentCache import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.data.ServerConfig import com.remotedisplay.player.data.ServerConfig
@ -66,6 +71,14 @@ class MainActivity : AppCompatActivity() {
private var screenshotStreamRunnable: Runnable? = null private var screenshotStreamRunnable: Runnable? = null
private var playbackStarted = false 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<Long>()
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 { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder val binder = service as WebSocketService.LocalBinder
@ -248,6 +261,16 @@ class MainActivity : AppCompatActivity() {
updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() } updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() }
updateChecker.startPeriodicCheck() 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 // Rotate the whole stage in software so portrait / flipped signage works even on
@ -783,10 +806,186 @@ class MainActivity : AppCompatActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onBackPressed() { 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)") 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 { private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java) val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java)

View file

@ -76,8 +76,12 @@ class ProvisioningActivity : AppCompatActivity() {
pairingSection = findViewById(R.id.pairingSection) pairingSection = findViewById(R.id.pairingSection)
serverSection = findViewById(R.id.serverSection) serverSection = findViewById(R.id.serverSection)
// Pre-fill if previously entered // Pre-fill if previously entered, OR if an external caller passed a URL
if (config.serverUrl.isNotEmpty()) { // (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) serverUrlInput.setText(config.serverUrl)
} }

View file

@ -121,7 +121,8 @@ class WebSocketService : Service() {
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()) {
Log.e("WebSocketService", "No server URL configured") consecutiveFailures++
Log.e("WebSocketService", "No server URL configured (${consecutiveFailures} consecutive)")
return return
} }
if (!ConnectionGuard.shouldOpenNewSocket(socket != null, currentUrl == url, socketActive)) { if (!ConnectionGuard.shouldOpenNewSocket(socket != null, currentUrl == url, socketActive)) {
@ -153,6 +154,7 @@ class WebSocketService : Service() {
socket = IO.socket(URI.create("$url/device"), options).apply { socket = IO.socket(URI.create("$url/device"), options).apply {
safeOn(Socket.EVENT_CONNECT) { safeOn(Socket.EVENT_CONNECT) {
Log.i("WebSocketService", "Connected to server") Log.i("WebSocketService", "Connected to server")
consecutiveFailures = 0
register() register()
} }
@ -173,7 +175,8 @@ class WebSocketService : Service() {
} }
safeOn(Socket.EVENT_CONNECT_ERROR) { args -> 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 -> safeOn("device:registered") { args ->
@ -679,6 +682,11 @@ class WebSocketService : Service() {
fun isConnected(): Boolean = socket?.connected() == true 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() { override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() } wakeLock?.let { if (it.isHeld) it.release() }
disconnect() disconnect()

View file

@ -4,4 +4,25 @@
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string> <string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
<string name="nothing_scheduled">Nothing scheduled right now</string> <string name="nothing_scheduled">Nothing scheduled right now</string>
<string name="waiting_for_content">Waiting for content…</string> <string name="waiting_for_content">Waiting for content…</string>
<!-- Hidden settings menu (triggered by 2x BACK or ESC) -->
<string name="settings_title">Settings</string>
<string name="settings_change_server">Change server URL</string>
<string name="settings_reconfigure">Reconfigure device (re-pair)</string>
<string name="settings_device_info">Device info</string>
<string name="settings_info_device">Device</string>
<string name="settings_exit">Exit app</string>
<string name="settings_save">Save</string>
<string name="settings_exit_title">Exit ScreenTinker?</string>
<string name="settings_exit_confirm">The app will stop displaying content. You can reopen it later from the app launcher.</string>
<string name="settings_exit_yes">Exit</string>
<string name="settings_connection_failed">Can\'t reach the server</string>
<string name="settings_connection_hint">Press BACK twice for settings</string>
<!-- Permissions -->
<string name="settings_permissions">Permissions</string>
<string name="settings_perm_accessibility">Accessibility</string>
<string name="settings_perm_notifications">Notifications</string>
<string name="settings_perm_hint">Tap \"Open\" to manage permissions in system settings</string>
<string name="settings_perm_open">Open settings</string>
</resources> </resources>

View file

@ -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 ## Fix: re-point the player to a different server
The app only shows its **setup screen** when it is *not provisioned/paired* Three ways to reconfigure the server URL, from easiest to most involved:
(`MainActivity`: `if (!config.isProvisioned || !config.isPaired) -> ProvisioningActivity`).
So to change servers you must reset that state. Two ways:
### 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.** 1. **Settings → Apps → RemoteDisplay → Storage → Clear data.**
This wipes the stale server URL and pairing. (Cached content is cleared too; This wipes the stale server URL and pairing. (Cached content is cleared too;
it re-downloads after pairing — no harm.) 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 > remote power/navigation is also reset. Re-enable it if you need remote
> reboot/screen control: Settings → Accessibility → RemoteDisplay → On. > 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 ```bash
D=<ip:port> D=<ip:port>
# Option 1: reset provisioning the same way "Clear data" does # Option 1: reset provisioning the same way "Clear data" does