feat(settings): optional "Calendar" launcher name (#44)

Add a Settings → Appearance toggle that switches the app's launcher label
between "Calendula" and "Calendar", for users on launchers that can't rename
apps themselves.

The launcher entry moves off MainActivity onto two <activity-alias> components
(DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via
PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent
filter; the android.app.shortcuts meta-data moves onto both aliases so the
long-press shortcut still publishes. Component-enabled state is the single source
of truth — no persisted preference.

The ComponentName uses the applicationId for the package (carrying the
.debug/.releasetest suffix) and the namespace for the class, since manifest
".Alias" names resolve against the namespace; switching to Calendar enables the
target alias before disabling the other to avoid a zero-entry launcher transient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:39:08 +02:00
parent 426ddf27ee
commit 03f6b7c8c1
7 changed files with 266 additions and 6 deletions

View File

@@ -63,10 +63,9 @@
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
<action android:name="android.intent.action.MAIN" /> the two <activity-alias> below, so the app name can be switched at
<category android:name="android.intent.category.LAUNCHER" /> runtime (issue #44). MainActivity keeps every other filter. -->
</intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for <!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters an app to make itself the default, so registering the filters
@@ -165,11 +164,47 @@
<data android:mimeType="vnd.android.cursor.item/event" /> <data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter> </intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). --> </activity>
<!-- Launcher entry for MainActivity, split into two aliases so the app's
launcher name can be switched at runtime between "Calendula" and
"Calendar" (issue #44). Exactly one is enabled at a time; the app
flips them via PackageManager.setComponentEnabledSetting
(LauncherNameManager). The shortcuts meta-data lives here, not on
MainActivity, because static shortcuts are published by whichever
component owns the MAIN/LAUNCHER filter. -->
<activity-alias
android:name=".DefaultNameAlias"
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>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity-alias>
<activity-alias
android:name=".CalendarNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_calendar_alias"
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>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<!-- Standalone surface for a captured crash report. MainActivity routes <!-- Standalone surface for a captured crash report. MainActivity routes
here on a startup crash-loop, so it stays clear of the app's Hilt here on a startup crash-loop, so it stays clear of the app's Hilt

View File

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.calendula.data.appname
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 label the app shows for itself (issue #44). */
enum class LauncherName { CALENDULA, CALENDAR }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, CALENDAR }
/** One component-enable change in a [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
* manifest default, "Calendula".
*/
fun launcherNameFor(calendarAliasState: Int): LauncherName =
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.CALENDAR
} else {
LauncherName.CALENDULA
}
/**
* The ordered enable/disable steps to switch the launcher name to [target].
* Always **enables the target alias first, then disables the other** — the two
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
* only transient the launcher can observe is a harmless two-entry state, never a
* zero-entry one (which would briefly drop the app from the launcher).
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.CALENDAR -> listOf(
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.CALENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
)
}
/**
* Switches the app's launcher label between "Calendula" and "Calendar" by
* enabling/disabling the two `<activity-alias>` components (issue #44). 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 [launcherNameFor] / [aliasWritePlan]
* functions above (JVM-tested); this class is only the thin framework seam.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The class portion is namespace-qualified (suffix-free), the package portion
// is the applicationId (which carries the .debug / .releasetest suffix). The
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
// the real component is <namespace>.CalendarNameAlias registered under the
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
// — its leading-dot form prepends the applicationId to the class too, producing
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.CALENDAR -> "CalendarNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
/** The launcher name currently in effect. */
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
/** Switch the launcher name to [name] (no-op cost if already active). */
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(
component(change.alias),
state,
PackageManager.DONT_KILL_APP,
)
}
}
private companion object {
/**
* The app's namespace (R-class package) — NOT `applicationId`, which
* carries the build-type suffix. Kept in sync with `namespace` in
* `app/build.gradle.kts`.
*/
const val NAMESPACE = "de.jeanlucmakiola.calendula"
}
}

View File

@@ -93,6 +93,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
@@ -489,6 +490,7 @@ private fun AppearanceScreen(
var showPlainFont by remember { mutableStateOf(false) } var showPlainFont by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle() val fonts by viewModel.fontState.collectAsStateWithLifecycle()
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice. // A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed) val importFailedMessage = stringResource(R.string.settings_font_import_failed)
@@ -679,6 +681,36 @@ private fun AppearanceScreen(
}, },
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
) )
Spacer(Modifier.height(16.dp))
// App name — flips the launcher label between "Calendula" and "Calendar"
// (issue #44). Own group: it's a launcher/system concern, not calendar
// formatting.
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = stringResource(R.string.settings_app_name_summary),
position = Position.Alone,
trailing = {
Switch(
checked = launcherName == LauncherName.CALENDAR,
onCheckedChange = {
viewModel.setLauncherName(
if (it) LauncherName.CALENDAR else LauncherName.CALENDULA,
)
},
)
},
onClick = {
viewModel.setLauncherName(
if (launcherName == LauncherName.CALENDAR) {
LauncherName.CALENDULA
} else {
LauncherName.CALENDAR
},
)
},
)
} }
if (showTheme) { if (showTheme) {

View File

@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
@@ -43,7 +45,9 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -62,6 +66,7 @@ class SettingsViewModel @Inject constructor(
repository: CalendarRepository, repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine, private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec, specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
@@ -202,6 +207,16 @@ class SettingsViewModel @Inject constructor(
initialValue = AppFontSettings(), initialValue = AppFontSettings(),
) )
/**
* The launcher-label choice (issue #44). Backed by the manifest aliases'
* component-enabled state rather than a stored preference, so it's read
* imperatively via [LauncherNameManager] and held here in its own flow — the
* main settings combine is already at its arity limit and this isn't a
* DataStore flow anyway.
*/
private val _launcherName = MutableStateFlow(launcherNameManager.current())
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
// Emitted when a picked font file couldn't be read as a font; the screen // Emitted when a picked font file couldn't be read as a font; the screen
// surfaces it and the previous selection stays put. // surfaces it and the previous selection stays put.
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@@ -469,6 +484,13 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
} }
/** Switch the launcher label between "Calendula" and "Calendar" (issue #44). */
fun setLauncherName(name: LauncherName) {
launcherNameManager.set(name)
// Re-read so the UI reflects the actual component state, not an assumption.
_launcherName.value = launcherNameManager.current()
}
fun setPastEventDisplay(mode: PastEventDisplay) { fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch { viewModelScope.launch {
prefs.setPastEventDisplay(mode) prefs.setPastEventDisplay(mode)

View File

@@ -1,6 +1,10 @@
<resources> <resources>
<string name="app_name">Calendula</string> <string name="app_name">Calendula</string>
<string name="app_tagline">A modern calendar.</string> <string name="app_tagline">A modern calendar.</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
translatable="false": this string becomes a launcher component's label, so
it must never be changed by an unreviewed community translation. -->
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
<!-- Loading / Failure / Success generic strings (used across screens) --> <!-- Loading / Failure / Success generic strings (used across screens) -->
<string name="state_loading">Loading…</string> <string name="state_loading">Loading…</string>
@@ -329,6 +333,8 @@
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string> <string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_today_toolbar">Today button in toolbar</string> <string name="settings_today_toolbar">Today button in toolbar</string>
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string> <string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_name_summary">Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching.</string>
<string name="settings_time_format">Time format</string> <string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string> <string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string> <string name="settings_time_format_12h">12-hour (2:00 PM)</string>

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.appname
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
* on-device; these tests pin the read interpretation and the write ordering.
*/
class LauncherNameManagerTest {
@Test
fun `enabled calendar alias reads as CALENDAR`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
.isEqualTo(LauncherName.CALENDAR)
}
@Test
fun `default and disabled calendar alias read as CALENDULA`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
.isEqualTo(LauncherName.CALENDULA)
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
.isEqualTo(LauncherName.CALENDULA)
}
@Test
fun `write plan enables the target alias before disabling the other`() {
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
assertThat(toCalendar).containsExactly(
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
).inOrder()
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
assertThat(toCalendula).containsExactly(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
).inOrder()
}
@Test
fun `write plan never disables both aliases in the same step`() {
// The first step is always an enable, so the launcher can never observe a
// zero-entry transient regardless of switch direction.
for (target in LauncherName.entries) {
assertThat(aliasWritePlan(target).first().enabled).isTrue()
}
}
}

View File

@@ -11,6 +11,11 @@
setting (Settings → Appearance, off by default) swaps the floating corner setting (Settings → Appearance, off by default) swaps the floating corner
button for a permanent today icon in the top bar — always there, matching the button for a permanent today icon in the top bar — always there, matching the
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]). familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
- Show the app as "Calendar" in your launcher. A new App name setting
(Settings → Appearance) switches the launcher label from "Calendula" to the
generic "Calendar" for anyone who prefers it — handy on launchers that can't
rename apps themselves. Only the launcher name changes; your home-screen icon
may move to a new spot after switching ([#44]).
### Changed ### Changed
- Dates in the Month, Week and Day title bars now follow your language and - Dates in the Month, Week and Day title bars now follow your language and