Android only. There is no iOS, macOS, web or desktop implementation and
there will not be one — Apple has no closed-testing prerequisite and no Install
Referrer. On every other platform only the Dart stub compiles,
DeveloperPact.start() returns unsupported at once, and nothing is stored,
sent or registered, so an app that targets both stores can leave the call
unguarded. Nothing needs to be added to the App Store privacy form.
Closed-testing engagement reporter for developerpact.com. Google Play wants 12 testers opted in for 14 consecutive days before a personal developer account can publish, and it can reject an app for "insufficient tester engagement" without saying who went quiet. This plugin lets every developer on the exchange see which of their testers actually opens the app: it measures daily foreground time on the device and reports it, per tester, to developerpact.com.
Source: github.com/cns-studio/developerpact,
tagged per release, and every published version carries the same files inside
its pub.dev archive. License: MIT. Trust artifacts
(line counts, reproducible archive hash, merged permissions):
developerpact.com/sdk, generated by
tool/trust.sh in this package.
dependencies:
developerpact: ^0.1.0import 'package:developerpact/developerpact.dart';
void main() {
DeveloperPact.start(); // returns at once, never throws
runApp(const MyApp());
}That is the whole integration: one pubspec line, one call, one closed-track build, two Data safety rows (below). No Cloud project, no Play Console permission, no companion app, no special permission, no Application subclass, no manifest edit.
enum DeveloperPactStatus { paired, notPaired, unsupported }
class DeveloperPact {
static Future<DeveloperPactStatus> start({Uri? endpoint}); // endpoint is for SDK testing only
static bool get isSupported;
}There is deliberately nothing else: no track(), no setUser(), no
properties. The SDK has no public function that accepts app data, so there is
nothing an integration can leak by mistake. start() does under a millisecond
of Dart work: it posts one method-channel call and awaits the native reply,
which is the pairing state as known right now; everything slow (prefs, the
referrer bind, HTTP) happens afterwards on the SDK's own single thread. Every
native entry point is wrapped in a catch-all that logs once at warning level;
the host never sees an exception.
-
Pairing ties this install to a testing on developerpact.com. Two paths, same server call:
- Install Referrer (new installs): the tester's store link carries
referrer=utm_source%3Ddeveloperpact%26utm_content%3D<token>. On first launch the SDK reads the Play Install Referrer once. Ifutm_sourceis notdeveloperpact— organic install,(not set),FEATURE_NOT_SUPPORTED,SERVICE_UNAVAILABLE, a timeout — it writes a flag and never touches the network on this install. Production users generate zero traffic. - Deep link (app already installed): the tester page opens
devpact://<applicationId>/pair?t=<token>, handled by the plugin's ownPairActivity(invisible, gone before it is drawn). It validates the token format, pairs directly, brings your launcher activity forward and finishes. YourMainActivityand Flutter's deep-link handling are untouched.
POST /api/sdk/v1/pairreturns a per-install secret (stored on the server only as a SHA-256 hash) and the app'sthresholdSeconds. - Install Referrer (new installs): the tester's store link carries
-
Measurement: an
Application.registerActivityLifecycleCallbacksstarted-activity counter. 0→1 starts a session, 1→0 ends it. "Foreground" means the app window is STARTED — split-screen and picture-in-picture count, a screen that turns off does not. The bucket day is the local calendar day the session started on; a session that crosses midnight credits the day it began and nothing to the next one. While visible, the bucket is written to SharedPreferences every 20 s so a crash loses at most 20 s. Session cap 4 h, day cap 8 h, at most 7 unsent days kept. -
Heartbeat:
POST /api/sdk/v1/heartbeatwith the day's cumulative seconds. The server keeps the max, never a sum, so a re-sent day is harmless. -
Installer check:
getInstallSourceInfoon API 30+ (both the installing and the initiating package must becom.android.vending),getInstallerPackageNamebelow. Anything else shows on the ledger as "sideloaded — Google does not count this".
| Moment | What | Why |
|---|---|---|
| First launch after a Play install through a developerpact link | one pair request |
Install Referrer carried a token |
devpact:// deep link opened from the tester page |
one pair request |
the tester tapped Pair; works even if the app is only alive in the background |
| App comes to the foreground while paired | one heartbeat per unsent day (up to 7) plus today's partial value |
catch-up for offline days; retry policy is "next launch" |
Today's bucket first crosses thresholdSeconds (default 60 s) |
one heartbeat |
so the day counts even if the app is force-killed later |
App goes to the background (onStop) |
nothing | prefs are written; the value is sent on the next foreground start |
| Never paired, or organic / production install | nothing, ever | the referrer flag is written once and the SDK stays silent |
Server answered 410 {stop:true} |
nothing until a new deep-link token | the testing ended, or this SDK version was retired; PairActivity stays reachable, everything else is off |
Payload of a heartbeat, fixed struct (the server rejects unknown fields):
{v, pkg, installId, day, tzOffsetMin, fgSeconds, sessions, appVersionCode, sdk, osApi, installer, sentAt}
with Authorization: Bearer <installSecret>. Not sent: device model, locale,
IP geolocation, advertising or App Set ID, account information, app content.
The protocol is frozen at v: 1; the REST spec is at
developerpact.com/sdk/api. The client
half of that contract is two pure functions in Protocol.java
(pairAction, heartbeatAction), each row covered by JUnit:
| Request | Answer | SDK does |
|---|---|---|
pair |
200 {installSecret, serverDay, rules} |
stores the secret and threshold; paired |
pair |
410 {stop:true} |
stopped until a new deep-link token |
pair |
410 {stop:false, reason:"token_expired"} |
not paired; the tester page hands out a fresh token and a re-pair link |
pair |
404, 422, other 4xx |
the token is never presented again (referrer marked checked / pending token dropped) |
pair |
5xx, offline |
keeps the token, tries again on the next launch |
heartbeat |
200 {ok, activeToday, serverDay} |
marks the day's value as sent; a serverDay equal to the raw local day clears any clock correction |
heartbeat |
410 {stop:true} |
stopped until a new deep-link token |
heartbeat |
404 |
unknown installId: the pairing is forgotten, the next deep link pairs afresh |
heartbeat |
401 |
secret mismatch: state untouched, retried next launch |
heartbeat |
422 {error:"day out of window", serverDay} |
that day's bucket is dropped for good and the local day is re-synced: the difference between the device's day and serverDay (both in the device's zone) is stored as a whole-day clock correction, applied to every bucket from then on; the running session restarts under the corrected day |
heartbeat |
429, 5xx, offline |
state untouched, retried next launch |
No WorkManager, no foreground service, no push, no ACCESS_NETWORK_STATE, no
OkHttp, no androidx.lifecycle, no Kotlin stdlib requirement. The only dependency
is com.android.installreferrer:installreferrer:2.2. Native code is about
1,450 lines of plain Java, comments included, in
android/src/main/java/com/developerpact/sdk/ — small enough to read in one
sitting, which is the point. The exact per-file counts are in trust.json
(written by tool/trust.sh) and on developerpact.com/sdk.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE" />INTERNET is a normal permission this plugin adds (Flutter's release manifest
does not carry it). BIND_GET_INSTALL_REFERRER_SERVICE comes from Google's
installreferrer AAR, not from this plugin's manifest. Nothing else is merged.
tool/check-merged-manifest.sh fails the build if any other permission appears;
run it after cd example && flutter build apk --debug.
One line you will also see in a merged manifest is not ours:
<applicationId>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION. It is an
app-private, signature-level permission that androidx.core (a dependency of
the Flutter embedding itself) declares in every Flutter app, with or without
this plugin. The check script tolerates it only under your own package name.
<activity
android:name="com.developerpact.sdk.PairActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="${applicationId}" android:scheme="devpact" />
</intent-filter>
</activity>${applicationId} resolves to your app's ID at merge time (the same mechanism
the Facebook, Stripe and Braintree SDKs use). The activity is exported because a
browser has to reach it. Any app on the device can send this intent, but a token
only pairs with the testing it was minted for, and an install id stays with the
first testing it paired with: the server answers a token for any other testing
with a plain 404 — never with the kill switch — so a foreign intent can neither
move this install onto someone else's testing nor silence it. (A different
tester on the same phone clears the app's data first, which gives the device a
fresh install id.) The token is validated for format (22 characters of
base64url) before it is stored.
consumer-rules.pro is merged automatically. It keeps PairActivity (nothing
in code references it) and the Install Referrer AIDL stubs.
If your app already reads the Install Referrer (Firebase, AppsFlyer, Branch,
Adjust): multiple readers are fine, the value is not consumed. Installs through
the platform show up in those tools as utm_source=developerpact, which is why
the link uses the utm_ format instead of something opaque.
Copy this into the Data safety form of the app that embeds the SDK:
developerpact SDK — data disclosure. Collected: (1) Device or other IDs — a random install identifier generated by the SDK; (2) App activity → App interactions — daily foreground time in seconds. Collection is automatic (required, no in-app toggle) and occurs only for installs paired with a developerpact.com testing account (via a testing link or a pairing link); ordinary users generate no traffic. Purpose: Analytics; App functionality. Shared: Yes, with developerpact.com — it uses the data for its own purposes (a cross-developer ledger), so Google's service-provider exemption does not apply. Encrypted in transit: Yes. Deletion: Yes — testers delete their data at developerpact.com/me. Not collected: names, emails, location, device identifiers, advertising ID, contacts, app content. Merged Android permissions:
android.permission.INTERNET,com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE.Privacy-policy sentence: "During closed testing this app uses the developerpact SDK, which reports a random install identifier and daily foreground usage time to developerpact.com for testers who joined through that platform; see developerpact.com/privacy."
A more conservative reading adds Personal info → User IDs because the pairing token is tied to an account on the platform. The default is not to add it: the SDK never sees the account, only a random token that the server maps; the rationale is spelled out at developerpact.com/sdk/data-safety.
| How / why | |
|---|---|
| ✗ A day by screenshot or by saying so | days come only from heartbeats |
| ✗ Earning a day for someone else with their token | the token is shown only to the signed-in tester and is bound to their testing |
✗ Posting with a random installId |
404, no day; a wrong secret is 401 |
| ✗ Speeding time up | the per-day delta must be ≤ elapsed time + 90 s; day window; day cap |
| ✗ Looking Play-installed after a sideload (the lazy version) | installer reports "sideloaded" |
| ✓ Opening the app and leaving it on the table | the rule is "one minute a day"; a minimal_pattern flag (threshold + < 10 s every day, the same minute across apps) informs the owner |
| ✓ Actually opening it in an emulator | shows as "device unverified" once Play Integrity lands |
| ✓ Several Google accounts or devices | accepted risk in v1; a co_located flag (same installer + osApi + versionCode, near-identical heartbeat times) informs the owner |
✓ A determined forger calling pair from curl with their own token and generating heartbeats, faking installer |
accepted risk in v1 (12–50 developers who know each other; a forger only cheats their own partner and contradicts Google's own count). Play Integrity is planned, not promised |
The full, current version of this table lives at developerpact.com/sdk.
A published SDK version keeps reporting for at least 6 months after the
next version ships. The server can retire versions below a minimum; such a
build gets 410 {stop:true} and goes silent. Removing the dependency is the
local kill switch — nothing on the device survives it.
Once Google grants production access:
- Delete the
developerpactline frompubspec.yamland thestart()call. - Ship an update. The merged permission, the
PairActivityand thedevpact://scheme disappear with it. - Remove the two Data safety rows and the privacy-policy sentence.
If you would rather keep the SDK in the app for the next closed-testing round,
the declaration stays honest as written: installs that were never paired with
the platform do not touch the network, and paired installs stop the moment the
testing ends (410 {stop:true}).
flutter pub get
flutter analyze
flutter test # Dart stub + channel contract
cd example && flutter build apk --debug # proves manifest merging on a real host
cd .. && tool/check-merged-manifest.sh # permission allowlist + PairActivity present
cd example/android && ./gradlew :developerpact:testDebugUnitTest # JUnit: day rule, caps, referrer parsing, bodies, response tables
cd ../.. && tool/trust.sh > trust.json # line counts, reproducible archive hash, merged permissions
dart pub publish --dry-run # must print 0 warningsThe day rule, the caps, the retention window, token validation, referrer
parsing, the two request bodies and the response → action tables live in pure
Java (DayBuckets, Protocol) so they are covered by plain JUnit under
android/src/test/java; there is no Dart mirror of that logic.
trust.json is excluded from the published archive (it describes the archive,
so it cannot be inside it) and must be regenerated after any change to a
published file; developerpact.com's test suite re-runs the script and fails
when the checked-in manifest is stale.