feat(core-android): receiver, permission, alarm and Flow helpers
New Android module (no Compose, no DI) with broadcastTicks, a coroutine BroadcastReceiver.goAsync, Context.hasPermission, AlarmManager.setWakeup and Flow.stateWhileSubscribed.
This commit is contained in:
1 parent
1cc7f8f71d
commit
394ac12236
13 files changed
+224
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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<T>` + `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)
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Unit> = 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) }
|
||||
@@ -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 <T> Flow<T>.stateWhileSubscribed(
|
||||
scope: CoroutineScope,
|
||||
initialValue: T,
|
||||
stopTimeoutMillis: Long = 5_000L,
|
||||
): StateFlow<T> = stateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis), initialValue)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user