Detect user habits from Android system data and third-party services β no user input required.
Tracker is an Android library that automatically identifies behaviors like language learning, reading, movie watching, social media usage, step counting, meditation, and exercise by analyzing device usage, Health Connect data, and third-party feeds. Your app gets structured habit data with confidence scores, without asking users to log anything manually.
-
β Building a habit tracking, wellness, or productivity app
-
β Want to detect behaviors without manual logging
-
β Need confidence scores for detected activities
-
β Want graceful degradation when permissions are missing
-
β You just need step counting β use Health Connect directly
-
β You want to track custom in-app actions β use your own analytics
val tracker = Tracker.Builder(context)
.setLetterboxdUsername("your_username") // optional: required only for movie watching
.setMinConfidence(0.50f)
.build()
lifecycleScope.launch {
try {
val reading = tracker.queryReading() // today by default
val learning = tracker.queryLanguageLearning()
val social = tracker.querySocialMedia()
// Reading β null means no activity in the time range
reading?.durationMinutes // total minutes across all sessions
reading?.confidence // 0.0β1.0
reading?.confidenceLevel // LOW / MEDIUM / HIGH
reading?.sessions // List<UsageSession> sorted by startTime
// Language learning
learning?.durationMinutes
learning?.sessions
// Social media
social?.durationMinutes
social?.sessions // List<UsageSession> β one entry per app-open
// Derive apps and session count from sessions
reading?.sessions?.map { it.appName }?.distinct() // ["Kindle"]
social?.sessions?.size // 23
social?.sessions?.groupBy { it.packageName } // per-app session breakdown
} catch (e: PermissionDeniedException) {
// PACKAGE_USAGE_STATS not granted β direct user to Settings
} catch (e: NoMonitorableAppsException) {
// None of the known apps are installed on this device
}
// Movie watching requires a Letterboxd username
// Throws IllegalStateException if username is not set
try {
val movies = tracker.queryMovieWatching()
movies?.count // number of films logged β null means no films in range
movies?.movies // List<MovieInfo> β title, watchedDate, publishedDate
} catch (e: IllegalStateException) {
// Username not configured β call tracker.setLetterboxdUsername("username") first
} catch (e: NetworkException) {
// Network request failed
}
// Step counting via Health Connect β returns null if HC unavailable or permission not granted
val steps = tracker.queryStepCounting()
steps?.steps // Long β deduplicated across all data sources
steps?.confidence // 0.99 when sourced from Health Connect
// Meditation β fuses Health Connect MindfulnessSessionRecord with known-meditation-app
// foreground sessions. Falls back to UsageStats-only if Health Connect is unavailable.
val meditation = tracker.queryMeditation()
meditation?.durationMinutes // total meditation time across all (deduplicated) sessions
meditation?.sessions // List<MeditationSession> sorted by startTime
meditation?.sources // [HEALTH_CONNECT], [USAGE_STATS], or [HEALTH_CONNECT, USAGE_STATS]
// A session that was seen by both HealthConnect and Calm is merged into one:
meditation?.sessions?.forEach { s ->
s.sources // e.g. [HEALTH_CONNECT, USAGE_STATS] for a merged session
s.packageName // "com.calm.android" when UsageStats contributed; null for HC-only
s.appName // "Calm" when UsageStats contributed; null for HC-only
}
// Exercise via Health Connect ExerciseSessionRecord β returns null if HC unavailable,
// READ_EXERCISE permission not granted, or no sessions in the window.
val exercise = tracker.queryExercise()
exercise?.durationMinutes // total exercise time across all sessions
exercise?.sessions // List<ExerciseSession> sorted by startTime
exercise?.confidence // 0.99 β ExerciseSessionRecord is authoritative
// Each session exposes both the raw HC type id and a snake_case string:
exercise?.sessions?.forEach { s ->
s.exerciseTypeId // Int β e.g. 56 (EXERCISE_TYPE_RUNNING)
s.exerciseType // String β e.g. "running", "strength_training", "yoga"
s.durationMinutes
}
// Derive per-type breakdowns directly:
val durationByType: Map<String, Int> = exercise?.sessions
?.groupBy { it.exerciseType }
?.mapValues { (_, s) -> s.sumOf { it.durationMinutes } }
?: emptyMap()
}Example output (today):
- Reading: 30 min Β· 2 sessions Β· Kindle Β· 75% confidence (MEDIUM)
- Language Learning: 45 min Β· 5 sessions Β· Duolingo, Anki Β· 85% confidence (HIGH)
- Movie Watching: 3 films Β· The Matrix, Inception, Interstellar Β· 95% confidence (HIGH)
- Social Media: 120 min Β· 23 sessions Β· Instagram, Reddit, WhatsApp Β· 88% confidence (HIGH)
- Steps: 7,622 steps Β· 99% confidence (HIGH)
- Meditation: 15 min Β· 1 session Β· Calm (HealthConnect + UsageStats merged) Β· 97% confidence (HIGH)
- Exercise: 45 min Β· 2 sessions Β· Running, Strength Training Β· 99% confidence (HIGH)
Session count and app list are derived from sessions:
result?.sessions?.size // session count
result?.sessions?.map { it.appName }?.distinct() // app listAll query methods accept an optional days parameter:
tracker.queryReading(days = 1) // today: midnight β now (default)
tracker.queryReading(days = 2) // yesterday midnight β now
tracker.queryReading(days = 7) // 6 days ago midnight β nowdays = 1 always starts at midnight of the current day in the device's local timezone, not 24 hours ago. This means results grow throughout the day as more activity is recorded.
days = 1 β ββββββββ today so far
days = 2 β ββββββββ ββββββββ yesterday (complete) + today so far
days = 7 β ββββββββ ββββββββ ββββββββ ββββββββ ββββββββ ββββββββ ββββββββ
Constraints:
- Must be
>= 1β throwsIllegalArgumentExceptionotherwise - Android retains usage events for approximately 14 days. Queries beyond that return empty results without error.
| Metric | Source | Apps / Data | Permission |
|---|---|---|---|
| LANGUAGE_LEARNING | Foreground session events | Duolingo, Anki, LingoDeer, Drops, Kanji Study, Renshuu, and 7 more | PACKAGE_USAGE_STATS |
| READING | Foreground session events | Kindle, Google Play Books | PACKAGE_USAGE_STATS |
| MOVIE_WATCHING | Letterboxd RSS | Film titles and watch dates from public feed | INTERNET (no user prompt) |
| SOCIAL_MEDIA | Foreground session events | Facebook, Instagram, Twitter/X, TikTok, Reddit, WhatsApp, and 9 more | PACKAGE_USAGE_STATS |
| STEP_COUNTING | Health Connect | Aggregated across all step sources, deduped by HC | health.READ_STEPS Β· API 26+ |
| MEDITATION | Health Connect + Foreground session events (fused) | MindfulnessSessionRecords plus Calm, Headspace, Insight Timer, Balance, Waking Up, Smiling Mind, Ten Percent Happier, Medito, MEISOON, Mindvalley |
health.READ_MINDFULNESS (optional, API 26+) Β· PACKAGE_USAGE_STATS |
| EXERCISE | Health Connect | ExerciseSessionRecords written by any fitness app (Strava, Google Fit, Samsung Health, Peloton, etc.) or logged manually |
health.READ_EXERCISE Β· API 26+ |
Note on Social Media: Includes messaging apps (WhatsApp, Telegram) with lower confidence scores (0.75) as they may be used for work or family communication.
Note on session accuracy: On Android 10+ (API 29), session tracking uses ACTIVITY_RESUMED/ACTIVITY_PAUSED events for precise per-session start and end times. Consecutive activity transitions within the same app are merged into a single session if the gap between them is under 30 seconds.
Note on sessions deduplication: When storing sessions locally across multiple queries, use (packageName, startTime) as the composite key. Exception: if session.startTime == result.timeRange.from, the session start was inferred (the app was already open at the query boundary) β use (packageName, endTime) for those. Sessions under 1 minute have durationMinutes = 0 but are still present in the list. See UsageSession for full details.
Note on step counting: queryStepCounting() uses Health Connect's aggregation API (StepsRecord.COUNT_TOTAL), which deduplicates across all contributing apps (e.g. Google Fit, phone step counter) before returning the total. Returns null if Health Connect is unavailable or the READ_STEPS permission has not been granted.
Note on meditation: queryMeditation() fuses two sources:
- Health Connect
MindfulnessSessionRecord(authoritative, confidence0.99) - UsageStats foreground sessions of known meditation apps (confidence
0.85β0.95per app)
Sessions that overlap significantly (β₯ 50% of the shorter session's duration) are deduplicated into a single MeditationSession whose sources list contains both HEALTH_CONNECT and USAGE_STATS. The result's top-level sources reports every source that contributed. If Health Connect is unavailable, the record type is unsupported on this device, or the READ_MINDFULNESS permission is denied, the query automatically falls back to UsageStats-only. Returns null only when neither source produced any sessions.
Note on exercise: queryExercise() reads ExerciseSessionRecords from Health Connect β these are authoritative entries written by fitness apps (Strava, Google Fit, Samsung Health, Peloton, Nike Run Club, and many others) or logged manually by the user. Confidence is fixed at 0.99. No minimum-duration filter is applied: short sessions appear in sessions with durationMinutes = 0 (rounded from seconds) so the session count stays accurate. Each ExerciseSession exposes both exerciseTypeId (the raw Health Connect integer, useful for programmatic mapping) and exerciseType (a snake_case string, e.g. "running", "strength_training", "yoga"). Returns null if Health Connect is unavailable, the API level is below 26, the READ_EXERCISE permission has not been granted, or no sessions exist in the window.
Note on the sources field: every HabitResult exposes sources: List<DataSource> (not source). Single-source results contain a one-element list; meditation may contain one or two elements depending on which sources contributed.
dependencies {
implementation("com.tracker:core:5.0.0")
}Add to AndroidManifest.xml:
<!-- Required for language learning, reading, and social media -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<!-- Required for movie watching (Letterboxd RSS) -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for step counting via Health Connect -->
<uses-permission android:name="android.permission.health.READ_STEPS" />
<!-- Optional but recommended for meditation β enables the Health Connect mindfulness source.
The meditation query falls back to UsageStats-only if this permission is not granted. -->
<uses-permission android:name="android.permission.health.READ_MINDFULNESS" />
<!-- Required for exercise via Health Connect -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />PACKAGE_USAGE_STATS is a protected permission β the user must grant it manually via Settings β Apps β Special app access β Usage access.
Health Connect permissions (health.READ_STEPS, health.READ_MINDFULNESS, health.READ_EXERCISE) must be requested at runtime using PermissionController.createRequestPermissionResultContract(). You can request all of them in a single prompt:
val launcher = registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { /* refresh UI */ }
launcher.launch(setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(MindfulnessSessionRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class)
))Add the following to the activity that handles the permission result:
<!-- Required for Health Connect (Android 9β13) -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter><!-- Required for Health Connect (Android 14+) -->
<activity-alias
android:name=".ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".YourActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>- All usage stats are processed entirely on-device
- Health Connect data never leaves the device
- Letterboxd data is fetched from public RSS feeds β no authentication, no private data
- No data is sent to any server beyond the third-party services you configure
- Min SDK: 21 (Android 5.0)
- Target SDK: 36
- Kotlin: 1.9+
- Step counting, meditation (HealthConnect branch), exercise: require API 26+ and Health Connect
./gradlew :app:installDebugDemonstrates the full flow: permission setup for PACKAGE_USAGE_STATS and Health Connect (steps + mindfulness + exercise, all requested in one prompt), querying all seven metrics for today (language learning, reading, social media, movie watching, step counting, meditation, exercise), and displaying results. The meditation row shows which sources contributed (HC, Usage, or HC+Usage); the exercise row lists the distinct exercise types detected (e.g. Running, Strength Training). To enable movie watching, set your Letterboxd username in MainActivity.kt.
Apache 2.0 β see LICENSE for details.