From 289ab69be00117ba57636b161459fc09e20a9e71 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:03:51 +0800 Subject: [PATCH] refactor!: group UTM getters under utm and rename installVersion to versionAtInstall --- packages/wherefrom/CHANGELOG.md | 13 +++ packages/wherefrom/TESTING.md | 2 +- .../wherefrom/ReferrerClientPort.kt | 2 +- .../wherefrom/ReferrerOutcome.kt | 4 +- .../wherefrom/ReferrerMappingTest.kt | 2 +- .../wherefrom/ReferrerReaderTest.kt | 2 +- packages/wherefrom/doc/api.md | 5 +- packages/wherefrom/doc/consume-once.md | 2 +- packages/wherefrom/doc/migration.md | 2 +- packages/wherefrom/example/lib/main.dart | 2 +- .../wherefrom/lib/src/install_referrer.dart | 85 ++++++++++++++----- .../wherefrom/lib/src/method_channel.dart | 6 +- packages/wherefrom/lib/src/result.dart | 4 +- .../test/install_attribution_test.dart | 12 +-- .../install_referrer_never_throws_test.dart | 10 +-- .../wherefrom/test/install_referrer_test.dart | 13 +-- .../wherefrom/test/method_channel_test.dart | 6 +- 17 files changed, 117 insertions(+), 55 deletions(-) diff --git a/packages/wherefrom/CHANGELOG.md b/packages/wherefrom/CHANGELOG.md index ee513d9..49b9144 100644 --- a/packages/wherefrom/CHANGELOG.md +++ b/packages/wherefrom/CHANGELOG.md @@ -1,3 +1,16 @@ +## [Unreleased] + +- **Breaking:** the UTM getters moved into a small value class. + `referrer.utmSource`, `utmMedium`, `utmCampaign`, `utmContent` and + `utmTerm` are now `referrer.utm.source`, `.medium`, `.campaign`, + `.content` and `.term` (`UtmParameters`), with the same `(not set)` + handling. The `utm_*` query keys themselves are unchanged. +- **Breaking:** `Attributed.installVersion` (and the `classifyReferrer` + parameter of the same name) is now `versionAtInstall`. It still carries + Play's `ReferrerDetails.getInstallVersion()`. +- No stored state changes: the consume-once marker, the failed-read count + and the first-start record keep their file names and contents. + ## 0.3.0 - Renamed from `imaji_install_attribution`. To migrate, replace the diff --git a/packages/wherefrom/TESTING.md b/packages/wherefrom/TESTING.md index ef0e25e..758c369 100644 --- a/packages/wherefrom/TESTING.md +++ b/packages/wherefrom/TESTING.md @@ -161,7 +161,7 @@ the Play Store app directly. when it has an install record but no campaign: the store page was opened without a usable `referrer`, or a testing track did not forward it. The plugin classifies a referrer that contains nothing but `(not set)` / empty - `utm_*` values as `Organic`, and `referrer.utmSource` returns `null` for + `utm_*` values as `Organic`, and `referrer.utm.source` returns `null` for `(not set)`. The raw string is still in `referrer.raw`. - **`utm_source=google-play&utm_medium=organic`** — Play's default for an install with no referrer (store search, side-load). `Organic`. diff --git a/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerClientPort.kt b/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerClientPort.kt index bfc5af3..9f92a63 100644 --- a/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerClientPort.kt +++ b/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerClientPort.kt @@ -53,7 +53,7 @@ internal class PlayReferrerClient(context: Context) : ReferrerClientPort { installBeginSeconds = details.installBeginTimestampSeconds, clickServerSeconds = details.referrerClickTimestampServerSeconds, installBeginServerSeconds = details.installBeginTimestampServerSeconds, - installVersion = details.installVersion, + versionAtInstall = details.installVersion, instantExperienceLaunched = details.googlePlayInstantParam, ) } diff --git a/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerOutcome.kt b/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerOutcome.kt index 66a4cf9..2e7671f 100644 --- a/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerOutcome.kt +++ b/packages/wherefrom/android/src/main/kotlin/io/github/saimskywalker/wherefrom/ReferrerOutcome.kt @@ -29,7 +29,7 @@ internal data class RawReferrerDetails( val installBeginSeconds: Long, val clickServerSeconds: Long, val installBeginServerSeconds: Long, - val installVersion: String?, + val versionAtInstall: String?, val instantExperienceLaunched: Boolean, ) @@ -45,7 +45,7 @@ internal sealed class ReferrerOutcome { "installBeginSeconds" to details.installBeginSeconds, "clickServerSeconds" to details.clickServerSeconds, "installBeginServerSeconds" to details.installBeginServerSeconds, - "installVersion" to details.installVersion, + "versionAtInstall" to details.versionAtInstall, "instantExperienceLaunched" to details.instantExperienceLaunched, ) } diff --git a/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerMappingTest.kt b/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerMappingTest.kt index 0c48f7f..a1587d7 100644 --- a/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerMappingTest.kt +++ b/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerMappingTest.kt @@ -80,7 +80,7 @@ class ReferrerMappingTest { installBeginSeconds = 20, clickServerSeconds = 11, installBeginServerSeconds = 21, - installVersion = "1.0.0", + versionAtInstall = "1.0.0", instantExperienceLaunched = false, ) val outcome = ReferrerMapping.readDetails { raw } diff --git a/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerReaderTest.kt b/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerReaderTest.kt index 303850f..ae3dfdc 100644 --- a/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerReaderTest.kt +++ b/packages/wherefrom/android/src/test/kotlin/io/github/saimskywalker/wherefrom/ReferrerReaderTest.kt @@ -14,7 +14,7 @@ class ReferrerReaderTest { installBeginSeconds = 2, clickServerSeconds = 3, installBeginServerSeconds = 4, - installVersion = null, + versionAtInstall = null, instantExperienceLaunched = false, ) diff --git a/packages/wherefrom/doc/api.md b/packages/wherefrom/doc/api.md index 1834e30..d3e6b5c 100644 --- a/packages/wherefrom/doc/api.md +++ b/packages/wherefrom/doc/api.md @@ -31,8 +31,9 @@ To act on the referrer only once, use `readOnce()` or `claimOnce()`; see - `raw`: the string exactly as Play returned it. - `parameters`, `referrer['key']`: values decoded **exactly once**. -- `utmSource`, `utmMedium`, `utmCampaign`, `utmContent`, `utmTerm`: `null` - when absent or `(not set)`. +- `utm`: a `UtmParameters` with `source`, `medium`, `campaign`, `content` + and `term` (`utm_source` ... `utm_term`), each `null` when absent or + `(not set)`. - `isOrganic`: the rule `read()` uses. - `deepLink({key = 'utm_content', required allow, hosts = const []})`: the in-app path, or `null`. Only paths under an allowlisted prefix pass diff --git a/packages/wherefrom/doc/consume-once.md b/packages/wherefrom/doc/consume-once.md index a18848e..3b020a4 100644 --- a/packages/wherefrom/doc/consume-once.md +++ b/packages/wherefrom/doc/consume-once.md @@ -30,7 +30,7 @@ returned, so nothing can route or show a banner about it. ```dart final result = await InstallAttribution.readOnce( maxClickAge: const Duration(days: 7), // your link's lifetime - pastInstallFlow: () => session.isSignedIn && firstRunDoneOnThisDevice, + pastInstallFlow: () => session.isSignedIn && onboardedOnThisDevice, ); ``` diff --git a/packages/wherefrom/doc/migration.md b/packages/wherefrom/doc/migration.md index 23472b7..873b1ec 100644 --- a/packages/wherefrom/doc/migration.md +++ b/packages/wherefrom/doc/migration.md @@ -25,7 +25,7 @@ Play's `ReferrerDetails` fields map to `Attributed` like this: `getInstallReferrer()` → `referrer.raw`; `getReferrerClickTimestampSeconds()` → `clickedAt` (a UTC `DateTime`, `null` when Play reports 0); `getInstallBeginTimestampSeconds()` → `installBeganAt`; `getInstallVersion()` -→ `installVersion`; the `*ServerSeconds` values → `clickedAtServer` / +→ `versionAtInstall`; the `*ServerSeconds` values → `clickedAtServer` / `installBeganAtServer`. ```dart diff --git a/packages/wherefrom/example/lib/main.dart b/packages/wherefrom/example/lib/main.dart index 878342e..bceb9e3 100644 --- a/packages/wherefrom/example/lib/main.dart +++ b/packages/wherefrom/example/lib/main.dart @@ -205,7 +205,7 @@ class _ResultCard extends StatelessWidget { 'Attributed (Google Play)', [ 'referrer: ${referrer?.raw}', - 'utm_source: ${referrer?.utmSource}', + 'utm_source: ${referrer?.utm.source}', 'clicked at: $clickedAt', 'deep link: ${referrer?.deepLink(allow: allowedPaths) ?? 'none (not allowlisted)'}', ], diff --git a/packages/wherefrom/lib/src/install_referrer.dart b/packages/wherefrom/lib/src/install_referrer.dart index f7712f4..b09f9e9 100644 --- a/packages/wherefrom/lib/src/install_referrer.dart +++ b/packages/wherefrom/lib/src/install_referrer.dart @@ -54,25 +54,9 @@ class InstallReferrer { /// The decoded value for [key], or `null` when it is absent. String? operator [](String key) => parameters[key]; - /// `utm_source`, or `null` when absent or `(not set)`. - String? get utmSource => _utm('utm_source'); - - /// `utm_medium`, or `null` when absent or `(not set)`. - String? get utmMedium => _utm('utm_medium'); - - /// `utm_campaign`, or `null` when absent or `(not set)`. - String? get utmCampaign => _utm('utm_campaign'); - - /// `utm_content`, or `null` when absent or `(not set)`. - String? get utmContent => _utm('utm_content'); - - /// `utm_term`, or `null` when absent or `(not set)`. - String? get utmTerm => _utm('utm_term'); - - String? _utm(String key) { - final value = parameters[key]; - return isNotSet(value) ? null : value; - } + /// The standard UTM parameters, each `null` when absent, empty, or + /// `(not set)`. + UtmParameters get utm => UtmParameters.fromParameters(parameters); /// Whether this referrer carries no campaign at all. /// @@ -331,6 +315,69 @@ class InstallReferrer { String toString() => 'InstallReferrer($raw)'; } +/// The five standard UTM parameters of a referrer (`utm_source`, +/// `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`), read through +/// [InstallReferrer.utm]. +/// +/// Each value is as decoded once by [InstallReferrer.parameters], or `null` +/// when the key is absent, empty, or Play's `(not set)`. +@immutable +final class UtmParameters { + /// Creates a set of UTM values directly, for example in a test. + const UtmParameters({ + this.source, + this.medium, + this.campaign, + this.content, + this.term, + }); + + /// Reads the UTM values out of decoded referrer [parameters]. + UtmParameters.fromParameters(Map parameters) + : source = _value(parameters, 'utm_source'), + medium = _value(parameters, 'utm_medium'), + campaign = _value(parameters, 'utm_campaign'), + content = _value(parameters, 'utm_content'), + term = _value(parameters, 'utm_term'); + + /// `utm_source`. + final String? source; + + /// `utm_medium`. + final String? medium; + + /// `utm_campaign`. + final String? campaign; + + /// `utm_content`. + final String? content; + + /// `utm_term`. + final String? term; + + static String? _value(Map parameters, String key) { + final value = parameters[key]; + return InstallReferrer.isNotSet(value) ? null : value; + } + + @override + bool operator ==(Object other) => + other is UtmParameters && + other.source == source && + other.medium == medium && + other.campaign == campaign && + other.content == content && + other.term == term; + + @override + int get hashCode => Object.hash(source, medium, campaign, content, term); + + @override + String toString() => + 'UtmParameters(source: $source, medium: $medium, ' + 'campaign: $campaign, content: $content, term: $term)'; +} + /// How [InstallReferrer] decodes each referrer value. Either way a value is /// decoded exactly once. enum ReferrerDecoding { diff --git a/packages/wherefrom/lib/src/method_channel.dart b/packages/wherefrom/lib/src/method_channel.dart index f1936f3..c20b200 100644 --- a/packages/wherefrom/lib/src/method_channel.dart +++ b/packages/wherefrom/lib/src/method_channel.dart @@ -112,7 +112,7 @@ class MethodChannelInstallAttribution extends WherefromPlatform { installBeganAt: _seconds(reply['installBeginSeconds']), clickedAtServer: _seconds(reply['clickServerSeconds']), installBeganAtServer: _seconds(reply['installBeginServerSeconds']), - installVersion: reply['installVersion'] as String?, + versionAtInstall: reply['versionAtInstall'] as String?, installSource: installSource, ); case 'token': @@ -152,7 +152,7 @@ class MethodChannelInstallAttribution extends WherefromPlatform { DateTime? installBeganAt, DateTime? clickedAtServer, DateTime? installBeganAtServer, - String? installVersion, + String? versionAtInstall, InstallSource? installSource, }) { final referrer = InstallReferrer(raw ?? ''); @@ -170,7 +170,7 @@ class MethodChannelInstallAttribution extends WherefromPlatform { installBeganAt: installBeganAt, clickedAtServer: clickedAtServer, installBeganAtServer: installBeganAtServer, - installVersion: installVersion, + versionAtInstall: versionAtInstall, installSource: installSource, ); } diff --git a/packages/wherefrom/lib/src/result.dart b/packages/wherefrom/lib/src/result.dart index d09a58d..456ebb0 100644 --- a/packages/wherefrom/lib/src/result.dart +++ b/packages/wherefrom/lib/src/result.dart @@ -115,7 +115,7 @@ final class Attributed extends InstallAttributionResult { this.installBeganAt, this.clickedAtServer, this.installBeganAtServer, - this.installVersion, + this.versionAtInstall, super.installSource, }); @@ -142,7 +142,7 @@ final class Attributed extends InstallAttributionResult { final DateTime? installBeganAtServer; /// The app version that was first installed, when Play reports it. - final String? installVersion; + final String? versionAtInstall; @override String toString() => diff --git a/packages/wherefrom/test/install_attribution_test.dart b/packages/wherefrom/test/install_attribution_test.dart index ce5a36e..25e9cee 100644 --- a/packages/wherefrom/test/install_attribution_test.dart +++ b/packages/wherefrom/test/install_attribution_test.dart @@ -194,7 +194,7 @@ void main() { clickedAtServer: fortyDaysAgo, installBeganAt: fortyDaysAgo, installBeganAtServer: fortyDaysAgo, - installVersion: '1.0.0', + versionAtInstall: '1.0.0', ); setUp(() { @@ -227,7 +227,7 @@ void main() { referrer: oldLink.referrer, clickedAt: installed, installBeganAt: installed, - installVersion: '2.0.0', + versionAtInstall: '2.0.0', ); fake.facts = InstallFacts( firstInstalledAt: installed, @@ -439,18 +439,18 @@ void main() { () async { // After a reinstall the plugin's marker is gone (it is excluded from // backups), but Auto Backup restored the app's SharedPreferences, - // including its own "first run done" flag and a remembered session. - final restoredPrefs = {'signedIn': true, 'firstRunDone': true}; + // including its own "onboarded" flag and a remembered session. + final restoredPrefs = {'signedIn': true, 'onboarded': true}; // The recipe keeps its first-run flag in no-backup storage instead; // a reinstall starts it empty. final noBackupStore = {}; bool recipe() => restoredPrefs['signedIn'] == true && - (noBackupStore['firstRunDone'] ?? false); + (noBackupStore['onboarded'] ?? false); bool naive() => restoredPrefs['signedIn'] == true && - restoredPrefs['firstRunDone'] == true; + restoredPrefs['onboarded'] == true; expect( await InstallAttribution.readOnce(pastInstallFlow: recipe), diff --git a/packages/wherefrom/test/install_referrer_never_throws_test.dart b/packages/wherefrom/test/install_referrer_never_throws_test.dart index 5931679..2ebc561 100644 --- a/packages/wherefrom/test/install_referrer_never_throws_test.dart +++ b/packages/wherefrom/test/install_referrer_never_throws_test.dart @@ -22,11 +22,11 @@ void main() { ); }); r.isOrganic; - r.utmSource; - r.utmMedium; - r.utmCampaign; - r.utmContent; - r.utmTerm; + r.utm.source; + r.utm.medium; + r.utm.campaign; + r.utm.content; + r.utm.term; r.toString(); r.hashCode; check(r.deepLink(allow: allow, hosts: hosts), raw); diff --git a/packages/wherefrom/test/install_referrer_test.dart b/packages/wherefrom/test/install_referrer_test.dart index 801eea1..1745c88 100644 --- a/packages/wherefrom/test/install_referrer_test.dart +++ b/packages/wherefrom/test/install_referrer_test.dart @@ -28,16 +28,17 @@ void main() { expect(r.parameters.containsKey(''), isFalse); }); - test('utm getters treat "(not set)" as absent', () { + test('utm treats "(not set)" as absent', () { final r = InstallReferrer( 'utm_source=(not%20set)&utm_medium=(not%20set)&utm_campaign=spring', ); expect(r['utm_source'], '(not set)'); - expect(r.utmSource, isNull); - expect(r.utmMedium, isNull); - expect(r.utmCampaign, 'spring'); - expect(r.utmContent, isNull); - expect(r.utmTerm, isNull); + expect(r.utm.source, isNull); + expect(r.utm.medium, isNull); + expect(r.utm.campaign, 'spring'); + expect(r.utm.content, isNull); + expect(r.utm.term, isNull); + expect(r.utm, const UtmParameters(campaign: 'spring')); }); }); diff --git a/packages/wherefrom/test/method_channel_test.dart b/packages/wherefrom/test/method_channel_test.dart index a0ed098..7d1a5f1 100644 --- a/packages/wherefrom/test/method_channel_test.dart +++ b/packages/wherefrom/test/method_channel_test.dart @@ -38,12 +38,12 @@ void main() { 'installBeginSeconds': 1700000060, 'clickServerSeconds': 1700000001, 'installBeginServerSeconds': 0, - 'installVersion': '1.2.3', + 'versionAtInstall': '1.2.3', 'instantExperienceLaunched': false, }) as Attributed; expect(result.source, AttributionSource.googlePlay); - expect(result.referrer!.utmSource, 'newsletter'); + expect(result.referrer!.utm.source, 'newsletter'); expect( result.clickedAt, DateTime.fromMillisecondsSinceEpoch(1700000000000, isUtc: true), @@ -51,7 +51,7 @@ void main() { expect(result.installBeganAt!.isAfter(result.clickedAt!), isTrue); expect(result.clickedAtServer, isNotNull); expect(result.installBeganAtServer, isNull, reason: '0 means unknown'); - expect(result.installVersion, '1.2.3'); + expect(result.versionAtInstall, '1.2.3'); }); test('the default Play referrer is Organic', () {