diff --git a/.gitignore b/.gitignore index 26afa44..6e52746 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ CLAUDE.md .swiftpm/ **/Flutter/ephemeral/ **/xcshareddata/swiftpm/ +ios/didomi_sdk/Package.resolved diff --git a/android/src/main/kotlin/io/didomi/fluttersdk/DidomiEventStreamHandler.kt b/android/src/main/kotlin/io/didomi/fluttersdk/DidomiEventStreamHandler.kt index ec390f0..1502d03 100644 --- a/android/src/main/kotlin/io/didomi/fluttersdk/DidomiEventStreamHandler.kt +++ b/android/src/main/kotlin/io/didomi/fluttersdk/DidomiEventStreamHandler.kt @@ -203,6 +203,18 @@ class DidomiEventStreamHandler : EventChannel.StreamHandler, DidomiEventListener sendEvent("preferencesClickSPIPurposeSaveChoices") } + /* + * Widgets events + */ + + override fun showWidget(event: ShowWidgetEvent) { + sendEvent("onShowWidget", mapOf("widgetId" to event.widgetId, "layerName" to event.layerName)) + } + + override fun hideWidget(event: HideWidgetEvent) { + sendEvent("onHideWidget") + } + /* * Consent events */ diff --git a/example/integration_test/purpose_test.dart b/example/integration_test/purpose_test.dart index 0fe0135..532d189 100644 --- a/example/integration_test/purpose_test.dart +++ b/example/integration_test/purpose_test.dart @@ -17,7 +17,7 @@ void main() { const purpose1Name = "Store and/or access information on a device"; const purpose1Description = "Cookies, device or similar onl..."; const purposeNames = "$purpose1Name, " - "Create profiles for personalised advertising, Actively scan device characteristics for identification, " + "Create profiles for personalised advertising, Identify devices based on information actively requested, " "Use precise geolocation data, Develop and improve services, Understand audiences through statistics or " "combinations of data from different sources, Measure advertising performance, " "Use limited data to select advertising, Use profiles to select personalised advertising."; diff --git a/example/integration_test/widget_events_test.dart b/example/integration_test/widget_events_test.dart new file mode 100644 index 0000000..dae3e77 --- /dev/null +++ b/example/integration_test/widget_events_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:didomi_sdk/didomi_sdk.dart'; +import 'package:didomi_sdk/events/event_listener.dart'; +import 'package:didomi_sdk/events/show_widget_event.dart'; +import 'package:didomi_sdk_example/testapps/sample_for_notice_tests.dart' as app; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'util/initialize_helper.dart'; + +/// Coverage note: +/// +/// onShowWidget / onHideWidget are emitted by the Web SDK from inside a +/// WebView-rendered experience. The notice served by this example app's +/// configuration is natively rendered and contains no widget, and there is no +/// Dart-side API to trigger a widget, so real widget event delivery cannot be +/// produced here. +/// +/// This test therefore covers what is verifiable automatically: +/// - the new listener fields exist, are assignable and are accepted by +/// addEventListener; +/// - registering them does not disturb the normal event flow (onReady still +/// fires, no error is reported); +/// - no widget event is spuriously emitted against a non-widget config, which +/// would indicate a wire-string or dispatch mistake; +/// - both native handlers still build and register — a missing Kotlin override +/// or a Swift typo regresses here. +/// +/// Asserting a real onShowWidget payload requires a Didomi configuration whose +/// experience is web-rendered and serves a widget. See the manual verification +/// steps in the pull request description. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final initializeBtnFinder = find.byKey(Key("initializeSmall")); + final setupUIBtnFinder = find.byKey(Key("setupUI")); + final showNoticeBtnFinder = find.byKey(Key("showNotice")); + + bool isError = false; + bool isReady = false; + bool widgetWasShown = false; + bool widgetWasHidden = false; + ShowWidgetEvent? lastShowWidgetEvent; + + final listener = EventListener(); + listener.onError = (String message) { + isError = true; + }; + listener.onReady = () { + isReady = true; + }; + listener.onShowWidget = (ShowWidgetEvent event) { + widgetWasShown = true; + lastShowWidgetEvent = event; + }; + listener.onHideWidget = () { + widgetWasHidden = true; + }; + + DidomiSdk.addEventListener(listener); + + group("Widget events", () { + testWidgets("No widget event before initialization", (WidgetTester tester) async { + // Start app + app.main(); + await tester.pumpAndSettle(); + + assert(isError == false); + assert(isReady == false); + assert(widgetWasShown == false); + assert(widgetWasHidden == false); + assert(lastShowWidgetEvent == null); + }); + + testWidgets("Widget event listeners do not disrupt the event flow", (WidgetTester tester) async { + // Start app + app.main(); + await tester.pumpAndSettle(); + + await InitializeHelper.initialize(tester, initializeBtnFinder); + + assert(isError == false); + assert(isReady == true); + + if (Platform.isIOS) { + await tester.tap(setupUIBtnFinder); + await tester.pumpAndSettle(); + } + + await tester.tap(showNoticeBtnFinder); + await tester.pumpAndSettle(); + + // Events coming from the native SDK are not flushed by pumpAndSettle. + await Future.delayed(Duration(seconds: 1)); + + // The notice served by this config is native and holds no widget, + // so no widget event must have been emitted. + assert(isError == false); + assert(widgetWasShown == false); + assert(widgetWasHidden == false); + assert(lastShowWidgetEvent == null); + }); + }); +} diff --git a/example/lib/events_helper.dart b/example/lib/events_helper.dart index bb666e7..b1f3db1 100644 --- a/example/lib/events_helper.dart +++ b/example/lib/events_helper.dart @@ -148,6 +148,14 @@ class EventsHelper { onEvent("Language has not changed: $reason"); }; + // Widgets events + didomiListener.onShowWidget = (event) { + onEvent("Widget displayed (widgetId: ${event.widgetId}, layerName: ${event.layerName})"); + }; + didomiListener.onHideWidget = () { + onEvent("Widget hidden"); + }; + DidomiSdk.addEventListener(didomiListener); } diff --git a/ios/didomi_sdk/Sources/DidomiSwift/DidomiEventStreamHandler.swift b/ios/didomi_sdk/Sources/DidomiSwift/DidomiEventStreamHandler.swift index 9b4a339..a3c7526 100644 --- a/ios/didomi_sdk/Sources/DidomiSwift/DidomiEventStreamHandler.swift +++ b/ios/didomi_sdk/Sources/DidomiSwift/DidomiEventStreamHandler.swift @@ -175,6 +175,16 @@ class DidomiEventStreamHandler: NSObject, FlutterStreamHandler { eventListener.onIntegrationError = { [weak self] event in self?.sendEvent(eventType: "onIntegrationError", arguments: ["integrationName": event.integrationName, "reason": event.reason]) } + + // Widgets events + // Note: the iOS SDK names the property `widgetID`, Android names it `widgetId`. + // The wire key must stay `widgetId` so both platforms match the Dart handler. + eventListener.onShowWidget = { [weak self] event in + self?.sendEvent(eventType: "onShowWidget", arguments: ["widgetId": event.widgetID, "layerName": event.layerName]) + } + eventListener.onHideWidget = { [weak self] event in + self?.sendEvent(eventType: "onHideWidget") + } } func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { diff --git a/lib/events/event_listener.dart b/lib/events/event_listener.dart index 47a3734..e7d69dd 100644 --- a/lib/events/event_listener.dart +++ b/lib/events/event_listener.dart @@ -1,6 +1,7 @@ import 'package:didomi_sdk/events/sync_ready_event.dart'; import 'integration_error_event.dart'; +import 'show_widget_event.dart'; /// Listener to events sent by the Didomi SDK class EventListener { @@ -179,4 +180,14 @@ class EventListener { /// Integration with an external SDK encountered an error dynamic Function(IntegrationErrorEvent event) onIntegrationError = (event) {}; + + /* + * Widgets events + */ + + /// A widget was displayed + dynamic Function(ShowWidgetEvent event) onShowWidget = (event) {}; + + /// A widget was hidden + dynamic Function() onHideWidget = () {}; } diff --git a/lib/events/events_handler.dart b/lib/events/events_handler.dart index 374ace9..ccc9b31 100644 --- a/lib/events/events_handler.dart +++ b/lib/events/events_handler.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'event_listener.dart'; import 'integration_error_event.dart'; +import 'show_widget_event.dart'; /// Handler for events emitted by native SDK class EventsHandler { @@ -327,6 +328,26 @@ class EventsHandler { } break; + /// Widgets events + + case "onShowWidget": + // Cast instead of toString(): both fields are nullable natively, and + // toString() would turn a null into the literal string "null". + final ShowWidgetEvent showWidgetEvent = ShowWidgetEvent( + event["widgetId"] as String?, + event["layerName"] as String?, + ); + for (var listener in listeners) { + listener.onShowWidget(showWidgetEvent); + } + break; + + case "onHideWidget": + for (var listener in listeners) { + listener.onHideWidget(); + } + break; + default: print("Received invalid event: $eventType"); break; diff --git a/lib/events/show_widget_event.dart b/lib/events/show_widget_event.dart new file mode 100644 index 0000000..0195a44 --- /dev/null +++ b/lib/events/show_widget_event.dart @@ -0,0 +1,10 @@ +/** A widget was displayed. */ +class ShowWidgetEvent { + // Identifier of the widget that was displayed, as resolved by the Rules Engine + String? widgetId; + + // Name of the layer at which the widget was displayed + String? layerName; + + ShowWidgetEvent(this.widgetId, this.layerName); +}