developerpact 0.1.0 copy "developerpact: ^0.1.0" to clipboard
developerpact: ^0.1.0 copied to clipboard

PlatformAndroid

Closed-testing engagement reporter for developerpact.com. Reports a random install id and daily foreground seconds for testers paired through the platform; ordinary users generate no traffic. Android only.

developerpact #

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/developer-pact (sdk/developerpact), tagged per release. License: MIT. Trust artifacts (line counts, reproducible archive hash, merged permissions): developerpact.com/sdk, generated by tool/trust.sh in this package.

Integration #

dependencies:
  developerpact: ^0.1.0
import '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.

API #

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.

What it does #

  1. 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. If utm_source is not developerpact — 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 own PairActivity (invisible, gone before it is drawn). It validates the token format, pairs directly, brings your launcher activity forward and finishes. Your MainActivity and Flutter's deep-link handling are untouched.

    POST /api/sdk/v1/pair returns a per-install secret (stored on the server only as a SHA-256 hash) and the app's thresholdSeconds.

  2. Measurement: an Application.registerActivityLifecycleCallbacks started-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.

  3. Heartbeat: POST /api/sdk/v1/heartbeat with the day's cumulative seconds. The server keeps the max, never a sum, so a re-sent day is harmless.

  4. Installer check: getInstallSourceInfo on API 30+ (both the installing and the initiating package must be com.android.vending), getInstallerPackageName below. Anything else shows on the ledger as "sideloaded — Google does not count this".

When is data sent #

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.

What each answer does to local state #

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.

What ends up in your app #

Merged permissions, verbatim #

<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.

Merged <activity> block #

<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.

ProGuard / R8 #

consumer-rules.pro is merged automatically. It keeps PairActivity (nothing in code references it) and the Install Referrer AIDL stubs.

Other referrer readers #

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.

Data safety (Play Console) and privacy policy #

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.

What a tester can and cannot fake #

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.

Support policy #

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.

Removing the SDK after production #

Once Google grants production access:

  1. Delete the developerpact line from pubspec.yaml and the start() call.
  2. Ship an update. The merged permission, the PairActivity and the devpact:// scheme disappear with it.
  3. 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}).

Development #

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 warnings

The 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.

0
likes
150
points
--
downloads

Documentation

API reference

Publisher

verified publisherdeveloperpact.com

Weekly Downloads

Closed-testing engagement reporter for developerpact.com. Reports a random install id and daily foreground seconds for testers paired through the platform; ordinary users generate no traffic. Android only.

Homepage
Repository (GitHub)
View/report issues

Topics

#google-play #closed-testing #android #analytics

License

MIT (license)

Dependencies

flutter

More

Packages that depend on developerpact

Packages that implement developerpact