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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user