Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/wherefrom/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/wherefrom/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ReferrerReaderTest {
installBeginSeconds = 2,
clickServerSeconds = 3,
installBeginServerSeconds = 4,
installVersion = null,
versionAtInstall = null,
instantExperienceLaunched = false,
)

Expand Down
5 changes: 3 additions & 2 deletions packages/wherefrom/doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/wherefrom/doc/consume-once.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
```

Expand Down
2 changes: 1 addition & 1 deletion packages/wherefrom/doc/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/wherefrom/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)'}',
],
Expand Down
85 changes: 66 additions & 19 deletions packages/wherefrom/lib/src/install_referrer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<String, String> 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<String, String> 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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/wherefrom/lib/src/method_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -152,7 +152,7 @@ class MethodChannelInstallAttribution extends WherefromPlatform {
DateTime? installBeganAt,
DateTime? clickedAtServer,
DateTime? installBeganAtServer,
String? installVersion,
String? versionAtInstall,
InstallSource? installSource,
}) {
final referrer = InstallReferrer(raw ?? '');
Expand All @@ -170,7 +170,7 @@ class MethodChannelInstallAttribution extends WherefromPlatform {
installBeganAt: installBeganAt,
clickedAtServer: clickedAtServer,
installBeganAtServer: installBeganAtServer,
installVersion: installVersion,
versionAtInstall: versionAtInstall,
installSource: installSource,
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/wherefrom/lib/src/result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ final class Attributed extends InstallAttributionResult {
this.installBeganAt,
this.clickedAtServer,
this.installBeganAtServer,
this.installVersion,
this.versionAtInstall,
super.installSource,
});

Expand All @@ -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() =>
Expand Down
12 changes: 6 additions & 6 deletions packages/wherefrom/test/install_attribution_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ void main() {
clickedAtServer: fortyDaysAgo,
installBeganAt: fortyDaysAgo,
installBeganAtServer: fortyDaysAgo,
installVersion: '1.0.0',
versionAtInstall: '1.0.0',
);

setUp(() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <String, bool>{};

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),
Expand Down
10 changes: 5 additions & 5 deletions packages/wherefrom/test/install_referrer_never_throws_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 7 additions & 6 deletions packages/wherefrom/test/install_referrer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});

Expand Down
6 changes: 3 additions & 3 deletions packages/wherefrom/test/method_channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,20 @@ 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),
);
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', () {
Expand Down
Loading