feat(alarms): ask which alarm, when the request names more than one

"Dismiss my alarm" with two alarms set is a question, not an instruction. When
the search matches more than one the app asks, in a dialog listing the
candidates; when it matches exactly one it acts without asking.

The Timers tab's setup effect no longer re-keys on a list that changes with
every readout tick.
This commit is contained in:
2026-09-23 11:02:20 +02:00
parent 02f3794ee8
commit 46a049f988
9 changed files with 420 additions and 1 deletions
@@ -0,0 +1,163 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.AlarmClock
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.domain.interop.AlarmClockContract
import de.jeanlucmakiola.clockula.interop.AlarmClockActivity
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRowState
import de.jeanlucmakiola.clockula.ui.alarms.DismissAlarmDialog
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* What only a device can answer about the `AlarmClock` contract: that our
* literals really are the platform's constants, that the manifest's two
* intent-filters really resolve — with and without a data URI — and that the
* door really opens no window.
*
* Compiled in the gate; **not run here** — no device is attached.
*/
@RunWith(AndroidJUnit4::class)
class InteropInstrumentedTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private fun row(id: Long, hour: Int, label: String) = AlarmRowState(
id = id,
time = TimeOfDay(hour, 0),
label = label,
enabled = true,
repeatDays = RepeatDays.NONE,
nextFire = NextFireLabel.NotScheduled,
snoozed = false,
ringing = false,
skippingNext = false,
isNext = false,
)
@Test
fun ourLiteralsAreThePlatformsOwnConstants() {
val ours = listOf(
AlarmClockContract.ACTION_SET_ALARM,
AlarmClockContract.ACTION_SET_TIMER,
AlarmClockContract.ACTION_SHOW_ALARMS,
AlarmClockContract.ACTION_SHOW_TIMERS,
AlarmClockContract.ACTION_DISMISS_ALARM,
AlarmClockContract.ACTION_DISMISS_TIMER,
AlarmClockContract.ACTION_SNOOZE_ALARM,
AlarmClockContract.EXTRA_HOUR,
AlarmClockContract.EXTRA_MINUTES,
AlarmClockContract.EXTRA_MESSAGE,
AlarmClockContract.EXTRA_DAYS,
AlarmClockContract.EXTRA_RINGTONE,
AlarmClockContract.EXTRA_VIBRATE,
AlarmClockContract.EXTRA_SKIP_UI,
AlarmClockContract.EXTRA_LENGTH,
AlarmClockContract.EXTRA_IS_PM,
AlarmClockContract.EXTRA_ALARM_SEARCH_MODE,
AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION,
AlarmClockContract.SEARCH_MODE_TIME,
AlarmClockContract.SEARCH_MODE_NEXT,
AlarmClockContract.SEARCH_MODE_ALL,
AlarmClockContract.SEARCH_MODE_LABEL,
AlarmClockContract.VALUE_RINGTONE_SILENT,
)
assertThat(ours).containsExactly(
AlarmClock.ACTION_SET_ALARM,
AlarmClock.ACTION_SET_TIMER,
AlarmClock.ACTION_SHOW_ALARMS,
AlarmClock.ACTION_SHOW_TIMERS,
AlarmClock.ACTION_DISMISS_ALARM,
AlarmClock.ACTION_DISMISS_TIMER,
AlarmClock.ACTION_SNOOZE_ALARM,
AlarmClock.EXTRA_HOUR,
AlarmClock.EXTRA_MINUTES,
AlarmClock.EXTRA_MESSAGE,
AlarmClock.EXTRA_DAYS,
AlarmClock.EXTRA_RINGTONE,
AlarmClock.EXTRA_VIBRATE,
AlarmClock.EXTRA_SKIP_UI,
AlarmClock.EXTRA_LENGTH,
AlarmClock.EXTRA_IS_PM,
AlarmClock.EXTRA_ALARM_SEARCH_MODE,
AlarmClock.EXTRA_ALARM_SNOOZE_DURATION,
AlarmClock.ALARM_SEARCH_MODE_TIME,
AlarmClock.ALARM_SEARCH_MODE_NEXT,
AlarmClock.ALARM_SEARCH_MODE_ALL,
AlarmClock.ALARM_SEARCH_MODE_LABEL,
AlarmClock.VALUE_RINGTONE_SILENT,
).inOrder()
}
@Test
fun everyContractActionResolvesToTheDoor() {
val unresolved = AlarmClockContract.ACTIONS.filterNot { action ->
context.packageManager
.queryIntentActivities(Intent(action).setPackage(context.packageName), 0)
.any { it.activityInfo.name == AlarmClockActivity::class.java.name }
}
assertThat(unresolved).isEmpty()
}
@Test
fun aDeeplinkedDismissResolvesToTheDoor() {
val intent = Intent(AlarmClockContract.ACTION_DISMISS_ALARM)
.setPackage(context.packageName)
.setData(Uri.parse("clockula://alarm/1"))
val resolved = context.packageManager.queryIntentActivities(intent, 0)
assertThat(resolved.map { it.activityInfo.name })
.contains(AlarmClockActivity::class.java.name)
}
@Test
fun tappingARowInTheChooserDismissesThatAlarm() {
val dismissed = mutableListOf<Long>()
composeTestRule.setContent {
ClockulaTheme {
DismissAlarmDialog(
rows = listOf(row(1L, 6, "Morning gym"), row(2L, 7, "Yoga")),
onDismissAlarm = { dismissed += it },
onCancel = {},
)
}
}
composeTestRule.onNodeWithText("Yoga").assertExists()
composeTestRule.onNodeWithText("Morning gym").performClick()
assertThat(dismissed).containsExactly(1L)
}
@Test
fun theDoorFinishesWithoutEverShowingAWindow() {
val intent = Intent(context, AlarmClockActivity::class.java)
.setAction(AlarmClockContract.ACTION_SHOW_ALARMS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ActivityScenario.launch<AlarmClockActivity>(intent).use { scenario ->
assertThat(scenario.state).isEqualTo(Lifecycle.State.DESTROYED)
}
}
}