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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.1

* **URL parameters are now delivered on a direct open** (app already installed), not just after a deferred install. A link shared as `?slug=titanic` previously returned only the link's stored configuration on a direct open, because `_resolveUrl` discarded the local parse of the tapped URL. `customParameters` now carries both, with URL values winning on a collision — the same precedence the server applies on the deferred path. `linkId`, `deepLinkPath`, `appScheme`, the store URLs and `utmParameters` remain server-provided.
Expand Down
21 changes: 21 additions & 0 deletions lib/models/install_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ class InstallResponse {
final List<String> matchedFactors;

/// Deep link data if attributed, null if organic
@JsonKey(fromJson: _deepLinkDataFromJson)
final DeepLinkData? deepLinkData;

/// Creates an install response
Expand Down Expand Up @@ -83,4 +85,23 @@ class InstallResponse {
static int _listHashCode(List<String> 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<String, dynamic>) return null;
try {
return DeepLinkData.fromJson(json);
} catch (e) {
LinkFortyLogger.log(
'Ignoring undecodable deepLinkData in install response: $e',
);
return null;
}
}
}
4 changes: 1 addition & 3 deletions lib/models/install_response.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions test/models/install_response_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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': <String>[],
'deepLinkData': <String, dynamic>{},
});

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': <String>[],
'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': <String>[],
'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': <String>[],
}),
throwsA(isA<TypeError>()),
);
});

test('equality works correctly', () {
final res1 = InstallResponse(
installId: '1',
Expand Down
Loading