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
4 changes: 3 additions & 1 deletion lib/deeplink/deeplink_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ class DeepLinkHandler {
);

LinkFortyLogger.log('Server-side resolution succeeded for $uri');
return resolved;
// The resolve returns the link's stored configuration; the parameters on
// the URL that was tapped are known only here.
return resolved.mergingUrlParameters(fallback?.customParameters);
} catch (e) {
LinkFortyLogger.log(
'Server-side resolution failed, using local parse: $e',
Expand Down
32 changes: 32 additions & 0 deletions lib/models/deep_link_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@ class DeepLinkData {
this.linkId,
});

/// Returns a copy with the parameters carried on the opened URL merged in.
///
/// Resolving a short code returns the link's *stored* configuration; the
/// server has no way to know what was appended to the URL that was actually
/// tapped. The SDK does, having just parsed it. Without this a link shared as
/// `?slug=titanic` reaches the app with that value missing on a direct open,
/// while the same link after a deferred install carries it — the server merges
/// the click's parameters there.
///
/// URL values win on a key collision, matching that server-side precedence:
/// what a sharer put on the URL is more specific than the link's stored setup.
///
/// Only [customParameters] is merged. [linkId], [deepLinkPath], [appScheme],
/// the store URLs and [utmParameters] are server truth that a local parse
/// cannot know and must not overwrite.
DeepLinkData mergingUrlParameters(Map<String, String>? fromUrl) {
if (fromUrl == null || fromUrl.isEmpty) return this;

return DeepLinkData(
shortCode: shortCode,
iosURL: iosURL,
androidURL: androidURL,
webURL: webURL,
utmParameters: utmParameters,
customParameters: {...?customParameters, ...fromUrl},
deepLinkPath: deepLinkPath,
appScheme: appScheme,
clickedAt: clickedAt,
linkId: linkId,
);
}

/// JSON deserialization
factory DeepLinkData.fromJson(Map<String, dynamic> json) =>
_$DeepLinkDataFromJson(json);
Expand Down
27 changes: 17 additions & 10 deletions lib/utilities/url_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,28 @@ class URLParser {
///
/// - [url]: The URL to parse
/// - Returns: Map of custom parameters, empty if none found
static Map<String, String> extractCustomParameters(Uri url) {
final utmKeys = {
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
};
/// Names LinkForty consumes, which are never a custom parameter:
/// utm_* surfaced separately as utmParameters
/// fp_* fingerprint signals the SDK appends when resolving a link, and
/// which the redirect reads server-side for attribution
/// lf_click the click id the redirect appends to a destination URL
///
/// Mirrors the server's own filter so a direct open and a deferred install
/// agree on what reaches the app. Matching is by prefix rather than an exact
/// set, so any `utm_` or `fp_` name is covered whatever the suffix.
static bool isReservedParameter(String name) {
final lower = name.toLowerCase();
return lower.startsWith('utm_') ||
lower.startsWith('fp_') ||
lower == 'lf_click';
}

static Map<String, String> extractCustomParameters(Uri url) {
final customParams = <String, String>{};
final params = url.queryParameters;

for (final entry in params.entries) {
// Skip UTM parameters
if (!utmKeys.contains(entry.key)) {
if (!isReservedParameter(entry.key)) {
customParams[entry.key] = entry.value;
}
}
Expand Down
48 changes: 47 additions & 1 deletion test/models/deep_link_data_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,51 @@ void main() {
expect(data1, equals(data2));
expect(data1, isNot(equals(data3)));
});

group('mergingUrlParameters', () {
test('adds URL parameters when the link configures none', () {
final merged = const DeepLinkData(shortCode: 'abc123')
.mergingUrlParameters({'slug': 'titanic'});

expect(merged.customParameters, {'slug': 'titanic'});
});

test('lets a URL parameter override a configured one', () {
// Same precedence the server applies on the deferred path.
final merged = const DeepLinkData(
shortCode: 'abc123',
customParameters: {'slug': 'default', 'keep': 'me'},
).mergingUrlParameters({'slug': 'titanic'});

expect(merged.customParameters, {'slug': 'titanic', 'keep': 'me'});
});

test('is a no-op when the URL carried nothing', () {
const resolved = DeepLinkData(
shortCode: 'abc123',
customParameters: {'a': '1'},
);

expect(identical(resolved.mergingUrlParameters(null), resolved), isTrue);
expect(identical(resolved.mergingUrlParameters({}), resolved), isTrue);
});

test('never overwrites fields only the server knows', () {
final merged = const DeepLinkData(
shortCode: 'abc123',
androidURL: 'https://play.google.com/store/apps/details?id=com.app',
deepLinkPath: '/product/1',
appScheme: 'myapp',
linkId: 'link-1',
).mergingUrlParameters({'slug': 'titanic'});

expect(merged.linkId, 'link-1');
expect(merged.deepLinkPath, '/product/1');
expect(merged.appScheme, 'myapp');
expect(merged.androidURL,
'https://play.google.com/store/apps/details?id=com.app');
});
});

});
}
}
36 changes: 35 additions & 1 deletion test/utilities/url_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,39 @@ void main() {
expect(data, isNull);
});
});

group('isReservedParameter', () {
test('covers the names LinkForty consumes', () {
// utm_* is surfaced separately as utmParameters; fp_* are fingerprint
// signals the redirect reads server-side; lf_click is the id appended
// to a destination URL. The server's extractor excludes all three.
expect(URLParser.isReservedParameter('utm_source'), isTrue);
expect(URLParser.isReservedParameter('fp_tz'), isTrue);
expect(URLParser.isReservedParameter('lf_click'), isTrue);
});

test('matches case-insensitively', () {
expect(URLParser.isReservedParameter('UTM_Source'), isTrue);
expect(URLParser.isReservedParameter('FP_TZ'), isTrue);
expect(URLParser.isReservedParameter('LF_Click'), isTrue);
});

test('does not sweep up near-misses', () {
expect(URLParser.isReservedParameter('slug'), isFalse);
expect(URLParser.isReservedParameter('utmost'), isFalse);
expect(URLParser.isReservedParameter('fps'), isFalse);
expect(URLParser.isReservedParameter('lf_clicks'), isFalse);
});
});

group('extractCustomParameters excludes reserved names', () {
test('keeps only the app\'s own parameters', () {
final url = Uri.parse(
'https://example.com/abc12345?slug=titanic&utm_source=ig&fp_tz=UTC&lf_click=abc',
);
expect(URLParser.extractCustomParameters(url), {'slug': 'titanic'});
});
});

});
}
}