developerpact SDK
A Flutter plugin that shows the owner of a closed-testing app which testers actually open it. Android only, version 0.1.1 on pub.dev.
Google asks a personal developer account for 12 testers over 14 days, and can reject the app for “insufficient tester engagement” without naming anyone. This plugin measures the one thing that settles it: how many seconds a day each paired tester actually has your app in the foreground. Both sides see the same number, so you know who is there before Google tells you nobody was.
It does nothing else. No login, no Cloud project, no Play Console permission, no manifest edit, no data from inside your app. An install that did not come through a developerpact link — every production user you will ever have — never touches the network.
- 1Add the package to
pubspec.yamldeveloperpact: ^0.1.1
Under
dependencies:, or let the tool do it:flutter pub add developerpact. Version 0.1.1 on pub.dev, MIT, one dependency of its own. - 2Call
DeveloperPact.start()inmain()import 'package:developerpact/developerpact.dart'; void main() { DeveloperPact.start(); // returns at once, never throws runApp(const MyApp()); }That is the whole API surface you need. It returns at once, never throws, and does nothing on a device it was not paired with.
- 3Paste the Data safety rows, then ship a closed-track build
Two rows in the Play Console form — Device or other IDs and App activity → App interactions — plus one sentence in your privacy policy. The exact text, the rows, and why each answer is what it is are on the Data safety page.
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.
Then verify from your own phone. Install your closed-track build, open the tester link for your own app and pair. Your app’s ledger has a self-test card that turns green on the first heartbeat from your install — that heartbeat is what marks the app SDK-verified, and it is the only check that the integration works.
Everything else, one click away
What merges into your appTwo permissions, and one line that is not ours
<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 2.2 AAR, not from this plugin’s manifest. Nothing else is merged. The dependency set is frozen at exactly one artifact, com.android.installreferrer:installreferrer:2.2; tool/check-merged-manifest.sh in the package fails the build if any other permission appears.
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.
Not used: WorkManager, foreground services, push, ACCESS_NETWORK_STATE, OkHttp, androidx.lifecycle, the Kotlin stdlib.consumer-rules.pro is merged automatically and keeps only PairActivity and the Install Referrer AIDL stubs.
The whole public API, for completeness — there is deliberately no track(), no setUser(), no properties, so there is no function an integration could leak app data through:
enum DeveloperPactStatus { paired, notPaired, unsupported }
class DeveloperPact {
static Future<DeveloperPactStatus> start({Uri? endpoint}); // endpoint is for SDK testing only
static bool get isSupported;
}The exported <activity>, and why it is safeThe one thing worth reading twice
<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 — so devpact://com.your.app/pair reaches this activity and nothing else. Your MainActivity and Flutter’s deep-link handling are untouched; the activity is invisible (Theme.NoDisplay), out of recents, and finished before it is drawn.
It is exported because a browser has to be able to open it. Any app on the device can send that 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 your install onto someone else’s testing nor silence it. The token is validated for format (22 characters of base64url) before it is stored, and the activity itself carries no data, only the tap.
When data is sent, and what is in itSeven moments, and nothing outside them
| Moment | What | Why |
|---|---|---|
| First launch after a Play install through a developerpact link | one pair request | the 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; the 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 an 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; PairActivity stays reachable, everything else is off |
Every heartbeat carries the day’s cumulative seconds; the server keeps the max and never sums, so a re-sent day is harmless. On the device: session cap 4 h, day cap 8 h, at most 7 unsent days kept, a checkpoint to SharedPreferences every 20 s while visible. “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.
Not sent, ever: device model, locale, IP geolocation, advertising or App Set ID, account information, app content.
iOS, macOS, web, desktopA stub that does nothing, on purpose
Android only, and only ever Android. There is no iOS implementation and there will not be one: Apple has no closed-testing prerequisite and no Install Referrer, and iOS code would enlarge the audit surface of the claim that this plugin can do nothing else.
On every other platform only the Dart stub compiles. DeveloperPact.start() returns unsupported at once, stores nothing, sends nothing, registers nothing and never throws — an app that targets both stores can leave the call unguarded. There is nothing to add to the App Store privacy form.
What a tester can and cannot fakeThe honest table, ✓ rows included
The platform is evidence for a person choosing whom to keep, not a proof system; the rows marked ✓ are open by design in v1 and say so.
| What | How / why | |
|---|---|---|
| ✗ cannot | A day by screenshot or by saying so | days come only from heartbeats |
| ✗ cannot | 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 |
| ✗ cannot | Posting with a random installId | 404, no day; a wrong secret is 401 |
| ✗ cannot | Speeding time up | the per-day delta must be ≤ elapsed time + 90 s; day window; day cap |
| ✗ cannot | Looking Play-installed after a sideload (the lazy version) | installer reports "sideloaded" |
| ✓ can | 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 |
| ✓ can | Actually opening it in an emulator | shows as "device unverified" once Play Integrity lands (Phase 4) |
| ✓ can | Several Google accounts or devices (Sybil) | accepted risk in v1; a co_located flag (same installer + osApi + versionCode, near-identical heartbeat times) informs the owner |
| ✓ can | A determined forger: calling pair from curl with their own token, generating heartbeats, faking installer | accepted risk in v1 — 12 to 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 |
Support policy, and removing it afterwards6 months, then three lines to take it out
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 until the app updates and re-pairs. Removing the dependency is the local kill switch — nothing on the device survives it: no service, no receiver, no scheduled work, no file outside the app’s own SharedPreferences.
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 it 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. Details on the Data safety page.
Trust artifactsLine counts, a reproducible hash, the merged permissions
The published archive is what the numbers describe, the line counts are measured, the hash is reproducible from a download, and this site’s test suite fails whenever any of it drifts from the package files.
- Package
- https://pub.dev/packages/developerpact — version 0.1.1, MIT
- Source
- https://github.com/cns-studio/developerpacta repository that holds this package and nothing else; the same files as the archive, for reading rather than hashing.
- Wire string
- every request carries
sdk: "flutter/0.1.0"it names the code build, not the release: 0.1.1 changed package metadata only and ships the same code as 0.1.0. - Size
- 1,469 lines of plain Java, comments included, and 60 lines of Dart — small enough to read in one sitting, which is the point
- Archive SHA-256
3f4897cdbde9934808b8aeb4827125328280cf35b2b713c6b7a2623a468a3a2e48 files, 174,080 bytes; ustar; entries sorted by path; mtime 0; uid/gid 0; mode 644; no compression. The same file set pub uploads, rebuilt in a form anyone can reproduce bit for bit — pub’s own gzipped archive is not byte-stable, so its hash is not the one to compare.- Merged permissions
- android.permission.INTERNET
- com.developerpact.developerpact_example.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
- com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE
read from the example app’s merged debug manifest; the third one is androidx.core’s app-private permission, present in every Flutter app. PairActivity merged: yes.- Support
- a published version keeps reporting for at least 6 months after the next one ships; a retired build hears
410 {stop:true}and goes silent
| File | Lines | What |
|---|---|---|
| …/sdk/Core.java | 324 | start() flow, prefs, executor, kill switch, day resync |
| …/sdk/Protocol.java | 228 | token rules, referrer parsing, request bodies, response tables |
| …/sdk/DayBuckets.java | 199 | per-local-day buckets, caps, 7-day retention |
| …/sdk/Foreground.java | 191 | started-activity counter, 20 s checkpoints |
| …/sdk/Sync.java | 171 | pair and heartbeat calls, response handling |
| …/sdk/Http.java | 95 | HttpURLConnection, 5 s timeout |
| …/sdk/DeveloperPactPlugin.java | 85 | Flutter method channel |
| …/sdk/Referrer.java | 82 | one-time Install Referrer read, 3 s timeout |
| …/sdk/Installer.java | 54 | getInstallSourceInfo / getInstallerPackageName |
| …/sdk/PairActivity.java | 40 | devpact:// entry point |
| File | Lines | What |
|---|---|---|
| lib/developerpact.dart | 60 | the whole Dart surface, stub included |
| android/src/main/AndroidManifest.xml | 31 | what merges into your manifest |
| android/build.gradle | 64 | the one dependency |
| android/consumer-rules.pro | 10 | keeps PairActivity and the referrer AIDL |
Measured by tool/trust.sh inside the package and checked in as trust.json; this site's test suite re-runs the script against the package files and fails when a number here has drifted.
Reproduce the hash
From pub.dev, which needs no repository, no git and no account — the archive for a version never changes. tool/trust.sh is inside it; python3 is all it needs.
mkdir developerpact-0.1.1 && curl -sL https://pub.dev/api/archives/developerpact-0.1.1.tar.gz | tar xz -C developerpact-0.1.1 cd developerpact-0.1.1 && bash tool/trust.sh | grep sha256
Or from the source repository, which is the same files with their history. Its branch moves with each release, so read it there and hash the published archive above.
git clone https://github.com/cns-studio/developerpact && cd developerpact bash tool/trust.sh | grep sha256
Not a Flutter app?Two POSTs; the plugin has no privileged access
The plugin is a convenience, not a gatekeeper: everything it does is two POSTs to https://developerpact.com/api/sdk/v1/… with content-type: application/json, and the heartbeat’s Authorization: Bearer <installSecret>. Any stack that can read the Install Referrer (or accept a devpact:// deep link) and count foreground seconds can report the same numbers. The full contract, the status codes and the generated JSON schemas are on the REST API page. The bodies below are parsed through the server’s own schemas in the test suite, so they are what the server accepts, not an approximation.
POST /pair
{
"v": 1,
"pkg": "com.example.myapp",
"token": "Qm9yaW5nVG9rZW4xMjM0NQ",
"installId": "5f0c2a1e-9b3d-4c7a-8e21-6d4f1b2a9c03",
"pairedVia": "referrer",
"installer": "com.android.vending",
"initiatingInstaller": "com.android.vending",
"appVersionCode": 42,
"sdk": "flutter/0.1.0",
"osApi": 34,
"tzOffsetMin": 180
}200 →
{
"installSecret": "K3q9ZbQ2xW8vL5nR7tY1uC4eH6jM0pA8sD2fG5hJ9kL",
"serverDay": "2026-09-06",
"rules": {
"thresholdSeconds": 60
}
}POST /heartbeat
{
"v": 1,
"pkg": "com.example.myapp",
"installId": "5f0c2a1e-9b3d-4c7a-8e21-6d4f1b2a9c03",
"day": "2026-09-06",
"tzOffsetMin": 180,
"fgSeconds": 97,
"sessions": 2,
"appVersionCode": 42,
"sdk": "flutter/0.1.0",
"osApi": 34,
"installer": "com.android.vending",
"sentAt": "2026-09-06T11:02:13.000Z"
}200 →
{
"ok": true,
"activeToday": true,
"serverDay": "2026-09-06"
}Native Android, React Native, Unity or Kotlin Multiplatform wrappers are not published yet — they arrive when a member needs one. Say so in the box at the bottom of this page.
Wishlist
What would you need to join? (e.g. React Native, Unity, a companion app)
One line. Nothing here is planned; this is how demand gets counted, and it is read only by the operator.
Sign in to send one — wishes are counted per member.