diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f45ad6..b12c162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 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. +- 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/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/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..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.1.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 4c31ca9..3ce8a65 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.3.0'; String? token; @@ -303,6 +305,41 @@ 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]. 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 && + defaultTargetPlatform != TargetPlatform.android) { + developer.log( + 'Linkrunner: TCF consent collection is only supported on iOS and Android, 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..8426944 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. + /// Supported on iOS and Android. + 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..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.1.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" diff --git a/test/tcf_consent_collection_test.dart b/test/tcf_consent_collection_test.dart new file mode 100644 index 0000000..d243e8e --- /dev/null +++ b/test/tcf_consent_collection_test.dart @@ -0,0 +1,74 @@ +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 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); + }); + }); +}