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:
2026-10-02 20:55:09 +02:00
parent f37c5f2f84
commit a8bc652b22
15 changed files with 510 additions and 8 deletions
+29 -1
View File
@@ -77,11 +77,39 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<!-- The MAIN/LAUNCHER entry lives on the two <activity-alias>
below, so the launcher icon can be switched at runtime. -->
</activity>
<!-- The launcher entry, once per icon: exactly one alias is enabled at a
time, toggled from Settings → Appearance → App icon
(LauncherIconManager). The bloom is the manifest default. -->
<activity-alias
android:name=".BloomIconAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity-alias>
<activity-alias
android:name=".PlainIconAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_plain"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_plain_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<!-- The one exported door for android.provider.AlarmClock: every other
app on the device drives Clockula through it. Guarded by the
@@ -52,7 +52,7 @@ class ClockulaApp : Application() {
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
appLabel = getString(R.string.app_brand_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
),
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.clockula.data.appicon
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/** The launcher icon the app shows for itself. */
enum class LauncherIcon { BLOOM, PLAIN }
/** One component-enable change in an [iconWritePlan]. */
data class IconAliasChange(val icon: LauncherIcon, val enabled: Boolean)
/**
* Interpret the `PlainIconAlias` component-enabled state as a [LauncherIcon].
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the plain icon is
* active; `DEFAULT` (never switched) and `DISABLED` both resolve to the
* manifest default, the bloom.
*/
fun launcherIconFor(plainAliasState: Int): LauncherIcon =
if (plainAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherIcon.PLAIN
} else {
LauncherIcon.BLOOM
}
/**
* The ordered enable/disable steps to switch the launcher icon to [target].
* Always **enables the target alias first, then disables the other**: the two
* `setComponentEnabledSetting` calls are not atomic, and this order means the
* only transient a launcher can observe is a harmless two-entry state, never a
* zero-entry one that would briefly drop the app from the launcher.
*/
fun iconWritePlan(target: LauncherIcon): List<IconAliasChange> =
listOf(IconAliasChange(target, enabled = true)) +
LauncherIcon.entries.filter { it != target }.map { IconAliasChange(it, enabled = false) }
/**
* Switches the launcher icon between the bloom mark and a plain clock, as
* Calendula does for its launcher name. The seam keeps the settings
* ViewModel JVM-testable; [SystemLauncherIconManager] is the real one.
*/
interface LauncherIconManager {
/** The launcher icon currently in effect. Blocking: a binder round-trip. */
fun current(): LauncherIcon
/** Switch the launcher icon to [icon] (cheap if already active). Blocking. */
fun set(icon: LauncherIcon)
}
/**
* Enables/disables the two `<activity-alias>` launcher entries. The
* component-enabled state is the single source of truth — there is no
* persisted preference — so [current] reads it straight from [PackageManager]
* and the two always agree.
*
* The decision logic lives in the pure [launcherIconFor] / [iconWritePlan]
* above (JVM-tested); this class is only the thin framework seam.
*/
@Singleton
class SystemLauncherIconManager @Inject constructor(
@param:ApplicationContext private val context: Context,
) : LauncherIconManager {
private val packageManager: PackageManager get() = context.packageManager
// The package is the applicationId (carrying the .debug / .releasetest
// suffix), the class the namespace: the manifest's ".PlainIconAlias"
// resolves against the namespace. ComponentName(context, ".PlainIconAlias")
// would prepend the applicationId to the class too and fail on every
// suffixed build.
private fun component(icon: LauncherIcon): ComponentName {
val simpleName = when (icon) {
LauncherIcon.BLOOM -> "BloomIconAlias"
LauncherIcon.PLAIN -> "PlainIconAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
override fun current(): LauncherIcon =
launcherIconFor(packageManager.getComponentEnabledSetting(component(LauncherIcon.PLAIN)))
override fun set(icon: LauncherIcon) {
for (change in iconWritePlan(icon)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(component(change.icon), state, PackageManager.DONT_KILL_APP)
}
}
private companion object {
/** The app's namespace, not its applicationId. Kept in sync with `app/build.gradle.kts`. */
const val NAMESPACE = "de.jeanlucmakiola.clockula"
}
}
@@ -0,0 +1,19 @@
package de.jeanlucmakiola.clockula.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
import de.jeanlucmakiola.clockula.data.appicon.SystemLauncherIconManager
import javax.inject.Singleton
/** The launcher-icon switch behind Settings → Appearance → App icon. */
@Module
@InstallIn(SingletonComponent::class)
abstract class AppIconModule {
@Binds
@Singleton
abstract fun bindLauncherIconManager(impl: SystemLauncherIconManager): LauncherIconManager
}
@@ -1,21 +1,53 @@
package de.jeanlucmakiola.clockula.ui.settings
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.prefs.ThemeMode
/** How the app looks: the theme and wallpaper colours. */
/** How the app looks: the theme, wallpaper colours and launcher icon. */
@Composable
internal fun AppearanceScreen(
state: SettingsUiState.Loaded,
@@ -24,6 +56,8 @@ internal fun AppearanceScreen(
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by rememberSaveable { mutableStateOf(false) }
var showAppIcon by rememberSaveable { mutableStateOf(false) }
val launcherIcon by viewModel.launcherIcon.collectAsStateWithLifecycle()
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
GroupedRow(
@@ -53,6 +87,51 @@ internal fun AppearanceScreen(
null
},
)
// A launcher concern rather than in-app styling, so its own group.
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_app_icon),
summary = launcherIconLabel(launcherIcon),
position = Position.Alone,
onClick = { showAppIcon = true },
)
}
if (showAppIcon) {
// Both icons shown as previews; a tap applies at once and the picker
// stays open so the choice stays visible (FullScreenPicker's third rung).
FullScreenPicker(
title = stringResource(R.string.settings_app_icon),
onDismiss = { showAppIcon = false },
predictiveBack = true,
) {
Text(
text = stringResource(R.string.settings_app_icon_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
LauncherIcon.entries.forEach { option ->
AppIconOptionCard(
icon = option,
selected = launcherIcon == option,
onClick = { viewModel.onLauncherIconSelected(option) },
modifier = Modifier.weight(1f),
)
}
}
}
}
if (showTheme) {
@@ -75,3 +154,91 @@ private fun themeModeLabel(mode: ThemeMode): String = stringResource(
ThemeMode.DARK -> R.string.settings_theme_dark
},
)
@Composable
private fun launcherIconLabel(icon: LauncherIcon): String = stringResource(
when (icon) {
LauncherIcon.BLOOM -> R.string.settings_app_icon_bloom
LauncherIcon.PLAIN -> R.string.settings_app_icon_plain
},
)
/** One selectable launcher-icon preview: the icon over its name, framed as a card. */
@Composable
private fun AppIconOptionCard(
icon: LauncherIcon,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(24.dp)
Column(
modifier = modifier
.clip(shape)
.background(if (selected) scheme.primaryContainer.copy(alpha = 0.4f) else scheme.surfaceContainerHigh)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) scheme.primary else scheme.outlineVariant,
shape = shape,
)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(vertical = 20.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// The adaptive icon rebuilt as a squircle, the way AppLogo draws it.
Box(
modifier = Modifier
.size(ICON_PREVIEW_SIZE)
.clip(RoundedCornerShape(18.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(
when (icon) {
LauncherIcon.BLOOM -> R.drawable.ic_launcher_foreground
LauncherIcon.PLAIN -> R.drawable.ic_launcher_foreground_plain
},
),
contentDescription = null,
modifier = Modifier.requiredSize(ICON_PREVIEW_SIZE * ICON_PREVIEW_OVERSCAN),
)
}
Text(
text = launcherIconLabel(icon),
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
)
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.then(
if (selected) {
Modifier.background(scheme.primary)
} else {
Modifier.border(1.dp, scheme.outlineVariant, CircleShape)
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = scheme.onPrimary,
modifier = Modifier.size(16.dp),
)
}
}
}
}
private val ICON_PREVIEW_SIZE = 64.dp
/** As AppLogo's overscan: the foreground's safe margin would leave the mark small at 1:1. */
private const val ICON_PREVIEW_OVERSCAN = 1.5f
@@ -278,7 +278,7 @@ private fun AboutCard() {
// own logo and author. Support is the [SupportRow] joined below.
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
appName = stringResource(R.string.app_brand_name),
author = stringResource(R.string.settings_about_author),
primaryLinks = emptyList(),
position = Position.Top,
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.data.appicon.LauncherIcon
import de.jeanlucmakiola.clockula.data.appicon.LauncherIconManager
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
@@ -18,7 +20,9 @@ import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerMode
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerUiState
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -29,6 +33,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import javax.inject.Inject
@@ -48,6 +53,8 @@ class SettingsViewModel @Inject internal constructor(
private val preview: RingtonePreviewer,
private val worldClocks: WorldClockSource,
private val deviceState: DeviceStateReader,
private val launcherIcons: LauncherIconManager,
@param:IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/**
@@ -65,11 +72,22 @@ class SettingsViewModel @Inject internal constructor(
SettingsUiState.Loaded(appearance, defaults, problemCount)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
/**
* The launcher icon, backed by the manifest aliases' component-enabled
* state rather than a preference. Seeded with the manifest default and
* corrected off the main thread — each read is a binder round-trip.
*/
private val _launcherIcon = MutableStateFlow(LauncherIcon.BLOOM)
internal val launcherIcon: StateFlow<LauncherIcon> = _launcherIcon.asStateFlow()
init {
viewModelScope.launch {
val results = SelfCheck.evaluate(deviceState.read())
selfCheckProblemCount.value = results.count { it.status == CheckStatus.PROBLEM }
}
viewModelScope.launch {
_launcherIcon.value = withContext(io) { launcherIcons.current() }
}
}
private val _ringtonePicker = MutableStateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?>(null)
@@ -96,6 +114,17 @@ class SettingsViewModel @Inject internal constructor(
fun onDynamicColorChanged(enabled: Boolean) = viewModelScope.launch { settings.setDynamicColor(enabled) }
/** The card highlights at once, then settles on what the component state reports. */
internal fun onLauncherIconSelected(icon: LauncherIcon) {
_launcherIcon.value = icon
viewModelScope.launch {
_launcherIcon.value = withContext(io) {
launcherIcons.set(icon)
launcherIcons.current()
}
}
}
fun onVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setVibrate(value) }
fun onSnoozeMinutesChanged(value: Int) = viewModelScope.launch { settings.setSnoozeMinutes(value) }
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The plain launcher icon foreground, offered next to the bloom mark in
Settings → Appearance → App icon (LauncherIconManager).
The bloom icon's dial and hands with the badge left off: the dial closes
into a full circle and is re-centred on the canvas, so the face sits in
the middle of the adaptive-icon safe zone rather than up-left of it. Same
off-white strokes over the same sage background, and it fills the
<monochrome> slot for themed-icon launchers too.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group
android:pivotX="256"
android:pivotY="256"
android:scaleX="0.66"
android:scaleY="0.66">
<!-- Dial -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M366 256 A110 110 0 1 0 146 256 A110 110 0 1 0 366 256" />
<!-- Minute hand, straight up -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M256 256 L256 166" />
<!-- Hour hand -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M256 256 L316 256" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground_plain" />
<monochrome android:drawable="@drawable/ic_launcher_foreground_plain" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground_plain" />
<monochrome android:drawable="@drawable/ic_launcher_foreground_plain" />
</adaptive-icon>
+9 -2
View File
@@ -1,5 +1,8 @@
<resources>
<string name="app_name">Clockula</string>
<!-- The launcher and system label: a plain, translatable "Clock". The
brand name below stays Clockula in every language. -->
<string name="app_name">Clock</string>
<string name="app_brand_name" translatable="false">Clockula</string>
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
<!-- Alarms (M3): the notification channels and the ring actions -->
@@ -272,7 +275,7 @@
<string name="settings_group_app">App</string>
<string name="settings_group_about">About</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</string>
<string name="settings_appearance_subtitle">Theme, colour and app icon</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
@@ -280,6 +283,10 @@
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_dynamic_color_unavailable">Needs Android 12 or newer</string>
<string name="settings_app_icon">App icon</string>
<string name="settings_app_icon_hint">The icon your launcher shows. After switching, the icon may move to a new spot on your home screen.</string>
<string name="settings_app_icon_bloom">Bloom</string>
<string name="settings_app_icon_plain">Plain</string>
<string name="settings_language">Language</string>
<string name="settings_language_auto">Follow system</string>
<string name="settings_section_alarms">Alarms</string>
@@ -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))