From 5fda773e0f863ca71b0392e2231e0ef26c2e4a17 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 1 Sep 2026 10:54:52 +0300 Subject: [PATCH 1/6] feat: contents and feedback widgets --- CHANGELOG.md | 13 + app-javafx/build.gradle | 3 + .../ly/count/javafx/demo/ui/ContentPane.java | 111 ++++ .../javafx/demo/ui/FeedbackWidgetsPane.java | 21 +- .../ly/count/javafx/demo/ui/InitPane.java | 3 + .../ly/count/javafx/demo/ui/MainView.java | 3 +- .../ly/count/javafx/demo/ui/WidgetCard.java | 8 +- sdk-java-ui/README.md | 95 ++++ sdk-java-ui/build.gradle | 50 ++ sdk-java-ui/gradle.properties | 6 + .../ly/count/sdk/java/ui/CountlyWebView.java | 177 ++++++ .../ly/count/sdk/java/ui/ExternalBrowser.java | 36 ++ .../sdk/java/ui/FeedbackWidgetPresenter.java | 165 ++++++ .../sdk/java/ui/JavaFxContentDisplay.java | 184 ++++++ .../count/sdk/java/ui/JavaFxWidgetHost.java | 211 +++++++ .../main/java/ly/count/sdk/java/ui/UiLog.java | 49 ++ .../ly/count/sdk/java/ui/WidgetJsBridge.java | 46 ++ .../sdk/java/ui/WidgetMessageParser.java | 79 +++ .../ly/count/sdk/java/ui/WidgetPlacement.java | 51 ++ .../ly/count/sdk/java/ui/WidgetSurface.java | 33 ++ .../ly/count/sdk/java/ui/WidgetWebHost.java | 73 +++ .../java/ui/FeedbackWidgetPresenterTests.java | 216 ++++++++ .../sdk/java/ui/WidgetPlacementTests.java | 106 ++++ .../main/java/ly/count/sdk/java/Config.java | 13 +- .../main/java/ly/count/sdk/java/Countly.java | 19 + .../sdk/java/internal/ConfigContent.java | 51 ++ .../sdk/java/internal/ContentCallback.java | 18 + .../java/internal/ContentCloseCallback.java | 19 + .../count/sdk/java/internal/ContentData.java | 41 ++ .../sdk/java/internal/ContentDisplay.java | 31 ++ .../sdk/java/internal/ContentParser.java | 61 ++ .../sdk/java/internal/ContentPlacement.java | 26 + .../java/internal/ContentRequestBuilder.java | 67 +++ .../sdk/java/internal/ContentScreen.java | 23 + .../sdk/java/internal/ContentStatus.java | 10 + .../count/sdk/java/internal/CoreFeature.java | 3 +- .../count/sdk/java/internal/CountlyTimer.java | 34 +- .../sdk/java/internal/ModuleContent.java | 523 ++++++++++++++++++ .../sdk/java/internal/ModuleFeedback.java | 18 +- .../ly/count/sdk/java/internal/SDKCore.java | 27 + .../count/sdk/java/internal/WidgetAction.java | 50 ++ .../sdk/java/internal/WidgetActionParser.java | 172 ++++++ .../sdk/java/internal/WidgetUrlBuilder.java | 68 +++ .../java/internal/ContentParsingTests.java | 216 ++++++++ .../sdk/java/internal/ModuleContentTests.java | 451 +++++++++++++++ .../java/internal/ModuleFeedbackTests.java | 8 + .../ly/count/sdk/java/internal/TestUtils.java | 9 + settings.gradle | 5 +- 48 files changed, 3670 insertions(+), 32 deletions(-) create mode 100644 app-javafx/src/main/java/ly/count/javafx/demo/ui/ContentPane.java create mode 100644 sdk-java-ui/README.md create mode 100644 sdk-java-ui/build.gradle create mode 100644 sdk-java-ui/gradle.properties create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/CountlyWebView.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/ExternalBrowser.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/FeedbackWidgetPresenter.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxContentDisplay.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxWidgetHost.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/UiLog.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetJsBridge.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetMessageParser.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetPlacement.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetSurface.java create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetWebHost.java create mode 100644 sdk-java-ui/src/test/java/ly/count/sdk/java/ui/FeedbackWidgetPresenterTests.java create mode 100644 sdk-java-ui/src/test/java/ly/count/sdk/java/ui/WidgetPlacementTests.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigContent.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCallback.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCloseCallback.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentData.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentDisplay.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentParser.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentPlacement.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentRequestBuilder.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentScreen.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ContentStatus.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetAction.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetActionParser.java create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetUrlBuilder.java create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/ContentParsingTests.java create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b42b9ead..47157b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ ## XX.XX.XX +* Added support for the Content feature, accessible through the "Countly.instance().content()" interface: + * "enterContentZone" / "exitContentZone" for starting and stopping periodic content fetching + * "refreshContentZone" for flushing the event queue and fetching again right away + * "previewContent" for showing one specific content block by its ID + * "setContentDisplay" for registering the component that draws content + * Added the "content" configuration group with "setZoneTimerInterval" and "setGlobalContentCallback" + * This feature uses "Content" consent. + * Needs the new "ly.count.sdk:java-ui" artifact for displaying content. +* Added a separately published artifact "ly.count.sdk:java-ui" (requires Java 11 and JavaFX 17) that displays feedback widgets and content on the desktop: + * "CountlyWebView.presentFeedbackWidget" for showing a feedback widget as a positioned card + * "CountlyWebView.enableContentZone" / "CountlyWebView.disableContentZone" for showing content + * "CountlyWebView.setShowWidgetsWithinApp" for keeping widget cards inside the application window + * Implement "ContentDisplay" yourself to draw content with another toolkit. * Updated JSON library version from "20250107" to "20250517". ## 24.1.6 diff --git a/app-javafx/build.gradle b/app-javafx/build.gradle index 9c8be04c..c18844d2 100644 --- a/app-javafx/build.gradle +++ b/app-javafx/build.gradle @@ -19,6 +19,9 @@ dependencies { // picked up without publishing or copying a jar. implementation project(':sdk-java') + // The JavaFX UI artifact: feedback widget cards and the content overlay. + implementation project(':sdk-java-ui') + // org.json is used by the demo for pretty-printing widget data; the // SDK also uses it internally (and exposes JSONObject in its API). implementation 'org.json:json:20250517' diff --git a/app-javafx/src/main/java/ly/count/javafx/demo/ui/ContentPane.java b/app-javafx/src/main/java/ly/count/javafx/demo/ui/ContentPane.java new file mode 100644 index 00000000..21e36a26 --- /dev/null +++ b/app-javafx/src/main/java/ly/count/javafx/demo/ui/ContentPane.java @@ -0,0 +1,111 @@ +package ly.count.javafx.demo.ui; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import ly.count.sdk.java.Countly; +import ly.count.sdk.java.ui.CountlyWebView; + +/** + * Drives the Content feature through the JavaFX UI artifact: enter and leave the content zone, force + * a refresh, and preview one specific content block by ID. + * + *

Content is fetched by the core SDK and drawn by {@code sdk-java-ui} as a borderless, always on + * top window placed where the server asked for it. The area around it stays usable. + */ +public class ContentPane { + + private final VBox root = new VBox(12); + private final LogPanel log; + private final TextField categoriesField = new TextField(); + private final TextField previewIdField = new TextField(); + private final CheckBox useCategories = new CheckBox("Filter by categories"); + + public ContentPane(LogPanel log) { + this.log = log; + + root.setPadding(new Insets(14)); + + Label title = new Label("Content zone"); + title.getStyleClass().add("section-title"); + + categoriesField.setPromptText("promo, onboarding"); + categoriesField.setPrefColumnCount(24); + categoriesField.disableProperty().bind(useCategories.selectedProperty().not()); + + Button enter = new Button("Enter content zone"); + enter.setOnAction(event -> enterZone()); + + Button exit = new Button("Exit content zone"); + exit.setOnAction(event -> SdkUtil.run(log, "[Content] exitContentZone", CountlyWebView::disableContentZone)); + + Button refresh = new Button("Refresh content zone"); + refresh.setOnAction(event -> SdkUtil.run(log, "[Content] refreshContentZone", + () -> Countly.instance().content().refreshContentZone())); + + previewIdField.setPromptText("content block ID"); + previewIdField.setPrefColumnCount(24); + + Button preview = new Button("Preview by ID"); + preview.setOnAction(event -> previewContent()); + + HBox zoneRow = new HBox(8, enter, exit, refresh); + zoneRow.setAlignment(Pos.CENTER_LEFT); + + HBox categoryRow = new HBox(8, useCategories, categoriesField); + categoryRow.setAlignment(Pos.CENTER_LEFT); + + HBox previewRow = new HBox(8, new Label("Preview:"), previewIdField, preview); + previewRow.setAlignment(Pos.CENTER_LEFT); + + Label hint = new Label( + "Enable the Content feature on the Init tab before entering a zone. " + + "The first fetch waits about 4 seconds, then the SDK polls on the configured interval."); + hint.setWrapText(true); + hint.getStyleClass().add("placeholder"); + + root.getChildren().addAll(title, categoryRow, zoneRow, previewRow, hint); + } + + private void enterZone() { + SdkUtil.run(log, "[Content] enterContentZone", () -> CountlyWebView.enableContentZone(parseCategories())); + } + + private void previewContent() { + String id = previewIdField.getText().trim(); + if (id.isEmpty()) { + log.warn("[Content] Enter a content block ID to preview."); + return; + } + SdkUtil.run(log, "[Content] previewContent " + id, () -> { + // Previewing still needs a display registered, which entering the zone does for us. + CountlyWebView.enableContentZone(parseCategories()); + Countly.instance().content().previewContent(id); + }); + } + + private String[] parseCategories() { + if (!useCategories.isSelected()) { + return null; + } + String raw = categoriesField.getText().trim(); + if (raw.isEmpty()) { + return null; + } + String[] parts = raw.split(","); + for (int i = 0; i < parts.length; i++) { + parts[i] = parts[i].trim(); + } + return parts; + } + + public Parent getRoot() { + return root; + } +} diff --git a/app-javafx/src/main/java/ly/count/javafx/demo/ui/FeedbackWidgetsPane.java b/app-javafx/src/main/java/ly/count/javafx/demo/ui/FeedbackWidgetsPane.java index 024fdeb6..a65ed164 100644 --- a/app-javafx/src/main/java/ly/count/javafx/demo/ui/FeedbackWidgetsPane.java +++ b/app-javafx/src/main/java/ly/count/javafx/demo/ui/FeedbackWidgetsPane.java @@ -31,10 +31,12 @@ import javafx.scene.layout.VBox; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; +import javafx.stage.Window; import ly.count.javafx.demo.AppContext; import ly.count.sdk.java.Countly; import ly.count.sdk.java.internal.CountlyFeedbackWidget; import ly.count.sdk.java.internal.FeedbackWidgetType; +import ly.count.sdk.java.ui.CountlyWebView; /** * Mirrors cpp_demo/main.cpp + Countly_Feedback_Widget_Implementation_Guide.html: @@ -164,7 +166,8 @@ private void refreshCards() { cardList.getChildren().add(new WidgetCard(w, wv, widget -> openWidget(widget, wv), this::inspectWidget, - this::openManualReportDialog)); + this::openManualReportDialog, + this::presentWithSdkUi)); } } @@ -213,6 +216,22 @@ private void showPlaceholder(String text) { webView.setVisible(false); } + // ------------------- Presentation by the SDK UI artifact ------------------- + + /** + * Hands the widget to {@code sdk-java-ui}, which builds the URL, drives the card and reports the + * dismissal itself. The panel above stays as the hand rolled reference implementation. + */ + private void presentWithSdkUi(CountlyFeedbackWidget widget) { + if (!SdkUtil.requireSdk(log)) { + return; + } + log.info("[Widget] Presenting " + widget.widgetId + " with the SDK UI artifact"); + Window owner = root.getScene() == null ? null : root.getScene().getWindow(); + CountlyWebView.presentFeedbackWidget(owner, widget, + () -> log.info("[Widget] SDK UI card for " + widget.widgetId + " was dismissed")); + } + // ------------------- Inspect widget data ------------------- private void inspectWidget(CountlyFeedbackWidget widget) { if (!SdkUtil.requireSdk(log)) return; diff --git a/app-javafx/src/main/java/ly/count/javafx/demo/ui/InitPane.java b/app-javafx/src/main/java/ly/count/javafx/demo/ui/InitPane.java index d46ecb6f..4dd44f68 100644 --- a/app-javafx/src/main/java/ly/count/javafx/demo/ui/InitPane.java +++ b/app-javafx/src/main/java/ly/count/javafx/demo/ui/InitPane.java @@ -209,6 +209,9 @@ private void initSdk() { .setLoggingLevel(loggingLevelBox.getValue()) .setLogListener((msg, lvl) -> log.sdk("[" + lvl + "] " + msg)); + config.content.setGlobalContentCallback((status, data) -> + log.info("[Content] callback: " + status + " " + data)); + Config.Feature[] selected = selectedFeatures(); if (selected.length > 0) { config.setFeatures(selected); diff --git a/app-javafx/src/main/java/ly/count/javafx/demo/ui/MainView.java b/app-javafx/src/main/java/ly/count/javafx/demo/ui/MainView.java index f0f444df..13922939 100644 --- a/app-javafx/src/main/java/ly/count/javafx/demo/ui/MainView.java +++ b/app-javafx/src/main/java/ly/count/javafx/demo/ui/MainView.java @@ -32,7 +32,8 @@ public MainView() { tab("Crashes", new CrashesPane(logPanel).getRoot()), tab("Device ID", new DeviceIdPane(logPanel, statusLabel).getRoot()), tab("Remote Config", new RemoteConfigPane(logPanel).getRoot()), - tab("Feedback Widgets", new FeedbackWidgetsPane(logPanel).getRoot()) + tab("Feedback Widgets", new FeedbackWidgetsPane(logPanel).getRoot()), + tab("Content", new ContentPane(logPanel).getRoot()) ); SplitPane split = new SplitPane(); diff --git a/app-javafx/src/main/java/ly/count/javafx/demo/ui/WidgetCard.java b/app-javafx/src/main/java/ly/count/javafx/demo/ui/WidgetCard.java index aefa0bbf..f3bd899b 100644 --- a/app-javafx/src/main/java/ly/count/javafx/demo/ui/WidgetCard.java +++ b/app-javafx/src/main/java/ly/count/javafx/demo/ui/WidgetCard.java @@ -16,7 +16,8 @@ public WidgetCard(CountlyFeedbackWidget widget, String widgetVersion, Consumer onOpen, Consumer onInspect, - Consumer onManualReport) { + Consumer onManualReport, + Consumer onPresentWithSdkUi) { getStyleClass().add("widget-card"); setPadding(new Insets(10)); setSpacing(4); @@ -53,7 +54,10 @@ public WidgetCard(CountlyFeedbackWidget widget, javafx.scene.control.Button manual = new javafx.scene.control.Button("Report manually"); manual.setOnAction(e -> onManualReport.accept(widget)); - HBox actions = new HBox(6, open, inspect, manual); + javafx.scene.control.Button sdkUi = new javafx.scene.control.Button("Present (SDK UI)"); + sdkUi.setOnAction(e -> onPresentWithSdkUi.accept(widget)); + + HBox actions = new HBox(6, open, sdkUi, inspect, manual); actions.setPadding(new Insets(6, 0, 0, 0)); getChildren().addAll(badgeRow, nameLabel, idLabel, tagsLabel, actions); diff --git a/sdk-java-ui/README.md b/sdk-java-ui/README.md new file mode 100644 index 00000000..e5c09723 --- /dev/null +++ b/sdk-java-ui/README.md @@ -0,0 +1,95 @@ +# Countly Java SDK UI + +JavaFX user interface for the [Countly Java SDK](https://github.com/Countly/countly-sdk-java). It +renders **Feedback Widgets** (Surveys, NPS, Ratings) and the **Content** feature on the desktop. It +is a thin companion to the core `java` artifact: the core drives analytics, this artifact only +displays. + +Published separately, so an application that does not show anything on screen, a backend service for +example, never pulls JavaFX in. + +## Requirements + +- Java 11 or newer. The core SDK still runs on Java 8; JavaFX is what raises the floor here. +- JavaFX 17 or newer, with the `javafx.controls` and `javafx.web` modules. +- The `ly.count.sdk:java` core artifact, which comes in as a dependency of this one. + +## Installation + +Replace `LATEST_VERSION` with the version published on +[Maven Central](https://central.sonatype.com/artifact/ly.count.sdk/java-ui). + +Gradle: + +```groovy +dependencies { + implementation 'ly.count.sdk:java-ui:LATEST_VERSION' +} +``` + +Maven: + +```xml + + ly.count.sdk + java-ui + LATEST_VERSION + +``` + +## Usage + +Initialize the core SDK as usual, then reach for `CountlyWebView`. + +### Feedback widgets + +```java +Countly.instance().feedback().getAvailableFeedbackWidgets((widgets, error) -> { + if (error != null || widgets.isEmpty()) { + return; + } + // Must run on the JavaFX application thread. + Platform.runLater(() -> CountlyWebView.presentFeedbackWidget(stage, widgets.get(0), null)); +}); +``` + +The card sizes and positions itself where the widget asks, on the primary screen's work area by +default, or inside the application window when you call +`CountlyWebView.setShowWidgetsWithinApp(true)`. + +### Content + +```java +Config config = new Config(serverUrl, appKey, storageDir) + .enableFeatures(Config.Feature.Content); +config.content.setZoneTimerInterval(30); +config.content.setGlobalContentCallback((status, data) -> System.out.println(status)); + +Countly.instance().init(config); + +CountlyWebView.enableContentZone(); +// ... +CountlyWebView.disableContentZone(); +``` + +Content interactions, the events it records, external links, resizes and closes, are handled for +you. Recorded events are pushed to the server straight away, so it can react to them. + +Content is an **experimental** feature and its API can change. + +### Your own display + +`CountlyWebView` is a convenience. To render content with a different toolkit, implement +`ly.count.sdk.java.internal.ContentDisplay` and register it yourself: + +```java +Countly.instance().content().setContentDisplay(myDisplay); +Countly.instance().content().enterContentZone(); +``` + +A display must call the `onClosed` callback it is handed exactly once, including when it fails to +show anything, otherwise the content zone never resumes fetching. + +## License + +MIT, see the [LICENSE](https://github.com/Countly/countly-sdk-java/blob/master/LICENSE). diff --git a/sdk-java-ui/build.gradle b/sdk-java-ui/build.gradle new file mode 100644 index 00000000..9a5d2332 --- /dev/null +++ b/sdk-java-ui/build.gradle @@ -0,0 +1,50 @@ +buildscript { + repositories { + mavenCentral() + } + // A widget or content link would otherwise be handed to the real system browser +// while tests run. +tasks.withType(Test) { + systemProperty 'java.awt.headless', 'true' +} + +dependencies { + // Load publish plugin ONLY when publish task is requested + if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { + // This requires minimum java 11 to work + classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' + } + } +} + +plugins { + id 'java-library' + id 'org.openjfx.javafxplugin' version '0.1.0' +} + +// JavaFX itself needs Java 11+, so this artifact has a higher floor than the +// core SDK, which stays on Java 8. Customers on Java 8 keep using the core. +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +javafx { + version = '17.0.20' + modules = ['javafx.controls', 'javafx.web'] +} + +dependencies { + // 'api' so a consumer of this artifact also gets the core SDK, whose types + // appear in this package's public signatures. + api project(':sdk-java') + + implementation 'org.json:json:20250517' + + testImplementation 'junit:junit:4.13.1' + testImplementation 'org.mockito:mockito-core:4.11.0' +} + +if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { + apply plugin: "com.vanniktech.maven.publish" +} diff --git a/sdk-java-ui/gradle.properties b/sdk-java-ui/gradle.properties new file mode 100644 index 00000000..980570f3 --- /dev/null +++ b/sdk-java-ui/gradle.properties @@ -0,0 +1,6 @@ +#RELEASE FIELDS +POM_ARTIFACT_ID=java-ui + +POM_NAME=Countly Java SDK UI +POM_DESCRIPTION=JavaFX user interface for the Countly Java SDK, displaying feedback widgets and content +POM_INCEPTION_YEAR=2026 diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/CountlyWebView.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/CountlyWebView.java new file mode 100644 index 00000000..78d64d0f --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/CountlyWebView.java @@ -0,0 +1,177 @@ +package ly.count.sdk.java.ui; + +import javafx.application.Platform; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.stage.Window; +import ly.count.sdk.java.Countly; +import ly.count.sdk.java.internal.CountlyFeedbackWidget; +import ly.count.sdk.java.internal.ModuleContent; +import ly.count.sdk.java.internal.ModuleFeedback; + +/** + * Entry point for showing Countly feedback widgets and Countly content in a JavaFX application. + *

+ * The core SDK stays headless: it fetches and reports, this package draws. Initialize the SDK as + * usual, then present a widget or turn the content zone on. + * + *

+ * // Feedback widgets
+ * Countly.instance().feedback().getAvailableFeedbackWidgets((widgets, error) ->
+ *     Platform.runLater(() -> CountlyWebView.presentFeedbackWidget(stage, widgets.get(0), null)));
+ *
+ * // Content (experimental)
+ * CountlyWebView.enableContentZone();
+ * CountlyWebView.disableContentZone();
+ * 
+ */ +public final class CountlyWebView { + + private static volatile boolean showWidgetsWithinApp = false; + private static volatile JavaFxContentDisplay contentDisplay = null; + + private CountlyWebView() { + } + + /** + * Place widget cards inside the application window instead of on the screen's work area. + * Defaults to {@code false}, which is what a desktop widget expects. + * + * @param withinApp {@code true} to keep cards inside the owner window + */ + public static void setShowWidgetsWithinApp(boolean withinApp) { + showWidgetsWithinApp = withinApp; + } + + /** + * Show a feedback widget as a borderless card, sized and positioned where the widget asks. Must + * be called on the JavaFX application thread. + * + * @param owner the application window the card belongs to, may be {@code null} + * @param widget the widget to show, obtained from + * {@link ModuleFeedback.Feedback#getAvailableFeedbackWidgets} + * @param onClosed called once, when the card is gone, may be {@code null} + */ + public static void presentFeedbackWidget(Window owner, CountlyFeedbackWidget widget, Runnable onClosed) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> presentFeedbackWidget(owner, widget, onClosed)); + return; + } + + if (widget == null) { + UiLog.w("[CountlyWebView] presentFeedbackWidget, no widget was given, ignoring the call"); + run(onClosed); + return; + } + + ModuleFeedback.Feedback feedback = Countly.instance().feedback(); + if (feedback == null) { + UiLog.w("[CountlyWebView] presentFeedbackWidget, the feedback interface is not available, ignoring the call"); + run(onClosed); + return; + } + + try { + WidgetSurface surface = resolveSurface(owner); + WebView webView = new WebView(); + + // Starts as a 1x1 window at the surface origin so the page can load before the widget + // tells us how big its card has to be; the presenter shows it once it knows. + Stage stage = new Stage(StageStyle.UNDECORATED); + stage.setAlwaysOnTop(true); + stage.setResizable(false); + if (owner != null) { + stage.initOwner(owner); + } + stage.setScene(new Scene(webView, 1, 1)); + stage.setX(surface.x); + stage.setY(surface.y); + + JavaFxWidgetHost host = new JavaFxWidgetHost(stage, webView, surface); + host.initialize(); + + FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(host, feedback, onClosed); + presenter.start(widget); + } catch (Throwable t) { + UiLog.e("[CountlyWebView] presentFeedbackWidget, could not show the widget, [" + t + "]"); + run(onClosed); + } + } + + /** + * Register the JavaFX content display with the SDK and enter the content zone. Must be called on + * the JavaFX application thread, after the SDK was initialized with + * {@code Config.Feature.Content} enabled. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public static void enableContentZone() { + enableContentZone(null); + } + + /** + * Register the JavaFX content display with the SDK and enter the content zone, limited to the + * given categories. Must be called on the JavaFX application thread. + * + * @param categories the content categories to ask for, {@code null} or empty for all + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public static void enableContentZone(String[] categories) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> enableContentZone(categories)); + return; + } + + ModuleContent.Content content = Countly.instance().content(); + if (content == null) { + UiLog.w("[CountlyWebView] enableContentZone, the content interface is not available, ignoring the call"); + return; + } + + if (contentDisplay == null) { + contentDisplay = new JavaFxContentDisplay(); + } + + content.setContentDisplay(contentDisplay); + content.enterContentZone(categories); + } + + /** + * Leave the content zone. A content block that is already on screen stays there, so the user + * can finish with it. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public static void disableContentZone() { + ModuleContent.Content content = Countly.instance().content(); + if (content == null) { + UiLog.w("[CountlyWebView] disableContentZone, the content interface is not available, ignoring the call"); + return; + } + content.exitContentZone(); + } + + private static WidgetSurface resolveSurface(Window owner) { + if (showWidgetsWithinApp && owner != null) { + return new WidgetSurface((int) owner.getX(), (int) owner.getY(), (int) owner.getWidth(), (int) owner.getHeight()); + } + + Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); + return new WidgetSurface((int) bounds.getMinX(), (int) bounds.getMinY(), (int) bounds.getWidth(), (int) bounds.getHeight()); + } + + private static void run(Runnable runnable) { + if (runnable == null) { + return; + } + try { + runnable.run(); + } catch (Throwable t) { + UiLog.e("[CountlyWebView] run, a callback threw, [" + t + "]"); + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/ExternalBrowser.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/ExternalBrowser.java new file mode 100644 index 00000000..e774d831 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/ExternalBrowser.java @@ -0,0 +1,36 @@ +package ly.count.sdk.java.ui; + +import java.awt.Desktop; +import java.net.URI; + +/** + * Opens a link outside of the SDK's own web views. + */ +class ExternalBrowser { + + private ExternalBrowser() { + } + + /** + * Best effort: a headless JVM, or a desktop environment without a browse action, simply cannot + * open links, and that must never take the host application down. + * + * @param url the link to open + * @return {@code true} when the link was handed to the system browser + */ + static boolean open(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + + try { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + return false; + } + Desktop.getDesktop().browse(new URI(url)); + return true; + } catch (Throwable t) { + return false; + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/FeedbackWidgetPresenter.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/FeedbackWidgetPresenter.java new file mode 100644 index 00000000..ccc09c33 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/FeedbackWidgetPresenter.java @@ -0,0 +1,165 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.CountlyFeedbackWidget; +import ly.count.sdk.java.internal.ModuleFeedback; +import ly.count.sdk.java.internal.SDKCore; +import ly.count.sdk.java.internal.WidgetAction; +import ly.count.sdk.java.internal.WidgetActionParser; + +/** + * Drives one feedback widget through a {@link WidgetWebHost}: loads the widget URL, tells the page + * how much room it has once it is up, places the card where the widget asks for it, and reports the + * result back to the SDK when the widget closes. + *

+ * Free of any UI toolkit, so it can be tested against a fake host. + */ +public class FeedbackWidgetPresenter implements WidgetWebHost.Listener { + + private final WidgetWebHost host; + private final ModuleFeedback.Feedback feedback; + private final Runnable onClosed; + + private CountlyFeedbackWidget widget; + private boolean surfaceReported = false; + private boolean finished = false; + + /** + * @param host the browser to drive + * @param feedback the SDK's feedback interface, used to build the URL and report the result + * @param onClosed called once, when the widget is gone, may be {@code null} + */ + public FeedbackWidgetPresenter(WidgetWebHost host, ModuleFeedback.Feedback feedback, Runnable onClosed) { + this.host = host; + this.feedback = feedback; + this.onClosed = onClosed; + host.setListener(this); + } + + /** + * Load the given widget. + * + * @param widgetToShow the widget to present + */ + public void start(CountlyFeedbackWidget widgetToShow) { + widget = widgetToShow; + + if (widgetToShow == null) { + UiLog.w("[FeedbackWidgetPresenter] start, no widget was given, nothing to present"); + finish(); + return; + } + + if (feedback == null) { + // The SDK is not initialized, or feedback consent was not given. + UiLog.w("[FeedbackWidgetPresenter] start, the feedback interface is not available, nothing to present"); + finish(); + return; + } + + String url = feedback.constructFeedbackWidgetUrl(widgetToShow); + if (url == null || url.trim().isEmpty()) { + UiLog.w("[FeedbackWidgetPresenter] start, could not build a URL for widget [" + widgetToShow.widgetId + "]"); + finish(); + return; + } + + UiLog.i("[FeedbackWidgetPresenter] start, presenting widget [" + widgetToShow.widgetId + "]"); + host.navigate(url); + } + + @Override + public void onPageLoaded() { + if (surfaceReported) { + return; + } + surfaceReported = true; + + WidgetSurface surface = host.getSurface(); + UiLog.d("[FeedbackWidgetPresenter] onPageLoaded, reporting surface " + surface); + // The widget can only work out its own card size once it knows the viewport, and it only + // listens for that after its own page has loaded. + host.reportSurfaceSize(surface.width, surface.height); + } + + @Override + public void onLoadFailed() { + UiLog.w("[FeedbackWidgetPresenter] onLoadFailed, the widget page could not be loaded"); + // Without this the caller would be left with an invisible card and no callback. + finish(); + } + + @Override + public void onNavigationStarting(String url) { + WidgetAction action = WidgetActionParser.parse(url, SDKCore.logger()); + if (!action.isSdkSignal) { + return; + } + + if (action.isExternalLink) { + UiLog.d("[FeedbackWidgetPresenter] onNavigationStarting, opening an external link"); + ExternalBrowser.open(action.link); + return; + } + + handle(action); + } + + @Override + public void onWidgetMessage(String json) { + WidgetAction action = WidgetMessageParser.parse(json); + if (action == null) { + return; + } + handle(action); + } + + private void handle(WidgetAction action) { + if (action.hasResize) { + ContentPlacement rect = WidgetPlacement.resolve(action, host.getSurface()); + if (rect != null) { + UiLog.d("[FeedbackWidgetPresenter] handle, placing the widget at " + rect); + host.placeAndShow(rect); + } + } + + if (action.link != null) { + ExternalBrowser.open(action.link); + } + + if (action.close) { + UiLog.i("[FeedbackWidgetPresenter] handle, the widget asked to be closed"); + finish(); + } + } + + /** + * Tears the presentation down exactly once: reports the dismissal to the SDK, closes the host + * and tells the caller. + */ + private void finish() { + if (finished) { + return; + } + finished = true; + + if (widget != null && feedback != null) { + // The widget itself reports a completed result; this marks the dismissal. + feedback.reportFeedbackWidgetManually(widget, null, null); + } + + try { + host.closeHost(); + } catch (Throwable t) { + UiLog.e("[FeedbackWidgetPresenter] finish, the host failed to close, [" + t + "]"); + } + + if (onClosed != null) { + try { + onClosed.run(); + } catch (Throwable t) { + UiLog.e("[FeedbackWidgetPresenter] finish, the close callback threw, [" + t + "]"); + } + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxContentDisplay.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxContentDisplay.java new file mode 100644 index 00000000..1b484388 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxContentDisplay.java @@ -0,0 +1,184 @@ +package ly.count.sdk.java.ui; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.application.Platform; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import ly.count.sdk.java.Countly; +import ly.count.sdk.java.internal.ContentCloseCallback; +import ly.count.sdk.java.internal.ContentData; +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.ContentDisplay; +import ly.count.sdk.java.internal.ContentScreen; +import ly.count.sdk.java.internal.ModuleContent; +import ly.count.sdk.java.internal.SDKCore; +import ly.count.sdk.java.internal.WidgetAction; +import ly.count.sdk.java.internal.WidgetActionParser; + +/** + * Shows Countly content in a borderless, always on top JavaFX window placed where the server asked, + * leaving the rest of the application usable. + *

+ * JavaFX lays a web page out in logical pixels, which is also the unit the server's coordinates come + * back in, so the reported surface and the applied rectangles need no density conversion. + */ +public class JavaFxContentDisplay implements ContentDisplay { + + private volatile WidgetSurface surface; + + /** + * Construct on the JavaFX application thread, so the primary screen can be measured. + */ + public JavaFxContentDisplay() { + surface = readPrimarySurface(); + } + + @Override + public ContentScreen getScreen() { + WidgetSurface current = surface; + return new ContentScreen(current.width, current.height); + } + + @Override + public void present(ContentData content, ContentCloseCallback onClosed) { + // The guard lives out here, not inside the JavaFX call, so the SDK is told the content is + // gone even when the toolkit never runs our block. Without that the content zone would wait + // forever for a close that cannot come. + AtomicBoolean closed = new AtomicBoolean(false); + + try { + Platform.runLater(() -> show(content, closed, onClosed)); + } catch (Throwable t) { + UiLog.e("[JavaFxContentDisplay] present, the JavaFX toolkit is not running, [" + t + "]"); + notifyClosed(closed, onClosed, Collections.emptyMap()); + } + } + + private void show(ContentData content, AtomicBoolean closed, ContentCloseCallback onClosed) { + try { + surface = readPrimarySurface(); + WidgetSurface currentSurface = surface; + + ContentPlacement placement = WidgetPlacement.resolve(content.placementFor(currentSurface.isLandscape()), currentSurface); + if (placement == null) { + UiLog.w("[JavaFxContentDisplay] show, the content has no usable placement, dropping it"); + notifyClosed(closed, onClosed, Collections.emptyMap()); + return; + } + + // Captured now, while consent is known to be good. Re-resolving it per navigation could + // hand back null after a consent change and silently drop the content's own events. + ModuleContent.Content contentInterface = Countly.instance().content(); + + WebView webView = new WebView(); + WebEngine engine = webView.getEngine(); + engine.setJavaScriptEnabled(true); + + Stage stage = new Stage(StageStyle.UNDECORATED); + stage.setAlwaysOnTop(true); + stage.setResizable(false); + stage.setScene(new Scene(webView, placement.width, placement.height)); + stage.setX(placement.x); + stage.setY(placement.y); + stage.setWidth(placement.width); + stage.setHeight(placement.height); + + engine.locationProperty().addListener((observable, oldUrl, newUrl) -> + onContentUrl(newUrl, engine, stage, currentSurface, contentInterface, closed, onClosed)); + engine.setCreatePopupHandler(features -> { + WebEngine popup = new WebEngine(); + popup.locationProperty().addListener((observable, oldUrl, newUrl) -> ExternalBrowser.open(newUrl)); + return popup; + }); + + // A window the user closed by other means must still release the content zone. + stage.setOnHidden(event -> notifyClosed(closed, onClosed, Collections.emptyMap())); + + UiLog.i("[JavaFxContentDisplay] show, showing content at " + placement); + engine.load(content.url); + stage.show(); + } catch (Throwable t) { + UiLog.e("[JavaFxContentDisplay] show, could not show the content, [" + t + "]"); + notifyClosed(closed, onClosed, Collections.emptyMap()); + } + } + + private void onContentUrl(String url, WebEngine engine, Stage stage, WidgetSurface currentSurface, + ModuleContent.Content contentInterface, AtomicBoolean closed, ContentCloseCallback onClosed) { + + WidgetAction action = WidgetActionParser.parse(url, SDKCore.logger()); + if (!action.isSdkSignal) { + return; + } + + engine.getLoadWorker().cancel(); + + if (action.isExternalLink) { + UiLog.d("[JavaFxContentDisplay] onContentUrl, opening an external link"); + ExternalBrowser.open(action.link); + return; + } + + // Events and links are processed first, the close comes last, as the content protocol asks. + if (action.eventPayload != null && contentInterface != null) { + try { + contentInterface.recordContentEvents(action.eventPayload); + } catch (Throwable t) { + UiLog.e("[JavaFxContentDisplay] onContentUrl, could not record the content events, [" + t + "]"); + } + } + + if (action.link != null) { + ExternalBrowser.open(action.link); + } + + if (action.hasResize) { + ContentPlacement resized = WidgetPlacement.resolve(action, currentSurface); + if (resized != null) { + UiLog.d("[JavaFxContentDisplay] onContentUrl, resizing the content to " + resized); + stage.setX(resized.x); + stage.setY(resized.y); + stage.setWidth(resized.width); + stage.setHeight(resized.height); + } + } + + if (action.close) { + UiLog.i("[JavaFxContentDisplay] onContentUrl, the content asked to be closed"); + notifyClosed(closed, onClosed, action.queryParams); + engine.load("about:blank"); + stage.close(); + } + } + + private void notifyClosed(AtomicBoolean closed, ContentCloseCallback onClosed, Map contentData) { + if (!closed.compareAndSet(false, true)) { + return; + } + if (onClosed == null) { + return; + } + try { + onClosed.onClosed(contentData); + } catch (Throwable t) { + UiLog.e("[JavaFxContentDisplay] notifyClosed, the close callback threw, [" + t + "]"); + } + } + + private WidgetSurface readPrimarySurface() { + try { + Rectangle2D bounds = Screen.getPrimary().getVisualBounds(); + return new WidgetSurface((int) bounds.getMinX(), (int) bounds.getMinY(), (int) bounds.getWidth(), (int) bounds.getHeight()); + } catch (Throwable t) { + UiLog.w("[JavaFxContentDisplay] readPrimarySurface, could not measure the screen, [" + t + "]"); + return new WidgetSurface(0, 0, 0, 0); + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxWidgetHost.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxWidgetHost.java new file mode 100644 index 00000000..4ad8b3e3 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/JavaFxWidgetHost.java @@ -0,0 +1,211 @@ +package ly.count.sdk.java.ui; + +import javafx.animation.PauseTransition; +import javafx.concurrent.Worker; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import javafx.stage.Stage; +import javafx.util.Duration; +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.SDKCore; +import ly.count.sdk.java.internal.WidgetAction; +import ly.count.sdk.java.internal.WidgetActionParser; +import netscape.javascript.JSObject; + +/** + * Maps {@link WidgetWebHost} onto a JavaFX {@link WebView} inside a borderless card {@link Stage}. + *

+ * JavaFX lays a web page out in logical pixels and handles high density displays itself, so the + * rectangles a widget asks for are applied to the stage as they are, with no density conversion. + *

+ * Every method must be called on the JavaFX application thread. + */ +class JavaFxWidgetHost implements WidgetWebHost { + + /** + * How long to wait for a widget to report its own size before falling back to measuring the + * rendered page. Widgets built from the rating template never report one. + */ + private static final Duration PLACEMENT_FALLBACK_DELAY = Duration.millis(900); + + private static final int FALLBACK_WIDTH = 400; + private static final int FALLBACK_HEIGHT = 500; + + private final Stage stage; + private final WebView webView; + private final WebEngine engine; + private final WidgetSurface surface; + + private Listener listener; + private boolean pageLoaded = false; + private boolean placed = false; + + JavaFxWidgetHost(Stage stage, WebView webView, WidgetSurface surface) { + this.stage = stage; + this.webView = webView; + this.engine = webView.getEngine(); + this.surface = surface; + } + + /** + * Wires up the engine. Call once, before navigating. + */ + void initialize() { + engine.setJavaScriptEnabled(true); + engine.locationProperty().addListener((observable, oldUrl, newUrl) -> onLocationChanged(newUrl)); + engine.setCreatePopupHandler(features -> openPopupExternally()); + engine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> onLoadStateChanged(newState)); + } + + @Override + public void setListener(Listener widgetListener) { + listener = widgetListener; + } + + @Override + public WidgetSurface getSurface() { + return surface; + } + + @Override + public void navigate(String url) { + engine.load(url); + } + + @Override + public void reportSurfaceSize(int width, int height) { + try { + engine.executeScript("window.postMessage({type:'resize',width:" + width + ",height:" + height + "},'*');"); + } catch (Throwable t) { + UiLog.w("[JavaFxWidgetHost] reportSurfaceSize, could not report the surface size, [" + t + "]"); + } + } + + @Override + public void placeAndShow(ContentPlacement rect) { + placed = true; + stage.setX(rect.x); + stage.setY(rect.y); + stage.setWidth(rect.width); + stage.setHeight(rect.height); + if (!stage.isShowing()) { + stage.show(); + } + } + + @Override + public void closeHost() { + try { + engine.load("about:blank"); + stage.close(); + } catch (Throwable t) { + UiLog.e("[JavaFxWidgetHost] closeHost, could not close the widget card, [" + t + "]"); + } + } + + private void onLocationChanged(String url) { + WidgetAction action = WidgetActionParser.parse(url, SDKCore.logger()); + if (!action.isSdkSignal) { + return; + } + + // Signalling URLs are not real destinations: navigating to them would replace the widget + // with a browser error page. + engine.getLoadWorker().cancel(); + + if (listener != null) { + listener.onNavigationStarting(url); + } + } + + private void onLoadStateChanged(Worker.State state) { + if (state == Worker.State.SUCCEEDED) { + pageLoaded = true; + installBridge(); + if (listener != null) { + listener.onPageLoaded(); + } + schedulePlacementFallback(); + } else if (state == Worker.State.FAILED && !pageLoaded) { + // Only the very first load matters here: later failures are the cancelled signalling + // navigations, which are expected. + if (listener != null) { + listener.onLoadFailed(); + } + } + } + + private void installBridge() { + try { + JSObject window = (JSObject) engine.executeScript("window"); + window.setMember(WidgetJsBridge.MEMBER_NAME, new WidgetJsBridge(listener)); + engine.executeScript(WidgetJsBridge.INSTALL_SCRIPT); + } catch (Throwable t) { + // Without the bridge a widget can still close itself through a signalling URL, it just + // cannot report its own card size, so the fallback placement takes over. + UiLog.w("[JavaFxWidgetHost] installBridge, could not install the JavaScript bridge, [" + t + "]"); + } + } + + private void schedulePlacementFallback() { + PauseTransition wait = new PauseTransition(PLACEMENT_FALLBACK_DELAY); + wait.setOnFinished(event -> { + if (!placed) { + placeByMeasuredContent(); + } + }); + wait.play(); + } + + /** + * Places a widget that never reported a size, by measuring the rendered page and centring a + * card of that height on the surface. + */ + private void placeByMeasuredContent() { + int height = FALLBACK_HEIGHT; + try { + Object measured = engine.executeScript( + "(function(){var e=document.getElementById('widget-body');" + + "return Math.ceil(e?e.scrollHeight:document.body.scrollHeight);})()"); + if (measured instanceof Number) { + int value = ((Number) measured).intValue(); + if (value > 0) { + height = value; + } + } + } catch (Throwable t) { + UiLog.w("[JavaFxWidgetHost] placeByMeasuredContent, could not measure the widget, [" + t + "]"); + } + + int width = Math.min(FALLBACK_WIDTH, surface.width); + height = Math.min(height, surface.height); + int x = Math.max(0, (surface.width - width) / 2); + int y = Math.max(0, (surface.height - height) / 2); + + UiLog.d("[JavaFxWidgetHost] placeByMeasuredContent, the widget reported no size, centring a measured card"); + placeAndShow(new ContentPlacement(surface.x + x, surface.y + y, width, height)); + } + + /** + * Sends {@code target="_blank"} links to the system browser instead of rendering them in the + * card. + * + * @return a throwaway engine that only reports where it was asked to go + */ + private WebEngine openPopupExternally() { + WebEngine popup = new WebEngine(); + popup.locationProperty().addListener((observable, oldUrl, newUrl) -> { + if (newUrl != null && !newUrl.isEmpty()) { + ExternalBrowser.open(newUrl); + } + }); + return popup; + } + + /** + * @return the web view this host drives, so a caller can put it in a scene + */ + WebView getWebView() { + return webView; + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/UiLog.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/UiLog.java new file mode 100644 index 00000000..a6d259a1 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/UiLog.java @@ -0,0 +1,49 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.Log; +import ly.count.sdk.java.internal.SDKCore; + +/** + * Logs through the SDK's own logger, so everything this package prints honours the logging level + * and the log listener the integrator configured. Silent while the SDK is not initialized. + */ +final class UiLog { + + private UiLog() { + } + + static void v(String message) { + Log logger = SDKCore.logger(); + if (logger != null) { + logger.v(message); + } + } + + static void d(String message) { + Log logger = SDKCore.logger(); + if (logger != null) { + logger.d(message); + } + } + + static void i(String message) { + Log logger = SDKCore.logger(); + if (logger != null) { + logger.i(message); + } + } + + static void w(String message) { + Log logger = SDKCore.logger(); + if (logger != null) { + logger.w(message); + } + } + + static void e(String message) { + Log logger = SDKCore.logger(); + if (logger != null) { + logger.e(message); + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetJsBridge.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetJsBridge.java new file mode 100644 index 00000000..660f701d --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetJsBridge.java @@ -0,0 +1,46 @@ +package ly.count.sdk.java.ui; + +/** + * Handed into the page as a JavaScript member so a widget's {@code postMessage} payloads can reach + * Java. Must be public, and its method must be public, for the JavaFX web engine to call it. + */ +public class WidgetJsBridge { + + /** + * The JavaScript member name the page posts to. + */ + static final String MEMBER_NAME = "countlyJavaBridge"; + + /** + * Forwards every {@code cly_widget_command} message the page receives to Java. Guarded so a + * reload does not install a second listener. + */ + static final String INSTALL_SCRIPT = + "(function(){if(window.__clyBridgeInstalled){return;}window.__clyBridgeInstalled=true;" + + "window.addEventListener('message',function(ev){try{" + + "var d=typeof ev.data==='string'?JSON.parse(ev.data):ev.data;" + + "if(d&&d.cly_widget_command){window." + MEMBER_NAME + ".post(JSON.stringify(d));}" + + "}catch(e){}});})();"; + + private final WidgetWebHost.Listener listener; + + WidgetJsBridge(WidgetWebHost.Listener listener) { + this.listener = listener; + } + + /** + * Called from JavaScript. + * + * @param json the payload the widget posted + */ + public void post(String json) { + if (listener == null) { + return; + } + try { + listener.onWidgetMessage(json); + } catch (Throwable t) { + UiLog.e("[WidgetJsBridge] post, failed to handle a widget message, [" + t + "]"); + } + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetMessageParser.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetMessageParser.java new file mode 100644 index 00000000..ae731457 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetMessageParser.java @@ -0,0 +1,79 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.WidgetAction; +import org.json.JSONObject; + +/** + * Parses the {@code postMessage} payloads a feedback widget sends, the + * {@code {cly_widget_command, action:'resize_me', resize_me:{p,l}, close}} shape. This is a + * different channel from the URL based signals that + * {@link ly.count.sdk.java.internal.WidgetActionParser} handles: a widget rendered with the web + * model reports its own card size this way rather than by navigating. + */ +public class WidgetMessageParser { + + private WidgetMessageParser() { + } + + /** + * @param json the bridged payload + * @return the parsed signal, or {@code null} when the payload is not a widget command + */ + public static WidgetAction parse(String json) { + if (json == null || json.trim().isEmpty()) { + return null; + } + + JSONObject root; + try { + root = new JSONObject(json); + } catch (Throwable t) { + return null; + } + + if (!root.has("cly_widget_command")) { + return null; + } + + WidgetAction action = new WidgetAction(); + action.isSdkSignal = true; + action.isWidgetCommand = true; + action.close = isTruthy(root.opt("close")); + + JSONObject resize = root.optJSONObject("resize_me"); + if (resize != null) { + action.portrait = toPlacement(resize.optJSONObject("p")); + action.landscape = toPlacement(resize.optJSONObject("l")); + action.hasResize = action.portrait != null || action.landscape != null; + } + + return action; + } + + private static ContentPlacement toPlacement(JSONObject rect) { + if (rect == null) { + return null; + } + int width = rect.optInt("w", 0); + int height = rect.optInt("h", 0); + if (width <= 0 || height <= 0) { + return null; + } + return new ContentPlacement(rect.optInt("x", 0), rect.optInt("y", 0), width, height); + } + + private static boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue() != 0; + } + String asString = value.toString(); + return "1".equals(asString) || "true".equalsIgnoreCase(asString); + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetPlacement.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetPlacement.java new file mode 100644 index 00000000..7339a46c --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetPlacement.java @@ -0,0 +1,51 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.WidgetAction; + +/** + * Maps a rectangle a widget or a content block asked for, which is relative to the surface the SDK + * reported, onto a screen absolute rectangle that stays inside that surface. + */ +public class WidgetPlacement { + + private WidgetPlacement() { + } + + /** + * @param rect the requested rectangle, relative to the surface + * @param surface the surface the rectangle has to fit into + * @return a screen absolute rectangle, or {@code null} when there is nothing to place + */ + public static ContentPlacement resolve(ContentPlacement rect, WidgetSurface surface) { + if (rect == null || surface == null) { + return null; + } + + int width = Math.min(rect.width, surface.width); + int height = Math.min(rect.height, surface.height); + int x = surface.x + clamp(rect.x, 0, Math.max(0, surface.width - width)); + int y = surface.y + clamp(rect.y, 0, Math.max(0, surface.height - height)); + + return new ContentPlacement(x, y, width, height); + } + + /** + * @param action the signal carrying the requested rectangle + * @param surface the surface the rectangle has to fit into + * @return a screen absolute rectangle, or {@code null} when the action carries none + */ + public static ContentPlacement resolve(WidgetAction action, WidgetSurface surface) { + if (action == null || surface == null) { + return null; + } + return resolve(action.resizeFor(surface.isLandscape()), surface); + } + + private static int clamp(int value, int low, int high) { + if (value < low) { + return low; + } + return Math.min(value, high); + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetSurface.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetSurface.java new file mode 100644 index 00000000..fc551fa6 --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetSurface.java @@ -0,0 +1,33 @@ +package ly.count.sdk.java.ui; + +/** + * The area a feedback widget or a content block may be placed on, in screen absolute JavaFX + * coordinates. JavaFX already works in logical pixels, so these are the same units a web page lays + * itself out in and no density conversion is needed. + */ +public class WidgetSurface { + + public final int x; + public final int y; + public final int width; + public final int height; + + public WidgetSurface(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + /** + * @return {@code true} when the surface is wider than it is tall + */ + public boolean isLandscape() { + return width >= height; + } + + @Override + public String toString() { + return "WidgetSurface{x=" + x + ", y=" + y + ", width=" + width + ", height=" + height + '}'; + } +} diff --git a/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetWebHost.java b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetWebHost.java new file mode 100644 index 00000000..4faef74b --- /dev/null +++ b/sdk-java-ui/src/main/java/ly/count/sdk/java/ui/WidgetWebHost.java @@ -0,0 +1,73 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.ContentPlacement; + +/** + * What {@link FeedbackWidgetPresenter} needs from an embedded browser. Keeping the presentation + * logic behind this interface is what makes it testable without starting a JavaFX toolkit. + */ +public interface WidgetWebHost { + + /** + * Events a host reports back to whoever drives it. + */ + interface Listener { + + /** + * @param url the URL the host is about to navigate to, including the SDK's own signalling URLs + */ + void onNavigationStarting(String url); + + /** + * A postMessage payload the widget sent, bridged out of the page as raw JSON. + * + * @param json the payload + */ + void onWidgetMessage(String json); + + /** + * The widget page finished loading, so it is safe to tell it how much room it has. + */ + void onPageLoaded(); + + /** + * The widget page could not be loaded at all. + */ + void onLoadFailed(); + } + + /** + * @param listener who to report events to + */ + void setListener(Listener listener); + + /** + * @return the area the widget may place itself on + */ + WidgetSurface getSurface(); + + /** + * @param url the URL to load + */ + void navigate(String url); + + /** + * Tell the page how much room it has, by posting a {@code {type:'resize',width,height}} message. + * + * @param width available width + * @param height available height + */ + void reportSurfaceSize(int width, int height); + + /** + * Move and size the host to the given screen absolute rectangle, and show it. + * + * @param rect where to put the host + */ + void placeAndShow(ContentPlacement rect); + + /** + * Dismiss the host. + */ + void closeHost(); +} diff --git a/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/FeedbackWidgetPresenterTests.java b/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/FeedbackWidgetPresenterTests.java new file mode 100644 index 00000000..f592151f --- /dev/null +++ b/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/FeedbackWidgetPresenterTests.java @@ -0,0 +1,216 @@ +package ly.count.sdk.java.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.CountlyFeedbackWidget; +import ly.count.sdk.java.internal.FeedbackWidgetType; +import ly.count.sdk.java.internal.ModuleFeedback; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The feedback widget presentation flow, driven against a fake browser host so no JavaFX toolkit is + * needed. + */ +@RunWith(JUnit4.class) +public class FeedbackWidgetPresenterTests { + + private static final String WIDGET_URL = "https://test.server.com/feedback/nps?widget_id=w1"; + private static final String CLOSE_URL = "https://countly_action_event/?cly_widget_command=1&close=1"; + + private FakeHost host; + private ModuleFeedback.Feedback feedback; + private CountlyFeedbackWidget widget; + private AtomicInteger closedCallbacks; + + @Before + public void beforeTest() { + host = new FakeHost(); + feedback = mock(ModuleFeedback.Feedback.class); + closedCallbacks = new AtomicInteger(0); + + widget = new CountlyFeedbackWidget(); + widget.widgetId = "w1"; + widget.type = FeedbackWidgetType.nps; + + when(feedback.constructFeedbackWidgetUrl(any())).thenReturn(WIDGET_URL); + } + + /** + * The whole happy path of one widget: load, report the viewport once the page is up, place the + * card where the widget asks for it, then report the dismissal exactly once when it closes. + */ + @Test + public void present_loadsPlacesAndReportsTheDismissalOnce() { + FeedbackWidgetPresenter presenter = newPresenter(); + presenter.start(widget); + + Assert.assertEquals(1, host.navigations.size()); + Assert.assertEquals(WIDGET_URL, host.navigations.get(0)); + Assert.assertTrue(host.reportedSizes.isEmpty()); + + host.listener.onPageLoaded(); + Assert.assertEquals(1, host.reportedSizes.size()); + Assert.assertArrayEquals(new int[] { 1600, 900 }, host.reportedSizes.get(0)); + + // A second load of the same page must not report the viewport again. + host.listener.onPageLoaded(); + Assert.assertEquals(1, host.reportedSizes.size()); + + host.listener.onWidgetMessage("{\"cly_widget_command\":1,\"action\":\"resize_me\"," + + "\"resize_me\":{\"p\":{\"x\":10,\"y\":10,\"w\":300,\"h\":400},\"l\":{\"x\":1200,\"y\":40,\"w\":360,\"h\":500}}}"); + + Assert.assertEquals(1, host.placements.size()); + ContentPlacement placed = host.placements.get(0); + Assert.assertEquals(1200, placed.x); + Assert.assertEquals(40, placed.y); + Assert.assertEquals(360, placed.width); + Assert.assertEquals(500, placed.height); + + host.listener.onNavigationStarting(CLOSE_URL); + + verify(feedback, times(1)).reportFeedbackWidgetManually(eq(widget), isNull(), isNull()); + Assert.assertEquals(1, host.closeCount); + Assert.assertEquals(1, closedCallbacks.get()); + + // A widget that signals close more than once must not report or close twice. + host.listener.onNavigationStarting(CLOSE_URL); + host.listener.onLoadFailed(); + verify(feedback, times(1)).reportFeedbackWidgetManually(eq(widget), isNull(), isNull()); + Assert.assertEquals(1, host.closeCount); + Assert.assertEquals(1, closedCallbacks.get()); + } + + /** + * Nothing to present: no widget, no feedback interface, or no URL. Each one has to tear down + * cleanly instead of leaving an invisible card and a caller waiting for a callback. + */ + @Test + public void present_withNothingToShow_tearsDownCleanly() { + newPresenter().start(null); + Assert.assertTrue(host.navigations.isEmpty()); + Assert.assertEquals(1, host.closeCount); + Assert.assertEquals(1, closedCallbacks.get()); + + beforeTest(); + new FeedbackWidgetPresenter(host, null, closedCallbacks::incrementAndGet).start(widget); + Assert.assertTrue(host.navigations.isEmpty()); + Assert.assertEquals(1, closedCallbacks.get()); + verify(feedback, never()).reportFeedbackWidgetManually(any(), any(), any()); + + beforeTest(); + when(feedback.constructFeedbackWidgetUrl(any())).thenReturn(null); + newPresenter().start(widget); + Assert.assertTrue(host.navigations.isEmpty()); + Assert.assertEquals(1, host.closeCount); + Assert.assertEquals(1, closedCallbacks.get()); + + beforeTest(); + when(feedback.constructFeedbackWidgetUrl(any())).thenReturn(" "); + newPresenter().start(widget); + Assert.assertTrue(host.navigations.isEmpty()); + Assert.assertEquals(1, closedCallbacks.get()); + } + + /** + * A widget page that never loads has to dismiss its own card and report the dismissal, so the + * caller is not left hanging. + */ + @Test + public void loadFailure_dismissesTheCard() { + FeedbackWidgetPresenter presenter = newPresenter(); + presenter.start(widget); + + host.listener.onLoadFailed(); + + Assert.assertEquals(1, host.closeCount); + Assert.assertEquals(1, closedCallbacks.get()); + verify(feedback, times(1)).reportFeedbackWidgetManually(eq(widget), isNull(), isNull()); + } + + /** + * Signals arriving as navigations rather than messages, on a portrait surface, plus the plain + * page navigations that have to be left alone. + */ + @Test + public void urlSignals_arePlacedAndFilteredCorrectly() { + host.surface = new WidgetSurface(100, 50, 600, 1000); + FeedbackWidgetPresenter presenter = newPresenter(); + presenter.start(widget); + + // The initial widget URL is a real navigation, not a signal. + host.listener.onNavigationStarting(WIDGET_URL); + Assert.assertTrue(host.placements.isEmpty()); + Assert.assertEquals(0, host.closeCount); + + host.listener.onNavigationStarting("https://countly_action_event/?cly_x_action_event=1" + + "&resize_me=%7B%22p%22%3A%7B%22x%22%3A20%2C%22y%22%3A30%2C%22w%22%3A900%2C%22h%22%3A2000%7D%7D&close=0"); + + Assert.assertEquals(1, host.placements.size()); + ContentPlacement placed = host.placements.get(0); + // Clamped to the surface and offset by its origin. + Assert.assertEquals(100, placed.x); + Assert.assertEquals(50, placed.y); + Assert.assertEquals(600, placed.width); + Assert.assertEquals(1000, placed.height); + Assert.assertEquals(0, host.closeCount); + } + + private FeedbackWidgetPresenter newPresenter() { + return new FeedbackWidgetPresenter(host, feedback, closedCallbacks::incrementAndGet); + } + + private static class FakeHost implements WidgetWebHost { + + Listener listener; + WidgetSurface surface = new WidgetSurface(0, 0, 1600, 900); + final List navigations = new ArrayList<>(); + final List reportedSizes = new ArrayList<>(); + final List placements = new ArrayList<>(); + int closeCount = 0; + + @Override + public void setListener(Listener widgetListener) { + listener = widgetListener; + } + + @Override + public WidgetSurface getSurface() { + return surface; + } + + @Override + public void navigate(String url) { + navigations.add(url); + } + + @Override + public void reportSurfaceSize(int width, int height) { + reportedSizes.add(new int[] { width, height }); + } + + @Override + public void placeAndShow(ContentPlacement rect) { + placements.add(rect); + } + + @Override + public void closeHost() { + closeCount++; + } + } +} diff --git a/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/WidgetPlacementTests.java b/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/WidgetPlacementTests.java new file mode 100644 index 00000000..aef918f0 --- /dev/null +++ b/sdk-java-ui/src/test/java/ly/count/sdk/java/ui/WidgetPlacementTests.java @@ -0,0 +1,106 @@ +package ly.count.sdk.java.ui; + +import ly.count.sdk.java.internal.ContentPlacement; +import ly.count.sdk.java.internal.WidgetAction; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Mapping a requested rectangle onto a surface, and reading the {@code postMessage} payloads a + * widget sends. + */ +@RunWith(JUnit4.class) +public class WidgetPlacementTests { + + /** + * A rectangle that fits is placed as asked, one that does not is clamped so it stays on the + * surface, and both are offset by the surface origin. + */ + @Test + public void resolve_offsetsAndClampsToTheSurface() { + WidgetSurface surface = new WidgetSurface(200, 100, 800, 600); + + ContentPlacement fits = WidgetPlacement.resolve(new ContentPlacement(10, 20, 300, 400), surface); + Assert.assertEquals(210, fits.x); + Assert.assertEquals(120, fits.y); + Assert.assertEquals(300, fits.width); + Assert.assertEquals(400, fits.height); + + // Too big: sized down to the surface and pinned to its origin. + ContentPlacement tooBig = WidgetPlacement.resolve(new ContentPlacement(50, 50, 2000, 2000), surface); + Assert.assertEquals(200, tooBig.x); + Assert.assertEquals(100, tooBig.y); + Assert.assertEquals(800, tooBig.width); + Assert.assertEquals(600, tooBig.height); + + // Pushed off the right and bottom edges: slid back so the whole card stays visible. + ContentPlacement offScreen = WidgetPlacement.resolve(new ContentPlacement(700, 500, 300, 200), surface); + Assert.assertEquals(200 + 500, offScreen.x); + Assert.assertEquals(100 + 400, offScreen.y); + + // Negative coordinates are pulled back onto the surface. + ContentPlacement negative = WidgetPlacement.resolve(new ContentPlacement(-50, -50, 100, 100), surface); + Assert.assertEquals(200, negative.x); + Assert.assertEquals(100, negative.y); + + Assert.assertNull(WidgetPlacement.resolve((ContentPlacement) null, surface)); + Assert.assertNull(WidgetPlacement.resolve(new ContentPlacement(0, 0, 10, 10), null)); + Assert.assertNull(WidgetPlacement.resolve((WidgetAction) null, surface)); + } + + /** + * The orientation of the surface decides which rectangle of a signal is used, and either one is + * used when only one was sent. + */ + @Test + public void resolve_picksTheRectangleMatchingTheSurface() { + WidgetAction action = new WidgetAction(); + action.hasResize = true; + action.portrait = new ContentPlacement(0, 0, 100, 200); + action.landscape = new ContentPlacement(0, 0, 200, 100); + + Assert.assertEquals(200, WidgetPlacement.resolve(action, new WidgetSurface(0, 0, 1600, 900)).width); + Assert.assertEquals(100, WidgetPlacement.resolve(action, new WidgetSurface(0, 0, 900, 1600)).width); + + action.landscape = null; + Assert.assertEquals(100, WidgetPlacement.resolve(action, new WidgetSurface(0, 0, 1600, 900)).width); + + action.hasResize = false; + Assert.assertNull(WidgetPlacement.resolve(action, new WidgetSurface(0, 0, 1600, 900))); + } + + /** + * Every shape of the widget's own message channel: a close, a resize, a rectangle without a + * usable size, and payloads that are not widget commands at all. + */ + @Test + public void widgetMessageParser_readsOnlyWidgetCommands() { + WidgetAction close = WidgetMessageParser.parse("{\"cly_widget_command\":1,\"close\":1}"); + Assert.assertNotNull(close); + Assert.assertTrue(close.isWidgetCommand); + Assert.assertTrue(close.close); + Assert.assertFalse(close.hasResize); + + Assert.assertTrue(WidgetMessageParser.parse("{\"cly_widget_command\":1,\"close\":true}").close); + Assert.assertFalse(WidgetMessageParser.parse("{\"cly_widget_command\":1,\"close\":0}").close); + Assert.assertFalse(WidgetMessageParser.parse("{\"cly_widget_command\":1}").close); + + WidgetAction resize = WidgetMessageParser.parse( + "{\"cly_widget_command\":1,\"resize_me\":{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4}}}"); + Assert.assertTrue(resize.hasResize); + Assert.assertEquals(3, resize.portrait.width); + Assert.assertNull(resize.landscape); + + WidgetAction unusable = WidgetMessageParser.parse( + "{\"cly_widget_command\":1,\"resize_me\":{\"p\":{\"x\":1,\"y\":2,\"w\":0,\"h\":4}}}"); + Assert.assertFalse(unusable.hasResize); + + Assert.assertNull(WidgetMessageParser.parse("{\"type\":\"resize\",\"width\":10}")); + Assert.assertNull(WidgetMessageParser.parse("not json")); + Assert.assertNull(WidgetMessageParser.parse("[1,2,3]")); + Assert.assertNull(WidgetMessageParser.parse("")); + Assert.assertNull(WidgetMessageParser.parse(null)); + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java index 11a83338..2e00f62d 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java @@ -10,6 +10,7 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import ly.count.sdk.java.internal.ConfigContent; import ly.count.sdk.java.internal.ConfigViews; import ly.count.sdk.java.internal.CoreFeature; import ly.count.sdk.java.internal.Log; @@ -238,6 +239,13 @@ public class Config { public ConfigViews views = new ConfigViews(this); + /** + * Init time options of the content feature. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public ConfigContent content = new ConfigContent(this); + protected String location = null; protected String ip = null; protected String city = null; @@ -1410,7 +1418,8 @@ public enum Feature { Location(CoreFeature.Location.getIndex()), UserProfiles(CoreFeature.UserProfiles.getIndex()), Feedback(CoreFeature.Feedback.getIndex()), - RemoteConfig(CoreFeature.RemoteConfig.getIndex()); + RemoteConfig(CoreFeature.RemoteConfig.getIndex()), + Content(CoreFeature.Content.getIndex()); // StarRating(1 << 12), // PerformanceMonitoring(1 << 14); @@ -1441,6 +1450,8 @@ public static Config.Feature byIndex(int index) { return RemoteConfig; } else if (index == Feedback.index) { return Feedback; + } else if (index == Content.index) { + return Content; } else { return null; } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Countly.java b/sdk-java/src/main/java/ly/count/sdk/java/Countly.java index e044a13f..a430f01a 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Countly.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Countly.java @@ -7,6 +7,7 @@ import ly.count.sdk.java.internal.InternalConfig; import ly.count.sdk.java.internal.Log; import ly.count.sdk.java.internal.ModuleBackendMode; +import ly.count.sdk.java.internal.ModuleContent; import ly.count.sdk.java.internal.ModuleCrashes; import ly.count.sdk.java.internal.ModuleDeviceIdCore; import ly.count.sdk.java.internal.ModuleEvents; @@ -402,6 +403,24 @@ public ModuleFeedback.Feedback feedback() { return sdk.feedback(); } + /** + * Content interface to enter, leave and refresh content zones, and to preview a + * content block. A {@link ly.count.sdk.java.internal.ContentDisplay} has to be registered + * before a content zone can be entered; the "ly.count.sdk:java-ui" artifact ships a JavaFX one. + * + * @return {@link ModuleContent.Content} instance. + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public ModuleContent.Content content() { + if (!isInitialized()) { + if (L != null) { + L.e("[Countly] content, SDK is not initialized yet."); + } + return null; + } + return sdk.content(); + } + /** * RemoteConfig interface to use remote config feature. * diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigContent.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigContent.java new file mode 100644 index 00000000..80434488 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigContent.java @@ -0,0 +1,51 @@ +package ly.count.sdk.java.internal; + +import ly.count.sdk.java.Config; + +/** + * Init time options of the content feature, reachable through {@link Config#content}. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public class ConfigContent { + + static final int DEFAULT_ZONE_TIMER_INTERVAL = 30; + static final int MIN_ZONE_TIMER_INTERVAL = 15; + + private final Config config; + protected int zoneTimerInterval = DEFAULT_ZONE_TIMER_INTERVAL; + protected ContentCallback globalContentCallback = null; + + public ConfigContent(Config config) { + this.config = config; + } + + /** + * Set how often the SDK asks the server whether there is content to show, while it is in a + * content zone. Values below {@value #MIN_ZONE_TIMER_INTERVAL} seconds are ignored, so the + * default of {@value #DEFAULT_ZONE_TIMER_INTERVAL} seconds stays in effect. + * + * @param zoneTimerIntervalSeconds the fetch interval, in seconds + * @return the same config object for convenient linking + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public synchronized Config setZoneTimerInterval(int zoneTimerIntervalSeconds) { + if (zoneTimerIntervalSeconds >= MIN_ZONE_TIMER_INTERVAL) { + this.zoneTimerInterval = zoneTimerIntervalSeconds; + } + return config; + } + + /** + * Set a callback that is called whenever a content block reaches an end state, with the query + * parameters the content sent along. + * + * @param callback to call when a content block ends + * @return the same config object for convenient linking + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public synchronized Config setGlobalContentCallback(ContentCallback callback) { + this.globalContentCallback = callback; + return config; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCallback.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCallback.java new file mode 100644 index 00000000..1a801ea4 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCallback.java @@ -0,0 +1,18 @@ +package ly.count.sdk.java.internal; + +import java.util.Map; + +/** + * Called when a content block reaches an end state. Register one with + * {@link ConfigContent#setGlobalContentCallback(ContentCallback)}. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public interface ContentCallback { + + /** + * @param contentStatus the state the content ended up in + * @param contentData the query parameters the content sent along with its close signal + */ + void onContentCallback(ContentStatus contentStatus, Map contentData); +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCloseCallback.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCloseCallback.java new file mode 100644 index 00000000..d6093f41 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentCloseCallback.java @@ -0,0 +1,19 @@ +package ly.count.sdk.java.internal; + +import java.util.Map; + +/** + * Handed to a {@link ContentDisplay} so it can tell the SDK that the content it was showing is + * gone. Must be called exactly once per {@link ContentDisplay#present(ContentData, ContentCloseCallback)}, + * including when the display fails to show anything, otherwise the content zone never resumes + * fetching. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public interface ContentCloseCallback { + + /** + * @param contentData the query parameters the content sent along with its close signal, may be empty + */ + void onClosed(Map contentData); +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentData.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentData.java new file mode 100644 index 00000000..ca691948 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentData.java @@ -0,0 +1,41 @@ +package ly.count.sdk.java.internal; + +/** + * One content block the server decided to show: the URL to load and where to put it. + *

+ * Only one of {@link #portrait} / {@link #landscape} is guaranteed to be set. A display picks the + * one matching its own surface and falls back to the other. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public class ContentData { + public final String url; + public final ContentPlacement portrait; + public final ContentPlacement landscape; + + public ContentData(String url, ContentPlacement portrait, ContentPlacement landscape) { + this.url = url; + this.portrait = portrait; + this.landscape = landscape; + } + + /** + * The placement to use for a surface of the given shape, or {@code null} if neither + * orientation carries one. + * + * @param landscapeSurface {@code true} when the surface is wider than it is tall + * @return the placement to lay the content out with + */ + public ContentPlacement placementFor(boolean landscapeSurface) { + ContentPlacement preferred = landscapeSurface ? landscape : portrait; + if (preferred != null) { + return preferred; + } + return landscapeSurface ? portrait : landscape; + } + + @Override + public String toString() { + return "ContentData{url=" + url + ", portrait=" + portrait + ", landscape=" + landscape + '}'; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentDisplay.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentDisplay.java new file mode 100644 index 00000000..a175aefb --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentDisplay.java @@ -0,0 +1,31 @@ +package ly.count.sdk.java.internal; + +/** + * The bridge between the headless content module and whatever can actually draw a web view. The + * SDK core never depends on a UI toolkit, so a display has to be registered with + * {@link ModuleContent.Content#setContentDisplay(ContentDisplay)} before entering a content zone. + *

+ * The "ly.count.sdk:java-ui" artifact ships a JavaFX implementation. Provide your own to render + * content with another toolkit. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public interface ContentDisplay { + + /** + * Called on every fetch, so the SDK can tell the server how much room there is. Return the + * dimensions in the same unit the content is laid out in, which is CSS pixels for a web view. + * + * @return the surface content can be placed on + */ + ContentScreen getScreen(); + + /** + * Show the given content. Called off the UI thread, so hop onto your toolkit's thread before + * touching any widget. + * + * @param content what to show and where to put it + * @param onClosed must be called exactly once, when the content is gone + */ + void present(ContentData content, ContentCloseCallback onClosed); +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentParser.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentParser.java new file mode 100644 index 00000000..485b8d02 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentParser.java @@ -0,0 +1,61 @@ +package ly.count.sdk.java.internal; + +import org.json.JSONObject; + +/** + * Turns a {@code /o/sdk/content} response into a {@link ContentData}. + *

+ * The server answers with a JSON array (for example {@code [{"result":"No content block found!"}]}) + * when it has nothing to show. {@link ImmediateRequestMaker} wraps such an array into a + * {@code {"jsonArray":[...]}} object, so an array response simply has no {@code html} key here and + * is reported as "nothing to show". + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +class ContentParser { + + private ContentParser() { + } + + /** + * @param response the parsed server response, may be {@code null} + * @param L logger + * @return the content to show, or {@code null} when the response carries none + */ + static ContentData parse(JSONObject response, Log L) { + if (response == null) { + L.d("[ContentParser] parse, no response to parse"); + return null; + } + + try { + String url = response.optString("html", ""); + JSONObject geo = response.optJSONObject("geo"); + + if (Utils.isEmptyOrNull(url) || geo == null) { + L.d("[ContentParser] parse, response does not contain a content block"); + return null; + } + + ContentPlacement portrait = toPlacement(geo.optJSONObject("p")); + ContentPlacement landscape = toPlacement(geo.optJSONObject("l")); + + if (portrait == null && landscape == null) { + L.w("[ContentParser] parse, content block has no usable placement, ignoring it"); + return null; + } + + return new ContentData(url, portrait, landscape); + } catch (Throwable t) { + L.e("[ContentParser] parse, failed to parse the content response, [" + t + "]"); + return null; + } + } + + private static ContentPlacement toPlacement(JSONObject rect) { + if (rect == null) { + return null; + } + return new ContentPlacement(rect.optInt("x", 0), rect.optInt("y", 0), rect.optInt("w", 0), rect.optInt("h", 0)); + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentPlacement.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentPlacement.java new file mode 100644 index 00000000..f2a1669b --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentPlacement.java @@ -0,0 +1,26 @@ +package ly.count.sdk.java.internal; + +/** + * A rectangle the server asked a content block to occupy, in the same units the SDK reported the + * screen resolution in (see {@link ContentScreen}). + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public class ContentPlacement { + public final int x; + public final int y; + public final int width; + public final int height; + + public ContentPlacement(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public String toString() { + return "ContentPlacement{x=" + x + ", y=" + y + ", width=" + width + ", height=" + height + '}'; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentRequestBuilder.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentRequestBuilder.java new file mode 100644 index 00000000..037145e7 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentRequestBuilder.java @@ -0,0 +1,67 @@ +package ly.count.sdk.java.internal; + +import java.util.Locale; + +/** + * Builds the content specific query parameters of a {@code /o/sdk/content} fetch. The common + * parameters (app key, device ID, timestamp, SDK name and version) are added by + * {@link ModuleRequests#prepareRequiredParamsAsString(InternalConfig, Object...)}. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +class ContentRequestBuilder { + + static final String DEVICE_TYPE = "desktop"; + + private ContentRequestBuilder() { + } + + /** + * @param screen the surface the content has to fit into + * @param categories content categories to filter by, may be {@code null} or empty + * @param contentId set to fetch one specific content block as a preview, {@code null} otherwise + * @param L logger + * @return the content specific parameters, ready to be appended to a request + */ + static Params build(ContentScreen screen, String[] categories, String contentId, Log L) { + int width = screen == null ? 0 : screen.width; + int height = screen == null ? 0 : screen.height; + + // A desktop surface does not rotate, so both orientations report the same rectangle. + String resolution = "{\"l\":{\"w\":" + width + ",\"h\":" + height + "},\"p\":{\"w\":" + width + ",\"h\":" + height + "}}"; + + Params params = new Params() + .add("method", "queue") + .add("resolution", resolution) + .add("category", categoryList(categories)) + .add("la", language()) + .add("dt", DEVICE_TYPE); + + if (!Utils.isEmptyOrNull(contentId)) { + params.add("content_id", contentId).add("preview", "true"); + } + + L.v("[ContentRequestBuilder] build, resolution:[" + resolution + "] preview:[" + !Utils.isEmptyOrNull(contentId) + "]"); + return params; + } + + private static String categoryList(String[] categories) { + if (categories == null || categories.length == 0) { + return "[]"; + } + + StringBuilder builder = new StringBuilder("["); + for (int i = 0; i < categories.length; i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(categories[i] == null ? "" : categories[i]); + } + return builder.append(']').toString(); + } + + private static String language() { + String language = Locale.getDefault().getLanguage(); + return language == null ? "" : language; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentScreen.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentScreen.java new file mode 100644 index 00000000..3aeef838 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentScreen.java @@ -0,0 +1,23 @@ +package ly.count.sdk.java.internal; + +/** + * The surface a {@link ContentDisplay} can place content on. The SDK reports these dimensions to + * the server, and the server answers with a {@link ContentPlacement} expressed in the same units, + * so a display must report and place in one consistent unit (CSS pixels on a desktop web view). + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public class ContentScreen { + public final int width; + public final int height; + + public ContentScreen(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public String toString() { + return "ContentScreen{width=" + width + ", height=" + height + '}'; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentStatus.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentStatus.java new file mode 100644 index 00000000..306b9b3e --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ContentStatus.java @@ -0,0 +1,10 @@ +package ly.count.sdk.java.internal; + +/** + * The state a content block ended up in. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public enum ContentStatus { + COMPLETED, CLOSED +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java index 76aa0a4d..b5753171 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java @@ -21,7 +21,8 @@ public enum CoreFeature { DeviceId(1 << 20, ModuleDeviceIdCore::new), Requests(1 << 21, ModuleRequests::new), Logs(1 << 22), - Feedback(1 << 23, ModuleFeedback::new); + Feedback(1 << 23, ModuleFeedback::new), + Content(1 << 24, ModuleContent::new); private final int index; diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java index 08b912d8..f2dd2205 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java @@ -16,11 +16,19 @@ protected CountlyTimer(Log logger) { } protected void stopTimer() { + stopTimer(true); + } + + /** + * @param awaitTermination whether to wait for a running task to finish. Must be {@code false} + * when called from the timer's own task, because a task cannot wait for itself. + */ + protected void stopTimer(boolean awaitTermination) { L.i("[CountlyTimer] stopTimer, Stopping global timer"); if (timerService != null) { try { timerService.shutdown(); - if (!timerService.awaitTermination(1, TimeUnit.SECONDS)) { + if (awaitTermination && !timerService.awaitTermination(1, TimeUnit.SECONDS)) { timerService.shutdownNow(); if (!timerService.awaitTermination(1, TimeUnit.SECONDS)) { L.e("[SDKCore] Global timer must be locked"); @@ -34,20 +42,30 @@ protected void stopTimer() { } protected void startTimer(long timerDelay, Runnable runnable) { - L.i("[CountlyTimer] startTimer, Starting global timer timerDelay: [" + timerDelay + "]"); - timerDelay = timerDelay * 1000; + startTimer(timerDelay, -1, runnable); + } + + /** + * @param timerDelay interval between two runs, in seconds + * @param initialDelayMs how long to wait before the first run, in milliseconds. A negative + * value uses the interval itself as the initial delay. + * @param runnable what to run on every tick + */ + protected void startTimer(long timerDelay, long initialDelayMs, Runnable runnable) { + L.i("[CountlyTimer] startTimer, Starting global timer timerDelay: [" + timerDelay + "] initialDelayMs: [" + initialDelayMs + "]"); + long delay = timerDelay * 1000; - if (timerDelay < 1000) { - timerDelay = 1000; + if (delay < 1000) { + delay = 1000; } - long startTime = timerDelay; + long startTime = initialDelayMs < 0 ? delay : initialDelayMs; if (TIMER_DELAY_MS > 0) { - timerDelay = TIMER_DELAY_MS; + delay = TIMER_DELAY_MS; startTime = 0; } - timerService.scheduleWithFixedDelay(runnable, startTime, timerDelay, TimeUnit.MILLISECONDS); + timerService.scheduleWithFixedDelay(runnable, startTime, delay, TimeUnit.MILLISECONDS); } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java new file mode 100644 index 00000000..37c9fb4b --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java @@ -0,0 +1,523 @@ +package ly.count.sdk.java.internal; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import javax.annotation.Nullable; +import ly.count.sdk.java.Countly; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Fetches content blocks from the server while the app is in a content zone, and hands whatever the + * server decided to show to a {@link ContentDisplay}. + *

+ * The module itself never touches a UI toolkit. A display has to be registered through + * {@link Content#setContentDisplay(ContentDisplay)} before a content zone can be entered; the + * "ly.count.sdk:java-ui" artifact ships a JavaFX one. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ +public class ModuleContent extends ModuleBase { + + /** + * How long the first fetch of a zone waits, so a zone entered right after init does not race + * the rest of the SDK coming up. + */ + static final long START_DELAY_MS = 4000; + + /** + * How many timer ticks are skipped after a content block was closed, giving the server time to + * process whatever the content recorded before the SDK asks for the next one. + */ + static final int POST_CLOSE_SKIPPED_TICKS = 2; + + Content contentInterface = null; + ContentDisplay display = null; + CountlyTimer contentTimer = null; + + private final Object contentLock = new Object(); + private boolean zoneActive = false; + private boolean shouldFetch = false; + private boolean contentShown = false; + private boolean fetching = false; + private int waitForDelay = 0; + private int generation = 0; + private String[] categories = null; + + private int zoneTimerInterval = ConfigContent.DEFAULT_ZONE_TIMER_INTERVAL; + private ContentCallback globalContentCallback = null; + + ModuleContent() { + } + + @Override + public void init(InternalConfig config) { + super.init(config); + L.v("[ModuleContent] Initializing"); + + zoneTimerInterval = config.content.zoneTimerInterval; + globalContentCallback = config.content.globalContentCallback; + contentInterface = new Content(); + } + + @Override + public Boolean onRequest(Request request) { + return true; + } + + @Override + public void stop(InternalConfig config, boolean clear) { + super.stop(config, clear); + exitContentZoneInternal(); + display = null; + contentInterface = null; + globalContentCallback = null; + } + + void setContentDisplayInternal(ContentDisplay contentDisplay) { + L.d("[ModuleContent] setContentDisplayInternal, display set:[" + (contentDisplay != null) + "]"); + synchronized (contentLock) { + display = contentDisplay; + } + } + + void enterContentZoneInternal(@Nullable String[] requestedCategories) { + if (display == null) { + L.w("[ModuleContent] enterContentZoneInternal, no content display is registered, ignoring the call"); + return; + } + + if (internalConfig.isTemporaryIdEnabled()) { + L.w("[ModuleContent] enterContentZoneInternal, content can't be fetched while in temporary device ID mode"); + return; + } + + synchronized (contentLock) { + if (zoneActive) { + L.d("[ModuleContent] enterContentZoneInternal, already in a content zone, ignoring the call"); + return; + } + + zoneActive = true; + shouldFetch = true; + contentShown = false; + fetching = false; + waitForDelay = 0; + // Any fetch left in flight from a previous zone belongs to an older generation and is + // discarded when it completes. + generation++; + categories = requestedCategories == null ? null : requestedCategories.clone(); + + contentTimer = new CountlyTimer(L); + contentTimer.startTimer(zoneTimerInterval, START_DELAY_MS, this::onZoneTimerTick); + } + + L.i("[ModuleContent] enterContentZoneInternal, entered the content zone, fetch interval:[" + zoneTimerInterval + "] seconds"); + } + + void exitContentZoneInternal() { + exitContentZoneInternal(true); + } + + /** + * @param awaitTimerTermination must be {@code false} when called from the zone timer's own + * tick, because a task cannot wait for itself to finish + */ + private void exitContentZoneInternal(boolean awaitTimerTermination) { + CountlyTimer timerToStop; + synchronized (contentLock) { + zoneActive = false; + shouldFetch = false; + contentShown = false; + fetching = false; + waitForDelay = 0; + generation++; + categories = null; + + timerToStop = contentTimer; + contentTimer = null; + } + + // Stopped outside the lock: stopping waits for a running tick, and that tick needs the lock. + if (timerToStop != null) { + timerToStop.stopTimer(awaitTimerTermination); + } + + L.i("[ModuleContent] exitContentZoneInternal, left the content zone"); + } + + void refreshContentZoneInternal() { + String[] previousCategories; + synchronized (contentLock) { + if (contentShown) { + L.d("[ModuleContent] refreshContentZoneInternal, a content block is on screen, ignoring the call"); + return; + } + previousCategories = categories == null ? null : categories.clone(); + } + + // Push whatever is queued out first, so the trigger the developer just recorded has a + // chance of being processed before the next fetch lands. + flushEventQueue(); + + exitContentZoneInternal(); + enterContentZoneInternal(previousCategories); + } + + void previewContentInternal(String contentId) { + if (display == null) { + L.w("[ModuleContent] previewContentInternal, no content display is registered, ignoring the call"); + return; + } + + if (internalConfig.isTemporaryIdEnabled()) { + L.w("[ModuleContent] previewContentInternal, content can't be fetched while in temporary device ID mode"); + return; + } + + int currentGeneration; + synchronized (contentLock) { + if (contentShown || fetching) { + L.d("[ModuleContent] previewContentInternal, another content block is already being fetched or shown, ignoring the call"); + return; + } + fetching = true; + currentGeneration = generation; + } + + L.i("[ModuleContent] previewContentInternal, previewing content:[" + contentId + "]"); + fetchContents(null, contentId, currentGeneration); + } + + void recordContentEventsInternal(String eventsJson) { + if (Utils.isEmptyOrNull(eventsJson)) { + L.d("[ModuleContent] recordContentEventsInternal, no events to record"); + return; + } + + ModuleEvents.Events events = SDKCore.instance == null ? null : SDKCore.instance.events(); + if (events == null) { + L.w("[ModuleContent] recordContentEventsInternal, events are not available, content events are dropped"); + return; + } + + boolean recorded = false; + try { + JSONArray array = new JSONArray(eventsJson); + for (int i = 0; i < array.length(); i++) { + JSONObject event = array.optJSONObject(i); + if (event == null) { + continue; + } + + String key = event.optString("key", ""); + if (Utils.isEmptyOrNull(key)) { + L.w("[ModuleContent] recordContentEventsInternal, an event without a key was received, dropping it"); + continue; + } + + JSONObject segmentation = event.optJSONObject("sg"); + if (segmentation == null) { + segmentation = event.optJSONObject("segmentation"); + } + + events.recordEvent(key, toSegmentation(segmentation)); + recorded = true; + } + } catch (Throwable t) { + L.e("[ModuleContent] recordContentEventsInternal, failed to record the content events, [" + t + "]"); + } + + if (recorded) { + // The server has to see these before it can decide what to show next. + flushEventQueue(); + } + } + + private Map toSegmentation(JSONObject segmentation) { + if (segmentation == null) { + return Collections.emptyMap(); + } + + Map result = new HashMap<>(); + Iterator keys = segmentation.keys(); + while (keys.hasNext()) { + String key = keys.next(); + result.put(key, segmentation.opt(key)); + } + return result; + } + + private void flushEventQueue() { + if (SDKCore.instance == null) { + return; + } + ModuleEvents module = SDKCore.instance.module(ModuleEvents.class); + if (module == null) { + L.d("[ModuleContent] flushEventQueue, events module is not available, nothing to flush"); + return; + } + module.checkEventQueueToSend(true); + } + + /** + * One poll of the content zone. Package visible so tests can drive the zone without waiting on + * a real timer. + */ + void onZoneTimerTick() { + try { + zoneTimerTick(); + } catch (Throwable t) { + // An exception escaping a scheduled task cancels the schedule, which would kill the zone + // for good. The SDK can be torn down under this timer at any moment, so swallow and live. + L.e("[ModuleContent] onZoneTimerTick, the content zone poll failed, [" + t + "]"); + } + } + + private void zoneTimerTick() { + if (SDKCore.instance == null || !Countly.isInitialized()) { + // The SDK was stopped while this timer was still armed; nothing left to fetch. + return; + } + + if (!SDKCore.instance.hasConsentForFeature(CoreFeature.Content)) { + L.d("[ModuleContent] onZoneTimerTick, content consent was removed, leaving the content zone"); + exitContentZoneInternal(false); + return; + } + + String[] currentCategories; + int currentGeneration; + synchronized (contentLock) { + if (waitForDelay > 0) { + waitForDelay--; + L.v("[ModuleContent] onZoneTimerTick, waiting for [" + waitForDelay + "] more ticks before fetching again"); + return; + } + + if (!shouldFetch || contentShown || fetching) { + return; + } + + fetching = true; + currentGeneration = generation; + currentCategories = categories == null ? null : categories.clone(); + } + + fetchContents(currentCategories, null, currentGeneration); + } + + private void fetchContents(String[] fetchCategories, String contentId, int fetchGeneration) { + try { + ContentDisplay currentDisplay; + synchronized (contentLock) { + currentDisplay = display; + } + + if (currentDisplay == null) { + L.w("[ModuleContent] fetchContents, the content display went away, aborting the fetch"); + clearFetching(fetchGeneration); + return; + } + + ContentScreen screen = currentDisplay.getScreen(); + String requestData = ModuleRequests.prepareRequiredParams(internalConfig) + .add(ContentRequestBuilder.build(screen, fetchCategories, contentId, L)) + .toString(); + + Transport transport = SDKCore.instance.networking.getTransport(); + final boolean networkingIsEnabled = internalConfig.getNetworkingEnabled(); + + L.d("[ModuleContent] fetchContents, requesting content with:[" + requestData + "]"); + + internalConfig.immediateRequestGenerator.createImmediateRequestMaker() + .doWork(requestData, "/o/sdk/content?", transport, false, networkingIsEnabled, + response -> onContentFetched(response, fetchGeneration), L); + } catch (Throwable t) { + L.e("[ModuleContent] fetchContents, failed to request content, [" + t + "]"); + clearFetching(fetchGeneration); + } + } + + private void onContentFetched(JSONObject response, int fetchGeneration) { + try { + ContentData content = ContentParser.parse(response, L); + if (content == null) { + L.d("[ModuleContent] onContentFetched, nothing to show"); + return; + } + + ContentDisplay currentDisplay; + synchronized (contentLock) { + if (fetchGeneration != generation) { + L.d("[ModuleContent] onContentFetched, the content zone changed while this fetch was in flight, discarding the content"); + return; + } + currentDisplay = display; + } + + if (currentDisplay == null) { + L.w("[ModuleContent] onContentFetched, the content display went away, discarding the content"); + return; + } + + L.i("[ModuleContent] onContentFetched, showing content:[" + content + "]"); + currentDisplay.present(content, this::onContentClosed); + + // Committed only once the display accepted the content: a display that throws must not + // leave the zone believing something is on screen, which would block every later fetch. + synchronized (contentLock) { + if (fetchGeneration == generation) { + contentShown = true; + shouldFetch = false; + } + } + } catch (Throwable t) { + L.e("[ModuleContent] onContentFetched, the content display failed to show the content, [" + t + "]"); + } finally { + clearFetching(fetchGeneration); + } + } + + private void onContentClosed(Map contentData) { + L.d("[ModuleContent] onContentClosed, content closed with:[" + contentData + "]"); + + synchronized (contentLock) { + contentShown = false; + if (zoneActive) { + shouldFetch = true; + waitForDelay = POST_CLOSE_SKIPPED_TICKS; + } + } + + ContentCallback callback = globalContentCallback; + if (callback == null) { + return; + } + + try { + callback.onContentCallback(ContentStatus.CLOSED, contentData == null ? Collections.emptyMap() : contentData); + } catch (Throwable t) { + L.e("[ModuleContent] onContentClosed, the global content callback threw, [" + t + "]"); + } + } + + /** + * Releases the single fetch slot, but only for the generation that took it, so a stale fetch + * completing late cannot release a newer one. + * + * @param fetchGeneration the generation the finished fetch was started in + */ + private void clearFetching(int fetchGeneration) { + synchronized (contentLock) { + if (fetchGeneration == generation) { + fetching = false; + } + } + } + + /** + * Retrieves and displays Countly content. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public class Content { + + /** + * Register the display that draws content blocks. Required before entering a content zone. + * Pass {@code null} to unregister. + * + * @param contentDisplay the display to draw content with + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void setContentDisplay(@Nullable ContentDisplay contentDisplay) { + synchronized (Countly.instance()) { + setContentDisplayInternal(contentDisplay); + } + } + + /** + * Start asking the server for content to show. Ignored while already in a content zone. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void enterContentZone() { + enterContentZone(null); + } + + /** + * Start asking the server for content to show, limited to the given categories. Ignored + * while already in a content zone. + * + * @param categories the content categories to ask for, {@code null} or empty for all + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void enterContentZone(@Nullable String[] categories) { + synchronized (Countly.instance()) { + L.i("[Content] enterContentZone, entering the content zone"); + enterContentZoneInternal(categories); + } + } + + /** + * Stop asking the server for content. A content block that is already on screen stays + * there, so the user can finish with it. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void exitContentZone() { + synchronized (Countly.instance()) { + L.i("[Content] exitContentZone, leaving the content zone"); + exitContentZoneInternal(); + } + } + + /** + * Re-enter the content zone right away, after flushing the event queue. Use it when a + * trigger condition just changed. Ignored while a content block is on screen. + * + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void refreshContentZone() { + synchronized (Countly.instance()) { + L.i("[Content] refreshContentZone, refreshing the content zone"); + refreshContentZoneInternal(); + } + } + + /** + * Fetch and show one specific content block, bypassing the server's targeting. Meant for + * previewing content while building it. + * + * @param contentId the ID of the content block to show + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void previewContent(@Nullable String contentId) { + synchronized (Countly.instance()) { + L.i("[Content] previewContent, previewing content:[" + contentId + "]"); + if (Utils.isEmptyOrNull(contentId)) { + L.w("[Content] previewContent, content ID is null or empty, ignoring the call"); + return; + } + previewContentInternal(contentId); + } + } + + /** + * Record the events a content block asked for, and push the event queue to the server so it + * can act on them straight away. Called by a {@link ContentDisplay} when the content sends + * an {@code action=event} signal. + * + * @param eventsJson a JSON array of {@code {key, sg}} objects, as sent by the content + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public void recordContentEvents(@Nullable String eventsJson) { + synchronized (Countly.instance()) { + L.d("[Content] recordContentEvents, recording the events of a content block"); + recordContentEventsInternal(eventsJson); + } + } + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java index c8659ac7..8c83718b 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java @@ -343,23 +343,9 @@ private String constructFeedbackWidgetUrlInternal(CountlyFeedbackWidget widgetIn return null; } - StringBuilder widgetListUrl = new StringBuilder(); - widgetListUrl.append(internalConfig.getServerURL()); - widgetListUrl.append("/feedback/"); - widgetListUrl.append(widgetInfo.type.name()); - widgetListUrl.append('?'); - Params params = new Params() - .add("widget_id", widgetInfo.widgetId) - .add("device_id", internalConfig.getDeviceId().id) - .add("app_key", internalConfig.getServerAppKey()) - .add("sdk_version", internalConfig.getSdkVersion()) - .add("sdk_name", internalConfig.getSdkName()) - .add("platform", internalConfig.getSdkPlatform()); - - widgetListUrl.append(params.toString()); - final String preparedWidgetUrl = widgetListUrl.toString(); + final String preparedWidgetUrl = WidgetUrlBuilder.build(internalConfig, widgetInfo, cachedAppVersion); - L.d("[ModuleFeedback] constructFeedbackWidgetUrlInternal, Using following url for widget:[" + widgetListUrl + "]"); + L.d("[ModuleFeedback] constructFeedbackWidgetUrlInternal, Using following url for widget:[" + preparedWidgetUrl + "]"); return preparedWidgetUrl; } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java index 1a273436..b7eaad03 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java @@ -69,6 +69,7 @@ protected static void registerDefaultModuleMappings() { moduleMappings.put(CoreFeature.CrashReporting, ModuleCrashes.class); moduleMappings.put(CoreFeature.BackendMode, ModuleBackendMode.class); moduleMappings.put(CoreFeature.Feedback, ModuleFeedback.class); + moduleMappings.put(CoreFeature.Content, ModuleContent.class); moduleMappings.put(CoreFeature.Events, ModuleEvents.class); moduleMappings.put(CoreFeature.RemoteConfig, ModuleRemoteConfig.class); moduleMappings.put(CoreFeature.UserProfiles, ModuleUserProfile.class); @@ -577,6 +578,18 @@ public void changeDeviceIdWithMerge(InternalConfig config, String id) { deviceId().changeWithMerge(id); } + /** + * The logger the SDK was configured with, so code outside of this package (a + * {@link ContentDisplay} implementation, for example) can log through the same level and + * listener the integrator set up. + * + * @return the SDK logger, or {@code null} while the SDK is not initialized + */ + public static Log logger() { + SDKCore core = instance; + return core == null ? null : core.L; + } + public static boolean enabled(int feature) { return (feature & instance.consents) == feature && (feature & instance.config().getFeatures1()) == feature; @@ -731,6 +744,20 @@ public ModuleFeedback.Feedback feedback() { return module(ModuleFeedback.class).feedbackInterface; } + public ModuleContent.Content content() { + if (!hasConsentForFeature(CoreFeature.Content)) { + L.v("[SDKCore] content, Content feature has no consent, returning null"); + return null; + } + + ModuleContent module = module(ModuleContent.class); + if (module == null) { + return null; + } + + return module.contentInterface; + } + public ModuleCrashes.Crashes crashes() { if (!hasConsentForFeature(CoreFeature.CrashReporting)) { L.v("[SDKCore] crash, Crash Reporting feature has no consent, returning null"); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetAction.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetAction.java new file mode 100644 index 00000000..1fbf8f59 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetAction.java @@ -0,0 +1,50 @@ +package ly.count.sdk.java.internal; + +import java.util.Collections; +import java.util.Map; + +/** + * A signal a feedback widget or a content block sent to the SDK by navigating to a + * {@code https://countly_action_event} URL, already parsed. See {@link WidgetActionParser}. + */ +public class WidgetAction { + + /** {@code true} when the URL is one of the SDK's own signalling URLs and not a real navigation. */ + public boolean isSdkSignal; + /** {@code true} for a feedback widget command ({@code cly_widget_command=1}). */ + public boolean isWidgetCommand; + /** {@code true} for a content action event ({@code cly_x_action_event=1}). */ + public boolean isActionEvent; + /** {@code true} when the whole URL should be opened in an external browser ({@code cly_x_int=1}). */ + public boolean isExternalLink; + /** {@code true} when whatever is on screen should be dismissed after the action was processed. */ + public boolean close; + /** {@code true} when {@link #portrait} or {@link #landscape} carries a new rectangle. */ + public boolean hasResize; + public ContentPlacement portrait; + public ContentPlacement landscape; + /** {@code action=link}: the destination to open in an external browser. */ + public String link; + /** {@code action=event}: the raw JSON array of {@code {key, sg|segmentation}} objects. */ + public String eventPayload; + /** Every query parameter of the URL, as sent. */ + public Map queryParams = Collections.emptyMap(); + + /** + * The rectangle to apply on a surface of the given shape, or {@code null} when the action + * carries none. + * + * @param landscapeSurface {@code true} when the surface is wider than it is tall + * @return the requested rectangle + */ + public ContentPlacement resizeFor(boolean landscapeSurface) { + if (!hasResize) { + return null; + } + ContentPlacement preferred = landscapeSurface ? landscape : portrait; + if (preferred != null) { + return preferred; + } + return landscapeSurface ? portrait : landscape; + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetActionParser.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetActionParser.java new file mode 100644 index 00000000..b83abfce --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetActionParser.java @@ -0,0 +1,172 @@ +package ly.count.sdk.java.internal; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.LinkedHashMap; +import java.util.Map; +import org.json.JSONObject; + +/** + * Parses the URLs a feedback widget or a content block navigates to in order to talk back to the + * SDK. Both use the {@code https://countly_action_event} host, plus an {@code cly_x_int=1} flag on + * any URL that should be handed to an external browser instead. + *

+ * Kept free of any UI toolkit so it can be unit tested and reused by every display implementation. + */ +public class WidgetActionParser { + + static final String ACTION_HOST = "countly_action_event"; + static final String ACTION_URL_START = "https://" + ACTION_HOST; + + private WidgetActionParser() { + } + + /** + * @param url the URL the web view is about to navigate to + * @param L logger + * @return the parsed signal, never {@code null}; check {@link WidgetAction#isSdkSignal} to see + * whether the URL was one of the SDK's own + */ + public static WidgetAction parse(String url, Log L) { + WidgetAction action = new WidgetAction(); + + if (Utils.isEmptyOrNull(url)) { + return action; + } + + Map query = parseQuery(url); + action.queryParams = query; + action.isExternalLink = "1".equals(query.get("cly_x_int")); + action.isWidgetCommand = "1".equals(query.get("cly_widget_command")); + action.isActionEvent = "1".equals(query.get("cly_x_action_event")); + action.isSdkSignal = url.contains(ACTION_HOST) || action.isExternalLink || action.isWidgetCommand || action.isActionEvent; + + if (!action.isSdkSignal) { + return action; + } + + if (action.isExternalLink) { + // The whole URL is the destination; there is nothing else to process. + action.link = url; + return action; + } + + action.close = isTruthy(query.get("close")); + + Object resize = query.get("resize_me"); + if (resize != null) { + readResize(action, resize.toString(), L); + } + + Object link = query.get("link"); + if (link != null && !Utils.isEmptyOrNull(link.toString())) { + // A close carried inside the destination's own query ("link=https://x?close=1") is a + // signal to us, not part of the destination, so it is honoured and then stripped. + Map linkQuery = parseQuery(link.toString()); + if (!action.close && isTruthy(linkQuery.get("close"))) { + action.close = true; + } + action.link = stripParam(link.toString(), "close"); + } + + Object event = query.get("event"); + if (event != null && !Utils.isEmptyOrNull(event.toString())) { + action.eventPayload = event.toString(); + } + + return action; + } + + private static void readResize(WidgetAction action, String resize, Log L) { + try { + JSONObject rects = new JSONObject(resize); + action.portrait = toPlacement(rects.optJSONObject("p")); + action.landscape = toPlacement(rects.optJSONObject("l")); + action.hasResize = action.portrait != null || action.landscape != null; + } catch (Throwable t) { + if (L != null) { + L.w("[WidgetActionParser] readResize, malformed 'resize_me' payload, ignoring it, [" + t + "]"); + } + } + } + + private static ContentPlacement toPlacement(JSONObject rect) { + if (rect == null) { + return null; + } + int width = rect.optInt("w", 0); + int height = rect.optInt("h", 0); + if (width <= 0 || height <= 0) { + return null; + } + return new ContentPlacement(rect.optInt("x", 0), rect.optInt("y", 0), width, height); + } + + private static boolean isTruthy(Object value) { + if (value == null) { + return false; + } + String asString = value.toString(); + return "1".equals(asString) || "true".equalsIgnoreCase(asString); + } + + /** + * @param url the URL to clean up + * @param name the query parameter to drop + * @return the URL without that parameter, and without the '?' if nothing else is left + */ + static String stripParam(String url, String name) { + int question = url.indexOf('?'); + if (question < 0) { + return url; + } + + StringBuilder kept = new StringBuilder(); + for (String pair : url.substring(question + 1).split("&")) { + int equals = pair.indexOf('='); + String key = equals > 0 ? pair.substring(0, equals) : pair; + if (name.equals(key)) { + continue; + } + if (kept.length() > 0) { + kept.append('&'); + } + kept.append(pair); + } + + String base = url.substring(0, question); + if (kept.length() == 0) { + return base; + } + return base + "?" + kept; + } + + /** + * @param url URL to read the query of + * @return the URL decoded query parameters, in the order they appeared + */ + static Map parseQuery(String url) { + Map result = new LinkedHashMap<>(); + int question = url.indexOf('?'); + if (question < 0 || question == url.length() - 1) { + return result; + } + + for (String pair : url.substring(question + 1).split("&")) { + int equals = pair.indexOf('='); + if (equals <= 0) { + continue; + } + result.put(decode(pair.substring(0, equals)), decode(pair.substring(equals + 1))); + } + return result; + } + + private static String decode(String value) { + try { + return URLDecoder.decode(value, Utils.UTF8); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + return value; + } + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetUrlBuilder.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetUrlBuilder.java new file mode 100644 index 00000000..54953edf --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/WidgetUrlBuilder.java @@ -0,0 +1,68 @@ +package ly.count.sdk.java.internal; + +import java.net.URL; + +/** + * Builds the URL that renders a feedback widget in a web view. Kept free of any UI toolkit so it + * can be unit tested and reused by every display implementation. + */ +class WidgetUrlBuilder { + + /** + * Desktop follows the web SDK model: the widget draws itself as a positioned card with its own + * close button, rather than filling the whole viewport. {@code tc} lets it close itself, + * {@code xb} makes it draw the close button. + */ + static final String CUSTOM_PARAMS = "{\"tc\":1,\"xb\":1}"; + + private WidgetUrlBuilder() { + } + + /** + * @param config to read the server URL, app key, device ID and SDK identity from + * @param widget the widget to display + * @param appVersion the application version to report + * @return the URL to load in a web view + */ + static String build(InternalConfig config, CountlyFeedbackWidget widget, String appVersion) { + Params params = new Params() + .add("widget_id", widget.widgetId) + .add("device_id", config.getDeviceId().id) + .add("app_key", config.getServerAppKey()) + .add("sdk_version", config.getSdkVersion()) + .add("sdk_name", config.getSdkName()) + .add("platform", config.getSdkPlatform()); + + if (!Utils.isEmptyOrNull(appVersion)) { + params.add("app_version", appVersion); + } + + params.add("custom", CUSTOM_PARAMS); + + // The widget page only accepts the SDK's post-load {type:'resize'} message when 'origin' + // matches the page's own origin. Without it the message is dropped and the widget has no + // viewport to size itself against. Sent unencoded, the same way the web SDK sends it. + String origin = originOf(config.getServerURL()); + if (origin != null) { + params.add("&origin=" + origin); + } + + return config.getServerURL() + "/feedback/" + widget.type.name() + "?" + params; + } + + /** + * @param serverUrl the configured server URL + * @return scheme and authority of the server URL, or {@code null} when there is none + */ + static String originOf(URL serverUrl) { + if (serverUrl == null || serverUrl.getProtocol() == null || Utils.isEmptyOrNull(serverUrl.getHost())) { + return null; + } + + String origin = serverUrl.getProtocol() + "://" + serverUrl.getHost(); + if (serverUrl.getPort() > 0) { + origin += ":" + serverUrl.getPort(); + } + return origin; + } +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ContentParsingTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ContentParsingTests.java new file mode 100644 index 00000000..2be9c37c --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ContentParsingTests.java @@ -0,0 +1,216 @@ +package ly.count.sdk.java.internal; + +import java.util.Map; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.mockito.Mockito.mock; + +/** + * The pure parsing and URL building around the content and feedback widget web views. + */ +@RunWith(JUnit4.class) +public class ContentParsingTests { + + private final Log L = mock(Log.class); + + @Before + public void beforeTest() { + TestUtils.createCleanTestState(); + } + + @After + public void stop() { + Countly.instance().halt(); + } + + /** + * Every shape of a {@code /o/sdk/content} response: a usable block, a block with only one + * orientation, and the several ways a response can carry nothing to show. + */ + @Test + public void contentParser_acceptsUsableBlocksAndRejectsTheRest() throws JSONException { + ContentData both = ContentParser.parse(new JSONObject( + "{\"html\":\"https://a.b/c\",\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4},\"l\":{\"x\":5,\"y\":6,\"w\":7,\"h\":8}}}"), L); + Assert.assertNotNull(both); + Assert.assertEquals("https://a.b/c", both.url); + Assert.assertEquals(1, both.portrait.x); + Assert.assertEquals(8, both.landscape.height); + Assert.assertEquals(both.landscape, both.placementFor(true)); + Assert.assertEquals(both.portrait, both.placementFor(false)); + + // Only one orientation: both shapes have to fall back to it. + ContentData portraitOnly = ContentParser.parse(new JSONObject( + "{\"html\":\"https://a.b/c\",\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4}}}"), L); + Assert.assertNotNull(portraitOnly); + Assert.assertEquals(portraitOnly.portrait, portraitOnly.placementFor(true)); + + // Missing coordinates default to zero rather than failing the whole block. + ContentData partial = ContentParser.parse(new JSONObject("{\"html\":\"https://a.b/c\",\"geo\":{\"p\":{\"w\":3}}}"), L); + Assert.assertNotNull(partial); + Assert.assertEquals(0, partial.portrait.x); + Assert.assertEquals(3, partial.portrait.width); + + Assert.assertNull(ContentParser.parse(null, L)); + Assert.assertNull(ContentParser.parse(new JSONObject("{\"jsonArray\":[{\"result\":\"No content block found!\"}]}"), L)); + Assert.assertNull(ContentParser.parse(new JSONObject("{\"html\":\"https://a.b/c\"}"), L)); + Assert.assertNull(ContentParser.parse(new JSONObject("{\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4}}}"), L)); + Assert.assertNull(ContentParser.parse(new JSONObject("{\"html\":\"\",\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":3,\"h\":4}}}"), L)); + Assert.assertNull(ContentParser.parse(new JSONObject("{\"html\":\"https://a.b/c\",\"geo\":{}}"), L)); + } + + /** + * The signalling URLs a content block navigates to: an event payload, a resize request, a link + * with the close flag hidden in its own query, and an external link. + */ + @Test + public void widgetActionParser_readsEveryContentSignal() { + WidgetAction event = WidgetActionParser.parse( + "https://countly_action_event/?cly_x_action_event=1&action=event" + + "&event=%5B%7B%22key%22%3A%22ev1%22%7D%5D&close=0", L); + Assert.assertTrue(event.isSdkSignal); + Assert.assertTrue(event.isActionEvent); + Assert.assertFalse(event.close); + Assert.assertEquals("[{\"key\":\"ev1\"}]", event.eventPayload); + Assert.assertEquals("1", event.queryParams.get("cly_x_action_event")); + + WidgetAction resize = WidgetActionParser.parse( + "https://countly_action_event/?cly_x_action_event=1&action=resize_me" + + "&resize_me=%7B%22p%22%3A%7B%22x%22%3A1%2C%22y%22%3A2%2C%22w%22%3A3%2C%22h%22%3A4%7D%2C" + + "%22l%22%3A%7B%22x%22%3A5%2C%22y%22%3A6%2C%22w%22%3A7%2C%22h%22%3A8%7D%7D&close=1", L); + Assert.assertTrue(resize.hasResize); + Assert.assertTrue(resize.close); + Assert.assertEquals(3, resize.resizeFor(false).width); + Assert.assertEquals(7, resize.resizeFor(true).width); + + // A rectangle without a positive size is not usable. + WidgetAction emptyResize = WidgetActionParser.parse( + "https://countly_action_event/?cly_x_action_event=1&resize_me=%7B%22p%22%3A%7B%22w%22%3A0%2C%22h%22%3A0%7D%7D", L); + Assert.assertFalse(emptyResize.hasResize); + Assert.assertNull(emptyResize.resizeFor(false)); + + WidgetAction malformedResize = WidgetActionParser.parse( + "https://countly_action_event/?cly_x_action_event=1&resize_me=not-json", L); + Assert.assertTrue(malformedResize.isSdkSignal); + Assert.assertFalse(malformedResize.hasResize); + + // The close flag inside the destination's own query belongs to us, not to the destination. + WidgetAction link = WidgetActionParser.parse( + "https://countly_action_event/?cly_x_action_event=1&action=link" + + "&link=https%3A%2F%2Fcount.ly%3Fa%3D1%26close%3D1", L); + Assert.assertTrue(link.close); + Assert.assertEquals("https://count.ly?a=1", link.link); + + WidgetAction external = WidgetActionParser.parse("https://count.ly/pricing?cly_x_int=1", L); + Assert.assertTrue(external.isSdkSignal); + Assert.assertTrue(external.isExternalLink); + Assert.assertEquals("https://count.ly/pricing?cly_x_int=1", external.link); + + WidgetAction widgetClose = WidgetActionParser.parse("https://countly_action_event/?cly_widget_command=1&close=1", L); + Assert.assertTrue(widgetClose.isWidgetCommand); + Assert.assertTrue(widgetClose.close); + + // A plain page navigation is not a signal and must be left alone. + WidgetAction plain = WidgetActionParser.parse("https://test.server.com/feedback/nps?widget_id=1", L); + Assert.assertFalse(plain.isSdkSignal); + Assert.assertFalse(plain.close); + + Assert.assertFalse(WidgetActionParser.parse(null, L).isSdkSignal); + Assert.assertFalse(WidgetActionParser.parse("", L).isSdkSignal); + } + + /** + * Dropping a query parameter has to leave a valid URL behind, whatever position it was in. + */ + @Test + public void widgetActionParser_stripsOneParameterAtATime() { + Assert.assertEquals("https://a.b/c", WidgetActionParser.stripParam("https://a.b/c?close=1", "close")); + Assert.assertEquals("https://a.b/c?x=1", WidgetActionParser.stripParam("https://a.b/c?close=1&x=1", "close")); + Assert.assertEquals("https://a.b/c?x=1", WidgetActionParser.stripParam("https://a.b/c?x=1&close=1", "close")); + Assert.assertEquals("https://a.b/c?x=1&y=2", WidgetActionParser.stripParam("https://a.b/c?x=1&close=1&y=2", "close")); + Assert.assertEquals("https://a.b/c", WidgetActionParser.stripParam("https://a.b/c", "close")); + + Map query = WidgetActionParser.parseQuery("https://a.b/c?x=1&broken&y=%7B%22a%22%3A1%7D"); + Assert.assertEquals(2, query.size()); + Assert.assertEquals("1", query.get("x")); + Assert.assertEquals("{\"a\":1}", query.get("y")); + } + + /** + * The feedback widget display URL carries everything a desktop web view needs: the widget + * identity, the SDK identity, the card rendering flags and the page origin. + */ + @Test + public void widgetUrlBuilder_buildsADesktopReadyUrl() { + Countly.instance().init(TestUtils.getConfigFeedback()); + + CountlyFeedbackWidget widget = new CountlyFeedbackWidget(); + widget.widgetId = "widget_1"; + widget.type = FeedbackWidgetType.nps; + + String url = Countly.instance().feedback().constructFeedbackWidgetUrl(widget); + Assert.assertTrue(url.startsWith(TestUtils.SERVER_URL + "/feedback/nps?")); + + Map params = TestUtils.parseQueryParams(url.substring(url.indexOf('?') + 1)); + Assert.assertEquals("widget_1", params.get("widget_id")); + Assert.assertEquals(TestUtils.DEVICE_ID, params.get("device_id")); + Assert.assertEquals(TestUtils.SERVER_APP_KEY, params.get("app_key")); + Assert.assertEquals(WidgetUrlBuilder.CUSTOM_PARAMS, Utils.urldecode(params.get("custom"))); + Assert.assertEquals(TestUtils.SERVER_URL, params.get("origin")); + Assert.assertFalse(params.get("sdk_name").isEmpty()); + Assert.assertFalse(params.get("sdk_version").isEmpty()); + } + + /** + * The origin of a server URL, with and without an explicit port. + */ + @Test + public void widgetUrlBuilder_readsTheOrigin() throws Exception { + Assert.assertEquals("https://try.count.ly", WidgetUrlBuilder.originOf(new java.net.URL("https://try.count.ly"))); + Assert.assertEquals("http://localhost:3001", WidgetUrlBuilder.originOf(new java.net.URL("http://localhost:3001/path"))); + Assert.assertNull(WidgetUrlBuilder.originOf(null)); + } + + /** + * The content fetch parameters, including the category filter and the preview flags. + */ + @Test + public void contentRequestBuilder_buildsTheFetchParameters() { + Params plain = ContentRequestBuilder.build(new ContentScreen(800, 600), null, null, L); + Map params = TestUtils.parseQueryParams(plain.toString()); + Assert.assertEquals("queue", params.get("method")); + Assert.assertEquals(ContentRequestBuilder.DEVICE_TYPE, params.get("dt")); + Assert.assertEquals("[]", Utils.urldecode(params.get("category"))); + Assert.assertEquals("{\"l\":{\"w\":800,\"h\":600},\"p\":{\"w\":800,\"h\":600}}", Utils.urldecode(params.get("resolution"))); + + Params filtered = ContentRequestBuilder.build(new ContentScreen(800, 600), new String[] { "promo", "news" }, "block_1", L); + Map filteredParams = TestUtils.parseQueryParams(filtered.toString()); + Assert.assertEquals("[promo, news]", Utils.urldecode(filteredParams.get("category"))); + Assert.assertEquals("block_1", filteredParams.get("content_id")); + Assert.assertEquals("true", filteredParams.get("preview")); + + // A missing screen must not throw; it simply reports nothing to fit into. + Params noScreen = ContentRequestBuilder.build(null, null, null, L); + Assert.assertEquals("{\"l\":{\"w\":0,\"h\":0},\"p\":{\"w\":0,\"h\":0}}", + Utils.urldecode(TestUtils.parseQueryParams(noScreen.toString()).get("resolution"))); + } + + /** + * The content feature has its own consent bit, and it has to survive the round trip through + * {@link Config.Feature#byIndex(int)} the consent plumbing relies on. + */ + @Test + public void contentFeature_isWiredIntoTheFeatureBitmask() { + Assert.assertEquals(CoreFeature.Content.getIndex(), Config.Feature.Content.getIndex()); + Assert.assertEquals(Config.Feature.Content, Config.Feature.byIndex(CoreFeature.Content.getIndex())); + Assert.assertNotNull(CoreFeature.Content.getCreator()); + } +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java new file mode 100644 index 00000000..6f99608a --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java @@ -0,0 +1,451 @@ +package ly.count.sdk.java.internal; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Content zone behaviour, driven through the public {@code Countly.instance().content()} interface. + *

+ * The zone timer is driven by hand ({@link ModuleContent#onZoneTimerTick()}) in every test but + * {@link #zoneTimer_drivesFetchesOnItsOwn()}, so the assertions do not depend on wall clock timing. + */ +@RunWith(JUnit4.class) +public class ModuleContentTests { + + private static final String CONTENT_URL = "https://content.count.ly/block-1"; + private static final String CONTENT_RESPONSE = + "{\"html\":\"" + CONTENT_URL + "\",\"geo\":{" + + "\"p\":{\"x\":10,\"y\":20,\"w\":300,\"h\":400}," + + "\"l\":{\"x\":30,\"y\":40,\"w\":500,\"h\":600}}}"; + private static final String NO_CONTENT_RESPONSE = "{\"jsonArray\":[{\"result\":\"No content block found!\"}]}"; + + private final List> requests = new ArrayList<>(); + private final List endpoints = new ArrayList<>(); + private JSONObject nextResponse = null; + private CountDownLatch requestLatch = null; + + private FakeDisplay display; + + @Before + public void beforeTest() { + TestUtils.createCleanTestState(); + requests.clear(); + endpoints.clear(); + nextResponse = null; + requestLatch = null; + display = new FakeDisplay(); + } + + @After + public void stop() { + CountlyTimer.TIMER_DELAY_MS = 0; + Countly.instance().halt(); + } + + // region scenarios + + /** + * Entering a content zone, then letting the zone poll once. + *

+ * Verifies the wire shape of the fetch, that the parsed content reaches the display with the + * placement matching the surface orientation, and that a second poll does not fetch again while + * that content is still on screen. + */ + @Test + public void enterContentZone_fetchesOnceAndPresentsTheContent() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + // The first fetch waits for the zone's start delay, so nothing is on the wire yet. + Assert.assertTrue(requests.isEmpty()); + + tick(); + + Assert.assertEquals(1, requests.size()); + Assert.assertEquals("/o/sdk/content?", endpoints.get(0)); + + Map params = requests.get(0); + TestUtils.validateRequiredParams(params); + Assert.assertEquals("queue", params.get("method")); + Assert.assertEquals("desktop", params.get("dt")); + Assert.assertEquals("[]", Utils.urldecode(params.get("category"))); + Assert.assertFalse(params.get("la").isEmpty()); + Assert.assertEquals("{\"l\":{\"w\":1600,\"h\":900},\"p\":{\"w\":1600,\"h\":900}}", Utils.urldecode(params.get("resolution"))); + Assert.assertNull(params.get("content_id")); + Assert.assertNull(params.get("preview")); + + Assert.assertEquals(1, display.presented.size()); + ContentData shown = display.presented.get(0); + Assert.assertEquals(CONTENT_URL, shown.url); + // A 1600x900 surface is landscape, so the landscape rectangle wins. + ContentPlacement placement = shown.placementFor(true); + Assert.assertEquals(30, placement.x); + Assert.assertEquals(40, placement.y); + Assert.assertEquals(500, placement.width); + Assert.assertEquals(600, placement.height); + + tick(); + Assert.assertEquals(1, requests.size()); + Assert.assertEquals(1, display.presented.size()); + } + + /** + * A content zone cannot be entered before a display is registered, and entering again once one + * is registered works. + */ + @Test + public void enterContentZone_withoutADisplay_isIgnored() throws JSONException { + init(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertTrue(requests.isEmpty()); + Assert.assertTrue(display.presented.isEmpty()); + + Countly.instance().content().setContentDisplay(display); + Countly.instance().content().enterContentZone(); + tick(); + + Assert.assertEquals(1, requests.size()); + Assert.assertEquals(1, display.presented.size()); + } + + /** + * With consent required, the content interface is unreachable until content consent is given, + * and a zone that is already running is torn down when that consent is taken away again. + */ + @Test + public void content_isGatedByConsent() throws JSONException { + Config config = TestUtils.getConfigContent().setRequiresConsent(true); + init(config); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Assert.assertNull(Countly.instance().content()); + + Countly.onConsent(Config.Feature.Content, Config.Feature.Events); + installRequestMaker(); + + ModuleContent.Content content = Countly.instance().content(); + Assert.assertNotNull(content); + content.setContentDisplay(display); + content.enterContentZone(); + tick(); + Assert.assertEquals(1, requests.size()); + + Countly.onConsentRemoval(Config.Feature.Content); + Assert.assertNull(Countly.instance().content()); + Assert.assertEquals(1, requests.size()); + } + + /** + * A response the server sends when it has nothing to show, and a failed request, both leave the + * zone polling instead of wedging it. + */ + @Test + public void noContentInResponse_keepsThePollingGoing() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + + Countly.instance().content().enterContentZone(); + + nextResponse = new JSONObject(NO_CONTENT_RESPONSE); + tick(); + Assert.assertEquals(1, requests.size()); + Assert.assertTrue(display.presented.isEmpty()); + + // A null response is what a failed request looks like to the module. + nextResponse = null; + tick(); + Assert.assertEquals(2, requests.size()); + Assert.assertTrue(display.presented.isEmpty()); + + nextResponse = new JSONObject(CONTENT_RESPONSE); + tick(); + Assert.assertEquals(3, requests.size()); + Assert.assertEquals(1, display.presented.size()); + } + + /** + * Closing a content block reports it to the global content callback and holds the zone back for + * a couple of polls, so the server can process whatever the content recorded. + */ + @Test + public void contentClose_reportsToTheCallbackAndPausesTheZone() throws JSONException { + final List statuses = new ArrayList<>(); + final List> payloads = new ArrayList<>(); + + Config config = TestUtils.getConfigContent(); + config.content.setGlobalContentCallback((status, data) -> { + statuses.add(status); + payloads.add(data); + }); + initWithContent(config); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertEquals(1, display.presented.size()); + + Map closeData = new HashMap<>(); + closeData.put("cly_x_action_event", "1"); + closeData.put("close", "1"); + display.lastCallback.onClosed(closeData); + + Assert.assertEquals(1, statuses.size()); + Assert.assertEquals(ContentStatus.CLOSED, statuses.get(0)); + Assert.assertEquals("1", payloads.get(0).get("close")); + + for (int i = 0; i < ModuleContent.POST_CLOSE_SKIPPED_TICKS; i++) { + tick(); + Assert.assertEquals(1, requests.size()); + } + + tick(); + Assert.assertEquals(2, requests.size()); + Assert.assertEquals(2, display.presented.size()); + } + + /** + * Leaving a content zone stops the polling but leaves a content block that is already on screen + * alone, and entering again restarts the cycle. + */ + @Test + public void exitContentZone_stopsPollingWithoutClosingWhatIsOnScreen() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertEquals(1, requests.size()); + Assert.assertEquals(1, display.presented.size()); + + Countly.instance().content().exitContentZone(); + Assert.assertFalse(display.closed.get()); + + tick(); + Assert.assertEquals(1, requests.size()); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertEquals(2, requests.size()); + Assert.assertEquals(2, display.presented.size()); + } + + /** + * A display that throws while showing content must not leave the zone believing something is on + * screen, which would block every later fetch. + */ + @Test + public void throwingDisplay_doesNotWedgeTheZone() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + display.throwOnPresent = true; + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertEquals(1, requests.size()); + Assert.assertEquals(1, display.presented.size()); + + tick(); + Assert.assertEquals(2, requests.size()); + Assert.assertEquals(2, display.presented.size()); + } + + /** + * Previewing one specific content block: the fetch carries the block's ID, a blank ID is + * rejected, and a preview cannot stack on top of a content block that is already on screen. + */ + @Test + public void previewContent_fetchesTheGivenBlockAndGuardsAgainstStacking() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().previewContent(null); + Countly.instance().content().previewContent(""); + Assert.assertTrue(requests.isEmpty()); + + Countly.instance().content().previewContent("block_42"); + Assert.assertEquals(1, requests.size()); + Assert.assertEquals("/o/sdk/content?", endpoints.get(0)); + Assert.assertEquals("block_42", requests.get(0).get("content_id")); + Assert.assertEquals("true", requests.get(0).get("preview")); + Assert.assertEquals(1, display.presented.size()); + + Countly.instance().content().previewContent("block_43"); + Assert.assertEquals(1, requests.size()); + } + + /** + * Refreshing a content zone flushes the event queue and re-enters, but is ignored while a + * content block is on screen. + */ + @Test + public void refreshContentZone_flushesEventsAndReEnters() throws JSONException { + initWithContent(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + + Countly.instance().content().enterContentZone(); + tick(); + Assert.assertEquals(1, display.presented.size()); + + Countly.instance().content().refreshContentZone(); + tick(); + Assert.assertEquals(1, requests.size()); + + display.lastCallback.onClosed(new HashMap<>()); + + Countly.instance().events().recordEvent("trigger_event"); + Assert.assertEquals(1, eventQueueSize()); + + Countly.instance().content().refreshContentZone(); + Assert.assertEquals(0, eventQueueSize()); + + // Re-entering resets the post close wait, so the very next poll fetches. + tick(); + Assert.assertEquals(2, requests.size()); + Assert.assertEquals(2, display.presented.size()); + } + + /** + * Events a content block asks for are recorded with either segmentation key, entries without a + * key are dropped, and the queue is pushed out so the server can act on them. + */ + @Test + public void recordContentEvents_recordsEveryUsableEntryAndFlushes() { + initWithContent(TestUtils.getConfigContent()); + + Countly.instance().content().recordContentEvents( + "[{\"key\":\"[CLY]_content_shown\",\"sg\":{\"a\":\"1\"}}," + + "{\"key\":\"with_segmentation\",\"segmentation\":{\"b\":2}}," + + "{\"sg\":{\"c\":\"3\"}}," + + "{\"key\":\"\"}]"); + + Assert.assertEquals(0, eventQueueSize()); + List events = TestUtils.readEventsFromRequest(); + Assert.assertEquals(2, events.size()); + Assert.assertEquals("[CLY]_content_shown", events.get(0).key); + Assert.assertEquals("1", events.get(0).segmentation.get("a")); + Assert.assertEquals("with_segmentation", events.get(1).key); + Assert.assertEquals(2, events.get(1).segmentation.get("b")); + + // Nothing usable, nothing recorded, and no crash on malformed input. + int requestCount = TestUtils.getCurrentRQ().length; + Countly.instance().content().recordContentEvents("not json at all"); + Countly.instance().content().recordContentEvents(""); + Countly.instance().content().recordContentEvents(null); + Assert.assertEquals(requestCount, TestUtils.getCurrentRQ().length); + } + + /** + * The zone fetch interval only accepts sane values, so a mistyped configuration cannot turn the + * zone into a busy loop. + */ + @Test + public void zoneTimerInterval_rejectsValuesBelowTheMinimum() { + Config config = TestUtils.getConfigContent(); + + config.content.setZoneTimerInterval(1); + Assert.assertEquals(ConfigContent.DEFAULT_ZONE_TIMER_INTERVAL, config.content.zoneTimerInterval); + + config.content.setZoneTimerInterval(ConfigContent.MIN_ZONE_TIMER_INTERVAL - 1); + Assert.assertEquals(ConfigContent.DEFAULT_ZONE_TIMER_INTERVAL, config.content.zoneTimerInterval); + + config.content.setZoneTimerInterval(60); + Assert.assertEquals(60, config.content.zoneTimerInterval); + } + + /** + * The zone really is driven by its own timer, not only by the hand driven ticks the other tests + * use. + */ + @Test + public void zoneTimer_drivesFetchesOnItsOwn() throws JSONException, InterruptedException { + CountlyTimer.TIMER_DELAY_MS = 50; + initWithContent(TestUtils.getConfigContent()); + nextResponse = new JSONObject(CONTENT_RESPONSE); + requestLatch = new CountDownLatch(1); + + Countly.instance().content().enterContentZone(); + + Assert.assertTrue("the zone timer never fetched", requestLatch.await(5, TimeUnit.SECONDS)); + Countly.instance().content().exitContentZone(); + } + + // endregion + // region helpers + + private void init(Config config) { + Countly.instance().init(config); + installRequestMaker(); + } + + private void initWithContent(Config config) { + init(config); + Countly.instance().content().setContentDisplay(display); + } + + private void installRequestMaker() { + ImmediateRequestI requestMaker = (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + synchronized (requests) { + requests.add(TestUtils.parseQueryParams(requestData)); + endpoints.add(customEndpoint); + } + if (requestLatch != null) { + requestLatch.countDown(); + } + callback.callback(nextResponse); + }; + SDKCore.instance.config.immediateRequestGenerator = () -> requestMaker; + } + + private void tick() { + SDKCore.instance.module(ModuleContent.class).onZoneTimerTick(); + } + + private int eventQueueSize() { + return SDKCore.instance.module(ModuleEvents.class).eventQueue.eqSize(); + } + + private static class FakeDisplay implements ContentDisplay { + + final List presented = new ArrayList<>(); + final AtomicBoolean closed = new AtomicBoolean(false); + ContentCloseCallback lastCallback; + boolean throwOnPresent = false; + + @Override + public ContentScreen getScreen() { + return new ContentScreen(1600, 900); + } + + @Override + public void present(ContentData content, ContentCloseCallback onClosed) { + presented.add(content); + lastCallback = data -> { + closed.set(true); + onClosed.onClosed(data); + }; + if (throwOnPresent) { + throw new IllegalStateException("this display cannot show anything"); + } + } + } + + // endregion +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java index 815dbbdd..3a88ceda 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java @@ -248,6 +248,14 @@ public void constructFeedbackWidgetUrl_base(CountlyFeedbackWidget widgetInfo, bo widgetListUrl.append(TestUtils.SDK_NAME); widgetListUrl.append("&platform="); widgetListUrl.append(Utils.urlencode(getOS(), L)); + widgetListUrl.append("&app_version="); + widgetListUrl.append(Utils.urlencode(TestUtils.APPLICATION_VERSION, L)); + // Desktop web views need the widget to draw itself as a card with its own close button, and + // the page only accepts the SDK's viewport message when the origin matches. + widgetListUrl.append("&custom="); + widgetListUrl.append(Utils.urlencode(WidgetUrlBuilder.CUSTOM_PARAMS, L)); + widgetListUrl.append("&origin="); + widgetListUrl.append(TestUtils.SERVER_URL); Assert.assertEquals(widgetListUrl.toString(), Countly.instance().feedback().constructFeedbackWidgetUrl(widgetInfo)); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java index 9f71340d..266b3dc2 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java @@ -124,6 +124,15 @@ static Config getConfigFeedback(Config.Feature... features) { return config; } + static Config getConfigContent(Config.Feature... features) { + Config config = getBaseConfig(); + + config.enableFeatures(features); + config.enableFeatures(Config.Feature.Content, Config.Feature.Events); + + return config; + } + public static File getTestSDirectory() { // System specific folder structure String[] sdkStorageRootPath = { System.getProperty("user.home"), "__COUNTLY", "java_test" }; diff --git a/settings.gradle b/settings.gradle index aa989e7f..eb9cf9fe 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,8 +7,9 @@ pluginManagement { include ':sdk-java', ':app-java' -// app-javafx uses the openjfx Gradle plugin which requires Java 11+. -// Skip it on older JVMs so sdk-java / app-java stay buildable on Java 8. +// sdk-java-ui and app-javafx use the openjfx Gradle plugin which requires Java 11+. +// Skip them on older JVMs so sdk-java / app-java stay buildable on Java 8. if (JavaVersion.current().isJava11Compatible()) { + include ':sdk-java-ui' include ':app-javafx' } From 814c026246b1a8f2ddb5a9433157f7aaf2c0402a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 1 Sep 2026 11:09:08 +0300 Subject: [PATCH 2/6] feat: code coverage --- .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 9 +- .idea/gradle.xml | 5 +- .idea/migrations.xml | 10 ++ .idea/misc.xml | 74 ++++----- .idea/modules.xml | 17 -- .../app-java/countly-sdk-java.app-java.iml | 12 -- .../countly-sdk-java.app-java.main.iml | 16 -- .../countly-sdk-java.app-java.test.iml | 16 -- .../countly-sdk-java.app-javafx.iml | 12 -- .../countly-sdk-java.app-javafx.main.iml | 22 --- .../countly-sdk-java.app-javafx.test.iml | 21 --- .idea/modules/countly-sdk-java.iml | 12 -- .../sdk-java/countly-sdk-java.sdk-java.iml | 12 -- .../countly-sdk-java.sdk-java.main.iml | 14 -- .../countly-sdk-java.sdk-java.test.iml | 22 --- .idea/runConfigurations.xml | 17 ++ CHANGELOG.md | 3 +- README.md | 13 ++ .../java/ly/count/javafx/demo/AppContext.java | 9 +- .../javafx/demo/ui/FeedbackWidgetsPane.java | 41 ++++- build.gradle | 131 +++++++++++++++ sdk-java-ui/README.md | 14 ++ sdk-java-ui/build.gradle | 18 +++ .../ly/count/sdk/java/ui/CountlyWebView.java | 151 +++++++++++++++++- sdk-java/build.gradle | 18 +++ .../java/internal/FeedbackWidgetSelector.java | 59 +++++++ .../java/internal/ModuleFeedbackTests.java | 47 ++++++ 28 files changed, 572 insertions(+), 229 deletions(-) create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/migrations.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/modules/app-java/countly-sdk-java.app-java.iml delete mode 100644 .idea/modules/app-java/countly-sdk-java.app-java.main.iml delete mode 100644 .idea/modules/app-java/countly-sdk-java.app-java.test.iml delete mode 100644 .idea/modules/app-javafx/countly-sdk-java.app-javafx.iml delete mode 100644 .idea/modules/app-javafx/countly-sdk-java.app-javafx.main.iml delete mode 100644 .idea/modules/app-javafx/countly-sdk-java.app-javafx.test.iml delete mode 100644 .idea/modules/countly-sdk-java.iml delete mode 100644 .idea/modules/sdk-java/countly-sdk-java.sdk-java.iml delete mode 100644 .idea/modules/sdk-java/countly-sdk-java.sdk-java.main.iml delete mode 100644 .idea/modules/sdk-java/countly-sdk-java.sdk-java.test.iml create mode 100644 .idea/runConfigurations.xml create mode 100644 sdk-java/src/main/java/ly/count/sdk/java/internal/FeedbackWidgetSelector.java diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 43f43bae..8144c3cf 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -11,13 +11,6 @@ - - - - - - - - + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 7d4c91b5..e2c9312a 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,15 +4,16 @@

- * // Feedback widgets
+ * // Feedback widgets, the quick way: fetch, pick and show in one call
+ * CountlyWebView.presentNPS(stage);
+ * CountlyWebView.presentSurvey(stage, "onboarding");
+ * CountlyWebView.presentRating(stage, "", () -> System.out.println("dismissed"));
+ *
+ * // Feedback widgets, picking one yourself
  * Countly.instance().feedback().getAvailableFeedbackWidgets((widgets, error) ->
  *     Platform.runLater(() -> CountlyWebView.presentFeedbackWidget(stage, widgets.get(0), null)));
  *
@@ -102,6 +109,135 @@ public static void presentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
         }
     }
 
+    /**
+     * Show the first available NPS widget.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     */
+    public static void presentNPS(Window owner) {
+        presentNPS(owner, null, null);
+    }
+
+    /**
+     * Show an NPS widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available NPS widget.
+     */
+    public static void presentNPS(Window owner, String nameIDorTag) {
+        presentNPS(owner, nameIDorTag, null);
+    }
+
+    /**
+     * Show an NPS widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available NPS widget.
+     * @param onClosed called once, when the card is gone, may be {@code null}
+     */
+    public static void presentNPS(Window owner, String nameIDorTag, Runnable onClosed) {
+        presentWidgetOfType(owner, FeedbackWidgetType.nps, nameIDorTag, onClosed);
+    }
+
+    /**
+     * Show the first available survey widget.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     */
+    public static void presentSurvey(Window owner) {
+        presentSurvey(owner, null, null);
+    }
+
+    /**
+     * Show a survey widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available survey widget.
+     */
+    public static void presentSurvey(Window owner, String nameIDorTag) {
+        presentSurvey(owner, nameIDorTag, null);
+    }
+
+    /**
+     * Show a survey widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available survey widget.
+     * @param onClosed called once, when the card is gone, may be {@code null}
+     */
+    public static void presentSurvey(Window owner, String nameIDorTag, Runnable onClosed) {
+        presentWidgetOfType(owner, FeedbackWidgetType.survey, nameIDorTag, onClosed);
+    }
+
+    /**
+     * Show the first available rating widget.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     */
+    public static void presentRating(Window owner) {
+        presentRating(owner, null, null);
+    }
+
+    /**
+     * Show a rating widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available rating widget.
+     */
+    public static void presentRating(Window owner, String nameIDorTag) {
+        presentRating(owner, nameIDorTag, null);
+    }
+
+    /**
+     * Show a rating widget picked by its ID, name or one of its tags.
+     *
+     * @param owner the application window the card belongs to, may be {@code null}
+     * @param nameIDorTag the widget ID, widget name or widget tag to look for. Leave it empty to take
+     *     the first available rating widget.
+     * @param onClosed called once, when the card is gone, may be {@code null}
+     */
+    public static void presentRating(Window owner, String nameIDorTag, Runnable onClosed) {
+        presentWidgetOfType(owner, FeedbackWidgetType.rating, nameIDorTag, onClosed);
+    }
+
+    /**
+     * Fetches the widget list, picks the one asked for, and shows it. The fetch is a network call, so
+     * this returns straight away and the card appears later.
+     */
+    private static void presentWidgetOfType(Window owner, FeedbackWidgetType type, String nameIDorTag, Runnable onClosed) {
+        ModuleFeedback.Feedback feedback = Countly.instance().feedback();
+        if (feedback == null) {
+            UiLog.w("[CountlyWebView] present" + type.name() + ", the feedback interface is not available, ignoring the call");
+            run(onClosed);
+            return;
+        }
+
+        feedback.getAvailableFeedbackWidgets((widgets, error) -> {
+            // This callback runs on the SDK's network thread. The happy path hands the callback back
+            // on the JavaFX thread, so these bail outs do the same and the caller only ever sees one.
+            if (error != null) {
+                UiLog.e("[CountlyWebView] present" + type.name() + ", could not retrieve the widget list, [" + error + "]");
+                runOnFxThread(onClosed);
+                return;
+            }
+
+            CountlyFeedbackWidget widget = FeedbackWidgetSelector.select(widgets, type, nameIDorTag);
+            if (widget == null) {
+                UiLog.w("[CountlyWebView] present" + type.name() + ", no widget of that type matches [" + nameIDorTag + "]");
+                runOnFxThread(onClosed);
+                return;
+            }
+
+            // The fetch completed off the JavaFX thread; presentFeedbackWidget hops back on its own.
+            presentFeedbackWidget(owner, widget, onClosed);
+        });
+    }
+
     /**
      * Register the JavaFX content display with the SDK and enter the content zone. Must be called on
      * the JavaFX application thread, after the SDK was initialized with
@@ -164,6 +300,19 @@ private static WidgetSurface resolveSurface(Window owner) {
         return new WidgetSurface((int) bounds.getMinX(), (int) bounds.getMinY(), (int) bounds.getWidth(), (int) bounds.getHeight());
     }
 
+    private static void runOnFxThread(Runnable runnable) {
+        if (runnable == null) {
+            return;
+        }
+        try {
+            Platform.runLater(() -> run(runnable));
+        } catch (Throwable t) {
+            // No toolkit running: better an off thread callback than none at all.
+            UiLog.w("[CountlyWebView] runOnFxThread, the JavaFX toolkit is not running, [" + t + "]");
+            run(runnable);
+        }
+    }
+
     private static void run(Runnable runnable) {
         if (runnable == null) {
             return;
diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle
index 1480f0e6..548d07ee 100644
--- a/sdk-java/build.gradle
+++ b/sdk-java/build.gradle
@@ -30,6 +30,24 @@ dependencies {
   //testImplementation 'com.squareup.okhttp3:mockwebserver:3.7.0'
 }
 
+// Coverage. Reports land in build/reports/jacoco/test/ (HTML to read, XML for tooling).
+// "./gradlew coverage" from the repo root runs this and prints a summary.
+apply plugin: 'jacoco'
+
+jacoco {
+  toolVersion = JACOCO_VERSION
+}
+
+jacocoTestReport {
+  dependsOn test
+
+  reports {
+    html.required = true
+    xml.required = true
+    csv.required = false
+  }
+}
+
 if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) {
   apply plugin: "com.vanniktech.maven.publish"
 }
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/FeedbackWidgetSelector.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/FeedbackWidgetSelector.java
new file mode 100644
index 00000000..22db4f78
--- /dev/null
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/FeedbackWidgetSelector.java
@@ -0,0 +1,59 @@
+package ly.count.sdk.java.internal;
+
+import java.util.List;
+
+/**
+ * Picks one feedback widget out of a fetched list, by type and optionally by a name, ID or tag.
+ * Kept free of any UI toolkit so the quick present calls of every display implementation select the
+ * same way.
+ */
+public class FeedbackWidgetSelector {
+
+    private FeedbackWidgetSelector() {
+    }
+
+    /**
+     * @param widgets the widgets available for this device, may be {@code null}
+     * @param type the type of widget to look for
+     * @param nameIDorTag the widget ID, name or one of its tags. {@code null} or empty takes the
+     *     first widget of that type.
+     * @return the widget to show, or {@code null} when the list holds no match
+     */
+    public static CountlyFeedbackWidget select(List widgets, FeedbackWidgetType type, String nameIDorTag) {
+        if (widgets == null || widgets.isEmpty() || type == null) {
+            return null;
+        }
+
+        boolean matchAny = Utils.isEmptyOrNull(nameIDorTag);
+
+        for (CountlyFeedbackWidget widget : widgets) {
+            if (widget == null || widget.type != type) {
+                continue;
+            }
+
+            if (matchAny || matches(widget, nameIDorTag)) {
+                return widget;
+            }
+        }
+
+        return null;
+    }
+
+    private static boolean matches(CountlyFeedbackWidget widget, String nameIDorTag) {
+        if (nameIDorTag.equals(widget.widgetId) || nameIDorTag.equals(widget.name)) {
+            return true;
+        }
+
+        if (widget.tags == null) {
+            return false;
+        }
+
+        for (String tag : widget.tags) {
+            if (nameIDorTag.equals(tag)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java
index 3a88ceda..044cb689 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleFeedbackTests.java
@@ -224,6 +224,53 @@ public void getAvailableFeedbackWidgets_base(List expecte
         });
     }
 
+    /**
+     * "FeedbackWidgetSelector.select"
+     * A fetched widget list is searched by type, and by widget ID, name or tag
+     * The first widget matching both the type and the selector is returned, and a selector that
+     * matches nothing returns "null" instead of an unrelated widget
+     */
+    @Test
+    public void selectWidget_byTypeAndNameIdOrTag() {
+        List widgets = new ArrayList<>();
+        widgets.add(widget("nps_1", FeedbackWidgetType.nps, "First NPS", new String[] { "checkout" }));
+        widgets.add(widget("nps_2", FeedbackWidgetType.nps, "Second NPS", new String[] { "onboarding", "beta" }));
+        widgets.add(widget("survey_1", FeedbackWidgetType.survey, "Only survey", new String[] {}));
+        widgets.add(widget("rating_1", FeedbackWidgetType.rating, "Only rating", null));
+
+        // No selector: the first widget of that type wins.
+        Assert.assertEquals("nps_1", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, null).widgetId);
+        Assert.assertEquals("nps_1", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "").widgetId);
+        Assert.assertEquals("survey_1", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.survey, "").widgetId);
+
+        // By ID, by name and by tag.
+        Assert.assertEquals("nps_2", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "nps_2").widgetId);
+        Assert.assertEquals("nps_2", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "Second NPS").widgetId);
+        Assert.assertEquals("nps_2", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "beta").widgetId);
+        Assert.assertEquals("nps_1", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "checkout").widgetId);
+
+        // The type always wins over the selector: a survey tag must not return the NPS widget.
+        Assert.assertNull(FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.survey, "checkout"));
+        Assert.assertNull(FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.nps, "nothing_matches"));
+
+        // A widget with no tags at all must not blow up the search.
+        Assert.assertEquals("rating_1", FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.rating, "Only rating").widgetId);
+        Assert.assertNull(FeedbackWidgetSelector.select(widgets, FeedbackWidgetType.rating, "some_tag"));
+
+        Assert.assertNull(FeedbackWidgetSelector.select(null, FeedbackWidgetType.nps, ""));
+        Assert.assertNull(FeedbackWidgetSelector.select(new ArrayList<>(), FeedbackWidgetType.nps, ""));
+        Assert.assertNull(FeedbackWidgetSelector.select(widgets, null, ""));
+    }
+
+    private static CountlyFeedbackWidget widget(String id, FeedbackWidgetType type, String name, String[] tags) {
+        CountlyFeedbackWidget widget = new CountlyFeedbackWidget();
+        widget.widgetId = id;
+        widget.type = type;
+        widget.name = name;
+        widget.tags = tags;
+        return widget;
+    }
+
     public void constructFeedbackWidgetUrl_base(CountlyFeedbackWidget widgetInfo, boolean goodResult) {
         init(TestUtils.getConfigFeedback());
 

From de234eb8ddd35c3225eb13c2274b7478ffdde9ab Mon Sep 17 00:00:00 2001
From: Arif Burak Demiray 
Date: Tue, 1 Sep 2026 11:34:47 +0300
Subject: [PATCH 3/6] feat: code coverage CI and appear fix

---
 .github/scripts/coverage_report.py            | 280 ++++++++++++++++++
 .github/workflows/coverage.yml                | 132 +++++++++
 README.md                                     |   4 +
 .../java/ly/count/javafx/demo/AppContext.java |   4 +-
 .../main/java/ly/count/javafx/demo/Main.java  |  36 ++-
 .../ly/count/javafx/demo/ui/ContentPane.java  |  93 ++++--
 sdk-java-ui/README.md                         |  12 +
 .../ly/count/sdk/java/ui/CountlyWebView.java  |  36 ++-
 .../java/ly/count/sdk/java/ui/FxSurfaces.java | 128 ++++++++
 .../sdk/java/ui/JavaFxContentDisplay.java     |  92 ++++--
 .../count/sdk/java/ui/JavaFxWidgetHost.java   |   2 +-
 .../java/internal/ContentRequestBuilder.java  |  19 +-
 .../sdk/java/internal/ModuleContent.java      |  51 ++--
 .../java/internal/ContentParsingTests.java    |  20 +-
 .../sdk/java/internal/ModuleContentTests.java |  71 ++++-
 15 files changed, 859 insertions(+), 121 deletions(-)
 create mode 100755 .github/scripts/coverage_report.py
 create mode 100644 .github/workflows/coverage.yml
 create mode 100644 sdk-java-ui/src/main/java/ly/count/sdk/java/ui/FxSurfaces.java

diff --git a/.github/scripts/coverage_report.py b/.github/scripts/coverage_report.py
new file mode 100755
index 00000000..6e9d5fb1
--- /dev/null
+++ b/.github/scripts/coverage_report.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+"""Turn jacoco XML reports into a Markdown coverage report for a pull request comment.
+
+Reads one report per Gradle module, plus the list of files the pull request touched, and
+writes:
+
+  * a per-module line and branch coverage table
+  * coverage of the source files this pull request actually changed, which is the part a
+    reviewer can act on
+  * the least covered classes, as a standing to-do list
+  * the test failure count, so nobody reads coverage numbers off a broken run
+
+Exit code is 0 unless a configured minimum is missed and enforcement is switched on, so the
+job can start out advisory and become a gate later without touching this script.
+
+Usage:
+  coverage_report.py --out coverage-report.md
+                     --changed-files changed.txt
+                     --module