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; + } } 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/GleapWidgetBridge.java b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java index 4d097847..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; @@ -119,6 +121,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 +245,72 @@ 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) { } } + // 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: 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); + } + } catch (Exception ignore) { + } + + String outboundId = eventData.optString("outboundId", ""); + if (outboundId.length() == 0) { + return; + } + + try { + if (GleapCallbacks.getInstance().getOutboundSentCallback() != null) { + JSONObject sent = new JSONObject(eventData.toString()); + sent.put("outboundId", outboundId); + sent.put("formData", formData); + 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) { 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; } 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) {