diff --git a/lib/deeplink/deeplink_handler.dart b/lib/deeplink/deeplink_handler.dart index 504fc5c..4ca685a 100644 --- a/lib/deeplink/deeplink_handler.dart +++ b/lib/deeplink/deeplink_handler.dart @@ -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', diff --git a/lib/models/deep_link_data.dart b/lib/models/deep_link_data.dart index 2e7eeab..6b3ff22 100644 --- a/lib/models/deep_link_data.dart +++ b/lib/models/deep_link_data.dart @@ -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? 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 json) => _$DeepLinkDataFromJson(json); diff --git a/lib/utilities/url_parser.dart b/lib/utilities/url_parser.dart index 03ccd96..dfb3bb6 100644 --- a/lib/utilities/url_parser.dart +++ b/lib/utilities/url_parser.dart @@ -57,21 +57,28 @@ class URLParser { /// /// - [url]: The URL to parse /// - Returns: Map of custom parameters, empty if none found - static Map 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 extractCustomParameters(Uri url) { final customParams = {}; 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; } } diff --git a/test/models/deep_link_data_test.dart b/test/models/deep_link_data_test.dart index 4a75a41..4487fe7 100644 --- a/test/models/deep_link_data_test.dart +++ b/test/models/deep_link_data_test.dart @@ -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'); + }); + }); + }); -} +} \ No newline at end of file diff --git a/test/utilities/url_parser_test.dart b/test/utilities/url_parser_test.dart index c859fc6..3aa791b 100644 --- a/test/utilities/url_parser_test.dart +++ b/test/utilities/url_parser_test.dart @@ -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'}); + }); + }); + }); -} +} \ No newline at end of file