feat(settings): call the app "Clock" and add an app icon picker

The launcher and system label is now a plain, translatable "Clock"; the
brand name Clockula stays on the About card and in crash reports via an
untranslated app_brand_name string.

Settings → Appearance → App icon chooses between the bloom mark and a new
plain clock icon (the same dial and hands without the badge). The launcher
entry moves off MainActivity onto two <activity-alias> components, exactly
one enabled at a time, as Calendula does for its launcher name. The
component-enabled state is the single source of truth, and switching
enables the target before disabling the other so the launcher never sees
zero entries.
This commit is contained in:
makiolaj committed 2026-10-02 20:55:09 +02:00
1 parent f37c5f2f84
commit a8bc652b22
15 files changed
+510 -8

No files matched your search

@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher, the door, the four widget configurators and the six widget receivers are exported`() {
fun `exactly the launcher and its two icon aliases, the door, the four widget configurators and the six widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
.findAll(text)
.map { it.value }
@@ -49,6 +49,8 @@ class ManifestRulesTest {
// widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
".BloomIconAlias",
".PlainIconAlias",
".interop.AlarmClockActivity",
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
@@ -99,6 +101,27 @@ class ManifestRulesTest {
assertThat(activityBlock(".MainActivity")).doesNotContain("android:permission")
}
/**
* The launcher entry moved onto one alias per icon (LauncherIconManager).
* Exactly one may ship enabled, or a fresh install shows two icons or none.
*/
@Test
fun `the launcher entry is two icon aliases, only the bloom enabled by default`() {
val aliases = Regex("<activity-alias\\b[\\s\\S]*?</activity-alias>").findAll(text)
.map { it.value }
.associateBy { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
assertThat(aliases.keys).containsExactly(".BloomIconAlias", ".PlainIconAlias")
for (block in aliases.values) {
assertThat(block).contains("android:targetActivity=\".MainActivity\"")
assertThat(block).contains("android.intent.category.LAUNCHER")
assertThat(block).doesNotContain("android:permission")
}
assertThat(aliases.getValue(".BloomIconAlias")).contains("android:enabled=\"true\"")
assertThat(aliases.getValue(".PlainIconAlias")).contains("android:enabled=\"false\"")
assertThat(activityBlock(".MainActivity")).doesNotContain("android.intent.category.LAUNCHER")
}
@Test
fun `every action the contract answers is named in the manifest`() {
val missing = AlarmClockContract.ACTIONS.filterNot { text.contains("android:name=\"$it\"") }
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.clockula.data.appicon
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure decision logic behind the app-icon picker. The `PackageManager`
* seam itself is exercised on-device; these pin the read interpretation and
* the write order.
*/
class LauncherIconManagerTest {
@Test
fun `an enabled plain alias reads as PLAIN`() {
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
.isEqualTo(LauncherIcon.PLAIN)
}
@Test
fun `a default or disabled plain alias reads as BLOOM`() {
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
.isEqualTo(LauncherIcon.BLOOM)
assertThat(launcherIconFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
.isEqualTo(LauncherIcon.BLOOM)
}
@Test
fun `the write plan enables the target before disabling the other`() {
assertThat(iconWritePlan(LauncherIcon.PLAIN)).containsExactly(
IconAliasChange(LauncherIcon.PLAIN, enabled = true),
IconAliasChange(LauncherIcon.BLOOM, enabled = false),
).inOrder()
assertThat(iconWritePlan(LauncherIcon.BLOOM)).containsExactly(
IconAliasChange(LauncherIcon.BLOOM, enabled = true),
IconAliasChange(LauncherIcon.PLAIN, enabled = false),
).inOrder()
}
}
@@ -0,0 +1,13 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
/** The launcher icon as a plain field, starting at the manifest default. */
class FakeLauncherIconManager(var icon: LauncherIcon = LauncherIcon.BLOOM) : LauncherIconManager {
override fun current(): LauncherIcon = icon
override fun set(icon: LauncherIcon) {
this.icon = icon
}
}
@@ -1,8 +1,10 @@
package de.jeanlucmakiola.clockula.ui.settings
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.testing.FakeDeviceStateReader
import de.jeanlucmakiola.clockula.testing.FakeLauncherIconManager
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
@@ -34,7 +36,10 @@ class SettingsViewModelTest {
@RegisterExtension
val main = MainDispatcherExtension()
private fun TestScope.newViewModel(tempDir: Path): SettingsViewModel {
private fun TestScope.newViewModel(
tempDir: Path,
launcherIcons: FakeLauncherIconManager = FakeLauncherIconManager(),
): SettingsViewModel {
val harness = worldClockHarness(tempDir)
return SettingsViewModel(
settings = harness.settings,
@@ -42,6 +47,8 @@ class SettingsViewModelTest {
preview = FakeRingtonePreviewer(),
worldClocks = harness.source,
deviceState = FakeDeviceStateReader(),
launcherIcons = launcherIcons,
io = main.dispatcher,
)
}
@@ -65,6 +72,21 @@ class SettingsViewModelTest {
assertThat(loaded.defaults.snoozeMinutes).isEqualTo(10)
}
@Test
fun `the launcher icon reads the component state and writes through`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val icons = FakeLauncherIconManager(LauncherIcon.PLAIN)
val viewModel = newViewModel(tempDir, icons)
advanceUntilIdle()
assertThat(viewModel.launcherIcon.value).isEqualTo(LauncherIcon.PLAIN)
viewModel.onLauncherIconSelected(LauncherIcon.BLOOM)
advanceUntilIdle()
assertThat(icons.icon).isEqualTo(LauncherIcon.BLOOM)
assertThat(viewModel.launcherIcon.value).isEqualTo(LauncherIcon.BLOOM)
}
@Test
fun `the theme mode writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))