# developerpact reporter — Flutter integration Package `developerpact` 0.1.1 · [https://developerpact.com/sdk](https://developerpact.com/sdk) · this file: https://developerpact.com/sdk/flutter.md This document is complete on its own. It says what the developerpact reporter is, why an app under Google Play closed testing adds it, every step to add it to a Flutter app, which steps need a human in Play Console, and the one check that proves it works. The whole integration is one dependency line and one call; the rest is Play Console. ## Why this exists Google Play requires a personal developer account to run a closed test with **12 testers opted in for 14 consecutive days** before the app may go to production, and it can reject the application for "insufficient tester engagement" without naming anyone. Play Console shows a count of opted-in testers and nothing per tester: no screen and no API says which of them still opens the app. [developerpact.com](https://developerpact.com) is an exchange where developers test each other's closed-test apps. A small open-source **reporter** inside each app measures how many seconds a day each paired tester has the app in the foreground, on the tester's own device, and reports one number per tester per day. 60 foreground seconds make a day count. Every owner sees which of their testers is actually there, drops the ones who are not, and gets replacements; and the people they test in return see the same numbers about them. **What the reporter is and is not.** Android only. No login, no Cloud project, no Play Console permission, no data from inside the app. It sends a random install id and daily foreground seconds, and only for installs that were paired through a developerpact link: an install that did not come through such a link — every production user the app will ever have — never touches the network. A paired install stops the moment its testing ends. **How an install is paired.** A tester opens their personal install link on their phone. For a fresh install the link sends the phone to Play with a referrer that carries a one-time token; on first launch the reporter reads the Play Install Referrer once and pairs. For an app the tester already has, the tester page opens a `devpact://` deep link carrying the same token. The server binds the token to the tester and to the app's registered package name — a token presented from any other package is refused — so the owner never configures anything and the reporter never learns who is holding the phone. ## Before you start You need: - An Android app with a **closed testing track** in Play Console. The reporter does nothing on iOS, web or desktop. - The app's **applicationId** (the Android package name, from `android/app/build.gradle` or `build.gradle.kts`). It must be exactly what is registered on developerpact.com. - An account on developerpact.com (any Google account) and the app registered there at [https://developerpact.com/apps/new](https://developerpact.com/apps/new): package name, how many testers, the rules a tester signs up to. Registration takes about two minutes and needs no key or token in the app. - Access to Play Console, for the Data safety form and for uploading the closed-track build. - An Android phone whose Play Store account is on the app's tester list, for the final check. **If you are an AI coding agent:** Steps 1 and 2 are code and a build; an agent can do them unattended. The Play Console steps and the phone test need the human — when you reach them, stop and hand over the exact text, table and instructions from this file rather than paraphrasing them. There is no configuration to invent: no key, no app id, no initialisation arguments, no manifest edit of your own. ## Step 1 — Add the package to `pubspec.yaml` Under `dependencies:`: ```yaml developerpact: ^0.1.1 ``` Or let the tool do it: ```sh flutter pub add developerpact ``` Version 0.1.1 on pub.dev, MIT, one dependency of its own (`com.android.installreferrer:installreferrer:2.2`). Then `flutter pub get`. ## Step 2 — Call `DeveloperPact.start()` in `main()` ```dart 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. `start()` returns at once, never throws, and does nothing on a device it was not paired with. Put it before `runApp`; do not wrap it in a platform check — on iOS, macOS, web, Windows and Linux only the Dart stub compiles and it returns `unsupported` without storing, sending or registering anything, so an app that targets both stores leaves the call unguarded. 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: ```dart enum DeveloperPactStatus { paired, notPaired, unsupported } class DeveloperPact { static Future start({Uri? endpoint}); // endpoint is for SDK testing only static bool get isSupported; } ``` The `endpoint` argument is for testing the SDK itself. Do not pass it. ## Step 3 — Declare it in Play Console (Data safety) — needs the human Google makes the host developer declare what every SDK inside the build collects. These two rows are the whole of what this reporter sends. **Where the form is:** Play Console → All apps → your app → Monitor and improve → Policy and programs → App content → Data safety → Manage URL pattern (the two ids are yours; the console fills them in): ```text play.google.com/console/u/0/developers//app//app-content/data-privacy-security ``` In the left menu the trail is collapsed by default: open **Monitor and improve**, then **Policy and programs**, and **App content** is inside it. **The form is a wizard** with these screens, in order: *Data collection and security* → *Data types* → *Data usage and handling* → *Preview* → *Submit*. On **Data types** the list is grouped by category — tick the two rows below where the table says. Every type you tick comes back on **Data usage and handling** in a dialog of its own, which asks the remaining columns in this order: | Data type | Collected | Shared | Processed ephemerally | Required or optional | Purposes | | --- | --- | --- | --- | --- | --- | | Device or other IDs › Device or other IDs (on the Data types screen) | Yes | Yes | No | Required | Analytics and App functionality | | App activity › App interactions (on the Data types screen) | Yes | Yes | No | Required | Analytics and App functionality | Every other data type in the form: **No**. Encrypted in transit: **Yes**. Deletion available: **Yes**. **Paste this into the form's free-text disclosure field:** > 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. **Add this sentence to the privacy policy you already publish:** > 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. For the permissions part of the same form, these are the merged Android permissions: ```xml ``` Why *Shared* is Yes: Google exempts a "service provider" that processes data only on the developer's behalf. developerpact.com does more than that — the same numbers feed a ledger every other member reads — so the exemption does not apply and the honest answer is Yes. A more conservative reading also ticks *Personal info → User IDs*; it is not needed, because the reporter never receives an account, an e-mail or a name, only a random token and a random install id, but adding it costs nothing and contradicts nothing above. ## Step 4 — Ship a closed-track build — needs the human Bump `versionCode`, build the release bundle, upload it to the **closed testing** track in Play Console and roll it out. Nothing pairs until an install arrives through a developerpact link, so the build is safe to release to the track at once. Testers who already have the app must **update to this build from the Play Store** and then tap *Pair this phone* on their tester page — Play never delivers a referrer to an app that is already installed, and a build without the pairing entry point cannot pair at all. ## Step 5 — Verify from your own phone — the only check, needs the human There is one check, and it is not a log line: **the first heartbeat from your own install.** 1. Sign in at [https://developerpact.com/apps](https://developerpact.com/apps) and open the app's ledger page. 2. Press **Start self-test**. It opens a self-test card with your own install link. (The self-test is not counted as a tester.) 3. On your Android phone, signed into the Play Store with an account that is on the app's closed-test list: opt in to the test, open the install link from the card and install the closed-track build from the store page it opens. If the app is already installed, update it in the Play Store first, then tap **Pair this phone** on the card. 4. Open the app and keep it in the foreground for at least 60 seconds. The self-test card turns green on the first heartbeat and the app is marked **SDK verified**. Only a verified app can take testers; until then the tester page refuses to open seats. You are also, at that point, looking at exactly what your testers will see. Paired but nothing appears? The usual causes, in order: - The install was not a Play install. Both installer fields must be `com.android.vending`; a build installed by hand (`adb install`, a shared APK) pairs and reports but never earns an active day. That is the rule, not a bug. - The app was not in the foreground for `thresholdSeconds` (60 s by default) yet. - `DeveloperPact.start()` is not reached on the Android build — for example it was placed after a `runApp` that never returns, or inside a platform check that excludes Android. ## What merges into your app Two permissions, verbatim: ```xml ``` `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. One line you will also see in a merged manifest is not ours: `.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`, an app-private permission that `androidx.core` declares in every Flutter app, with or without this plugin. One exported activity, the pairing entry point for installs that predate the testing link: ```xml ``` `${applicationId}` resolves to your app's id at merge time, so `devpact:///pair` reaches this activity and nothing else. Your `MainActivity` and Flutter's deep-link handling are untouched; the activity is invisible, out of recents, and finished before it is drawn. It is exported because a browser has to be able to open it; a token only pairs with the testing it was minted for, so a foreign intent can neither move your install onto someone else's testing nor silence it. 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. ## When data is sent | 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 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. 60 seconds make the day count. **Not sent, ever:** device model, locale, IP geolocation, advertising or App Set ID, account information, app content. ## Removing it after production Once Google grants production access: delete the `developerpact` line from `pubspec.yaml` and the `start()` call, ship an update, and take the two Data safety rows and the privacy-policy sentence back out. The merged permission, the `PairActivity` and the `devpact://` scheme leave with the dependency. Nothing on the device survives it: no service, no receiver, no scheduled work, no file outside the app's own SharedPreferences. Keeping it for the next closed-testing round is fine, and the declaration stays honest as written: an install that was never paired never touches the network, and a paired install stops the moment its testing ends. ## Done when - [ ] `developerpact` is under `dependencies:` in `pubspec.yaml` and `flutter pub get` succeeds. - [ ] `DeveloperPact.start()` is called in `main()` before `runApp`, on every platform, unguarded. - [ ] The Android build compiles. (The merged manifest gains `INTERNET`, the referrer permission and `PairActivity`; nothing else.) - [ ] Human: the two Data safety rows are declared and the disclosure text is pasted. - [ ] Human: the privacy-policy sentence is published. - [ ] Human: a closed-track build with the reporter is live. - [ ] Human: the self-test card on the app's ledger is green and the app shows as SDK verified. ## Where everything else is - The integration page, with the same steps and copy buttons: [https://developerpact.com/sdk](https://developerpact.com/sdk) - The package on pub.dev: [https://pub.dev/packages/developerpact](https://pub.dev/packages/developerpact) - The reporter's source, a public repository that holds the package and nothing else: [https://github.com/cns-studio/developerpact](https://github.com/cns-studio/developerpact) - What a tester can and cannot fake, the support policy, and the trust artifacts (line counts, a reproducible archive hash, the merged permissions): [https://developerpact.com/sdk#trust](https://developerpact.com/sdk#trust) - Privacy policy and terms: [https://developerpact.com/privacy](https://developerpact.com/privacy), [https://developerpact.com/terms](https://developerpact.com/terms)