From 2e99a5a5b8734f75da25753d322e167e4500c626 Mon Sep 17 00:00:00 2001 From: Shofiya <86974918+Shofiya2003@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:01:14 +0530 Subject: [PATCH 1/3] feat: derive Google Ads consent from a TCF CMP [LIN-2078] Adds enableTCFConsentCollection to the Flutter SDK, matching the native iOS SDK and the React Native wrapper. Apps using an IAB TCF v2.2/v2.3 Consent Management Platform can now let the SDK read the CMP's IABTCF_* keys instead of mirroring every consent change through setConsent by hand. Anything set explicitly with setConsent still wins, per signal, so an app can let the CMP supply most of the state and override one field. iOS only. The native Android SDK has no TCF support, so the Android side of the plugin has no handler for this method and reaching it would throw MissingPluginException in the caller's app. The Dart layer returns early on any non-iOS platform, which is what the React Native wrapper does with Platform.OS, rather than adding a no-op handler that would imply Android support. The iOS handler is deliberately not gated on isInitialized: the flag is meant to be set before init so the first payload already carries the CMP's values, and the native SDK resolves consent per payload rather than snapshotting it. Version 4.2.0 (new public API), with pubspec.yaml, the podspec and the hardcoded packageVersion kept in step. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +++ ios/Classes/SwiftLinkrunnerPlugin.swift | 17 ++++++- ios/linkrunner.podspec | 2 +- lib/linkrunner.dart | 38 ++++++++++++++- lib/linkrunner_native_bridge.dart | 19 ++++++++ pubspec.yaml | 2 +- test/tcf_consent_collection_test.dart | 65 +++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 test/tcf_consent_collection_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f45ad6..e286d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.2.0 + +- Added `enableTCFConsentCollection(enabled)`, which derives the Google Ads consent state from an IAB TCF v2.2/v2.3 Consent Management Platform instead of requiring `setConsent`. The native SDK reads the CMP's standard `IABTCF_*` keys and applies Google's published purpose mapping. Anything you set explicitly with `setConsent` still wins, per signal. +- It is opt-in rather than automatic because interpreting a TC string on your behalf is a legal judgement. Only enable it if you use a TCF-compliant CMP: custom consent screens and Firebase Consent Mode do not write those keys. +- Not persisted, so call it on every launch before `init`. iOS only, matching the React Native SDK: the native Android SDK has no TCF support, so on Android the call logs and returns rather than failing on a missing method channel handler. + ## 4.1.0 - Added support for Google Integrated Conversion Measurement (ICM) on iOS. The native SDK fetches Google's On-Device Measurement value during initialization and forwards it to Linkrunner. There is no Dart API to call, but ICM is **opt-in**: it stays inactive until you add Google's SDK to your app (see below). ICM is iOS-only for now; Android is unaffected. diff --git a/ios/Classes/SwiftLinkrunnerPlugin.swift b/ios/Classes/SwiftLinkrunnerPlugin.swift index 377870b..765103c 100644 --- a/ios/Classes/SwiftLinkrunnerPlugin.swift +++ b/ios/Classes/SwiftLinkrunnerPlugin.swift @@ -103,6 +103,14 @@ public class SwiftLinkrunnerPlugin: NSObject, FlutterPlugin { let args = call.arguments as? [String: Any] setConsent(args: args ?? [:], result: result) + case "enableTCFConsentCollection": + if let args = call.arguments as? [String: Any], + let enabled = args["enabled"] as? Bool { + enableTCFConsentCollection(enabled: enabled, result: result) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "enabled parameter is required", details: nil)) + } + case "setPushToken": if let args = call.arguments as? [String: Any], let pushToken = args["pushToken"] as? String { @@ -384,7 +392,14 @@ public class SwiftLinkrunnerPlugin: NSObject, FlutterPlugin { LinkrunnerSDK.shared.setConsent(consent) result(nil) } - + + /// Not gated on `isInitialized`: the Dart layer calls this before `init` so the first + /// payload already carries the CMP's values, and the native SDK reads the keys lazily. + private func enableTCFConsentCollection(enabled: Bool, result: @escaping FlutterResult) { + LinkrunnerSDK.shared.enableTCFConsentCollection(enabled) + result(nil) + } + private func setPushToken(pushToken: String, result: @escaping FlutterResult) { guard isInitialized else { result(FlutterError(code: "NOT_INITIALIZED", message: "SDK not initialized", details: nil)) diff --git a/ios/linkrunner.podspec b/ios/linkrunner.podspec index 57e04b3..14e24e9 100644 --- a/ios/linkrunner.podspec +++ b/ios/linkrunner.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |s| s.name = 'linkrunner' # Keep in step with pubspec.yaml — these drifted apart previously (pubspec 4.0.1 # against podspec 3.4.0). - s.version = '4.1.0' + s.version = '4.2.0' s.summary = 'Flutter Package for linkrunner, track every click, download and dropoff for your app links' s.description = <<-DESC Flutter Package for linkrunner.io - Advanced app attribution and link tracking service. diff --git a/lib/linkrunner.dart b/lib/linkrunner.dart index 4c31ca9..27e6651 100644 --- a/lib/linkrunner.dart +++ b/lib/linkrunner.dart @@ -1,5 +1,7 @@ import 'dart:developer' as developer; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; import 'package:linkrunner/models/attribution_data.dart'; import 'package:linkrunner/models/lr_capture_payment.dart'; import 'package:linkrunner/models/lr_consent.dart'; @@ -13,7 +15,7 @@ import 'models/lr_user_data.dart'; class LinkRunner { static final LinkRunner _singleton = LinkRunner._internal(); - final String packageVersion = '4.1.0'; + final String packageVersion = '4.2.0'; String? token; @@ -303,6 +305,40 @@ class LinkRunner { } } + /// Collect the Google Ads consent state from an IAB TCF v2.2/v2.3 Consent Management + /// Platform instead of setting it yourself. + /// + /// When enabled, the SDK reads the CMP's standard `IABTCF_*` keys. Anything set + /// explicitly through [setConsent] still wins, per signal. + /// + /// Opt in rather than automatic, because interpreting a TC string on your behalf is a + /// legal judgement. Only enable it if you use a TCF-compliant CMP: custom consent + /// screens and Firebase Consent Mode do not write those keys. + /// + /// Not persisted, so call it on every launch before [init]. iOS only, because the + /// native Android SDK has no TCF support. On other platforms this is a no-op. + /// + /// - Parameter enabled: Whether TCF consent collection should be enabled (default: true) + Future enableTCFConsentCollection([bool enabled = true]) async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + developer.log( + 'Linkrunner: TCF consent collection is iOS only, ignoring', + name: packageName, + ); + return; + } + + try { + await LinkRunnerNativeBridge.enableTCFConsentCollection(enabled); + } catch (e) { + developer.log( + 'Linkrunner: Failed to ${enabled ? 'enable' : 'disable'} TCF consent collection', + name: packageName, + error: e, + ); + } + } + /// Disable AAID (Google Advertising ID) collection on Android /// When disabled, the SDK will not collect or send the Google Advertising ID (GAID). /// This is useful for apps targeting children or families to comply with Google Play's Family Policy. diff --git a/lib/linkrunner_native_bridge.dart b/lib/linkrunner_native_bridge.dart index 51aa4b8..9b1cbb6 100644 --- a/lib/linkrunner_native_bridge.dart +++ b/lib/linkrunner_native_bridge.dart @@ -324,6 +324,25 @@ class LinkRunnerNativeBridge { } } + /// Collect the Google Ads consent state from an IAB TCF Consent Management Platform. + /// iOS only: the native Android SDK has no TCF support. + static Future enableTCFConsentCollection(bool enabled) async { + try { + await _channel.invokeMethod('enableTCFConsentCollection', { + 'enabled': enabled, + }); + developer.log( + 'TCF consent collection ${enabled ? 'enabled' : 'disabled'} successfully', + name: packageName); + } on PlatformException catch (e) { + developer.log( + 'Failed to ${enabled ? 'enable' : 'disable'} TCF consent collection: ${e.message}', + error: e, + name: packageName); + rethrow; + } + } + /// Check if AAID collection is currently disabled (Android only) /// Returns true if AAID collection is disabled, false otherwise static Future isAaidCollectionDisabled() async { diff --git a/pubspec.yaml b/pubspec.yaml index 5469f82..d2a17b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: linkrunner description: "Flutter Package for linkrunner, track every click, download and dropoff for your app links" -version: 4.1.0 +version: 4.2.0 homepage: "https://www.linkrunner.io/" repository: "https://github.com/RathodDarshil/linkrunner_flutter" documentation: "https://github.com/RathodDarshil/linkrunner_flutter#getting-started" diff --git a/test/tcf_consent_collection_test.dart b/test/tcf_consent_collection_test.dart new file mode 100644 index 0000000..e836e2c --- /dev/null +++ b/test/tcf_consent_collection_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkrunner/linkrunner.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('linkrunner_native'); + final calls = []; + + setUp(() { + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('enableTCFConsentCollection', () { + test('forwards the flag to the native SDK on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await LinkRunner().enableTCFConsentCollection(true); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'enableTCFConsentCollection'); + expect(calls.single.arguments, {'enabled': true}); + }); + + test('defaults to enabling collection', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await LinkRunner().enableTCFConsentCollection(); + + expect(calls.single.arguments, {'enabled': true}); + }); + + test('forwards a disable call', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await LinkRunner().enableTCFConsentCollection(false); + + expect(calls.single.arguments, {'enabled': false}); + }); + + // The native Android SDK has no TCF support, so the platform channel has no + // handler for this method. Reaching it would throw MissingPluginException in + // the caller's app, which is why the Dart layer returns before invoking it. + test('is a no-op on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + await LinkRunner().enableTCFConsentCollection(true); + + expect(calls, isEmpty); + }); + }); +} From aab462df8eb00d32d4ab92da279f2dfd8d98406b Mon Sep 17 00:00:00 2001 From: Shofiya <86974918+Shofiya2003@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:22:47 +0530 Subject: [PATCH 2/3] feat: support TCF consent collection on Android [LIN-2078] The native Android SDK (4.2.0) now derives Google Ads consent from an IAB TCF CMP, matching iOS. Add the enableTCFConsentCollection method channel handler to the Android plugin and drop the iOS-only gate in Dart. Explicit setConsent values still win per signal. Bumps io.linkrunner:android-sdk to 4.2.0 and the package to 4.3.0. --- CHANGELOG.md | 4 ++++ android/build.gradle | 2 +- .../io/linkrunner/flutter/LinkrunnerPlugin.kt | 18 ++++++++++++++++++ ios/linkrunner.podspec | 2 +- lib/linkrunner.dart | 11 ++++++----- lib/linkrunner_native_bridge.dart | 2 +- pubspec.yaml | 2 +- 7 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e286d54..b12c162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.3.0 + +- `enableTCFConsentCollection(enabled)` now works on Android too, matching iOS. The native Android SDK (4.2.0) reads the CMP's standard `IABTCF_*` keys from the default SharedPreferences and applies the same Google TCF purpose mapping; anything set explicitly with `setConsent` still wins, per signal. Bumped the native Android SDK to `io.linkrunner:android-sdk:4.2.0`. + ## 4.2.0 - Added `enableTCFConsentCollection(enabled)`, which derives the Google Ads consent state from an IAB TCF v2.2/v2.3 Consent Management Platform instead of requiring `setConsent`. The native SDK reads the CMP's standard `IABTCF_*` keys and applies Google's published purpose mapping. Anything you set explicitly with `setConsent` still wins, per signal. diff --git a/android/build.gradle b/android/build.gradle index bff46ae..2228701 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -48,7 +48,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'io.linkrunner:android-sdk:4.1.0' + implementation 'io.linkrunner:android-sdk:4.2.0' } } diff --git a/android/src/main/kotlin/io/linkrunner/flutter/LinkrunnerPlugin.kt b/android/src/main/kotlin/io/linkrunner/flutter/LinkrunnerPlugin.kt index 33544ff..d4922f6 100644 --- a/android/src/main/kotlin/io/linkrunner/flutter/LinkrunnerPlugin.kt +++ b/android/src/main/kotlin/io/linkrunner/flutter/LinkrunnerPlugin.kt @@ -151,6 +151,14 @@ class LinkrunnerPlugin: FlutterPlugin, MethodCallHandler { "setConsent" -> { setConsent(call, result) } + "enableTCFConsentCollection" -> { + val enabled = call.argument("enabled") + if (enabled != null) { + enableTCFConsentCollection(enabled, result) + } else { + result.error("INVALID_ARGUMENT", "enabled parameter is required", null) + } + } "handleDeeplink" -> { val deeplinkUrl = call.argument("deeplinkUrl") if (deeplinkUrl != null) { @@ -547,6 +555,16 @@ class LinkrunnerPlugin: FlutterPlugin, MethodCallHandler { } } + private fun enableTCFConsentCollection(enabled: Boolean, result: Result) { + try { + NativeLinkRunner.getInstance().enableTCFConsentCollection(enabled) + android.util.Log.d("LinkRunner", "TCF consent collection ${if (enabled) "enabled" else "disabled"}") + result.success(null) + } catch (e: Exception) { + result.error("ENABLE_TCF_CONSENT_COLLECTION_FAILED", e.message, null) + } + } + private fun isAaidCollectionDisabled(result: Result) { try { val isDisabled = NativeLinkRunner.getInstance().isAaidCollectionDisabled() diff --git a/ios/linkrunner.podspec b/ios/linkrunner.podspec index 14e24e9..5311df9 100644 --- a/ios/linkrunner.podspec +++ b/ios/linkrunner.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |s| s.name = 'linkrunner' # Keep in step with pubspec.yaml — these drifted apart previously (pubspec 4.0.1 # against podspec 3.4.0). - s.version = '4.2.0' + s.version = '4.3.0' s.summary = 'Flutter Package for linkrunner, track every click, download and dropoff for your app links' s.description = <<-DESC Flutter Package for linkrunner.io - Advanced app attribution and link tracking service. diff --git a/lib/linkrunner.dart b/lib/linkrunner.dart index 27e6651..3ce8a65 100644 --- a/lib/linkrunner.dart +++ b/lib/linkrunner.dart @@ -15,7 +15,7 @@ import 'models/lr_user_data.dart'; class LinkRunner { static final LinkRunner _singleton = LinkRunner._internal(); - final String packageVersion = '4.2.0'; + final String packageVersion = '4.3.0'; String? token; @@ -315,14 +315,15 @@ class LinkRunner { /// legal judgement. Only enable it if you use a TCF-compliant CMP: custom consent /// screens and Firebase Consent Mode do not write those keys. /// - /// Not persisted, so call it on every launch before [init]. iOS only, because the - /// native Android SDK has no TCF support. On other platforms this is a no-op. + /// Not persisted, so call it on every launch before [init]. Supported on iOS and + /// Android. On other platforms this is a no-op. /// /// - Parameter enabled: Whether TCF consent collection should be enabled (default: true) Future enableTCFConsentCollection([bool enabled = true]) async { - if (defaultTargetPlatform != TargetPlatform.iOS) { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) { developer.log( - 'Linkrunner: TCF consent collection is iOS only, ignoring', + 'Linkrunner: TCF consent collection is only supported on iOS and Android, ignoring', name: packageName, ); return; diff --git a/lib/linkrunner_native_bridge.dart b/lib/linkrunner_native_bridge.dart index 9b1cbb6..8426944 100644 --- a/lib/linkrunner_native_bridge.dart +++ b/lib/linkrunner_native_bridge.dart @@ -325,7 +325,7 @@ class LinkRunnerNativeBridge { } /// Collect the Google Ads consent state from an IAB TCF Consent Management Platform. - /// iOS only: the native Android SDK has no TCF support. + /// Supported on iOS and Android. static Future enableTCFConsentCollection(bool enabled) async { try { await _channel.invokeMethod('enableTCFConsentCollection', { diff --git a/pubspec.yaml b/pubspec.yaml index d2a17b3..615ed0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: linkrunner description: "Flutter Package for linkrunner, track every click, download and dropoff for your app links" -version: 4.2.0 +version: 4.3.0 homepage: "https://www.linkrunner.io/" repository: "https://github.com/RathodDarshil/linkrunner_flutter" documentation: "https://github.com/RathodDarshil/linkrunner_flutter#getting-started" From 7d913eea84bf322ebce94409906ee18d2ca0bd64 Mon Sep 17 00:00:00 2001 From: Shofiya <86974918+Shofiya2003@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:23:59 +0530 Subject: [PATCH 3/3] test: cover TCF consent collection on Android [LIN-2078] --- test/tcf_consent_collection_test.dart | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/tcf_consent_collection_test.dart b/test/tcf_consent_collection_test.dart index e836e2c..d243e8e 100644 --- a/test/tcf_consent_collection_test.dart +++ b/test/tcf_consent_collection_test.dart @@ -51,14 +51,23 @@ void main() { expect(calls.single.arguments, {'enabled': false}); }); - // The native Android SDK has no TCF support, so the platform channel has no - // handler for this method. Reaching it would throw MissingPluginException in - // the caller's app, which is why the Dart layer returns before invoking it. - test('is a no-op on Android', () async { + // The native Android SDK supports TCF from 4.2.0, so Android goes through the + // same method channel handler as iOS. + test('forwards the flag to the native SDK on Android', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; await LinkRunner().enableTCFConsentCollection(true); + expect(calls, hasLength(1)); + expect(calls.single.method, 'enableTCFConsentCollection'); + expect(calls.single.arguments, {'enabled': true}); + }); + + test('is a no-op on other platforms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + await LinkRunner().enableTCFConsentCollection(true); + expect(calls, isEmpty); }); });