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
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<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 {
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)

View file

@ -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)
}

View file

@ -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()

View file

@ -4,4 +4,25 @@
<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="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>

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
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=<ip:port>
# Option 1: reset provisioning the same way "Clear data" does