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:
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user