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:
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))
|
||||
|
||||
Reference in new issue
Block a user