diff --git a/app/src/main/java/dev/r1ptt/PttService.kt b/app/src/main/java/dev/r1ptt/PttService.kt index fd5b2d9..2ff089c 100644 --- a/app/src/main/java/dev/r1ptt/PttService.kt +++ b/app/src/main/java/dev/r1ptt/PttService.kt @@ -35,6 +35,14 @@ import kotlinx.coroutines.launch * the foreground, the launcher starts it. Idle, it costs nothing: the button reader is blocked in * a read and nothing polls. */ +/** What the button may do right now. Hermes can send texts, so nothing runs past Android's lock. */ +internal enum class PressGate { LOCKED, RECOVERY, OPEN } +internal fun pressGate(app: App): PressGate = when { + app.screen.deviceLocked() -> PressGate.LOCKED + app.workspace.blocked -> PressGate.RECOVERY + else -> PressGate.OPEN +} + class PttService : Service() { private val main = Handler(Looper.getMainLooper()) private lateinit var gestures: Gestures @@ -136,12 +144,18 @@ class PttService : Service() { } private fun routeButton(down: Boolean) { val app = application as App - if (app.workspace.blocked) { - cancelPressForRecovery() - if (down) { app.workspace.recoverNow(); app.workspace.management.stopRinging() } - return + when (pressGate(app)) { + PressGate.LOCKED -> { + // Android's lock screen is up: the press only wakes it. A ringing R1 can still be silenced. + cancelPressForRecovery() + if (down) { app.workspace.management.stopRinging(); EventLog.record("button ignored: Android is locked") } + } + PressGate.RECOVERY -> { + cancelPressForRecovery() + if (down) { app.workspace.recoverNow(); app.workspace.management.stopRinging() } + } + PressGate.OPEN -> pressRouter.edge(down) } - pressRouter.edge(down) } // Not sticky: Android 14 won't let a microphone service restart from the background. If the diff --git a/app/src/main/java/dev/r1ptt/power/ScreenPolicy.kt b/app/src/main/java/dev/r1ptt/power/ScreenPolicy.kt index 17ae020..013eecc 100644 --- a/app/src/main/java/dev/r1ptt/power/ScreenPolicy.kt +++ b/app/src/main/java/dev/r1ptt/power/ScreenPolicy.kt @@ -1,5 +1,6 @@ package dev.r1ptt.power +import android.app.KeyguardManager import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Context @@ -12,10 +13,15 @@ import dev.r1ptt.sys.Root * Screen and CPU-wake decisions. The screen itself is kept on by the launcher window while a turn * is active (FLAG_KEEP_SCREEN_ON) and otherwise left to the short system timeout. */ -class ScreenPolicy(ctx: Context) { +class ScreenPolicy( + ctx: Context, + private val sleepKey: ((Boolean) -> Unit) -> Unit = { slept -> Root.async("input keyevent 223") { slept(it == 0) } }, +) { private val pm = ctx.getSystemService(PowerManager::class.java) private val dpm = ctx.getSystemService(DevicePolicyManager::class.java) + private val keyguard = ctx.getSystemService(KeyguardManager::class.java) private val admin = ComponentName(ctx, AdminReceiver::class.java) + internal var lockNow: () -> Unit = { dpm.lockNow() } private var wakeLock: PowerManager.WakeLock? = null @Volatile private var screenOn = pm.isInteractive @@ -47,10 +53,15 @@ class ScreenPolicy(ctx: Context) { */ fun tapWasWake(): Boolean = offAtPress || SystemClock.elapsedRealtime() - lastOnAt < 1000 - /** Screen off now: device admin's lockNow() if provisioned, else the SLEEP key via root. */ + /** Android's lock screen is up and needs the PIN, password or pattern. */ + fun deviceLocked(): Boolean = keyguard.isDeviceLocked + + /** + * Screen off now, as the power button would, so Android's "lock after screen timeout" still + * applies. Device admin's lockNow() locks at once, so it is only the fallback without root. + */ fun sleepNow() { - val viaAdmin = dpm.isAdminActive(admin) && runCatching { dpm.lockNow() }.isSuccess - if (!viaAdmin) Root.async("input keyevent 223") + sleepKey { slept -> if (!slept && dpm.isAdminActive(admin)) runCatching { lockNow() } } } /** Keeps the CPU up for one turn even if the screen goes off; bounded so a bug can't drain the battery. */ diff --git a/app/src/main/java/dev/r1ptt/sys/AdminReceiver.kt b/app/src/main/java/dev/r1ptt/sys/AdminReceiver.kt index df4d663..c049728 100644 --- a/app/src/main/java/dev/r1ptt/sys/AdminReceiver.kt +++ b/app/src/main/java/dev/r1ptt/sys/AdminReceiver.kt @@ -3,8 +3,8 @@ package dev.r1ptt.sys import android.app.admin.DeviceAdminReceiver /** - * Device-admin hook whose only policy is force-lock: DevicePolicyManager.lockNow() turns the - * screen off instantly when the button is tapped. Activated by tools/provision.sh. + * Device-admin hook whose only policy is force-lock: lost mode asks Android to lock at once + * (DevicePolicyManager.lockNow()). Enabled by the owner from robotOS's host settings. */ class AdminReceiver : DeviceAdminReceiver() { override fun onEnabled(context: android.content.Context, intent: android.content.Intent) = changed(context) diff --git a/app/src/main/java/dev/r1ptt/ui/DeviceActivity.kt b/app/src/main/java/dev/r1ptt/ui/DeviceActivity.kt index a774155..f6672b2 100644 --- a/app/src/main/java/dev/r1ptt/ui/DeviceActivity.kt +++ b/app/src/main/java/dev/r1ptt/ui/DeviceActivity.kt @@ -135,8 +135,10 @@ open class DeviceActivity : Activity() { // A restricted R1 can be rung by its owner at any time; otherwise offer this only while // ringing. The side button also stops ringing. val ringing = hub.quarantined || hub.management.ringing + // With an Android PIN the owner can open robotOS while the host can't be reached; never when restricted. + val ownerPin = !hub.quarantined && getSystemService(android.app.KeyguardManager::class.java).isDeviceSecure val key = listOf(label, detail, identity.optString("return_message"), identity.optBoolean("secure_lock_applied"), - hub.canReenroll, ringing).joinToString("\u0000") + hub.canReenroll, ringing, ownerPin).joinToString("\u0000") // A restricted R1 shows its page (and the owner's return message) after a short beat. val covering = if (hub.quarantined) coverBeat() else !revealed val shell = gate @@ -173,6 +175,7 @@ open class DeviceActivity : Activity() { else "robotOS is restricted. A secure Android screen lock is not active through robotOS; this does not lock other Android apps or protect against physical system access.")) } page.body.addView(ui.button("Check recovery status", primary = true) { hub.recoverNow() }) + if (ownerPin) page.body.addView(ui.button("Open with this R1's PIN") { launchOwnerPin() }) page.body.addView(ui.button("Connect to a network") { startActivity(android.content.Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)) }) @@ -190,6 +193,20 @@ open class DeviceActivity : Activity() { gatePage?.fade(!covering, smooth) } + /** Android's own PIN screen, with its guess limits; robotOS never sees or stores the PIN. */ + @Suppress("DEPRECATION") + private fun launchOwnerPin() { + val intent = getSystemService(android.app.KeyguardManager::class.java).createConfirmDeviceCredentialIntent( + "Open robotOS", "Enter this R1's PIN. robotOS checks with your host as soon as it can.") ?: return + startActivityForResult(intent, OWNER_PIN) + } + + @Deprecated("Uses platform activity result for Android's PIN confirmation") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == OWNER_PIN && resultCode == RESULT_OK) recoveryApp?.workspace?.confirmedByOwner() + } + private fun liftGate(content: View) { val root = gate if (root == null) { @@ -354,6 +371,7 @@ open class DeviceActivity : Activity() { internal const val COVER_MS = 3_500L /** The shortest logo beat once shown, so a check that passes at once never flickers. */ private const val MIN_COVER_MS = 900L + private const val OWNER_PIN = 4517 private const val FADE_MS = 220L private var coverFrom = 0L private var gateUp = false @@ -382,6 +400,12 @@ open class DeviceActivity : Activity() { showPage.run() } private fun coverHold() = coverFrom + MIN_COVER_MS - SystemClock.uptimeMillis() + /** Unit tests share these statics across cases; each case starts with no cover and no screens. */ + internal fun resetForTest() { + phases.removeCallbacksAndMessages(null) + coverFrom = 0L; gateUp = false; revealed = false; startedActivities = 0 + activities.clear() + } private var startedActivities = 0 private val activities = java.util.Collections.newSetFromMap(java.util.WeakHashMap()) /** Main-thread navigation may only originate from the currently resumed robotOS screen. */ diff --git a/app/src/main/java/dev/r1ptt/workspace/WorkspaceClient.kt b/app/src/main/java/dev/r1ptt/workspace/WorkspaceClient.kt index 60e6d05..148b652 100644 --- a/app/src/main/java/dev/r1ptt/workspace/WorkspaceClient.kt +++ b/app/src/main/java/dev/r1ptt/workspace/WorkspaceClient.kt @@ -77,6 +77,18 @@ class WorkspaceClient(private val app: App) { } recoverNow() } + /** + * The owner confirmed this R1's Android PIN on the recovery screen. It opens like a passing check, + * grace included. A restricted R1 stays restricted, and the host's next answer still applies: + * a lost reply takes the screen back at once. + */ + fun confirmedByOwner() { + if (!identity.paired || quarantined || !recoveryPending) return + recoveryConfirmed(recoveryTicket()) + mutable.value = "Opened with this R1's PIN · checking with your host when it can" + EventLog.record("recovery: opened with the owner's Android PIN") + dev.r1ptt.ui.DeviceActivity.refreshRecovery(app) + } /** Screen off or network lost: a fresh R1 stays open for its next wake; otherwise fence now. */ fun fenceUnlessFresh() { if (!identity.paired) return diff --git a/app/src/test/java/dev/r1ptt/power/ScreenPolicyTest.kt b/app/src/test/java/dev/r1ptt/power/ScreenPolicyTest.kt new file mode 100644 index 0000000..35398d3 --- /dev/null +++ b/app/src/test/java/dev/r1ptt/power/ScreenPolicyTest.kt @@ -0,0 +1,40 @@ +package dev.r1ptt.power + +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import dev.r1ptt.App +import dev.r1ptt.sys.AdminReceiver +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = App::class) +class ScreenPolicyTest { + private val app get() = RuntimeEnvironment.getApplication() as App + private fun admin() = shadowOf(app.getSystemService(DevicePolicyManager::class.java)) + .setActiveAdmin(ComponentName(app, AdminReceiver::class.java)) + + /** lockNow() would demand the PIN on every tap; the sleep key keeps Android's lock-after delay. */ + @Test fun tapToSleepNeverHardLocksWhenRootWorks() { + admin() + var locks = 0 + val screen = ScreenPolicy(app, sleepKey = { slept -> slept(true) }).apply { lockNow = { locks++ } } + screen.sleepNow() + assertEquals(0, locks) + } + + @Test fun withoutRootDeviceAdminStillTurnsTheScreenOff() { + var locks = 0 + val screen = ScreenPolicy(app, sleepKey = { slept -> slept(false) }).apply { lockNow = { locks++ } } + screen.sleepNow() + assertEquals(0, locks) // No admin: nothing else to try. + admin() + screen.sleepNow() + assertEquals(1, locks) + } +} diff --git a/app/src/test/java/dev/r1ptt/workspace/RecoveryProtectionTest.kt b/app/src/test/java/dev/r1ptt/workspace/RecoveryProtectionTest.kt index c1403c0..1b309b2 100644 --- a/app/src/test/java/dev/r1ptt/workspace/RecoveryProtectionTest.kt +++ b/app/src/test/java/dev/r1ptt/workspace/RecoveryProtectionTest.kt @@ -39,6 +39,7 @@ import java.util.concurrent.TimeUnit @Config(sdk = [34], application = App::class) class RecoveryProtectionTest { private val app get() = RuntimeEnvironment.getApplication() as App + @org.junit.Before fun freshCover() = DeviceActivity.resetForTest() @After fun close() { app.deviceApi.stop(); app.history.close() } private fun enrolled(url: String = "http://127.0.0.1:9") { val data = JSONObject().put("device_id", "r1-a").put("base_url", url) @@ -604,12 +605,67 @@ class RecoveryProtectionTest { controller.pause().stop().destroy() } - @Test fun aCheckThatPassesAtOnceStillHoldsTheLogoForABeat() { + private fun pinButton(activity: DeviceActivity) = + activity.window.decorView.find(TextView::class.java).firstOrNull { it.text == "Open with this R1's PIN" } + + /** With an Android PIN, the owner can open an R1 whose host can't be reached (#45), using Android's own PIN screen. */ + @Test fun theOwnersAndroidPinOpensAnUnverifiedR1() { enrolled() + val keyguard = shadowOf(app.getSystemService(android.app.KeyguardManager::class.java)) + keyguard.setIsDeviceSecure(true); keyguard.setIsDeviceSecure(android.os.Process.myUserHandle().hashCode(), true) val controller = Robolectric.buildActivity(DeviceActivity::class.java).create().start().resume().visible() val activity = controller.get() activity.setContentView(TextView(activity).apply { text = "Private cached history" }) + app.workspace.requireRecovery(checkNow = false) + shadowOf(Looper.getMainLooper()).idle() + activity.window.decorView.find(dev.r1ptt.ui.LogoGlow::class.java).single().performClick() + shadowOf(Looper.getMainLooper()).idle() + requireNotNull(pinButton(activity)).performClick() + val asked = requireNotNull(shadowOf(activity).nextStartedActivityForResult) + assertTrue(asked.intent.action.orEmpty().contains("CONFIRM_DEVICE_CREDENTIAL")) + assertTrue(app.workspace.blocked) // Cancelling Android's PIN screen opens nothing. + shadowOf(activity).receiveResult(asked.intent, android.app.Activity.RESULT_OK, null) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(1_000)) + assertFalse(app.workspace.blocked) + assertTrue(app.workspace.networkAllowed) + assertEquals(View.VISIBLE, activity.findViewById(android.R.id.content).visibility) + assertTrue(dev.r1ptt.data.EventLog.lines().any { it.endsWith("recovery: opened with the owner's Android PIN") }) + controller.pause().stop().destroy() + } + + @Test fun noPinOpensARestrictedR1OrOneWithoutAnAndroidPin() { + enrolled() + val controller = Robolectric.buildActivity(DeviceActivity::class.java).create().start().resume().visible() + val activity = controller.get() + activity.setContentView(TextView(activity).apply { text = "Private cached history" }) + app.workspace.requireRecovery(checkNow = false) + shadowOf(Looper.getMainLooper()).idle() + assertNull(pinButton(activity)) // No Android PIN: nothing to confirm. + shadowOf(app.getSystemService(android.app.KeyguardManager::class.java)).setIsDeviceSecure(true) + app.workspace.identity.update { it.put("lost", true).put("lost_message", "Please return this R1") } + dev.r1ptt.ui.DeviceActivity.refreshRecovery(app) + shadowOf(Looper.getMainLooper()).idle() + assertNull(pinButton(activity)) + app.workspace.confirmedByOwner() + assertTrue(app.workspace.blocked) + controller.pause().stop().destroy() + } + + @Test fun theButtonOnlyWakesALockedR1() { + assertEquals(dev.r1ptt.PressGate.OPEN, dev.r1ptt.pressGate(app)) + enrolled() + app.workspace.requireRecovery(checkNow = false) + assertEquals(dev.r1ptt.PressGate.RECOVERY, dev.r1ptt.pressGate(app)) + shadowOf(app.getSystemService(android.app.KeyguardManager::class.java)).setIsDeviceLocked(true) + assertEquals(dev.r1ptt.PressGate.LOCKED, dev.r1ptt.pressGate(app)) + } + + @Test fun aCheckThatPassesAtOnceStillHoldsTheLogoForABeat() { val before = dev.r1ptt.data.EventLog.lines().size + enrolled() + val controller = Robolectric.buildActivity(DeviceActivity::class.java).create().start().resume().visible() + val activity = controller.get() + activity.setContentView(TextView(activity).apply { text = "Private cached history" }) app.workspace.requireRecovery(checkNow = false) shadowOf(Looper.getMainLooper()).idle() app.workspace.recoveryConfirmed(app.workspace.recoveryTicket()) diff --git a/docs/HUB.md b/docs/HUB.md index 553229c..fcb2116 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -210,7 +210,12 @@ Protection adapts to the security features actually enabled on the R1. Without a credential, robotOS restricts its own screens and action entry points; this does not lock Android or prevent access through other apps, debugging or a modified operating system. With a secure credential, active device admin and locally enabled management, lost mode can also request an -Android lock. Capability checks refresh when robotOS resumes and reports its state, so enabling +Android lock. A secure credential is the recommended setup ([INSTALL.md](INSTALL.md#lock-it-with-a-pin)): +Android's PIN encrypts the R1's data and limits guessing, which robotOS's own screens cannot do. +While Android is locked the button only wakes the lock screen. When the host can't confirm a wake, +the recovery screen can open robotOS with Android's own PIN confirmation; robotOS never stores the +PIN, and a lost, revoked or cleared R1 stays restricted. robotOS turns the screen off with the +sleep key, not device admin's immediate lock, so Android's "lock after screen timeout" applies. Capability checks refresh when robotOS resumes and reports its state, so enabling those protections locally does not require re-enrollment. No local permission is silently granted. The console reports app restrictions, secure credential availability, remote-lock support and device-reported lock application separately, together with the last connection time. A lock diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e2a6158..5fbd75c 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -124,8 +124,8 @@ The script makes these changes: - Installs the app with its permissions. - Grants the app root. - Makes the app the home screen. -- Preserves the existing lock screen and device-admin state. Configure a PIN/password and grant - device-admin access locally if you want Android secure locking; provisioning does not grant it. +- Preserves the existing lock screen and device-admin state; provisioning grants neither. See + [Lock it with a PIN](#lock-it-with-a-pin) below. - Sets a 15 s screen timeout and low brightness. - Turns on Android's dark theme, colored from the R1's orange, so Android's own screens and the keyboard match robotOS. @@ -186,6 +186,26 @@ Work through these with the R1 on USB: 8. **Battery.** Unplug the R1 overnight, then plug it in and run `tools/battery-report.sh`. See [BATTERY.md](BATTERY.md). +## Lock it with a PIN + +Recommended. Only Android's own lock protects the R1's data: with a PIN, its storage is encrypted +with a key tied to the PIN, and Android limits how fast anyone can guess. robotOS's recovery cover +is not a lock; it can be bypassed through debugging, safe mode or other Android screens. + +1. In robotOS, open **Settings → Host & devices**, tap **Configure Android screen lock**, and choose + **PIN**. +2. On the same Android screen, open the screen lock's settings (the gear). Set **Lock after screen + timeout** to **15 minutes**, which matches robotOS's recovery grace, and turn **Power button + instantly locks** off. A wake within 15 minutes then opens at once; a longer one asks for the PIN. +3. Back in **Host & devices**, turn on **Allow remote management** and **Allow secure remote + lock**, then tap **Enable Android device admin** and activate it. Marking the R1 lost on your host + now locks Android too. + +While Android is locked, the button does nothing but wake the lock screen (it still silences a +ringing R1), so nobody can talk to Hermes without the PIN. If your host can't be reached, the +recovery screen offers **Open with this R1's PIN**, which uses Android's own PIN screen; robotOS +never sees or stores the PIN. It never opens an R1 the host has marked lost or revoked. + ## Troubleshooting **To see what the R1 did recently,** run `adb shell dumpsys activity service dev.r1ptt/.PttService`. diff --git a/docs/releases/v0.6.7.md b/docs/releases/v0.6.7.md new file mode 100644 index 0000000..30565e2 --- /dev/null +++ b/docs/releases/v0.6.7.md @@ -0,0 +1,31 @@ +robotOS now works with Android's own lock screen, the recommended way to protect an R1. + +Only Android's PIN protects the R1's data: its storage is encrypted with a key tied to the PIN, and +Android limits how fast anyone can guess. robotOS's recovery cover is not a lock; it can be +bypassed through debugging, safe mode or other Android screens. INSTALL.md has the three-step +setup: a PIN, "lock after screen timeout" set to 15 minutes to match robotOS's grace, and device +admin so marking the R1 lost also locks Android. + +- **The button respects Android's lock.** While the R1 is locked, a press only wakes the lock + screen; it still silences a ringing R1. Nobody can talk to Hermes, which can send texts, + without the PIN. +- **Tapping to sleep no longer forces a lock.** With device admin turned on, robotOS used to turn + the screen off with Android's immediate lock, so every tap would have demanded the PIN. + It now uses the sleep key, so Android's lock-after delay applies. The immediate lock is kept for + lost mode and as the fallback without root. +- **Open with this R1's PIN (#45).** When your host can't confirm a wake, the recovery screen + offers to open robotOS with Android's own PIN screen. robotOS never sees or stores the PIN, + Android's guess limits apply, and a lost, revoked or cleared R1 stays restricted. The host's next + answer still applies: a lost reply takes the screen back at once. + +App update only. The hub and Hermes tools are unchanged. + +Automated tests cover the following: +- the button doing nothing while Android is locked; +- tap-to-sleep never calling the immediate lock when root works, and falling back to it without + root; +- the PIN option opening an unverified R1 through Android's PIN confirmation; +- the PIN option never appearing for, or opening, a restricted R1 or one without a PIN. + +A signed 0.6.7 preflight ran normally on a physical R1 without a PIN. The locked-button and PIN +paths need a PIN on the device, so they haven't been exercised on hardware yet.