diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2b933..0e1bfdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,16 @@ are no binary releases; "version" tracks the shared `version` in the root build status lines build through `DateUtils.getRelativeTimeSpanString`. - **`core-reminders`** — `ReminderOverride.minutesOrEmpty()`, the lead times of a `Minutes` override and empty for `Inherit`/`None`. +- **`core-android`** (new module) — the small Android plumbing Calendula's data + layer writes over and over, in an Android library with no Compose and no DI: + `Context.broadcastTicks(vararg actions)` (a Flow that ticks once on + collection and again on each broadcast, behind its `TodaySource` and + `ZoneSource`), `BroadcastReceiver.goAsync(context) { }` (the + `goAsync()` + one-off scope + `finish()` dance), `Context.hasPermission`, + `AlarmManager.canScheduleExactCompat()` and `AlarmManager.setWakeup(…)` + (exact and allow-while-idle when the capability is held, the inexact + allow-while-idle alarm otherwise), and + `Flow.stateWhileSubscribed(scope, initialValue, stopTimeoutMillis = 5_000)`. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 684959a..94fcd76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ practical how. ./gradlew :core-crash:testDebugUnitTest ./gradlew :core-prefs:testDebugUnitTest ./gradlew :core-di:test +./gradlew :core-android:testDebugUnitTest ./gradlew :identity:assembleDebug ./gradlew build # everything ``` diff --git a/README.md b/README.md index ba1f958..dbd0ce2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order) and `relativeTimeAgo(…)` ("5 minutes ago"). The app passes its own `locales_config`. | | `core-prefs` | Android | The typed DataStore layer: `Pref` + `PrefStore` (typed read/write, distinct-until-changed flows), `toEnum()` for values that degrade instead of throwing, and the family's shared appearance prefs (`ThemeMode`, `dynamicColor`). The store's name and construction stay with the app. | | `core-di` | JVM | The shared DI vocabulary: `@IoDispatcher`, `@DefaultDispatcher`, `@ApplicationScope` and the Hilt module that provides them. | +| `core-android` | Android | Small Android plumbing: `broadcastTicks` (a Flow that ticks on system broadcasts), a coroutine `BroadcastReceiver.goAsync { }`, `Context.hasPermission`, `AlarmManager.setWakeup` (exact, or inexact allow-while-idle without the capability) and `Flow.stateWhileSubscribed`. No Compose, no DI. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | @@ -63,5 +64,6 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md) ./gradlew :core-crash:testDebugUnitTest ./gradlew :core-prefs:testDebugUnitTest ./gradlew :core-di:test +./gradlew :core-android:testDebugUnitTest ./gradlew build # everything (Android modules need an SDK; see above) ``` diff --git a/core-android/build.gradle.kts b/core-android/build.gradle.kts new file mode 100644 index 0000000..b386c91 --- /dev/null +++ b/core-android/build.gradle.kts @@ -0,0 +1,47 @@ +// core-android — small Android plumbing every app in the family writes again: +// a broadcast-driven tick Flow, a coroutine `goAsync` for receivers, a +// permission check, the exact-or-inexact alarm wake-up, and the +// `stateIn(WhileSubscribed(5s))` idiom. No Compose and no DI, so only the +// Android library plugin is applied; coroutines are part of the public API. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.floret.android" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // `Flow`/`StateFlow`/`CoroutineScope` appear in the public API. + api(libs.kotlinx.coroutines.core) + implementation(libs.androidx.core.ktx) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt new file mode 100644 index 0000000..9cf2655 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.floret.android + +import android.app.AlarmManager +import android.app.PendingIntent +import android.os.Build + +/** + * True below API 31 (no restriction), and on 31+ when the exact-alarm capability + * is held — `USE_EXACT_ALARM`, or a `SCHEDULE_EXACT_ALARM` the user hasn't + * revoked. + */ +fun AlarmManager.canScheduleExactCompat(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms() + +/** + * A doze-proof wake-up at [triggerAtMillis]: exact when [canScheduleExactCompat], + * otherwise the inexact allow-while-idle alarm, which needs no permission. + * Returns whether the exact alarm was set. + */ +fun AlarmManager.setWakeup( + triggerAtMillis: Long, + operation: PendingIntent, + type: Int = AlarmManager.RTC_WAKEUP, +): Boolean { + val exact = canScheduleExactCompat() + if (exact) { + setExactAndAllowWhileIdle(type, triggerAtMillis, operation) + } else { + setAndAllowWhileIdle(type, triggerAtMillis, operation) + } + return exact +} diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt new file mode 100644 index 0000000..36d8c29 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.floret.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.onStart + +/** + * Emits once on collection and again on every broadcast of [actions], through a + * not-exported receiver registered for as long as the flow is collected. Map + * each tick to a fresh read of whatever the broadcasts announce — the date, the + * zone — rather than reading the intent. + */ +fun Context.broadcastTicks(vararg actions: String): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { actions.forEach(::addAction) } + ContextCompat.registerReceiver(this@broadcastTicks, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { unregisterReceiver(receiver) } +}.onStart { emit(Unit) } diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt new file mode 100644 index 0000000..bf3d235 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.floret.android + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * `stateIn(scope, WhileSubscribed(stopTimeoutMillis), initialValue)` — the + * ViewModel idiom: upstream runs while the UI collects and survives a + * configuration change inside the timeout. + */ +fun Flow.stateWhileSubscribed( + scope: CoroutineScope, + initialValue: T, + stopTimeoutMillis: Long = 5_000L, +): StateFlow = stateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis), initialValue) diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt new file mode 100644 index 0000000..8a6b987 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt @@ -0,0 +1,9 @@ +package de.jeanlucmakiola.floret.android + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +/** Whether [permission] is currently granted to this app. */ +fun Context.hasPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt new file mode 100644 index 0000000..958a4e4 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.floret.android + +import android.content.BroadcastReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * Runs [block] off the main thread with the broadcast kept alive until it + * returns: `goAsync()` plus a one-off scope, finishing the pending result even if + * [block] throws. Call it from `onReceive`; the usual ~10s receiver budget still + * applies. + */ +fun BroadcastReceiver.goAsync( + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> Unit, +) { + val pending = goAsync() + CoroutineScope(SupervisorJob() + context).launch { + try { + block() + } finally { + pending.finish() + } + } +} diff --git a/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt b/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt new file mode 100644 index 0000000..e0d27c0 --- /dev/null +++ b/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.floret.android + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StateWhileSubscribedTest { + + @Test + fun `starts at the initial value and follows upstream while collected`() = runTest { + val upstream = MutableStateFlow(1) + val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0) + assertThat(state.value).isEqualTo(0) + + backgroundScope.launch { state.collect {} } + runCurrent() + assertThat(state.value).isEqualTo(1) + + upstream.value = 2 + runCurrent() + assertThat(state.value).isEqualTo(2) + } + + @Test + fun `keeps upstream alive for the stop timeout after the last collector leaves`() = runTest { + val upstream = MutableStateFlow(1) + val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0, stopTimeoutMillis = 1_000L) + + val collector = backgroundScope.launch { state.collect {} } + runCurrent() + collector.cancel() + runCurrent() + assertThat(upstream.subscriptionCount.value).isEqualTo(1) + + advanceTimeBy(1_001L) + runCurrent() + assertThat(upstream.subscriptionCount.value).isEqualTo(0) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dfb1413..41e189e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,6 +75,7 @@ should scan the submodule's Gradle scripts (see §5). | Plumbing | `core-time` (JVM) | Pure logic; no Android, no deps. | | Plumbing | `core-crash` (Android) | Self-contained subsystem; parameterized per app. | | Plumbing | `core-prefs` (Android) | Typed DataStore layer. The store's name and construction stay with the app. | +| Plumbing | `core-android` (Android) | Receiver, permission, alarm and Flow helpers. No Compose, no DI. | | Plumbing | `core-di` (JVM) | Dispatcher/scope qualifiers + their Hilt module. The only module that knows about DI. | | Design — identity | `identity` (Android) | Theme factory + motion. App supplies seed/palette. | | Design — components | `components` (Android) | Shared Compose primitives. App composes screens. | @@ -95,7 +96,7 @@ Two kinds of module: - **JVM library** (`core-time`) — `org.jetbrains.kotlin.jvm`, `jvmTarget = 17`, JUnit 5 + Truth tests. The right home for pure logic; cheapest to build and test. -- **Android library** (`core-crash`, `core-prefs`, `identity`, `components`) — +- **Android library** (`core-crash`, `core-prefs`, `core-android`, `identity`, `components`) — `com.android.library` + the Compose plugin, `compileSdk 37` / `minSdk 29`. > **AGP 9.x provides built-in Kotlin compilation.** Android modules therefore diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f57ac4..98b86ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,6 +17,7 @@ divergent or app-specific code where it lives. | `core-crash` | Android | Agendula | | `core-prefs` | Android | Clockula | | `core-di` | JVM | Clockula | +| `core-android` | Android | — (Calendula pending) | | `identity` | Android | Agendula | | `components` | Android | Agendula | | `glance` | Android | Agendula (Calendula pending — its own M3 Expressive widget pass) | diff --git a/settings.gradle.kts b/settings.gradle.kts index ba063be..fef090c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":core-locale") include(":core-crash") include(":core-prefs") include(":core-di") +include(":core-android") include(":identity") include(":components") include(":glance")