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-platformPermissionControllerabstraction, and Android foreground-service + boot-resume infrastructure.pulsekit-location: GPS/network locationDataSource.pulsekit-motion: Raw accelerometerDataSourceand step-counterDataSource.pulsekit-bluetooth: BLE scanDataSource.- These three are reference implementations, not a closed set — any
DataSourceyou write plugs into the same engine (see Adding a custom data source). pulsekit-sync: Inversion-of-control network sync engine (SyncEnginehandles retry/ backoff;SyncUploaderis the contract your app implements for its own backend, withJsonHttpSyncUploaderprovided as a ready-made default).pulsekit-ui: Android infrastructure (Services, Receivers) and Jetpack Compose layer overPermissionController's staged request flow -- pull this in if you want a ready-madePermissionGatecomposable instead of hand-writing the sequencing.pulsekit-export: Pure, streaming-friendly exporters (NdjsonExporter,GpxExporter,CsvExporter) that turn aFlow/Sequenceof 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, andinMemoryPulseKitDatabase()so an app can drivePulseKitdeterministically 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.
- Continuous or periodic background collection designed to survive for days: Android
foreground service with
START_STICKY+ periodically-renewed wake lock, boot-resume via aBroadcastReceiver; iOS background location as the anchor that keeps motion/BLE sampling alive. Per-sourceCollectionMode.Periodic(interval, window)collects in bursts (e.g. scan BLE 30s every 5min) for power-sensitive sources. - Per-source control:
startSources(setOf("location"))/stopSources(...)begin or end individual sources on demand, withactiveSourceIds: StateFlow<Set<String>>as an observable, UI-bindable single source of truth for what's collecting. - Self-describing data sources: each
DataSourcedeclares its owndisplayName,requiredPermissions, andoptionalPermissions, 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. - Pluggable by design, not just by name:
DataSourceis a four-method interface (start/stop/events/permission metadata) with no knowledge of location, motion, or BLE baked intopulsekit-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. - Batteries-included Android service: extend
BasePulseKitService, override onlypulseKit, and get the whole foreground-service lifecycle, a customizable notification, wake-lock renewal, and permission-masked service typing for free. - Batched, bounded local persistence (SQLDelight) — high-frequency sensor bursts are coalesced
into transactions instead of one write per sample, with a count-based prune cap
(
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.- A single cross-platform
PermissionControllerthat 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. - Sync is entirely inversion-of-control:
SyncEngineowns claim/retry/backoff,SyncUploaderis the one interface you implement for your own backend's wire format — the library never assumes JSON-over-HTTP unless you opt into the providedJsonHttpSyncUploader. 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.- A pluggable
PulseKitLogger(PulseKit.Builder.logger(...), also accepted bySyncEngine) so you can route PulseKit's internal diagnostics (prune passes, failed uploads) through your own Timber/Crashlytics/etc. — silent by default. - Injectable
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. - A public, read-only event query API:
PulseKit.queryEvents(EventQuery)/observeEvents(EventQuery)filter by type and time range with a requiredlimit— 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'sNdjsonExporter/GpxExporter/CsvExporterturn 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-testingships aFakeDataSource(scriptable start/stop/events, call-count assertions),MutableTimeProvider,RecordingPulseKitLogger, andinMemoryPulseKitDatabase()so consumers can unit test their ownPulseKitintegration 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:
- Infrastructure:
FOREGROUND_SERVICE,WAKE_LOCK,POST_NOTIFICATIONS,RECEIVE_BOOT_COMPLETED. - Permissions: Every permission required by your data sources (e.g.,
ACCESS_FINE_LOCATION,ACTIVITY_RECOGNITION,FOREGROUND_SERVICE_LOCATION). - Components: Your
BasePulseKitServicesubclass andPulseKitBootReceiversubclass.
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:
NSLocationWhenInUseUsageDescriptionandNSLocationAlwaysAndWhenInUseUsageDescriptionNSMotionUsageDescription- A
UIBackgroundModesarray containinglocation(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.




