
A Kotlin Multiplatform (Android + iOS) library for continuous or periodic, multi-day background sensor tracking with batched local persistence and a pluggable sync engine. Each data source describes its own permissions, so the library handles manifest validation, foreground-service typing, and the permission-request UI for you.
A Kotlin Multiplatform (Android + iOS) engine for continuous or periodic, multi-day background
data collection from any pluggable DataSource — location, motion, step count, and BLE scanning
ship out of the box, and adding your own (heart rate, a custom BLE peripheral, an internal sensor)
is a matter of implementing one interface, not touching the core. Collected events get batched
local persistence and a pluggable sync engine that never assumes your backend's wire format. Each
data source describes its own permissions, so the library handles manifest validation,
foreground-service typing, and the permission-request UI for you.
The demo app (app/) showcases every feature area behind bottom navigation:
![]() Home |
![]() Sensors |
![]() Bluetooth |
![]() Sync |
![]() Settings |
Follows the Now In Android-style modular architecture, adapted for Kotlin Multiplatform:
pulsekit-core: Tracking engine, SQLDelight-backed batched/bounded persistence, the
cross-platform PermissionController abstraction, and Android foreground-service +
boot-resume infrastructure.pulsekit-location: GPS/network location DataSource.pulsekit-motion: Raw accelerometer DataSource and step-counter DataSource.pulsekit-bluetooth: BLE scan DataSource.DataSource you write plugs
into the same engine (see Adding a custom data source).pulsekit-sync: Inversion-of-control network sync engine (SyncEngine handles retry/
backoff; SyncUploader is the contract your app implements for its own backend, with
JsonHttpSyncUploader provided as a ready-made default).pulsekit-ui: Android infrastructure (Services, Receivers) and Jetpack Compose layer over
PermissionController's staged request flow -- pull this in if you want a ready-made
PermissionGate composable instead of hand-writing the sequencing.pulsekit-export: Pure, streaming-friendly exporters (NdjsonExporter, GpxExporter,
CsvExporter) that turn a Flow/Sequence of stored events into NDJSON, GPX 1.1, or CSV without
materializing the whole table in memory.pulsekit-testing: First-class test artifact for consumers -- FakeDataSource,
MutableTimeProvider, RecordingPulseKitLogger, and inMemoryPulseKitDatabase() so an app can
drive PulseKit deterministically in its own tests without Robolectric.app: Android demo app wiring all of the above together end-to-end.Feature modules depend only on pulsekit-core, never on each other — if you only need location
tracking, you only pull in pulsekit-core + pulsekit-location.
START_STICKY + periodically-renewed wake lock, boot-resume via a
BroadcastReceiver; iOS background location as the anchor that keeps motion/BLE sampling alive.
Per-source CollectionMode.Periodic(interval, window) collects in bursts (e.g. scan BLE 30s
every 5min) for power-sensitive sources.startSources(setOf("location")) / stopSources(...) begin or end
individual sources on demand, with activeSourceIds: StateFlow<Set<String>> as an observable,
UI-bindable single source of truth for what's collecting.DataSource declares its own displayName,
requiredPermissions, and optionalPermissions, so the library derives manifest validation,
foreground-service typing, and the permission-request UI for you — the app never hardcodes which
permissions a source needs.DataSource is a four-method interface
(start/stop/events/permission metadata) with no knowledge of location, motion, or BLE baked
into pulsekit-core. Location/motion/BLE are the shipped reference implementations; a new sensor
is a new module that implements the interface, wired in the same way as the built-in ones.BasePulseKitService, override only pulseKit,
and get the whole foreground-service lifecycle, a customizable notification, wake-lock renewal,
and permission-masked service typing for free.PulseKitConfig.maxStoredEvents) and an optional age-based cap
(PulseKitConfig.maxEventAgeMillis) so an offline device can't grow the database unbounded.PulseKit.eraseAllData() for right-to-erasure (GDPR/CCPA) requests — unconditionally removes
every stored row, regardless of sync status, distinct from routine pruning.PermissionController that encodes the OS-mandated staged permission
flows for you: Android's foreground-then-background location sequence (10+ requires this as two
separate calls) and iOS's when-in-use-then-always progression.SyncEngine owns claim/retry/backoff, SyncUploader is
the one interface you implement for your own backend's wire format — the library never assumes
JSON-over-HTTP unless you opt into the provided JsonHttpSyncUploader.SyncEngine.observeState(): StateFlow<SyncState> for a UI-ready sync status (last success time,
last error, consecutive failure count) instead of guessing at internal retry state.PulseKitLogger (PulseKit.Builder.logger(...), also accepted by SyncEngine) so
you can route PulseKit's internal diagnostics (prune passes, failed uploads) through your own
Timber/Crashlytics/etc. — silent by default.TimeProvider/IdProvider (PulseKit.Builder.timeProvider(...)/idProvider(...)) —
every timestamp and id in the event log can be driven deterministically in tests instead of by an
unmockable process-wide clock/UUID singleton. Defaults are byte-for-byte unchanged for existing
callers.PulseKit.queryEvents(EventQuery) /
observeEvents(EventQuery) filter by type and time range with a required limit — the sync claim
path stays the only place that mutates rows, so reads are always safe to call regardless of
collection or sync state.pulsekit-export's NdjsonExporter/GpxExporter/CsvExporter turn queried events straight into
shareable files — GPX for maps/fitness tools, NDJSON for a full-fidelity dump of every payload
type, CSV for spreadsheets — each silently skipping payload types it doesn't understand instead of
throwing on a mixed stream.pulsekit-testing ships a FakeDataSource (scriptable start/stop/events, call-count assertions),
MutableTimeProvider, RecordingPulseKitLogger, and inMemoryPulseKitDatabase() so consumers can
unit test their own PulseKit integration against real SQL without Robolectric.Add whichever modules you need to your build.gradle.kts:
dependencies {
implementation("io.github.alirezajavan:pulsekit-core:0.3.0")
implementation("io.github.alirezajavan:pulsekit-location:0.3.0")
implementation("io.github.alirezajavan:pulsekit-motion:0.3.0")
implementation("io.github.alirezajavan:pulsekit-bluetooth:0.3.0")
implementation("io.github.alirezajavan:pulsekit-sync:0.3.0")
implementation("io.github.alirezajavan:pulsekit-ui:0.3.0") // optional
implementation("io.github.alirezajavan:pulsekit-export:0.3.0") // optional
testImplementation("io.github.alirezajavan:pulsekit-testing:0.3.0") // optional
}val database = createPulseKitDatabase(applicationContext)
val pulseKit = PulseKit.builder(database)
.addDataSource(LocationDataSource(applicationContext))
.addDataSource(MotionDataSource(applicationContext))
.addDataSource(StepCounterDataSource(applicationContext))
// Periodic sources collect in bursts instead of continuously:
.addDataSource(
BluetoothDataSource(applicationContext),
mode = CollectionMode.Periodic(intervalMillis = 5.minutes.inWholeMilliseconds,
windowMillis = 30.seconds.inWholeMilliseconds),
)
.build()Drive collection through the platform host (Android Service or iOS IosPulseKitHost) to
ensure the app process is kept alive for background collection:
// Android: Start the tracking service
BasePulseKitService.startCollection(context, MyTrackingService::class)
// iOS: Start the keep-alive host
val host = IosPulseKitHost(pulseKit)
host.start()Location/motion data is sensitive (GDPR/CCPA-relevant). By default createPulseKitDatabase(context)
uses a plain, unencrypted driver -- if your app needs at-rest encryption, call the
createPulseKitDatabase(driver: SqlDriver) overload with your own driver instead, e.g. one
wrapping SQLCipher-for-Android's SupportOpenHelperFactory. PulseKit doesn't bundle SQLCipher
itself since there's no equivalent turnkey story on iOS.
BasePulseKitService owns the entire collection lifecycle, wake lock, foreground-service typing
and a default notification — a subclass usually just points it at the shared instance:
class MyTrackingService : BasePulseKitService() {
override val pulseKit get() = MyApp.from(this).pulseKit
// Everything below is optional — override to customize the tray notification (or override
// createForegroundNotification() wholesale for full control):
override val notificationContentTitle = "Tracking active"
override val notificationSmallIcon = R.drawable.ic_tracking
}Declare it (and, optionally, a PulseKitBootReceiver subclass to resume after reboot) in your
manifest — see Platform setup below.
On iOS, there is no background service. To keep the app alive for continuous sensor collection,
use IosPulseKitHost from pulsekit-ui:
val host = IosPulseKitHost(pulseKit)
host.start() // Requests 'Always' location and keeps process awakeEnsure your Info.plist declares the location UIBackgroundModes — see Platform setup below.
PulseKit.Builder.validateAndroidSetup(...) cross-checks your app's manifest against the data
sources you've added and throws one exception listing every gap found -- missing permissions, a
service/receiver class that isn't declared as a component, or a <service> missing a required
foregroundServiceType flag -- instead of letting a setup mistake surface later as an opaque
SecurityException from startForegroundService():
pulseKit = PulseKit.builder(database)
.addDataSource(LocationDataSource(applicationContext))
.addDataSource(MotionDataSource(applicationContext))
.addDataSource(StepCounterDataSource(applicationContext))
.addDataSource(BluetoothDataSource(applicationContext))
.validateAndroidSetup(
context = applicationContext,
serviceClass = MyTrackingService::class, // your BasePulseKitService subclass
bootReceiverClass = MyBootReceiver::class, // optional; your PulseKitBootReceiver subclass
)
.build()If your app deliberately has no BasePulseKitService subclass, pass serviceClass = null (the
default): permission/manifest checks still run, and instead of failing, PulseKit logs a warning
through the builder's configured logger that collection will only work while the app is in the
foreground -- without a foreground service the OS is free to suspend the process (and every
sensor callback with it) as soon as the app is backgrounded.
This only checks declarations -- it says nothing about whether the user has actually granted a
permission at runtime. That's PermissionController's job, below.
You don't enumerate a source's permissions yourself. Each DataSource declares its own
requiredPermissions/optionalPermissions, and PermissionController encodes the OS-mandated
staged ordering (foreground location before background, etc.). If your UI is Compose, the
pulsekit-ui module turns a source straight into a permission-first flow:
@Composable
fun SourceButton(source: DataSource, controller: PermissionController, onStart: () -> Unit) {
// Derives the whole staged request (required -> optional -> session permissions) from the
// source itself — no permission list in your app code.
val permissions = rememberDataSourcePermissionState(controller, source)
Button(onClick = {
permissions.request { canCollect -> if (canCollect) onStart() }
}) { Text("Collect ${source.displayName}") }
if (permissions.missingRequired.isNotEmpty()) Text("Needs: ${permissions.missingRequired}")
}canCollect is true only once every requiredPermissions is granted; denied optional
permissions (background location, notifications, battery exemption) surface via missingOptional
so you can show "collecting without …" instead of blocking. Render one button per
pulseKit.dataSources entry and you have the whole screen — see MainActivity.kt in app/.
Prefer to drive it by hand? PermissionController.request(Permission.LOCATION_FOREGROUND) returns
a PermissionStatus and handles the staged ordering; PermissionGate /
rememberPermissionGateState are the lower-level generic building blocks if you want to list
permissions explicitly.
// Bring your own wire format:
class MyBackendUploader(private val api: MyApiClient) : SyncUploader {
override suspend fun upload(batch: List<SensorEventLog>): Boolean =
api.postEvents(batch.map { it.toMyWireFormat() })
}
val syncEngine = SyncEngine(pulseKit.syncSource, MyBackendUploader(api))
syncEngine.start(applicationScope)
// Or, if a simple JSON-over-HTTP endpoint is enough:
val syncEngine = SyncEngine(pulseKit.syncSource, JsonHttpSyncUploader(HttpClient(), endpointUrl))
// Observe sync status for your own UI:
syncEngine.observeState().collect { state ->
// state.isSyncing, state.lastSuccessTimestampMillis, state.lastError, state.consecutiveFailures
}PulseKit exposes a read-only query API alongside the sync claim path — reading never mutates
syncStatus, so it's safe to call whether or not anything is currently collecting or syncing:
val query = EventQuery(
types = setOf("location"), // null = every source
fromTimestamp = oneHourAgoMillis,
toTimestamp = System.currentTimeMillis(),
limit = 500, // required — no unbounded query on a large table
)
val recent: List<SensorEventLog> = pulseKit.queryEvents(query)
val liveFeed: Flow<List<SensorEventLog>> = pulseKit.observeEvents(query)pulsekit-export turns a Flow/Sequence of events into a shareable file without materializing
the whole table in memory — pick the format that fits the consumer:
val events = pulseKit.queryEvents(query).asFlow()
NdjsonExporter.export(events, output) // one SensorEventLog per line, every payload type
GpxExporter.export(events, output) // GPX 1.1 <trk>, Location rows only
CsvExporter.export(events, output) // flattened Location/StepCount rows; MotionChunk expands
// to one row per sampleMixed-type streams are handled gracefully: GpxExporter/CsvExporter silently skip payload types
they don't understand instead of throwing, and an empty result set still produces a well-formed
(if empty) document. See HistoryScreen.kt in app/ for the full share-as-file flow.
pulsekit-testing ships fakes for the pieces of PulseKit a test needs to control, so your app's
tests can drive real PulseKit orchestration deterministically without Robolectric:
val timeProvider = MutableTimeProvider(initialMillis = 1000L)
val logger = RecordingPulseKitLogger()
val source = FakeDataSource(id = "fake")
val pulseKit = PulseKit.builder(inMemoryPulseKitDatabase())
.addDataSource(source)
.timeProvider(timeProvider)
.logger(logger)
.build()
pulseKit.start()
source.emit(SensorPayload.StepCount(steps = 10))
timeProvider.advanceBy(5_000)
// source.getStartCount() / source.isRunning() assert the "safe to call again" DataSource contract;
// inMemoryPulseKitDatabase() returns a fresh, isolated in-memory database on every call.See PulseKitShowcaseTest under app/src/test/ for the pattern end-to-end.
pulsekit-location, pulsekit-motion, and pulsekit-bluetooth are reference implementations —
pulsekit-core has no knowledge of any of them. Anything that can produce a stream of values on a
schedule can become a DataSource:
class HeartRateDataSource(private val sensorManager: SensorManager) : DataSource {
override val id = "heart_rate"
override val displayName = "Heart Rate"
override val requiredPermissions = listOf(Permission.BODY_SENSORS)
private val _events = MutableSharedFlow<SensorPayload>(extraBufferCapacity = 16)
override suspend fun start(): Boolean {
// register your sensor listener, emit into _events
return true
}
override suspend fun stop() { /* unregister listener */ }
override fun events(): Flow<SensorPayload> = _events
}Register it exactly like a built-in source — PulseKit.builder(database).addDataSource(HeartRateDataSource(sensorManager))
— and it gets manifest validation, foreground-service typing, and permission-request UI for free,
the same as LocationDataSource or BluetoothDataSource. If it needs a new Permission that
doesn't exist yet, see Adding a new Permission in CLAUDE.md.
PulseKit follows a Zero-Manifest policy. Library modules do not declare any permissions or
components in their own manifests. Your app must explicitly declare everything it needs in its
AndroidManifest.xml:
FOREGROUND_SERVICE, WAKE_LOCK, POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED.ACCESS_FINE_LOCATION,
ACTIVITY_RECOGNITION, FOREGROUND_SERVICE_LOCATION).BasePulseKitService subclass and PulseKitBootReceiver subclass.Use pulseKit.validateAndroidSetup(...) from pulsekit-ui at startup to catch any missing
manifest declarations early with a descriptive error.
There's no iosApp/Info.plist in this repo — those live in your consuming app. Add:
NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription
NSMotionUsageDescriptionUIBackgroundModes array containing location (this is what keeps motion/BLE sampling alive
in the background too — there's no separate background mode for them on iOS)./gradlew build runs the full verification suite (all KMP targets, unit tests, API compatibility
check). A pre-commit hook is provided for fast local checks before every commit:
git config core.hooksPath scripts/git-hooksSee CLAUDE.md for architecture conventions and REFACTOR_PLAN.md for the current roadmap.
Apache License 2.0 — see LICENSE.
A Kotlin Multiplatform (Android + iOS) engine for continuous or periodic, multi-day background
data collection from any pluggable DataSource — location, motion, step count, and BLE scanning
ship out of the box, and adding your own (heart rate, a custom BLE peripheral, an internal sensor)
is a matter of implementing one interface, not touching the core. Collected events get batched
local persistence and a pluggable sync engine that never assumes your backend's wire format. Each
data source describes its own permissions, so the library handles manifest validation,
foreground-service typing, and the permission-request UI for you.
The demo app (app/) showcases every feature area behind bottom navigation:
![]() Home |
![]() Sensors |
![]() Bluetooth |
![]() Sync |
![]() Settings |
Follows the Now In Android-style modular architecture, adapted for Kotlin Multiplatform:
pulsekit-core: Tracking engine, SQLDelight-backed batched/bounded persistence, the
cross-platform PermissionController abstraction, and Android foreground-service +
boot-resume infrastructure.pulsekit-location: GPS/network location DataSource.pulsekit-motion: Raw accelerometer DataSource and step-counter DataSource.pulsekit-bluetooth: BLE scan DataSource.DataSource you write plugs
into the same engine (see Adding a custom data source).pulsekit-sync: Inversion-of-control network sync engine (SyncEngine handles retry/
backoff; SyncUploader is the contract your app implements for its own backend, with
JsonHttpSyncUploader provided as a ready-made default).pulsekit-ui: Android infrastructure (Services, Receivers) and Jetpack Compose layer over
PermissionController's staged request flow -- pull this in if you want a ready-made
PermissionGate composable instead of hand-writing the sequencing.pulsekit-export: Pure, streaming-friendly exporters (NdjsonExporter, GpxExporter,
CsvExporter) that turn a Flow/Sequence of stored events into NDJSON, GPX 1.1, or CSV without
materializing the whole table in memory.pulsekit-testing: First-class test artifact for consumers -- FakeDataSource,
MutableTimeProvider, RecordingPulseKitLogger, and inMemoryPulseKitDatabase() so an app can
drive PulseKit deterministically in its own tests without Robolectric.app: Android demo app wiring all of the above together end-to-end.Feature modules depend only on pulsekit-core, never on each other — if you only need location
tracking, you only pull in pulsekit-core + pulsekit-location.
START_STICKY + periodically-renewed wake lock, boot-resume via a
BroadcastReceiver; iOS background location as the anchor that keeps motion/BLE sampling alive.
Per-source CollectionMode.Periodic(interval, window) collects in bursts (e.g. scan BLE 30s
every 5min) for power-sensitive sources.startSources(setOf("location")) / stopSources(...) begin or end
individual sources on demand, with activeSourceIds: StateFlow<Set<String>> as an observable,
UI-bindable single source of truth for what's collecting.DataSource declares its own displayName,
requiredPermissions, and optionalPermissions, so the library derives manifest validation,
foreground-service typing, and the permission-request UI for you — the app never hardcodes which
permissions a source needs.DataSource is a four-method interface
(start/stop/events/permission metadata) with no knowledge of location, motion, or BLE baked
into pulsekit-core. Location/motion/BLE are the shipped reference implementations; a new sensor
is a new module that implements the interface, wired in the same way as the built-in ones.BasePulseKitService, override only pulseKit,
and get the whole foreground-service lifecycle, a customizable notification, wake-lock renewal,
and permission-masked service typing for free.PulseKitConfig.maxStoredEvents) and an optional age-based cap
(PulseKitConfig.maxEventAgeMillis) so an offline device can't grow the database unbounded.PulseKit.eraseAllData() for right-to-erasure (GDPR/CCPA) requests — unconditionally removes
every stored row, regardless of sync status, distinct from routine pruning.PermissionController that encodes the OS-mandated staged permission
flows for you: Android's foreground-then-background location sequence (10+ requires this as two
separate calls) and iOS's when-in-use-then-always progression.SyncEngine owns claim/retry/backoff, SyncUploader is
the one interface you implement for your own backend's wire format — the library never assumes
JSON-over-HTTP unless you opt into the provided JsonHttpSyncUploader.SyncEngine.observeState(): StateFlow<SyncState> for a UI-ready sync status (last success time,
last error, consecutive failure count) instead of guessing at internal retry state.PulseKitLogger (PulseKit.Builder.logger(...), also accepted by SyncEngine) so
you can route PulseKit's internal diagnostics (prune passes, failed uploads) through your own
Timber/Crashlytics/etc. — silent by default.TimeProvider/IdProvider (PulseKit.Builder.timeProvider(...)/idProvider(...)) —
every timestamp and id in the event log can be driven deterministically in tests instead of by an
unmockable process-wide clock/UUID singleton. Defaults are byte-for-byte unchanged for existing
callers.PulseKit.queryEvents(EventQuery) /
observeEvents(EventQuery) filter by type and time range with a required limit — the sync claim
path stays the only place that mutates rows, so reads are always safe to call regardless of
collection or sync state.pulsekit-export's NdjsonExporter/GpxExporter/CsvExporter turn queried events straight into
shareable files — GPX for maps/fitness tools, NDJSON for a full-fidelity dump of every payload
type, CSV for spreadsheets — each silently skipping payload types it doesn't understand instead of
throwing on a mixed stream.pulsekit-testing ships a FakeDataSource (scriptable start/stop/events, call-count assertions),
MutableTimeProvider, RecordingPulseKitLogger, and inMemoryPulseKitDatabase() so consumers can
unit test their own PulseKit integration against real SQL without Robolectric.Add whichever modules you need to your build.gradle.kts:
dependencies {
implementation("io.github.alirezajavan:pulsekit-core:0.3.0")
implementation("io.github.alirezajavan:pulsekit-location:0.3.0")
implementation("io.github.alirezajavan:pulsekit-motion:0.3.0")
implementation("io.github.alirezajavan:pulsekit-bluetooth:0.3.0")
implementation("io.github.alirezajavan:pulsekit-sync:0.3.0")
implementation("io.github.alirezajavan:pulsekit-ui:0.3.0") // optional
implementation("io.github.alirezajavan:pulsekit-export:0.3.0") // optional
testImplementation("io.github.alirezajavan:pulsekit-testing:0.3.0") // optional
}val database = createPulseKitDatabase(applicationContext)
val pulseKit = PulseKit.builder(database)
.addDataSource(LocationDataSource(applicationContext))
.addDataSource(MotionDataSource(applicationContext))
.addDataSource(StepCounterDataSource(applicationContext))
// Periodic sources collect in bursts instead of continuously:
.addDataSource(
BluetoothDataSource(applicationContext),
mode = CollectionMode.Periodic(intervalMillis = 5.minutes.inWholeMilliseconds,
windowMillis = 30.seconds.inWholeMilliseconds),
)
.build()Drive collection through the platform host (Android Service or iOS IosPulseKitHost) to
ensure the app process is kept alive for background collection:
// Android: Start the tracking service
BasePulseKitService.startCollection(context, MyTrackingService::class)
// iOS: Start the keep-alive host
val host = IosPulseKitHost(pulseKit)
host.start()Location/motion data is sensitive (GDPR/CCPA-relevant). By default createPulseKitDatabase(context)
uses a plain, unencrypted driver -- if your app needs at-rest encryption, call the
createPulseKitDatabase(driver: SqlDriver) overload with your own driver instead, e.g. one
wrapping SQLCipher-for-Android's SupportOpenHelperFactory. PulseKit doesn't bundle SQLCipher
itself since there's no equivalent turnkey story on iOS.
BasePulseKitService owns the entire collection lifecycle, wake lock, foreground-service typing
and a default notification — a subclass usually just points it at the shared instance:
class MyTrackingService : BasePulseKitService() {
override val pulseKit get() = MyApp.from(this).pulseKit
// Everything below is optional — override to customize the tray notification (or override
// createForegroundNotification() wholesale for full control):
override val notificationContentTitle = "Tracking active"
override val notificationSmallIcon = R.drawable.ic_tracking
}Declare it (and, optionally, a PulseKitBootReceiver subclass to resume after reboot) in your
manifest — see Platform setup below.
On iOS, there is no background service. To keep the app alive for continuous sensor collection,
use IosPulseKitHost from pulsekit-ui:
val host = IosPulseKitHost(pulseKit)
host.start() // Requests 'Always' location and keeps process awakeEnsure your Info.plist declares the location UIBackgroundModes — see Platform setup below.
PulseKit.Builder.validateAndroidSetup(...) cross-checks your app's manifest against the data
sources you've added and throws one exception listing every gap found -- missing permissions, a
service/receiver class that isn't declared as a component, or a <service> missing a required
foregroundServiceType flag -- instead of letting a setup mistake surface later as an opaque
SecurityException from startForegroundService():
pulseKit = PulseKit.builder(database)
.addDataSource(LocationDataSource(applicationContext))
.addDataSource(MotionDataSource(applicationContext))
.addDataSource(StepCounterDataSource(applicationContext))
.addDataSource(BluetoothDataSource(applicationContext))
.validateAndroidSetup(
context = applicationContext,
serviceClass = MyTrackingService::class, // your BasePulseKitService subclass
bootReceiverClass = MyBootReceiver::class, // optional; your PulseKitBootReceiver subclass
)
.build()If your app deliberately has no BasePulseKitService subclass, pass serviceClass = null (the
default): permission/manifest checks still run, and instead of failing, PulseKit logs a warning
through the builder's configured logger that collection will only work while the app is in the
foreground -- without a foreground service the OS is free to suspend the process (and every
sensor callback with it) as soon as the app is backgrounded.
This only checks declarations -- it says nothing about whether the user has actually granted a
permission at runtime. That's PermissionController's job, below.
You don't enumerate a source's permissions yourself. Each DataSource declares its own
requiredPermissions/optionalPermissions, and PermissionController encodes the OS-mandated
staged ordering (foreground location before background, etc.). If your UI is Compose, the
pulsekit-ui module turns a source straight into a permission-first flow:
@Composable
fun SourceButton(source: DataSource, controller: PermissionController, onStart: () -> Unit) {
// Derives the whole staged request (required -> optional -> session permissions) from the
// source itself — no permission list in your app code.
val permissions = rememberDataSourcePermissionState(controller, source)
Button(onClick = {
permissions.request { canCollect -> if (canCollect) onStart() }
}) { Text("Collect ${source.displayName}") }
if (permissions.missingRequired.isNotEmpty()) Text("Needs: ${permissions.missingRequired}")
}canCollect is true only once every requiredPermissions is granted; denied optional
permissions (background location, notifications, battery exemption) surface via missingOptional
so you can show "collecting without …" instead of blocking. Render one button per
pulseKit.dataSources entry and you have the whole screen — see MainActivity.kt in app/.
Prefer to drive it by hand? PermissionController.request(Permission.LOCATION_FOREGROUND) returns
a PermissionStatus and handles the staged ordering; PermissionGate /
rememberPermissionGateState are the lower-level generic building blocks if you want to list
permissions explicitly.
// Bring your own wire format:
class MyBackendUploader(private val api: MyApiClient) : SyncUploader {
override suspend fun upload(batch: List<SensorEventLog>): Boolean =
api.postEvents(batch.map { it.toMyWireFormat() })
}
val syncEngine = SyncEngine(pulseKit.syncSource, MyBackendUploader(api))
syncEngine.start(applicationScope)
// Or, if a simple JSON-over-HTTP endpoint is enough:
val syncEngine = SyncEngine(pulseKit.syncSource, JsonHttpSyncUploader(HttpClient(), endpointUrl))
// Observe sync status for your own UI:
syncEngine.observeState().collect { state ->
// state.isSyncing, state.lastSuccessTimestampMillis, state.lastError, state.consecutiveFailures
}PulseKit exposes a read-only query API alongside the sync claim path — reading never mutates
syncStatus, so it's safe to call whether or not anything is currently collecting or syncing:
val query = EventQuery(
types = setOf("location"), // null = every source
fromTimestamp = oneHourAgoMillis,
toTimestamp = System.currentTimeMillis(),
limit = 500, // required — no unbounded query on a large table
)
val recent: List<SensorEventLog> = pulseKit.queryEvents(query)
val liveFeed: Flow<List<SensorEventLog>> = pulseKit.observeEvents(query)pulsekit-export turns a Flow/Sequence of events into a shareable file without materializing
the whole table in memory — pick the format that fits the consumer:
val events = pulseKit.queryEvents(query).asFlow()
NdjsonExporter.export(events, output) // one SensorEventLog per line, every payload type
GpxExporter.export(events, output) // GPX 1.1 <trk>, Location rows only
CsvExporter.export(events, output) // flattened Location/StepCount rows; MotionChunk expands
// to one row per sampleMixed-type streams are handled gracefully: GpxExporter/CsvExporter silently skip payload types
they don't understand instead of throwing, and an empty result set still produces a well-formed
(if empty) document. See HistoryScreen.kt in app/ for the full share-as-file flow.
pulsekit-testing ships fakes for the pieces of PulseKit a test needs to control, so your app's
tests can drive real PulseKit orchestration deterministically without Robolectric:
val timeProvider = MutableTimeProvider(initialMillis = 1000L)
val logger = RecordingPulseKitLogger()
val source = FakeDataSource(id = "fake")
val pulseKit = PulseKit.builder(inMemoryPulseKitDatabase())
.addDataSource(source)
.timeProvider(timeProvider)
.logger(logger)
.build()
pulseKit.start()
source.emit(SensorPayload.StepCount(steps = 10))
timeProvider.advanceBy(5_000)
// source.getStartCount() / source.isRunning() assert the "safe to call again" DataSource contract;
// inMemoryPulseKitDatabase() returns a fresh, isolated in-memory database on every call.See PulseKitShowcaseTest under app/src/test/ for the pattern end-to-end.
pulsekit-location, pulsekit-motion, and pulsekit-bluetooth are reference implementations —
pulsekit-core has no knowledge of any of them. Anything that can produce a stream of values on a
schedule can become a DataSource:
class HeartRateDataSource(private val sensorManager: SensorManager) : DataSource {
override val id = "heart_rate"
override val displayName = "Heart Rate"
override val requiredPermissions = listOf(Permission.BODY_SENSORS)
private val _events = MutableSharedFlow<SensorPayload>(extraBufferCapacity = 16)
override suspend fun start(): Boolean {
// register your sensor listener, emit into _events
return true
}
override suspend fun stop() { /* unregister listener */ }
override fun events(): Flow<SensorPayload> = _events
}Register it exactly like a built-in source — PulseKit.builder(database).addDataSource(HeartRateDataSource(sensorManager))
— and it gets manifest validation, foreground-service typing, and permission-request UI for free,
the same as LocationDataSource or BluetoothDataSource. If it needs a new Permission that
doesn't exist yet, see Adding a new Permission in CLAUDE.md.
PulseKit follows a Zero-Manifest policy. Library modules do not declare any permissions or
components in their own manifests. Your app must explicitly declare everything it needs in its
AndroidManifest.xml:
FOREGROUND_SERVICE, WAKE_LOCK, POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED.ACCESS_FINE_LOCATION,
ACTIVITY_RECOGNITION, FOREGROUND_SERVICE_LOCATION).BasePulseKitService subclass and PulseKitBootReceiver subclass.Use pulseKit.validateAndroidSetup(...) from pulsekit-ui at startup to catch any missing
manifest declarations early with a descriptive error.
There's no iosApp/Info.plist in this repo — those live in your consuming app. Add:
NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription
NSMotionUsageDescriptionUIBackgroundModes array containing location (this is what keeps motion/BLE sampling alive
in the background too — there's no separate background mode for them on iOS)./gradlew build runs the full verification suite (all KMP targets, unit tests, API compatibility
check). A pre-commit hook is provided for fast local checks before every commit:
git config core.hooksPath scripts/git-hooksSee CLAUDE.md for architecture conventions and REFACTOR_PLAN.md for the current roadmap.
Apache License 2.0 — see LICENSE.