Closed Test SDK
Integrate telemetry into anyapp — your app under test. Organizers use Dozenflow (mobile app + this site) to create tests and view metrics; testers install your APK, not the Dozenflow app, unless you want optional binding features.
Library: com.groundspaceteam:closed-test-sdk · latest 0.2.18 · API: io.closedtest.sdk · Ingest: https://api.groundspaceteam.com
Minimum settings
Add the dependency and enable Base (keyless) ingest on the test card in Dozenflow. Auto-init starts telemetry without extra app code.
dependencies {
implementation("com.groundspaceteam:closed-test-sdk:0.2.18")
}
Current documented release: 0.2.18 on Maven Central. Release notes live in the SDK repository.
Advanced settings
Optional configuration beyond the minimum dependency — keys, manual init, custom events, reminders, and troubleshooting.
Terminology
| Term | Meaning |
|---|---|
| anyapp | Your app under test — where you embed the SDK. Testers install this APK. |
| Dozenflow | Organizer product (mobile app + this site). Create tests, issue keys, view dashboards. Testers do not need the Dozenflow app for basic telemetry. |
| Base mode | No publishable_key in anyapp. Enable on the test card in the Dozenflow app: section SDK ingest without publishable key → toggle Allow Base (keyless) ingest. The server then accepts POST /v1/init for that test’s package_name; the SDK still sends package_name, build_type, versionName, and versionCode. Advanced mode with a key is unchanged. |
| Advanced mode | Uses pk_live_… from Dozenflow or this site’s dashboard copy helpers. Server applies key-based policy. |
Initialization (auto-init, recommended)
The SDK starts via AndroidX App Startup when the dependency is on the classpath.
AndroidManifest.xml
<application ...>
<!-- Optional: Advanced ingest. Omit for Base when keyless ingest is enabled on the test card. -->
<meta-data
android:name="io.closedtest.sdk.publishable_key"
android:value="pk_live_YOUR_KEY_HERE" />
</application>
- Backend URL is not configured in app code — it is embedded in the SDK.
- Missing or empty
publishable_key→ Base handshake (when enabled on the test card). - Non-empty key → Advanced handshake.
ClosedTest.initialize(...)remains available; duplicate calls are ignored.
Disable auto-init (optional)
<meta-data
android:name="io.closedtest.sdk.auto_init_enabled"
android:value="false" />
Then call ClosedTest.initialize from Application.onCreate or your entry Activity.
Keep the publishable key out of git
Do not commit pk_live_… in AndroidManifest.xml. Inject at build time.
Gradle property (local)
In ~/.gradle/gradle.properties (user-wide, not in the repo) or a gitignored project gradle.properties:
closedTest.publishableKey=pk_live_YOUR_KEY_HERE
project.findProperty("closedTest.publishableKey") reads this. local.properties is not merged into findProperty automatically — load it explicitly in build.gradle.kts if you store the key only there.
build.gradle.kts
android {
defaultConfig {
val pk = (project.findProperty("closedTest.publishableKey") as String?)
?: System.getenv("CLOSED_TEST_PUBLISHABLE_KEY")
?: ""
manifestPlaceholders["closedTestPublishableKey"] = pk
}
}
<meta-data
android:name="io.closedtest.sdk.publishable_key"
android:value="${closedTestPublishableKey}" />
CI
Set secret CLOSED_TEST_PUBLISHABLE_KEY in GitHub Actions or your CI — never commit the key. See examples/sample in the SDK repo for a working sample.
Manual initialization
ClosedTest.initialize(
context = applicationContext,
publishableKey = "pk_live_YOUR_KEY_HERE",
options = ClosedTestOptions(/* see below */),
)
If auto-init already ran with the same key, a later call is a no-op.
ClosedTestOptions
| Option | Purpose |
|---|---|
heartbeatIntervalMs | Foreground heartbeat interval |
backgroundSessionEndDelayMs | Delay before session_end after background |
collectInDebuggableBuilds | Collect telemetry in debug builds |
okHttpClient | Custom OkHttp client |
maxQueuedEvents | Room queue cap |
eventsBatchSize | Batch upload size |
uploadBackoffInitialMs / uploadBackoffMaxMs | Retry backoff |
proofFlowHintEnabled | Show “open organizer app” dialog after init (default true) |
proofFlowHintMaxShows | Max dialog shows per install (default 3) |
proofFlowHintCooldownMs | Cooldown after “Later” (default 7 days) |
proofFlowPackageNames | Packages checked for the installed Dozenflow / organizer app |
dailyReminderEnabled | Local daily notification when the app was not opened today (default true) |
dailyReminderHourLocal | Reminder hour, device timezone (default 15 = 3 PM) |
dailyReminderMinuteLocal | Reminder minute (default 0) |
Manifest meta-data for auto-init (optional):
<meta-data android:name="io.closedtest.sdk.daily_reminder_enabled" android:value="false" />
<meta-data android:name="io.closedtest.sdk.daily_reminder_hour" android:value="18" />
<meta-data android:name="io.closedtest.sdk.daily_reminder_minute" android:value="30" />
Disable the organizer-app hint via manifest:
<meta-data
android:name="io.closedtest.sdk.proofflow_hint_enabled"
android:value="false" />
Manual events
ClosedTest.trackScreen("Home")
ClosedTest.trackInteraction("tap")
ClosedTest.trackEvent("onboarding_done", mapOf("step" to "2"))
ClosedTest.flush() // force upload
For navigation funnels and automatic Compose tracking, see the screen flow guide (screen_view events).
Tester binding
Link a tester session when your flow provides IDs (e.g. from Dozenflow):
ClosedTest.handleDeepLink(intent?.data)
// or
ClosedTest.bindTester(
testerId = "…",
testSessionId = "…",
)
Organizer app hint dialog
When POST /v1/init returns proofflow_test_id and hints are enabled, the SDK may show a dialog to open the Dozenflow organizer app:
proofflow://test/{proofflow_test_id}
Testers can dismiss, snooze, or opt out permanently. Your app is not blocked if the organizer app is not installed.
Local daily test reminder (not FCM)
The SDK can show a local notification on the tester’s device — no Firebase in anyapp, no remote push from Dozenflow. This complements (does not replace) Remind participants in the Dozenflow organizer app, which uses FCM to the Dozenflow APK.
- Default time: 15:00 in the device’s local timezone.
- Condition: the app was not brought to the foreground yet today (calendar day, local).
- Tap: opens your app’s launcher activity.
- Configure:
ClosedTestOptionsor manifestio.closedtest.sdk.daily_reminder_*meta-data.
ClosedTest.initialize(
context = applicationContext,
publishableKey = publishableKey,
options = ClosedTestOptions(
dailyReminderEnabled = true,
dailyReminderHourLocal = 15,
dailyReminderMinuteLocal = 0,
),
)
Android 13+: add POST_NOTIFICATIONS to your app manifest and request runtime permission — otherwise the SDK skips the notification. Some OEMs may delay alarms under battery optimization.
Discovery marker (organizer app can detect the SDK)
The SDK merges an exported ContentProvider so the Dozenflow organizer app can verify a specific package has this SDK installed.
| Item | Value |
|---|---|
| Authority | {applicationId}.closedtest.discovery — ClosedTest.discoveryAuthority(applicationId) |
| Query columns | sdk_version, host_package |
| Non-whitelisted callers | Empty cursor (no secrets exposed) |
Disable:
<meta-data
android:name="io.closedtest.sdk.discovery_enabled"
android:value="false" />
Implicit discovery intent (optional): action io.closedtest.sdk.DISCOVERY (ClosedTest.DISCOVERY_INTENT_ACTION). On Android 11+, clients need <queries> for the intent. For a known package, prefer ContentProvider verification over intent-only checks.
What runs automatically
- Lifecycle:
session_start,session_end,app_foreground,app_background - Foreground heartbeat
- Room queue + batched upload
- Token refresh on HTTP 401
- Optional local daily reminder alarm (if enabled)
Organizer workflow
- Create a test in the Dozenflow mobile app (package name, quotas, policy).
- Copy
publishable_keyfrom Dozenflow or this site’s dashboard (when signed in). - Add the SDK to anyapp with that key (or use Base when keyless ingest is enabled on the test card).
- Distribute your test build; testers use anyapp only.
- Monitor progress in the Dozenflow app or the dashboard on this site.
Troubleshooting
| Symptom | Check |
|---|---|
| No events on server | Network; POST /v1/init 403/401; wrong package in policy; Base ingest not enabled on test card; key typo |
| 403 on init (Advanced) | Key disabled/rotated; wrong package_name in server policy |
| No organizer-app dialog | proofflow_test_id not returned; hint disabled; max shows reached |
| No daily local reminder | daily_reminder_enabled=false; app already opened today; notifications off; POST_NOTIFICATIONS denied on Android 13+ |
| Organizer app cannot detect SDK | Wrong package; discovery_enabled=false; missing <queries> on Android 11+ |
Further reference
This page documents the organizer-facing integration path. Ingest contract and server policy are defined in the closed-test-sdk repository.