From d6f6340fe68df5d6c5c141e27703a8cece02b554 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 7 Oct 2026 11:51:38 +0200 Subject: [PATCH 1/4] fix: server-triggered surveys pass resume, resumeData and outboundAction in start-survey Like the JavaScript SDK: a resume reminder continues the server's copy of the response and a Surveys 2.0 response records the outbound action that delivered it. Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/gleap/GleapEventService.java | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/gleap/src/main/java/io/gleap/GleapEventService.java b/gleap/src/main/java/io/gleap/GleapEventService.java index fd597270..49dcb578 100644 --- a/gleap/src/main/java/io/gleap/GleapEventService.java +++ b/gleap/src/main/java/io/gleap/GleapEventService.java @@ -694,14 +694,7 @@ public void run() { } } } else if (currentAction.getString("format").contains("survey")) { - JSONObject jsonObject = new JSONObject(); - try { - jsonObject.put("isSurvey", true); - jsonObject.put("hideBackButton", true); - jsonObject.put("format", currentAction.getString("format")); - jsonObject.put("flow", currentAction.getString("actionType")); - } catch (Exception ex) { - } + JSONObject jsonObject = surveyStartData(currentAction); GleapMainThread.post(new Runnable() { @Override @@ -749,4 +742,36 @@ public void run() { } } } + /** + * The start-survey message data for a survey the server triggered, as the JavaScript SDK + * sends it: the flow and format, and for Surveys 2.0 the outbound action that delivered it + * (the response's source) and, for a resume reminder, the server's copy of the response so + * far ({@code resumeData}). + */ + static JSONObject surveyStartData(JSONObject action) { + JSONObject data = new JSONObject(); + try { + data.put("isSurvey", true); + data.put("hideBackButton", true); + data.put("format", action.optString("format", "survey")); + data.put("flow", action.optString("actionType", "")); + + boolean resume = action.optBoolean("resume", false); + data.put("resume", resume); + JSONObject resumeData = action.optJSONObject("data"); + if (resume && resumeData != null) { + data.put("resumeData", resumeData); + } + + String outboundAction = action.optString("_id", ""); + if (outboundAction.length() == 0) { + outboundAction = action.optString("id", ""); + } + if (outboundAction.length() > 0) { + data.put("outboundAction", outboundAction); + } + } catch (Exception ignore) { + } + return data; + } } From ec25d10d8eea906d2f871aed13b941b6e5305458 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 7 Oct 2026 11:51:38 +0200 Subject: [PATCH 2/4] fix: Surveys 2.0 completion fires feedbackSent/outboundSent and the submitted event The messenger saves v2 answers itself and reports completion as notify-event outbound-sent, which only flow-started was handled for. It now invokes the FeedbackSent and OutboundSent callbacks in the legacy shape (outboundId, outbound, formData, plus responseId/endingId) and tracks outbound--submitted. survey-* lifecycle, height-update and sheet-viewport are handled (content seen, full-screen layout) or accepted as no-ops. Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/gleap/GleapWidgetBridge.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java index 4d097847..da7c480a 100644 --- a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java +++ b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java @@ -119,6 +119,24 @@ public void run() { case "send-feedback": sendFeedback(gleapCallback); break; + case "height-update": + activity.onWidgetContent(); + break; + case "survey-shown": + activity.onWidgetContent(); + activity.onSurveyShown(gleapCallback.optJSONObject("data")); + break; + case "survey-legacy": + activity.onSurveyLegacy(); + break; + case "survey-answered": + case "survey-completed": + case "survey-closed": + case "survey-step-viewed": + case "sheet-viewport": + // Surveys 2.0 lifecycle and shell layout: handled by the page; + // completion reaches the app as notify-event outbound-sent. + break; case "capture-start": case "capture-cancel": case "capture-done": @@ -225,11 +243,58 @@ private static void notifyEvent(JSONObject object) { if (GleapCallbacks.getInstance().getFeedbackFlowStartedCallback() != null) { GleapCallbacks.getInstance().getFeedbackFlowStartedCallback().invoke(eventData.toString()); } + } else if (eventType.equals("outbound-sent")) { + surveyCompleted(eventData); } } catch (Exception ex) { } } + /** + * A Surveys 2.0 survey was completed. The messenger saves its answers itself (no + * send-feedback), so the callbacks and the outbound-<id>-submitted event a legacy survey + * gets after sending (see HttpHelper) come from here, in the legacy shape. + */ + static void surveyCompleted(JSONObject eventData) { + if (eventData == null) { + return; + } + JSONObject formData = eventData.optJSONObject("formData"); + if (formData == null) { + formData = new JSONObject(); + } + + try { + if (GleapCallbacks.getInstance().getFeedbackSentCallback() != null) { + GleapCallbacks.getInstance().getFeedbackSentCallback().invoke(formData); + } + } catch (Exception ignore) { + } + + String outboundId = eventData.optString("outboundId", ""); + if (outboundId.length() == 0) { + return; + } + + try { + if (GleapCallbacks.getInstance().getOutboundSentCallback() != null) { + JSONObject sent = new JSONObject(); + sent.put("outboundId", outboundId); + sent.putOpt("outbound", eventData.opt("outbound")); + sent.put("formData", formData); + sent.putOpt("responseId", eventData.opt("responseId")); + sent.putOpt("endingId", eventData.opt("endingId")); + GleapCallbacks.getInstance().getOutboundSentCallback().invoke(sent); + } + } catch (Exception ignore) { + } + + try { + Gleap.getInstance().trackEvent("outbound-" + outboundId + "-submitted", formData); + } catch (Exception ignore) { + } + } + private static void sendPendingActions(GleapMainActivity activity) { List queue = GleapActionQueueHandler.getInstance().getActionQueue(); for (GleapAction action : queue) { From 531a97394c1b23f83401c8404ddb2684a5c39867 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 7 Oct 2026 11:51:38 +0200 Subject: [PATCH 3/4] fix: survey containers on Android - edge-to-edge full screen, even card dim, 30s load timeout Full screen: once a v2 survey shows, the page draws behind the status and navigation bars and gets the insets as safe-area-update (it pads its close button, question and bar); the bars no longer show the loader's header color over the pale survey page. Legacy fallback keeps the old layout. Card: the bands the container keeps clear of the system bars and the keyboard stayed at the loader dim (#66000000) while the page dim stacked on the rest (two tones). After the reveal the loader fades out and the bands get the page's own dim (rgba(0,0,0,0.25)), so the screen is dimmed evenly. Load timeout: 30s instead of 15s, and only when the page never pinged and never reported a height or a shown survey. Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/gleap/GleapMainActivity.java | 230 +++++++++++++++++- .../main/java/io/gleap/ScreenshotTaker.java | 1 + 2 files changed, 218 insertions(+), 13 deletions(-) diff --git a/gleap/src/main/java/io/gleap/GleapMainActivity.java b/gleap/src/main/java/io/gleap/GleapMainActivity.java index 249a757d..6058c8db 100644 --- a/gleap/src/main/java/io/gleap/GleapMainActivity.java +++ b/gleap/src/main/java/io/gleap/GleapMainActivity.java @@ -6,7 +6,13 @@ import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.res.Configuration; +import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.net.http.SslError; import android.annotation.TargetApi; @@ -38,7 +44,9 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; import org.json.JSONObject; @@ -61,9 +69,24 @@ public class GleapMainActivity extends AppCompatActivity implements OnHttpRespon private String url = GleapConfig.getInstance().getiFrameUrl(); private static String urlToOpenAfterClose = null; public static final int REQUEST_SELECT_FILE = 100; - private Runnable exitAfterFifteenSeconds; + // How long the widget may take to load before it gives up and closes. + private static final long LOAD_TIMEOUT_MS = 30000; + // The page's own dim behind a card survey (appnew.html: rgba(0,0,0,0.25) on its body). + private static final int SURVEY_DIM_COLOR = 0x40000000; + private Runnable exitIfNeverLoaded; private Handler handler; + // A card survey (the floating sheet over the dimmed app). private boolean isSurvey = false; + // A full-screen survey. + private boolean isFullSurvey = false; + // A full-screen Surveys 2.0 survey is showing: the page draws behind the system bars and + // pads itself by the insets it gets (safe-area-update). + private boolean surveyEdgeToEdge = false; + // The page reported content (a height or a shown survey): it loads, however slowly. + private boolean widgetContentSeen = false; + private FrameLayout webViewContainer; + private Insets systemBarInsets = Insets.NONE; + private Insets imeInsets = Insets.NONE; private boolean isImeVisible = false; private int lockedScrollY = 0; // The widget answered its first ping, so it listens for config updates. @@ -162,22 +185,27 @@ protected void onCreate(Bundle savedInstanceState) { if (getPackageManager().hasSystemFeature("android.software.webview")) { webView = findViewById(R.id.gleap_webview); - setUpInsets((FrameLayout) findViewById(R.id.webview_container)); + webViewContainer = findViewById(R.id.webview_container); + setUpInsets(webViewContainer); - exitAfterFifteenSeconds = new Runnable() { + // Only a page that never answered and never showed anything is given up on: a + // slow network may well take longer than a few seconds to load the messenger. + exitIfNeverLoaded = new Runnable() { @Override public void run() { - if (webView.getVisibility() == View.INVISIBLE) { + if (!widgetPinged && !widgetContentSeen && webView != null + && webView.getVisibility() != View.VISIBLE) { closeMainGleapActivity(); } } }; isSurvey = getIntent().getBooleanExtra("IS_SURVEY", false); + isFullSurvey = getIntent().getBooleanExtra("IS_SURVEY_FULL", false); setUpLoader(savedInstanceState); this.handler = new Handler(Looper.getMainLooper()); - this.handler.postDelayed(exitAfterFifteenSeconds, 15000); + this.handler.postDelayed(exitIfNeverLoaded, LOAD_TIMEOUT_MS); GleapCallbacks.getInstance().setCallCloseCallback(new CallCloseCallback() { @Override @@ -261,11 +289,23 @@ private void setUpInsets(FrameLayout webViewContainer) { (view, insets) -> { Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); - - int topInset = bars.top; - int bottomInset = Math.max(bars.bottom, ime.bottom); - - view.setPadding(view.getPaddingLeft(), topInset, view.getPaddingRight(), bottomInset); + boolean changed = !bars.equals(systemBarInsets) || !ime.equals(imeInsets); + systemBarInsets = bars; + imeInsets = ime; + + if (surveyEdgeToEdge) { + // Behind the system bars; only the keyboard takes room away. + view.setPadding(view.getPaddingLeft(), 0, view.getPaddingRight(), ime.bottom); + if (changed) { + sendSafeAreaInsets(); + } + } else { + int topInset = bars.top; + int bottomInset = Math.max(bars.bottom, ime.bottom); + view.setPadding(view.getPaddingLeft(), topInset, view.getPaddingRight(), bottomInset); + } + // The card survey's dim bands follow the padding. + view.invalidate(); if (Build.VERSION.SDK_INT >= 36) { boolean nowImeVisible = ime.bottom > 0; @@ -306,6 +346,11 @@ private void setUpLoader(Bundle savedInstanceState) { loaderView.setVisibility(View.VISIBLE); loaderView.setBackgroundColor(Color.parseColor("#66000000")); } + // Once the survey shows, the page dims the app itself; the container keeps clear of + // the status bar, the navigation bar and the keyboard, and those bands get the same + // dim (see revealWidget). The loader's darker dim stayed in them: two tones. + webViewContainer.setBackground(new InsetBandsDrawable(webViewContainer, SURVEY_DIM_COLOR)); + webViewContainer.getBackground().setAlpha(0); } else { // Widget loader: mirror the messenger's home background so // the reveal is seamless (see GleapLoadingBackgroundView). @@ -356,10 +401,169 @@ public void run() { // a layer — without it the "fade" pops in // as a single-frame swap. webView.animate().alpha(1f).setDuration(300).withLayer().start(); + if (isSurvey) { + revealSurveyDim(); + } } }, 500); } + /** + * Card surveys: the loader's dim gives way to the page's own, and the bands outside the web + * view (system bars, keyboard) get the same dim, so the whole screen is dimmed evenly. + */ + private void revealSurveyDim() { + final View loaderView = findViewById(R.id.loader); + if (loaderView != null) { + loaderView.animate().alpha(0f).setDuration(300).withEndAction(new Runnable() { + @Override + public void run() { + loaderView.setVisibility(View.GONE); + } + }).start(); + } + if (webViewContainer != null && webViewContainer.getBackground() != null) { + webViewContainer.getBackground().setAlpha(255); + webViewContainer.invalidate(); + } + } + + /** The page reported content: it is loading, so the load timeout no longer applies. */ + void onWidgetContent() { + widgetContentSeen = true; + } + + /** + * A Surveys 2.0 survey is showing. Full screen, it paints its own backdrop: the page goes + * behind the status and navigation bars and pads its close button, question and bar by the + * insets (the bars showed the loader's header color over the pale survey page before). + */ + void onSurveyShown(JSONObject data) { + if (!isFullSurvey || isSurvey || surveyEdgeToEdge) { + return; + } + if (data == null || !"full".equals(data.optString("format", ""))) { + return; + } + try { + surveyEdgeToEdge = true; + Window window = getWindow(); + WindowCompat.setDecorFitsSystemWindows(window, false); + window.setStatusBarColor(Color.TRANSPARENT); + window.setNavigationBarColor(Color.TRANSPARENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setStatusBarContrastEnforced(false); + window.setNavigationBarContrastEnforced(false); + } + // The survey's backdrop follows the widget's background (light or dark mode). + boolean light = isLightColor(GleapConfig.getInstance().getBackgroundColor()); + WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(window, window.getDecorView()); + controller.setAppearanceLightStatusBars(light); + controller.setAppearanceLightNavigationBars(light); + sendSafeAreaInsets(); + ViewCompat.requestApplyInsets(webViewContainer); + } catch (Error | Exception error) { + GleapLog.w("Could not show the survey edge to edge", error); + } + } + + /** The survey falls back to the legacy flow: back to the layout below the system bars. */ + void onSurveyLegacy() { + if (!surveyEdgeToEdge) { + return; + } + surveyEdgeToEdge = false; + try { + sendSafeAreaInsets(); + WindowCompat.setDecorFitsSystemWindows(getWindow(), true); + ViewCompat.requestApplyInsets(webViewContainer); + } catch (Error | Exception ignore) { + } + } + + /** + * The insets the page pads itself by while it draws behind the system bars (CSS px; zero + * otherwise). The bottom one is 0 while the keyboard is up: the web view ends above it. + */ + private void sendSafeAreaInsets() { + try { + float density = getResources().getDisplayMetrics().density; + Insets bars = surveyEdgeToEdge ? systemBarInsets : Insets.NONE; + JSONObject data = new JSONObject(); + data.put("top", Math.round(bars.top / density)); + data.put("right", Math.round(bars.right / density)); + data.put("bottom", imeInsets.bottom > 0 ? 0 : Math.round(bars.bottom / density)); + data.put("left", Math.round(bars.left / density)); + sendMessage(GleapWidgetMessages.message("safe-area-update", data)); + } catch (Error | Exception ignore) { + } + } + + static boolean isLightColor(String color) { + try { + int value = Color.parseColor(color); + double luminance = (0.299 * Color.red(value) + 0.587 * Color.green(value) + 0.114 * Color.blue(value)) / 255.0; + return luminance > 0.5; + } catch (Exception ignore) { + return true; + } + } + + /** + * Paints only a view's padding (the bands it keeps clear of the system bars and the + * keyboard), never behind its content. + */ + static final class InsetBandsDrawable extends Drawable { + private final View view; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final int color; + + InsetBandsDrawable(View view, int color) { + this.view = view; + this.color = color; + paint.setColor(color); + } + + @Override + public void draw(@NonNull Canvas canvas) { + Rect bounds = getBounds(); + int top = view.getPaddingTop(); + int bottom = view.getPaddingBottom(); + int left = view.getPaddingLeft(); + int right = view.getPaddingRight(); + if (top > 0) { + canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + top, paint); + } + if (bottom > 0) { + canvas.drawRect(bounds.left, bounds.bottom - bottom, bounds.right, bounds.bottom, paint); + } + if (left > 0) { + canvas.drawRect(bounds.left, bounds.top + top, bounds.left + left, bounds.bottom - bottom, paint); + } + if (right > 0) { + canvas.drawRect(bounds.right - right, bounds.top + top, bounds.right, bounds.bottom - bottom, paint); + } + } + + @Override + public void setAlpha(int alpha) { + paint.setColor(color); + paint.setAlpha(Color.alpha(color) * alpha / 255); + invalidateSelf(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + paint.setColorFilter(colorFilter); + invalidateSelf(); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + } + @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); @@ -519,9 +723,9 @@ public void run() { GleapCallbacks.getInstance().setCallCloseCallback(null); } - if (this.exitAfterFifteenSeconds != null) { - this.handler.removeCallbacks(this.exitAfterFifteenSeconds); - this.exitAfterFifteenSeconds = null; + if (this.exitIfNeverLoaded != null) { + this.handler.removeCallbacks(this.exitIfNeverLoaded); + this.exitIfNeverLoaded = null; } this.handler = null; diff --git a/gleap/src/main/java/io/gleap/ScreenshotTaker.java b/gleap/src/main/java/io/gleap/ScreenshotTaker.java index 23370c2d..ed9634ee 100644 --- a/gleap/src/main/java/io/gleap/ScreenshotTaker.java +++ b/gleap/src/main/java/io/gleap/ScreenshotTaker.java @@ -74,6 +74,7 @@ public void openScreenshot(Bitmap imageFile, SurveyType type) { Intent intent = new Intent(activityToOpen, GleapMainActivity.class); intent.putExtra("IS_SURVEY", type == SurveyType.SURVEY); + intent.putExtra("IS_SURVEY_FULL", type == SurveyType.SURVEY_FULL); intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); if (isSingleInstanceMode) { From 6d4fe2dc94f130ad0955823435df1a748fac5864 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 7 Oct 2026 15:23:37 +0200 Subject: [PATCH 4/4] fix: Surveys 2.0 completion also fires FeedbackWillBeSent, once per response; announce surveyCallbacks A completed v2 survey fires FeedbackWillBeSent, FeedbackSent (the answers by key) and OutboundSent like a sent legacy survey; OutboundSent passes on everything the messenger reports (outbound, surveyId, responseId, endingId, status) next to outboundId and formData. A response is reported once per process, also when the page reports it again. config-update carries surveyCallbacks: true, so the messenger sends this SDK no legacy send-feedback after a completion (older SDKs get one, which the server acknowledges without storing anything, to fire the same callbacks). Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/gleap/GleapWidgetBridge.java | 26 +++++++++++++++---- .../java/io/gleap/GleapWidgetMessages.java | 3 +++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java index da7c480a..69710ee8 100644 --- a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java +++ b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java @@ -10,7 +10,9 @@ import org.json.JSONObject; import java.lang.ref.WeakReference; +import java.util.HashSet; import java.util.List; +import java.util.Set; import io.gleap.callbacks.GleapAgentToolResultCallback; @@ -250,20 +252,37 @@ private static void notifyEvent(JSONObject object) { } } + // Responses whose completion the app was told about (surveyCompleted runs on the main thread). + private static final Set reportedSurveyResponses = new HashSet<>(); + /** * A Surveys 2.0 survey was completed. The messenger saves its answers itself (no * send-feedback), so the callbacks and the outbound-<id>-submitted event a legacy survey - * gets after sending (see HttpHelper) come from here, in the legacy shape. + * gets after sending (see HttpHelper) come from here, in the legacy shape: FeedbackWillBeSent + * and FeedbackSent get the answers by key, OutboundSent {outboundId, formData} plus what the + * messenger adds (outbound, surveyId, responseId, endingId, status). Once per response, also + * when the page reports it again. */ static void surveyCompleted(JSONObject eventData) { if (eventData == null) { return; } + String responseId = eventData.optString("responseId", ""); + if (responseId.length() > 0 && !reportedSurveyResponses.add(responseId)) { + return; + } JSONObject formData = eventData.optJSONObject("formData"); if (formData == null) { formData = new JSONObject(); } + try { + if (GleapCallbacks.getInstance().getFeedbackWillBeSentCallback() != null) { + GleapCallbacks.getInstance().getFeedbackWillBeSentCallback().invoke(formData.toString()); + } + } catch (Exception ignore) { + } + try { if (GleapCallbacks.getInstance().getFeedbackSentCallback() != null) { GleapCallbacks.getInstance().getFeedbackSentCallback().invoke(formData); @@ -278,12 +297,9 @@ static void surveyCompleted(JSONObject eventData) { try { if (GleapCallbacks.getInstance().getOutboundSentCallback() != null) { - JSONObject sent = new JSONObject(); + JSONObject sent = new JSONObject(eventData.toString()); sent.put("outboundId", outboundId); - sent.putOpt("outbound", eventData.opt("outbound")); sent.put("formData", formData); - sent.putOpt("responseId", eventData.opt("responseId")); - sent.putOpt("endingId", eventData.opt("endingId")); GleapCallbacks.getInstance().getOutboundSentCallback().invoke(sent); } } catch (Exception ignore) { diff --git a/gleap/src/main/java/io/gleap/GleapWidgetMessages.java b/gleap/src/main/java/io/gleap/GleapWidgetMessages.java index 36f68412..99758083 100644 --- a/gleap/src/main/java/io/gleap/GleapWidgetMessages.java +++ b/gleap/src/main/java/io/gleap/GleapWidgetMessages.java @@ -33,6 +33,9 @@ static JSONObject configUpdate() throws JSONException { data.put("actions", jsonObject.getJSONObject("projectActions")); data.put("overrideLanguage", GleapConfig.getInstance().getLanguage()); data.put("isApp", true); + // Surveys 2.0: this SDK fires the survey callbacks from notify-event outbound-sent + // (GleapWidgetBridge.surveyCompleted), so the messenger sends no legacy send-feedback. + data.put("surveyCallbacks", true); return data; }