diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 18e455e..4f69466 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -77,11 +77,39 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain.xml
new file mode 100644
index 0000000..1332d58
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain_round.xml
new file mode 100644
index 0000000..1332d58
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5ef81df..a1fde1f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,8 @@
- Clockula
+
+ Clock
+ Clockula
A modern Material 3 Expressive clock app.\nComing into bloom.
@@ -272,7 +275,7 @@
App
About
Appearance
- Theme and colour
+ Theme, colour and app icon
Theme
Follow system
Light
@@ -280,6 +283,10 @@
Dynamic colour
Use colours from your wallpaper
Needs Android 12 or newer
+ App icon
+ The icon your launcher shows. After switching, the icon may move to a new spot on your home screen.
+ Bloom
+ Plain
Language
Follow system
Alarms
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
index be01a62..8e7eaa0 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -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("").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\"") }
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/appicon/LauncherIconManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/appicon/LauncherIconManagerTest.kt
new file mode 100644
index 0000000..fb89d10
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/appicon/LauncherIconManagerTest.kt
@@ -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()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeLauncherIconManager.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeLauncherIconManager.kt
new file mode 100644
index 0000000..e79e6a5
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeLauncherIconManager.kt
@@ -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
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
index 43b3bc9..94e4a53 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
@@ -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))