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:
@@ -63,10 +63,9 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||
the two <activity-alias> below, so the app name can be switched at
|
||||
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
@@ -165,11 +164,47 @@
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</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
|
||||
android:name="android.app.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
|
||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
@@ -489,6 +490,7 @@ private fun AppearanceScreen(
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
|
||||
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.
|
||||
val context = LocalContext.current
|
||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||
@@ -679,6 +681,36 @@ private fun AppearanceScreen(
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.contacts.SpecialDatesCalendarSpec
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -62,6 +66,7 @@ class SettingsViewModel @Inject constructor(
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -202,6 +207,16 @@ class SettingsViewModel @Inject constructor(
|
||||
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
|
||||
// surfaces it and the previous selection stays put.
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
@@ -469,6 +484,13 @@ class SettingsViewModel @Inject constructor(
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<resources>
|
||||
<string name="app_name">Calendula</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) -->
|
||||
<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_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_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_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@
|
||||
setting (Settings → Appearance, off by default) swaps the floating corner
|
||||
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]).
|
||||
- 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
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
|
||||
Reference in New Issue
Block a user