From a8bc652b22fe2cdb6658bceb1d73d24b0818692c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 20:55:09 +0200 Subject: [PATCH] feat(settings): call the app "Clock" and add an app icon picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- app/src/main/AndroidManifest.xml | 30 +++- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 2 +- .../data/appicon/LauncherIconManager.kt | 99 ++++++++++ .../clockula/data/di/AppIconModule.kt | 19 ++ .../clockula/ui/settings/AppearanceScreen.kt | 169 +++++++++++++++++- .../clockula/ui/settings/SettingsScreen.kt | 2 +- .../clockula/ui/settings/SettingsViewModel.kt | 29 +++ .../drawable/ic_launcher_foreground_plain.xml | 44 +++++ .../mipmap-anydpi-v26/ic_launcher_plain.xml | 6 + .../ic_launcher_plain_round.xml | 6 + app/src/main/res/values/strings.xml | 11 +- .../clockula/ManifestRulesTest.kt | 25 ++- .../data/appicon/LauncherIconManagerTest.kt | 39 ++++ .../testing/FakeLauncherIconManager.kt | 13 ++ .../ui/settings/SettingsViewModelTest.kt | 24 ++- 15 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/appicon/LauncherIconManager.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/di/AppIconModule.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_plain.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain_round.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/appicon/LauncherIconManagerTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeLauncherIconManager.kt 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]*?(?:/>|)") .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))