feat(ring): the ring screen, over the lock screen

Shows over the lock screen, turns it on, keeps it on, and swallows back for
the whole lifetime of the window — including while the session is still
loading, which is where a back press used to finish the activity and leave
the alarm sounding with nothing on screen to stop it.

The optional dismiss challenge cannot strand the user: the standing wrong
answer clears the moment they start typing the next one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:18 +02:00
parent 83c5e9fe3f
commit 959eb599ff
8 changed files with 1183 additions and 100 deletions
@@ -0,0 +1,121 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import android.view.WindowManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* What only a device can answer about the ring screen: that its window really
* keeps the screen on, that a real back press cannot silence an alarm, and that
* the hold challenge is operable without a press-and-hold gesture (D16) — which
* is what keeps it from stranding a screen-reader user.
*
* Compiled in the gate, run on a device (slice plan D18).
*/
@RunWith(AndroidJUnit4::class)
class AlarmRingInstrumentedTest {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Seams {
fun engine(): AlarmEngine
fun alarms(): AlarmRepository
fun states(): AlarmStateRepository
fun wallClock(): WallClock
}
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val seams: Seams =
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
private var scenario: ActivityScenario<AlarmRingActivity>? = null
@After
fun tearDown() {
scenario?.close()
runBlocking { seams.states().clearAll() }
}
private fun string(id: Int): String = context.getString(id)
/** Seeds a genuinely ringing alarm through the engine, and returns its id. */
private fun givenRinging(challenge: DismissChallenge): Long = runBlocking {
val now = seams.wallClock().now()
val id = seams.alarms().create(
AlarmDraft(time = TimeOfDay(7, 0), label = "Work", dismissChallenge = challenge),
)
seams.engine().onFire(id, now)
id
}
private fun launch(): ActivityScenario<AlarmRingActivity> =
ActivityScenario.launch<AlarmRingActivity>(
Intent(context, AlarmRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
).also { scenario = it }
@Test
fun theRingWindowKeepsTheScreenOn() {
givenRinging(DismissChallenge.NONE)
var flags = 0
launch().onActivity { flags = it.window.attributes.flags }
assertThat(flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON).isNotEqualTo(0)
}
@Test
fun backCannotSilenceARingingAlarm() {
val id = givenRinging(DismissChallenge.NONE)
val activity = launch()
composeTestRule.waitForIdle()
activity.onActivity { it.onBackPressedDispatcher.onBackPressed() }
composeTestRule.waitForIdle()
assertThat(runBlocking { seams.states().state(id).ringingSince }).isNotNull()
}
@Test
fun anAccessibilityClickCompletesTheHold() {
val id = givenRinging(DismissChallenge.HOLD)
launch()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.ring_challenge_hold))
.performSemanticsAction(SemanticsActions.OnClick)
composeTestRule.waitForIdle()
assertThat(runBlocking { seams.states().state(id).ringingSince }).isNull()
}
}