From 11ac577ced386ceff0263290e87d38939d68b153 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Sat, 15 Aug 2026 12:44:13 -0700 Subject: [PATCH] fix: tolerate empty deepLinkData object in InstallResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install endpoint returns `deepLinkData: {}` rather than `null` for organic (unattributed) installs. `DeepLinkData.shortCode` is required, so `InstallResponse.fromJson` threw a TypeError and `initialize()` failed for every organic install. Parse `deepLinkData` leniently: an empty object — or any payload without a usable short code — is now treated as "no deep link" (null), matching the `null` and absent cases. The failure is logged when debug logging is on instead of failing the whole response, since a deep link we cannot parse is never worth aborting attribution over. Required top-level fields stay strict. Ports the iOS SDK fix (LinkForty/mobile-sdk-ios#4) to Flutter. --- CHANGELOG.md | 4 ++ lib/models/install_response.dart | 21 ++++++++ lib/models/install_response.g.dart | 4 +- test/models/install_response_test.dart | 74 ++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3426f..740e5e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* **Fixed:** `InstallResponse.fromJson` no longer throws when the backend returns `deepLinkData: {}` for an organic (unattributed) install, which surfaced as an error out of `initialize()`. An empty — or otherwise unusable — `deepLinkData` object is now treated as "no deep link" (`null`), the same as an explicit `null`. + ## 0.2.0 * The SDK now identifies itself on every request: a `sdkName` (`"flutter"`) and `sdkVersion` field is included on the install and event payloads, and an `X-LinkForty-SDK: flutter/` header is sent on all requests. This lets the backend report which SDKs and versions are in use and flag outdated integrations. No API or integration changes are required. diff --git a/lib/models/install_response.dart b/lib/models/install_response.dart index 484a411..6767f09 100644 --- a/lib/models/install_response.dart +++ b/lib/models/install_response.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:json_annotation/json_annotation.dart'; +import '../link_forty_logger.dart'; import 'deep_link_data.dart'; part 'install_response.g.dart'; @@ -23,6 +24,7 @@ class InstallResponse { final List matchedFactors; /// Deep link data if attributed, null if organic + @JsonKey(fromJson: _deepLinkDataFromJson) final DeepLinkData? deepLinkData; /// Creates an install response @@ -83,4 +85,23 @@ class InstallResponse { static int _listHashCode(List list) { return list.fold(0, (hash, item) => hash ^ item.hashCode); } + + /// Parses the `deepLinkData` field of an install response. + /// + /// Organic (unattributed) installs come back as `deepLinkData: {}` rather + /// than `null`, and an object without a `shortCode` carries no link to route + /// to. Any payload that cannot be parsed is treated as "no deep link" so a + /// missing or unexpected field can never fail the whole install response — + /// that would abort SDK initialization for every organic install. + static DeepLinkData? _deepLinkDataFromJson(Object? json) { + if (json is! Map) return null; + try { + return DeepLinkData.fromJson(json); + } catch (e) { + LinkFortyLogger.log( + 'Ignoring undecodable deepLinkData in install response: $e', + ); + return null; + } + } } diff --git a/lib/models/install_response.g.dart b/lib/models/install_response.g.dart index 2f0ee9e..b9a5f91 100644 --- a/lib/models/install_response.g.dart +++ b/lib/models/install_response.g.dart @@ -14,9 +14,7 @@ InstallResponse _$InstallResponseFromJson(Map json) => matchedFactors: (json['matchedFactors'] as List) .map((e) => e as String) .toList(), - deepLinkData: json['deepLinkData'] == null - ? null - : DeepLinkData.fromJson(json['deepLinkData'] as Map), + deepLinkData: InstallResponse._deepLinkDataFromJson(json['deepLinkData']), ); Map _$InstallResponseToJson(InstallResponse instance) => diff --git a/test/models/install_response_test.dart b/test/models/install_response_test.dart index 9125719..48fa953 100644 --- a/test/models/install_response_test.dart +++ b/test/models/install_response_test.dart @@ -33,6 +33,80 @@ void main() { expect(response.deepLinkData, isNull); }); + // The backend returns `deepLinkData: {}` (not null) for organic installs. + test('treats an empty deepLinkData object as no deep link', () { + final response = InstallResponse.fromJson({ + 'installId': 'inst_456', + 'attributed': false, + 'confidenceScore': 0.0, + 'matchedFactors': [], + 'deepLinkData': {}, + }); + + expect(response.installId, 'inst_456'); + expect(response.attributed, isFalse); + expect(response.deepLinkData, isNull); + }); + + test('treats a null deepLinkData as no deep link', () { + final response = InstallResponse.fromJson({ + 'installId': 'inst_456', + 'attributed': false, + 'confidenceScore': 0.0, + 'matchedFactors': [], + 'deepLinkData': null, + }); + + expect(response.deepLinkData, isNull); + }); + + // A deep link with no short code can't be routed to, so it is not worth + // failing the whole response over. + test('treats deepLinkData without a shortCode as no deep link', () { + final response = InstallResponse.fromJson({ + 'installId': 'inst_456', + 'attributed': false, + 'confidenceScore': 0.0, + 'matchedFactors': [], + 'deepLinkData': {'iosUrl': 'myapp://product/456'}, + }); + + expect(response.deepLinkData, isNull); + }); + + test('deserializes an attributed response with deep link data', () { + final response = InstallResponse.fromJson({ + 'installId': 'inst_789', + 'attributed': true, + 'confidenceScore': 85, + 'matchedFactors': ['ip', 'ua'], + 'deepLinkData': { + 'shortCode': 'abc123', + 'iosUrl': 'myapp://product/456', + 'deepLinkPath': '/product/456', + 'clickedAt': '2026-01-15T10:30:00Z', + }, + }); + + expect(response.attributed, isTrue); + expect(response.confidenceScore, 85); + expect(response.deepLinkData?.shortCode, 'abc123'); + expect(response.deepLinkData?.iosURL, 'myapp://product/456'); + expect(response.deepLinkData?.deepLinkPath, '/product/456'); + expect(response.deepLinkData?.clickedAt, isNotNull); + }); + + test('throws when a required field is missing', () { + expect( + () => InstallResponse.fromJson({ + 'attributed': false, + 'confidenceScore': 0.0, + 'matchedFactors': [], + }), + throwsA(isA()), + ); + }); + test('equality works correctly', () { final res1 = InstallResponse( installId: '1',