Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions app/src/main/java/dev/r1ptt/PttService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions app/src/main/java/dev/r1ptt/power/ScreenPolicy.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/dev/r1ptt/sys/AdminReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion app/src/main/java/dev/r1ptt/ui/DeviceActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
})
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DeviceActivity, Boolean>())
/** Main-thread navigation may only originate from the currently resumed robotOS screen. */
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/dev/r1ptt/workspace/WorkspaceClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/src/test/java/dev/r1ptt/power/ScreenPolicyTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
58 changes: 57 additions & 1 deletion app/src/test/java/dev/r1ptt/workspace/RecoveryProtectionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<View>(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())
Expand Down
7 changes: 6 additions & 1 deletion docs/HUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
Loading
Loading