From 97b76dce906459d23a27c2d5318e8e9173c42228 Mon Sep 17 00:00:00 2001 From: Asad Raza Date: Tue, 8 Sep 2026 17:33:53 +0100 Subject: [PATCH] feat(banner): add consent-or-pay login/subscribe callbacks Bridges the native SDK's Consent-or-Pay 1st-layer banner support (MSDK-3779): onLoginClicked/onSubscribeClicked event streams fire when the user taps the subscriber-login link or Reject & Subscribe button, and notifyLoginSuccess/ notifySubscribeSuccess let the host app clear stored TCF consent after a successful login or subscription. --- .../sdk/flutter/UsercentricsPlugin.kt | 39 ++++++- .../flutter/api/ConsentOrPayEventNotifier.kt | 22 ++++ .../flutter/api/UsercentricsBannerProxy.kt | 9 +- .../bridge/NotifyLoginSuccessBridge.kt | 35 ++++++ .../bridge/NotifySubscribeSuccessBridge.kt | 35 ++++++ example/lib/main.dart | 9 ++ .../API/ConsentOrPayEventNotifier.swift | 18 ++++ .../API/UsercentricsBannerProxy.swift | 14 ++- .../Bridge/LoginClickedStreamHandler.swift | 20 ++++ .../Bridge/NotifyLoginSuccessBridge.swift | 20 ++++ .../Bridge/NotifySubscribeSuccessBridge.swift | 20 ++++ .../SubscribeClickedStreamHandler.swift | 20 ++++ .../usercentrics_sdk/UsercentricsPlugin.swift | 29 ++++- lib/src/internal/bridge/bridge.dart | 2 + .../bridge/notify_login_success_bridge.dart | 18 ++++ .../notify_subscribe_success_bridge.dart | 18 ++++ .../platform/method_channel_usercentrics.dart | 37 ++++++- .../serializer/tcf2_settings_serializer.dart | 37 ++++++- lib/src/model/tcf2_settings.dart | 102 +++++++++++++++++- lib/src/platform/usercentrics_platform.dart | 16 +++ lib/src/usercentrics.dart | 17 +++ 21 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 android/src/main/kotlin/com/usercentrics/sdk/flutter/api/ConsentOrPayEventNotifier.kt create mode 100644 android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifyLoginSuccessBridge.kt create mode 100644 android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifySubscribeSuccessBridge.kt create mode 100644 ios/usercentrics_sdk/Sources/usercentrics_sdk/API/ConsentOrPayEventNotifier.swift create mode 100644 ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/LoginClickedStreamHandler.swift create mode 100644 ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifyLoginSuccessBridge.swift create mode 100644 ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifySubscribeSuccessBridge.swift create mode 100644 ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/SubscribeClickedStreamHandler.swift create mode 100644 lib/src/internal/bridge/notify_login_success_bridge.dart create mode 100644 lib/src/internal/bridge/notify_subscribe_success_bridge.dart diff --git a/android/src/main/kotlin/com/usercentrics/sdk/flutter/UsercentricsPlugin.kt b/android/src/main/kotlin/com/usercentrics/sdk/flutter/UsercentricsPlugin.kt index 8b73a308..9d45dfb8 100644 --- a/android/src/main/kotlin/com/usercentrics/sdk/flutter/UsercentricsPlugin.kt +++ b/android/src/main/kotlin/com/usercentrics/sdk/flutter/UsercentricsPlugin.kt @@ -2,6 +2,7 @@ package com.usercentrics.sdk.flutter import android.app.Activity import androidx.annotation.NonNull +import com.usercentrics.sdk.flutter.api.ConsentOrPayEventNotifier import com.usercentrics.sdk.flutter.api.FlutterActivityProvider import com.usercentrics.sdk.flutter.api.FlutterAssetsProvider import com.usercentrics.sdk.flutter.api.FlutterMethodCallWrapper @@ -33,6 +34,10 @@ class UsercentricsPlugin : FlutterPlugin, private var activityBinding: ActivityPluginBinding? = null private var flutterAssets: FlutterAssets? = null + private val consentOrPayEventNotifier = ConsentOrPayEventNotifier() + private var loginClickedEventChannel: EventChannel? = null + private var subscribeClickedEventChannel: EventChannel? = null + private val methods: Map by lazy { listOf( InitializeBridge( @@ -42,7 +47,7 @@ class UsercentricsPlugin : FlutterPlugin, ShowFirstLayerBridge( assetsProvider = this, activityProvider = this, - bannerProxy = UsercentricsBannerProxyImpl(this), + bannerProxy = UsercentricsBannerProxyImpl(this, consentOrPayEventNotifier), ), ShowSecondLayerBridge( assetsProvider = this, @@ -73,7 +78,9 @@ class UsercentricsPlugin : FlutterPlugin, GetGPPDataBridge(), GetGPPStringBridge(), SetGPPConsentBridge(), - GetDpsMetadataBridge() + GetDpsMetadataBridge(), + NotifyLoginSuccessBridge(), + NotifySubscribeSuccessBridge(), ).associateBy { it.name } } @@ -118,6 +125,28 @@ class UsercentricsPlugin : FlutterPlugin, gppSectionChangeSubscription = null } }) + + loginClickedEventChannel = EventChannel(binding.binaryMessenger, "usercentrics/onLoginClicked") + loginClickedEventChannel?.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + consentOrPayEventNotifier.loginClickedSink = events + } + + override fun onCancel(arguments: Any?) { + consentOrPayEventNotifier.loginClickedSink = null + } + }) + + subscribeClickedEventChannel = EventChannel(binding.binaryMessenger, "usercentrics/onSubscribeClicked") + subscribeClickedEventChannel?.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + consentOrPayEventNotifier.subscribeClickedSink = events + } + + override fun onCancel(arguments: Any?) { + consentOrPayEventNotifier.subscribeClickedSink = null + } + }) } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { @@ -127,6 +156,12 @@ class UsercentricsPlugin : FlutterPlugin, gppSectionChangeSubscription = null gppSectionChangeEventChannel?.setStreamHandler(null) gppSectionChangeEventChannel = null + consentOrPayEventNotifier.loginClickedSink = null + consentOrPayEventNotifier.subscribeClickedSink = null + loginClickedEventChannel?.setStreamHandler(null) + loginClickedEventChannel = null + subscribeClickedEventChannel?.setStreamHandler(null) + subscribeClickedEventChannel = null } override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) { diff --git a/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/ConsentOrPayEventNotifier.kt b/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/ConsentOrPayEventNotifier.kt new file mode 100644 index 00000000..842738db --- /dev/null +++ b/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/ConsentOrPayEventNotifier.kt @@ -0,0 +1,22 @@ +package com.usercentrics.sdk.flutter.api + +import io.flutter.plugin.common.EventChannel + +/** + * Holds the EventSinks for the Consent-or-Pay 1st-layer click events (onLoginClicked/ + * onSubscribeClicked). These fire independently of showFirstLayer's own completion callback + * (the banner stays open), so they're modeled as persistent EventChannels rather than as part + * of showFirstLayer's result, mirroring the existing onGppSectionChange EventChannel. + */ +internal class ConsentOrPayEventNotifier { + var loginClickedSink: EventChannel.EventSink? = null + var subscribeClickedSink: EventChannel.EventSink? = null + + fun onLoginClicked(url: String?) { + loginClickedSink?.success(url) + } + + fun onSubscribeClicked(url: String?) { + subscribeClickedSink?.success(url) + } +} diff --git a/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/UsercentricsBannerProxy.kt b/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/UsercentricsBannerProxy.kt index ff3e49a8..743e2dc9 100644 --- a/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/UsercentricsBannerProxy.kt +++ b/android/src/main/kotlin/com/usercentrics/sdk/flutter/api/UsercentricsBannerProxy.kt @@ -18,6 +18,9 @@ internal interface UsercentricsBannerProxy { internal class UsercentricsBannerProxyImpl( private val activityProvider: FlutterActivityProvider, + // Optional: only Consent-or-Pay first-layer screens ever invoke these, so a plain first-layer + // call with no such buttons configured never touches this at all. + private val consentOrPayEventNotifier: ConsentOrPayEventNotifier? = null, ) : UsercentricsBannerProxy { override fun showFirstLayer( @@ -25,7 +28,11 @@ internal class UsercentricsBannerProxyImpl( callback: (UsercentricsConsentUserResponse?) -> Unit, ) { val context = activityProvider.provide() ?: return - UsercentricsBanner(context, bannerSettings).showFirstLayer(callback) + UsercentricsBanner(context, bannerSettings).showFirstLayer( + callback = callback, + onLoginClicked = { url -> consentOrPayEventNotifier?.onLoginClicked(url) }, + onSubscribeClicked = { url -> consentOrPayEventNotifier?.onSubscribeClicked(url) }, + ) } override fun showSecondLayer( diff --git a/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifyLoginSuccessBridge.kt b/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifyLoginSuccessBridge.kt new file mode 100644 index 00000000..f69e23f8 --- /dev/null +++ b/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifyLoginSuccessBridge.kt @@ -0,0 +1,35 @@ +package com.usercentrics.sdk.flutter.bridge + +import com.usercentrics.sdk.flutter.api.FlutterMethodCall +import com.usercentrics.sdk.flutter.api.FlutterResult +import com.usercentrics.sdk.flutter.api.UsercentricsProxy +import com.usercentrics.sdk.flutter.api.UsercentricsProxySingleton + +internal class NotifyLoginSuccessBridge( + private val usercentrics: UsercentricsProxy = UsercentricsProxySingleton +) : MethodBridge { + + companion object { + private const val notifyLoginSuccessErrorCode = + "usercentrics_flutter_notifyLoginSuccess_error" + } + + override val name: String + get() = "notifyLoginSuccess" + + override fun invoke(call: FlutterMethodCall, result: FlutterResult) { + assert(name == call.method) + usercentrics.instance.notifyLoginSuccess( + onSuccess = { + result.success(null) + }, + onError = { + result.error( + notifyLoginSuccessErrorCode, + it.message, + it + ) + }, + ) + } +} diff --git a/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifySubscribeSuccessBridge.kt b/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifySubscribeSuccessBridge.kt new file mode 100644 index 00000000..35798b8f --- /dev/null +++ b/android/src/main/kotlin/com/usercentrics/sdk/flutter/bridge/NotifySubscribeSuccessBridge.kt @@ -0,0 +1,35 @@ +package com.usercentrics.sdk.flutter.bridge + +import com.usercentrics.sdk.flutter.api.FlutterMethodCall +import com.usercentrics.sdk.flutter.api.FlutterResult +import com.usercentrics.sdk.flutter.api.UsercentricsProxy +import com.usercentrics.sdk.flutter.api.UsercentricsProxySingleton + +internal class NotifySubscribeSuccessBridge( + private val usercentrics: UsercentricsProxy = UsercentricsProxySingleton +) : MethodBridge { + + companion object { + private const val notifySubscribeSuccessErrorCode = + "usercentrics_flutter_notifySubscribeSuccess_error" + } + + override val name: String + get() = "notifySubscribeSuccess" + + override fun invoke(call: FlutterMethodCall, result: FlutterResult) { + assert(name == call.method) + usercentrics.instance.notifySubscribeSuccess( + onSuccess = { + result.success(null) + }, + onError = { + result.error( + notifySubscribeSuccessErrorCode, + it.message, + it + ) + }, + ) + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index d4c920d4..91240cab 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -59,6 +59,15 @@ class HomePageState extends State { consentMediation: _kMediationTestEnabled, ); + // Consent-or-Pay: notify the SDK once the host app confirms a successful login/subscription + // so it can clear stored TCF consent data. The banner is not dismissed automatically. + Usercentrics.onLoginClicked.listen((url) async { + await Usercentrics.notifyLoginSuccess(); + }); + Usercentrics.onSubscribeClicked.listen((url) async { + await Usercentrics.notifySubscribeSuccess(); + }); + final status = await Usercentrics.status; setState(() { diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/ConsentOrPayEventNotifier.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/ConsentOrPayEventNotifier.swift new file mode 100644 index 00000000..6cd8e93a --- /dev/null +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/ConsentOrPayEventNotifier.swift @@ -0,0 +1,18 @@ +import Flutter + +/// Holds the EventSinks for the Consent-or-Pay 1st-layer click events (onLoginClicked/ +/// onSubscribeClicked). These fire independently of showFirstLayer's own completion handler +/// (the banner stays open), so they're modeled as persistent EventChannels rather than as part +/// of showFirstLayer's result, mirroring the existing onGppSectionChange EventChannel. +class ConsentOrPayEventNotifier { + var loginClickedSink: FlutterEventSink? + var subscribeClickedSink: FlutterEventSink? + + func onLoginClicked(_ url: String?) { + loginClickedSink?(url) + } + + func onSubscribeClicked(_ url: String?) { + subscribeClickedSink?(url) + } +} diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/UsercentricsBannerProxy.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/UsercentricsBannerProxy.swift index a202ca03..1f4c874a 100644 --- a/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/UsercentricsBannerProxy.swift +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/API/UsercentricsBannerProxy.swift @@ -12,9 +12,21 @@ protocol UsercentricsBannerProxyProtocol { struct UsercentricsBannerProxy: UsercentricsBannerProxyProtocol { + // Optional: only Consent-or-Pay first-layer screens ever invoke these, so a plain first-layer + // call with no such buttons configured never touches this at all. + let consentOrPayEventNotifier: ConsentOrPayEventNotifier? + + init(consentOrPayEventNotifier: ConsentOrPayEventNotifier? = nil) { + self.consentOrPayEventNotifier = consentOrPayEventNotifier + } + func showFirstLayer(bannerSettings: BannerSettings?, completionHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { - UsercentricsBanner(bannerSettings: bannerSettings).showFirstLayer { response in + UsercentricsBanner(bannerSettings: bannerSettings).showFirstLayer(onLoginClicked: { [weak consentOrPayEventNotifier] url in + consentOrPayEventNotifier?.onLoginClicked(url) + }, onSubscribeClicked: { [weak consentOrPayEventNotifier] url in + consentOrPayEventNotifier?.onSubscribeClicked(url) + }) { response in completionHandler(response) } } diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/LoginClickedStreamHandler.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/LoginClickedStreamHandler.swift new file mode 100644 index 00000000..8f2dfedf --- /dev/null +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/LoginClickedStreamHandler.swift @@ -0,0 +1,20 @@ +import Flutter + +class LoginClickedStreamHandler: NSObject, FlutterStreamHandler { + + private let notifier: ConsentOrPayEventNotifier + + init(notifier: ConsentOrPayEventNotifier) { + self.notifier = notifier + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + notifier.loginClickedSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + notifier.loginClickedSink = nil + return nil + } +} diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifyLoginSuccessBridge.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifyLoginSuccessBridge.swift new file mode 100644 index 00000000..4d05a3ca --- /dev/null +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifyLoginSuccessBridge.swift @@ -0,0 +1,20 @@ +import Flutter +import Foundation + +struct NotifyLoginSuccessBridge : MethodBridge { + + let name: String = "notifyLoginSuccess" + let usercentrics: UsercentricsProxyProtocol + + func invoke(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + assert(call.method == name) + + usercentrics.shared.notifyLoginSuccess(onSuccess: { + result(nil) + }, onError: { error in + result(FlutterError(code: "usercentrics_flutter_notifyLoginSuccess_error", + message: error.localizedDescription, + details: nil)) + }) + } +} diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifySubscribeSuccessBridge.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifySubscribeSuccessBridge.swift new file mode 100644 index 00000000..17d68b87 --- /dev/null +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/NotifySubscribeSuccessBridge.swift @@ -0,0 +1,20 @@ +import Flutter +import Foundation + +struct NotifySubscribeSuccessBridge : MethodBridge { + + let name: String = "notifySubscribeSuccess" + let usercentrics: UsercentricsProxyProtocol + + func invoke(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + assert(call.method == name) + + usercentrics.shared.notifySubscribeSuccess(onSuccess: { + result(nil) + }, onError: { error in + result(FlutterError(code: "usercentrics_flutter_notifySubscribeSuccess_error", + message: error.localizedDescription, + details: nil)) + }) + } +} diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/SubscribeClickedStreamHandler.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/SubscribeClickedStreamHandler.swift new file mode 100644 index 00000000..bb6e0d89 --- /dev/null +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/Bridge/SubscribeClickedStreamHandler.swift @@ -0,0 +1,20 @@ +import Flutter + +class SubscribeClickedStreamHandler: NSObject, FlutterStreamHandler { + + private let notifier: ConsentOrPayEventNotifier + + init(notifier: ConsentOrPayEventNotifier) { + self.notifier = notifier + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + notifier.subscribeClickedSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + notifier.subscribeClickedSink = nil + return nil + } +} diff --git a/ios/usercentrics_sdk/Sources/usercentrics_sdk/UsercentricsPlugin.swift b/ios/usercentrics_sdk/Sources/usercentrics_sdk/UsercentricsPlugin.swift index 53611770..38546444 100644 --- a/ios/usercentrics_sdk/Sources/usercentrics_sdk/UsercentricsPlugin.swift +++ b/ios/usercentrics_sdk/Sources/usercentrics_sdk/UsercentricsPlugin.swift @@ -6,6 +6,8 @@ import Usercentrics public class UsercentricsPlugin: NSObject, FlutterPlugin { private static var gppStreamHandler: GppSectionChangeStreamHandler? + private static var loginClickedStreamHandler: LoginClickedStreamHandler? + private static var subscribeClickedStreamHandler: SubscribeClickedStreamHandler? public static func register(with registrar: FlutterPluginRegistrar) { // XCTest bootstraps app plugins differently; avoid channel registration there. @@ -16,28 +18,45 @@ public class UsercentricsPlugin: NSObject, FlutterPlugin { let messenger = registrar.messenger() let channel = FlutterMethodChannel(name: "usercentrics", binaryMessenger: messenger) - let instance = UsercentricsPlugin(assetProvider: FlutterAssetProviderImpl(registrar: registrar)) + let consentOrPayEventNotifier = ConsentOrPayEventNotifier() + let instance = UsercentricsPlugin(assetProvider: FlutterAssetProviderImpl(registrar: registrar), + consentOrPayEventNotifier: consentOrPayEventNotifier) registrar.addMethodCallDelegate(instance, channel: channel) let gppEventChannel = FlutterEventChannel(name: "usercentrics/onGppSectionChange", binaryMessenger: messenger) let streamHandler = GppSectionChangeStreamHandler() gppEventChannel.setStreamHandler(streamHandler) gppStreamHandler = streamHandler + + let loginClickedEventChannel = FlutterEventChannel(name: "usercentrics/onLoginClicked", binaryMessenger: messenger) + let loginHandler = LoginClickedStreamHandler(notifier: consentOrPayEventNotifier) + loginClickedEventChannel.setStreamHandler(loginHandler) + loginClickedStreamHandler = loginHandler + + let subscribeClickedEventChannel = FlutterEventChannel(name: "usercentrics/onSubscribeClicked", binaryMessenger: messenger) + let subscribeHandler = SubscribeClickedStreamHandler(notifier: consentOrPayEventNotifier) + subscribeClickedEventChannel.setStreamHandler(subscribeHandler) + subscribeClickedStreamHandler = subscribeHandler } let assetProvider: FlutterAssetProvider let usercentrics: UsercentricsProxyProtocol + let consentOrPayEventNotifier: ConsentOrPayEventNotifier - init(assetProvider: FlutterAssetProvider, usercentrics: UsercentricsProxyProtocol = UsercentricsProxy()) { + init(assetProvider: FlutterAssetProvider, + usercentrics: UsercentricsProxyProtocol = UsercentricsProxy(), + consentOrPayEventNotifier: ConsentOrPayEventNotifier = ConsentOrPayEventNotifier()) { self.assetProvider = assetProvider self.usercentrics = usercentrics + self.consentOrPayEventNotifier = consentOrPayEventNotifier } lazy var methods: [String : MethodBridge] = { let bridges: [MethodBridge] = [ InitializeBridge(usercentrics: usercentrics), IsReadyBridge(usercentrics: usercentrics), - ShowFirstLayerBridge(assetProvider: assetProvider), + ShowFirstLayerBridge(assetProvider: assetProvider, + bannerProxy: UsercentricsBannerProxy(consentOrPayEventNotifier: consentOrPayEventNotifier)), ShowSecondLayerBridge(assetProvider: assetProvider), GetControllerIdBridge(usercentrics: usercentrics), GetConsentsBridge(usercentrics: usercentrics), @@ -63,7 +82,9 @@ public class UsercentricsPlugin: NSObject, FlutterPlugin { GetGPPDataBridge(usercentrics: usercentrics), GetGPPStringBridge(usercentrics: usercentrics), SetGPPConsentBridge(usercentrics: usercentrics), - GetDpsMetadataBridge(usercentrics: usercentrics) + GetDpsMetadataBridge(usercentrics: usercentrics), + NotifyLoginSuccessBridge(usercentrics: usercentrics), + NotifySubscribeSuccessBridge(usercentrics: usercentrics) ] return bridges.reduce([String : MethodBridge]()) { dict, value in var dict = dict diff --git a/lib/src/internal/bridge/bridge.dart b/lib/src/internal/bridge/bridge.dart index 687b6fb2..6a0db422 100644 --- a/lib/src/internal/bridge/bridge.dart +++ b/lib/src/internal/bridge/bridge.dart @@ -27,3 +27,5 @@ export 'get_gpp_data_bridge.dart'; export 'get_gpp_string_bridge.dart'; export 'set_gpp_consent_bridge.dart'; export 'get_dps_metadata_bridge.dart'; +export 'notify_login_success_bridge.dart'; +export 'notify_subscribe_success_bridge.dart'; diff --git a/lib/src/internal/bridge/notify_login_success_bridge.dart b/lib/src/internal/bridge/notify_login_success_bridge.dart new file mode 100644 index 00000000..98505e50 --- /dev/null +++ b/lib/src/internal/bridge/notify_login_success_bridge.dart @@ -0,0 +1,18 @@ +import 'package:flutter/services.dart'; + +abstract class NotifyLoginSuccessBridge { + const NotifyLoginSuccessBridge(); + + Future invoke({required MethodChannel channel}); +} + +class MethodChannelNotifyLoginSuccess extends NotifyLoginSuccessBridge { + const MethodChannelNotifyLoginSuccess(); + + static const String _name = 'notifyLoginSuccess'; + + @override + Future invoke({required MethodChannel channel}) async { + await channel.invokeMethod(_name); + } +} diff --git a/lib/src/internal/bridge/notify_subscribe_success_bridge.dart b/lib/src/internal/bridge/notify_subscribe_success_bridge.dart new file mode 100644 index 00000000..f72c3417 --- /dev/null +++ b/lib/src/internal/bridge/notify_subscribe_success_bridge.dart @@ -0,0 +1,18 @@ +import 'package:flutter/services.dart'; + +abstract class NotifySubscribeSuccessBridge { + const NotifySubscribeSuccessBridge(); + + Future invoke({required MethodChannel channel}); +} + +class MethodChannelNotifySubscribeSuccess extends NotifySubscribeSuccessBridge { + const MethodChannelNotifySubscribeSuccess(); + + static const String _name = 'notifySubscribeSuccess'; + + @override + Future invoke({required MethodChannel channel}) async { + await channel.invokeMethod(_name); + } +} diff --git a/lib/src/internal/platform/method_channel_usercentrics.dart b/lib/src/internal/platform/method_channel_usercentrics.dart index 944dfd18..16c276a3 100644 --- a/lib/src/internal/platform/method_channel_usercentrics.dart +++ b/lib/src/internal/platform/method_channel_usercentrics.dart @@ -38,11 +38,18 @@ class MethodChannelUsercentrics extends UsercentricsPlatform { this.getGPPDataBridge = const MethodChannelGetGPPData(), this.getGPPStringBridge = const MethodChannelGetGPPString(), this.setGPPConsentBridge = const MethodChannelSetGPPConsent(), - this.getDpsMetadataBridge = const MethodChannelGetDpsMetadata()}); + this.getDpsMetadataBridge = const MethodChannelGetDpsMetadata(), + this.notifyLoginSuccessBridge = const MethodChannelNotifyLoginSuccess(), + this.notifySubscribeSuccessBridge = + const MethodChannelNotifySubscribeSuccess()}); static const MethodChannel _channel = MethodChannel('usercentrics'); static const EventChannel _gppSectionChangeEventChannel = EventChannel('usercentrics/onGppSectionChange'); + static const EventChannel _loginClickedEventChannel = + EventChannel('usercentrics/onLoginClicked'); + static const EventChannel _subscribeClickedEventChannel = + EventChannel('usercentrics/onSubscribeClicked'); final InitializeBridge initializeBridge; final IsReadyBridge isReadyBridge; @@ -73,6 +80,8 @@ class MethodChannelUsercentrics extends UsercentricsPlatform { final GetGPPStringBridge getGPPStringBridge; final SetGPPConsentBridge setGPPConsentBridge; final GetDpsMetadataBridge getDpsMetadataBridge; + final NotifyLoginSuccessBridge notifyLoginSuccessBridge; + final NotifySubscribeSuccessBridge notifySubscribeSuccessBridge; @visibleForTesting Completer? isReadyCompleter; @@ -401,4 +410,30 @@ class MethodChannelUsercentrics extends UsercentricsPlatform { .receiveBroadcastStream() .map((event) => GppDataSerializer.deserializePayload(event)); } + + @override + Stream get onLoginClicked { + return _loginClickedEventChannel + .receiveBroadcastStream() + .map((event) => event as String?); + } + + @override + Stream get onSubscribeClicked { + return _subscribeClickedEventChannel + .receiveBroadcastStream() + .map((event) => event as String?); + } + + @override + Future notifyLoginSuccess() async { + await _ensureIsReady(); + await notifyLoginSuccessBridge.invoke(channel: _channel); + } + + @override + Future notifySubscribeSuccess() async { + await _ensureIsReady(); + await notifySubscribeSuccessBridge.invoke(channel: _channel); + } } diff --git a/lib/src/internal/serializer/tcf2_settings_serializer.dart b/lib/src/internal/serializer/tcf2_settings_serializer.dart index c2edd5a0..1b79014a 100644 --- a/lib/src/internal/serializer/tcf2_settings_serializer.dart +++ b/lib/src/internal/serializer/tcf2_settings_serializer.dart @@ -136,6 +136,41 @@ class TCF2ConsentOrPaySettingsSerializer { (value['publisherRestrictions'] as Map?)?.cast() ?? {}, specialFeatures: - (value['specialFeatures'] as Map?)?.cast() ?? {}); + (value['specialFeatures'] as Map?)?.cast() ?? {}, + loginLink: value['loginLink'], + rejectLink: value['rejectLink'], + rejectButtonText: value['rejectButtonText'], + rejectButtonBgColor: value['rejectButtonBgColor'], + rejectButtonTextColor: value['rejectButtonTextColor'], + firstLayer: TCF2ConsentOrPayFirstLayerSettingsSerializer.deserialize( + value['firstLayer']), + secondLayer: TCF2ConsentOrPaySecondLayerSettingsSerializer.deserialize( + value['secondLayer'])); + } +} + +class TCF2ConsentOrPayFirstLayerSettingsSerializer { + static TCF2ConsentOrPayFirstLayerSettings? deserialize(value) { + if (value == null) return null; + return TCF2ConsentOrPayFirstLayerSettings( + headerTitle: value['headerTitle'], + optinBannerTitle: value['optinBannerTitle'], + optinBannerMessage: value['optinBannerMessage'], + rejectAndSubscribeTitle: value['rejectAndSubscribeTitle'], + rejectAndSubscribeBannerMessage: + value['rejectAndSubscribeBannerMessage'], + pricingText: value['pricingText'], + subscriberLoginMessage: value['subscriberLoginMessage'], + subscriberLoginHyperlinkText: value['subscriberLoginHyperlinkText'], + ); + } +} + +class TCF2ConsentOrPaySecondLayerSettingsSerializer { + static TCF2ConsentOrPaySecondLayerSettings? deserialize(value) { + if (value == null) return null; + return TCF2ConsentOrPaySecondLayerSettings( + granularConsentMessage: value['granularConsentMessage'], + ); } } diff --git a/lib/src/model/tcf2_settings.dart b/lib/src/model/tcf2_settings.dart index 63138cb9..c4db3ee9 100644 --- a/lib/src/model/tcf2_settings.dart +++ b/lib/src/model/tcf2_settings.dart @@ -320,7 +320,14 @@ class TCF2ConsentOrPaySettings { {required this.enableConsentOrPay, required this.showTogglesForVendors, required this.publisherRestrictions, - required this.specialFeatures}); + required this.specialFeatures, + this.loginLink, + this.rejectLink, + this.rejectButtonText, + this.rejectButtonBgColor, + this.rejectButtonTextColor, + this.firstLayer, + this.secondLayer}); final bool enableConsentOrPay; final bool showTogglesForVendors; @@ -331,6 +338,17 @@ class TCF2ConsentOrPaySettings { /// Maps Special Feature ID (as string) to "flexible". Absent entries are mandatory. final Map specialFeatures; + /// URL the host app should open in response to the 1st-layer subscriber-login link. + final String? loginLink; + + /// URL the host app should open in response to the 1st-layer Reject & Subscribe button. + final String? rejectLink; + final String? rejectButtonText; + final String? rejectButtonBgColor; + final String? rejectButtonTextColor; + final TCF2ConsentOrPayFirstLayerSettings? firstLayer; + final TCF2ConsentOrPaySecondLayerSettings? secondLayer; + @override bool operator ==(Object other) => identical(this, other) || @@ -339,12 +357,90 @@ class TCF2ConsentOrPaySettings { enableConsentOrPay == other.enableConsentOrPay && showTogglesForVendors == other.showTogglesForVendors && mapEquals(publisherRestrictions, other.publisherRestrictions) && - mapEquals(specialFeatures, other.specialFeatures); + mapEquals(specialFeatures, other.specialFeatures) && + loginLink == other.loginLink && + rejectLink == other.rejectLink && + rejectButtonText == other.rejectButtonText && + rejectButtonBgColor == other.rejectButtonBgColor && + rejectButtonTextColor == other.rejectButtonTextColor && + firstLayer == other.firstLayer && + secondLayer == other.secondLayer; @override int get hashCode => enableConsentOrPay.hashCode ^ showTogglesForVendors.hashCode ^ publisherRestrictions.hashCode ^ - specialFeatures.hashCode; + specialFeatures.hashCode ^ + loginLink.hashCode ^ + rejectLink.hashCode ^ + rejectButtonText.hashCode ^ + rejectButtonBgColor.hashCode ^ + rejectButtonTextColor.hashCode ^ + firstLayer.hashCode ^ + secondLayer.hashCode; +} + +class TCF2ConsentOrPayFirstLayerSettings { + const TCF2ConsentOrPayFirstLayerSettings({ + this.headerTitle, + this.optinBannerTitle, + this.optinBannerMessage, + this.rejectAndSubscribeTitle, + this.rejectAndSubscribeBannerMessage, + this.pricingText, + this.subscriberLoginMessage, + this.subscriberLoginHyperlinkText, + }); + + final String? headerTitle; + final String? optinBannerTitle; + final String? optinBannerMessage; + final String? rejectAndSubscribeTitle; + final String? rejectAndSubscribeBannerMessage; + final String? pricingText; + final String? subscriberLoginMessage; + final String? subscriberLoginHyperlinkText; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TCF2ConsentOrPayFirstLayerSettings && + runtimeType == other.runtimeType && + headerTitle == other.headerTitle && + optinBannerTitle == other.optinBannerTitle && + optinBannerMessage == other.optinBannerMessage && + rejectAndSubscribeTitle == other.rejectAndSubscribeTitle && + rejectAndSubscribeBannerMessage == + other.rejectAndSubscribeBannerMessage && + pricingText == other.pricingText && + subscriberLoginMessage == other.subscriberLoginMessage && + subscriberLoginHyperlinkText == other.subscriberLoginHyperlinkText; + + @override + int get hashCode => + headerTitle.hashCode ^ + optinBannerTitle.hashCode ^ + optinBannerMessage.hashCode ^ + rejectAndSubscribeTitle.hashCode ^ + rejectAndSubscribeBannerMessage.hashCode ^ + pricingText.hashCode ^ + subscriberLoginMessage.hashCode ^ + subscriberLoginHyperlinkText.hashCode; +} + +class TCF2ConsentOrPaySecondLayerSettings { + const TCF2ConsentOrPaySecondLayerSettings({this.granularConsentMessage}); + + final String? granularConsentMessage; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TCF2ConsentOrPaySecondLayerSettings && + runtimeType == other.runtimeType && + granularConsentMessage == other.granularConsentMessage; + + @override + int get hashCode => granularConsentMessage.hashCode; } diff --git a/lib/src/platform/usercentrics_platform.dart b/lib/src/platform/usercentrics_platform.dart index ef558369..8c386530 100644 --- a/lib/src/platform/usercentrics_platform.dart +++ b/lib/src/platform/usercentrics_platform.dart @@ -120,4 +120,20 @@ abstract class UsercentricsPlatform { Future?> getDpsMetadata({ required String templateId, }); + + /// Fires when the user taps the Consent-or-Pay 1st-layer subscriber-login link. + /// The banner is not dismissed automatically — call [notifyLoginSuccess] once the + /// host app confirms login, then dismiss the banner yourself. + Stream get onLoginClicked; + + /// Fires when the user taps the Consent-or-Pay 1st-layer Reject & Subscribe button. + /// The banner is not dismissed automatically — call [notifySubscribeSuccess] once the + /// host app confirms the subscription, then dismiss the banner yourself. + Stream get onSubscribeClicked; + + /// Clears stored TCF consent data after a successful Consent-or-Pay login. + Future notifyLoginSuccess(); + + /// Clears stored TCF consent data after a successful Consent-or-Pay subscription. + Future notifySubscribeSuccess(); } diff --git a/lib/src/usercentrics.dart b/lib/src/usercentrics.dart index 05668819..78a78fee 100644 --- a/lib/src/usercentrics.dart +++ b/lib/src/usercentrics.dart @@ -255,4 +255,21 @@ class Usercentrics { required String templateId, }) => _delegate.getDpsMetadata(templateId: templateId); + + /// Fires when the user taps the Consent-or-Pay 1st-layer subscriber-login link. + /// The banner is not dismissed automatically — call [notifyLoginSuccess] once the + /// host app confirms login, then dismiss the banner yourself. + static Stream get onLoginClicked => _delegate.onLoginClicked; + + /// Fires when the user taps the Consent-or-Pay 1st-layer Reject & Subscribe button. + /// The banner is not dismissed automatically — call [notifySubscribeSuccess] once the + /// host app confirms the subscription, then dismiss the banner yourself. + static Stream get onSubscribeClicked => _delegate.onSubscribeClicked; + + /// Clears stored TCF consent data after a successful Consent-or-Pay login. + static Future notifyLoginSuccess() => _delegate.notifyLoginSuccess(); + + /// Clears stored TCF consent data after a successful Consent-or-Pay subscription. + static Future notifySubscribeSuccess() => + _delegate.notifySubscribeSuccess(); }