GleapCallbacks.getInstance().setGetBitmapCallback(getBitmapCallback));
}
+ /**
+ * Enables or disables screenshots and screen recordings for capture requests: when a
+ * workflow, an AI agent or a teammate asks the customer in the widget to show the issue, the
+ * widget closes, a small bar lets the customer go to the right screen, and the SDK captures
+ * your app's windows once they tap Capture (or records them between Start and Stop, Android 8
+ * and newer). Nothing is captured without that tap. While disabled the widget only offers to
+ * upload a file. Enabled by default; can be called at any time: turned off while a capture
+ * runs, the capture stops, nothing of it is kept and the widget opens again.
+ *
+ * @param enabled false to turn in-app screenshots and recordings off
+ */
+ public void setCaptureEnabled(boolean enabled) {
+ GleapErrors.guard("setCaptureEnabled", () -> {
+ GleapCapture.setCaptureEnabled(enabled);
+ if (!enabled) {
+ GleapMainThread.post(() -> GleapCaptureCoordinator.getInstance().stopForDisabledCapture());
+ }
+ });
+ }
+
+ /**
+ * Enables or disables sending the app's logs for capture requests: a workflow or an AI agent
+ * can ask for the logs of the app while it runs (no customer action), and screenshots and
+ * recordings can bring the logs around them. The logs are what a ticket carries (console and
+ * network logs, custom data, env data, events; the replay only when it is asked for and
+ * replays are enabled) and the existing settings apply (e.g. {@link #disableConsoleLog()},
+ * {@link #setDisableEnvData(boolean)}). While disabled, log requests are answered as
+ * unsupported and captures are sent without logs. Enabled by default.
+ *
+ * @param enabled false to never send logs for capture requests
+ */
+ public void setRemoteLogCollectionEnabled(boolean enabled) {
+ GleapErrors.guard("setRemoteLogCollectionEnabled", () -> GleapCapture.setRemoteLogCollectionEnabled(enabled));
+ }
+
+ /**
+ * Paints the view (and everything in it) black in the screenshots and screen recordings of
+ * capture requests, e.g. a view showing card or account details. Password fields and windows
+ * with {@code FLAG_SECURE} are always masked. The SDK keeps only a weak reference: a masked
+ * view that goes away needs no {@link #unmaskView(View)}. Views drawn by a cross-platform
+ * framework itself (e.g. Flutter widgets, Compose text fields) cannot be found this way:
+ * mask the view that hosts them.
+ *
+ * @param view the view to mask
+ */
+ public void maskView(View view) {
+ GleapErrors.guard("maskView", () -> GleapCapture.maskView(view));
+ }
+
+ /**
+ * Shows a view masked with {@link #maskView(View)} in captures again.
+ *
+ * @param view the masked view
+ */
+ public void unmaskView(View view) {
+ GleapErrors.guard("unmaskView", () -> GleapCapture.unmaskView(view));
+ }
+
+ /**
+ * For wrapper SDKs (React Native, Flutter, Capacitor): called right before the logs for a
+ * capture request are collected, so the wrapper can hand over the logs it buffers itself
+ * (see {@link GleapLogFlushHandler}). The SDK waits at most 500 ms for it. null removes it.
+ *
+ * @param handler called on the main thread before the logs are collected
+ */
+ public void setLogFlushHandler(GleapLogFlushHandler handler) {
+ GleapErrors.guard("setLogFlushHandler", () -> GleapCapture.setLogFlushHandler(handler));
+ }
+
/**
* This is called, when the config is received from the server. The config is loaded once per
* process: a callback set after it was loaded is called once with the loaded config, posted
@@ -1228,6 +1298,10 @@ public void close() {
public void run() {
if (GleapInitializer.getApplication() != null && GleapCallbacks.getInstance().getCallCloseCallback() != null && isOpened()) {
GleapCallbacks.getInstance().getCallCloseCallback().invoke();
+ } else if (GleapCaptureCoordinator.isWidgetClosedForCapture()) {
+ // The widget is closed for a capture, which to the app is still the open
+ // widget: closing it ends the capture.
+ GleapCaptureCoordinator.getInstance().closeByApp();
}
}
});
diff --git a/gleap/src/main/java/io/gleap/GleapCapture.java b/gleap/src/main/java/io/gleap/GleapCapture.java
new file mode 100644
index 00000000..5caf459b
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCapture.java
@@ -0,0 +1,352 @@
+package io.gleap;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.view.View;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.WeakHashMap;
+import java.util.regex.Pattern;
+
+import gleap.io.gleap.BuildConfig;
+
+/**
+ * Capture requests ("show me the issue"): the app's switches for them, the views it masks, and
+ * what the SDK reports it can do (to the widget, on the WebSocket and with the pings).
+ *
+ * Nothing here runs on its own: the capture code only starts when a request arrives, from the
+ * widget ({@link GleapCaptureCoordinator}) or from the server ({@link GleapCaptureLogs}).
+ */
+final class GleapCapture {
+ static final int CAPABILITIES_VERSION = 1;
+ static final String PLATFORM = "android";
+ // Native in-app frame capture (screenshots and recordings alike).
+ static final String METHOD_FRAMES = "frames";
+
+ static final String CAP_SCREENSHOT = "capture.screenshot";
+ static final String CAP_RECORDING = "capture.recording";
+ static final String CAP_LOGS = "capture.logs";
+
+ static final int MIN_RECORDING_SEC = 5;
+ static final int DEFAULT_RECORDING_SEC = 60;
+ static final int MAX_RECORDING_SEC = 180;
+
+ // Server ids are ObjectIds; anything else is never put into a request path.
+ private static final Pattern REQUEST_ID = Pattern.compile("^[A-Za-z0-9_-]{1,64}$");
+ private static final String PREFS = "gleap-capture";
+ private static final String DEVICE_ID_KEY = "deviceId";
+ private static final String CAPTURE_DIR = "gleap-capture";
+ // The recordings of the running capture (absolute paths): never deleted as leftovers.
+ private static final Set filesInUse = Collections.synchronizedSet(new HashSet());
+
+ private static volatile boolean captureEnabled = true;
+ private static volatile boolean remoteLogCollectionEnabled = true;
+ private static volatile GleapLogFlushHandler logFlushHandler;
+ // The views the app masked; weak, so a masked view never outlives its screen. Guarded by itself.
+ private static final Map maskedViews = new WeakHashMap<>();
+ private static String deviceId;
+
+ private GleapCapture() {
+ }
+
+ static boolean isCaptureEnabled() {
+ return captureEnabled;
+ }
+
+ static void setCaptureEnabled(boolean enabled) {
+ captureEnabled = enabled;
+ }
+
+ static boolean isRemoteLogCollectionEnabled() {
+ return remoteLogCollectionEnabled;
+ }
+
+ static void setRemoteLogCollectionEnabled(boolean enabled) {
+ remoteLogCollectionEnabled = enabled;
+ }
+
+ static GleapLogFlushHandler getLogFlushHandler() {
+ return logFlushHandler;
+ }
+
+ static void setLogFlushHandler(GleapLogFlushHandler handler) {
+ logFlushHandler = handler;
+ }
+
+ static void maskView(View view) {
+ if (view == null) {
+ return;
+ }
+ synchronized (maskedViews) {
+ maskedViews.put(view, Boolean.TRUE);
+ }
+ }
+
+ static void unmaskView(View view) {
+ if (view == null) {
+ return;
+ }
+ synchronized (maskedViews) {
+ maskedViews.remove(view);
+ }
+ }
+
+ /**
+ * The masked views that are still alive, as an identity set.
+ */
+ static Set maskedViews() {
+ synchronized (maskedViews) {
+ if (maskedViews.isEmpty()) {
+ return Collections.emptySet();
+ }
+ Set views = Collections.newSetFromMap(new IdentityHashMap());
+ views.addAll(maskedViews.keySet());
+ views.remove(null);
+ return views;
+ }
+ }
+
+ /**
+ * Recordings are frames of the app's own windows, encoded with MediaCodec: PixelCopy of a
+ * window needs API 26.
+ */
+ static boolean isRecordingSupported() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
+ }
+
+ static String sdkType() {
+ try {
+ APPLICATIONTYPE type = GleapBug.getInstance().getApplicationType();
+ return type != null ? type.name() : APPLICATIONTYPE.NATIVE.name();
+ } catch (Throwable ignore) {
+ return APPLICATIONTYPE.NATIVE.name();
+ }
+ }
+
+ static String sdkVersion() {
+ return BuildConfig.VERSION_NAME;
+ }
+
+ /**
+ * The capability flags the SDK announces on the WebSocket and with the pings.
+ */
+ static List caps(boolean captureEnabled, boolean recordingSupported, boolean logsEnabled) {
+ List caps = new ArrayList<>();
+ if (captureEnabled) {
+ caps.add(CAP_SCREENSHOT);
+ if (recordingSupported) {
+ caps.add(CAP_RECORDING);
+ }
+ }
+ if (logsEnabled) {
+ caps.add(CAP_LOGS);
+ }
+ return caps;
+ }
+
+ static List currentCaps() {
+ return caps(captureEnabled, isRecordingSupported(), remoteLogCollectionEnabled);
+ }
+
+ static JSONArray currentCapsJson() {
+ JSONArray caps = new JSONArray();
+ for (String cap : currentCaps()) {
+ caps.put(cap);
+ }
+ return caps;
+ }
+
+ /**
+ * The value of the WebSocket url's {@code caps} parameter, e.g.
+ * {@code capture.screenshot,capture.recording,capture.logs}; empty when nothing is supported.
+ */
+ static String capsQueryValue(List caps) {
+ StringBuilder value = new StringBuilder();
+ for (String cap : caps) {
+ if (value.length() > 0) {
+ value.append(',');
+ }
+ value.append(cap);
+ }
+ return value.toString();
+ }
+
+ /**
+ * The capture-capabilities message data for the widget. With capture disabled nothing is
+ * offered, so the widget only offers the upload.
+ */
+ static JSONObject capabilities(boolean captureEnabled, boolean recordingSupported, String sdkType,
+ String sdkVersion) throws JSONException {
+ JSONObject data = new JSONObject();
+ data.put("version", CAPABILITIES_VERSION);
+ data.put("platform", PLATFORM);
+ data.put("sdkType", sdkType);
+ data.put("sdkVersion", sdkVersion);
+ data.put("screenshot", captureEnabled);
+ boolean recording = captureEnabled && recordingSupported;
+ data.put("recording", recording);
+ if (recording) {
+ data.put("recordingMethod", METHOD_FRAMES);
+ data.put("maxRecordingSec", MAX_RECORDING_SEC);
+ }
+ data.put("annotate", true);
+ data.put("microphone", false);
+ return data;
+ }
+
+ static JSONObject currentCapabilities() throws JSONException {
+ return capabilities(captureEnabled, isRecordingSupported(), sdkType(), sdkVersion());
+ }
+
+ static boolean isValidRequestId(String id) {
+ return id != null && REQUEST_ID.matcher(id).matches();
+ }
+
+ /**
+ * Whether {@code url} has the scheme, host and port of {@code expectedUrl} (the widget url).
+ * Used to accept capture commands only from the Gleap messenger page.
+ */
+ static boolean sameOrigin(String url, String expectedUrl) {
+ if (url == null || expectedUrl == null) {
+ return false;
+ }
+ try {
+ URL actual = new URL(url.trim());
+ URL expected = new URL(expectedUrl.trim());
+ String scheme = actual.getProtocol().toLowerCase(Locale.ROOT);
+ if (!scheme.equals("https") && !scheme.equals("http")) {
+ return false;
+ }
+ if (!scheme.equals(expected.getProtocol().toLowerCase(Locale.ROOT))) {
+ return false;
+ }
+ String host = actual.getHost();
+ if (host == null || host.isEmpty() || !host.equalsIgnoreCase(expected.getHost())) {
+ return false;
+ }
+ return port(actual) == port(expected);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static int port(URL url) {
+ return url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
+ }
+
+ /**
+ * A random id of this installation, sent when the SDK claims a capture request (so the
+ * server can tell devices apart). Created on the first capture request.
+ */
+ static synchronized String deviceId() {
+ if (deviceId != null) {
+ return deviceId;
+ }
+ try {
+ Application application = GleapInitializer.getApplication();
+ if (application != null) {
+ SharedPreferences prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ String stored = prefs.getString(DEVICE_ID_KEY, null);
+ if (stored == null || stored.isEmpty()) {
+ stored = UUID.randomUUID().toString();
+ prefs.edit().putString(DEVICE_ID_KEY, stored).apply();
+ }
+ deviceId = stored;
+ return deviceId;
+ }
+ } catch (Throwable ignore) {
+ }
+ deviceId = UUID.randomUUID().toString();
+ return deviceId;
+ }
+
+ /**
+ * Where recordings are written while they are recorded, previewed and uploaded: app storage
+ * the system never clears by itself (it may clear the cache when the device runs low on
+ * storage, also between Stop and Send). The SDK deletes every recording itself: when it is
+ * sent, retaken, cancelled or fails, and leftovers when the SDK starts.
+ */
+ static File captureDir(Context context) {
+ File dir = new File(context.getNoBackupFilesDir(), CAPTURE_DIR);
+ if (!dir.exists() && !dir.mkdirs()) {
+ GleapLog.w("Could not create the capture directory");
+ }
+ return dir;
+ }
+
+ /**
+ * A recording of the running capture: kept until {@link #deleteCaptureFile} (the cleanup at
+ * start leaves it alone).
+ */
+ static void useCaptureFile(File file) {
+ if (file != null) {
+ filesInUse.add(file.getAbsolutePath());
+ }
+ }
+
+ /**
+ * Deletes a recording of the running capture (sent, retaken, cancelled, failed). Any thread.
+ */
+ static void deleteCaptureFile(File file) {
+ if (file == null) {
+ return;
+ }
+ filesInUse.remove(file.getAbsolutePath());
+ if (file.exists() && !file.delete()) {
+ GleapLog.w("Could not delete " + file.getName());
+ }
+ }
+
+ /**
+ * Deletes the recordings left behind, e.g. by a process that ended while it recorded or sent
+ * one (when the SDK starts).
+ */
+ static void deleteCaptureFiles(Context context) {
+ try {
+ if (context != null) {
+ deleteLeftovers(new File(context.getNoBackupFilesDir(), CAPTURE_DIR));
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ /**
+ * Deletes the files in {@code dir} that are not a recording of the running capture.
+ *
+ * @return how many were deleted
+ */
+ static int deleteLeftovers(File dir) {
+ File[] files = dir != null ? dir.listFiles() : null;
+ if (files == null) {
+ return 0;
+ }
+ int deleted = 0;
+ for (File file : files) {
+ if (filesInUse.contains(file.getAbsolutePath())) {
+ continue;
+ }
+ if (file.delete()) {
+ deleted++;
+ } else {
+ GleapLog.w("Could not delete " + file.getName());
+ }
+ }
+ return deleted;
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureApi.java b/gleap/src/main/java/io/gleap/GleapCaptureApi.java
new file mode 100644
index 00000000..197d8b5e
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureApi.java
@@ -0,0 +1,301 @@
+package io.gleap;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import okhttp3.Call;
+import okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.ResponseBody;
+import okio.Buffer;
+import okio.BufferedSink;
+import okio.Okio;
+import okio.Source;
+
+/**
+ * The capture request endpoints the SDK calls (shared routes: the SDK key and the session), and
+ * the streaming upload of recordings. All calls block: run them on a background thread.
+ */
+final class GleapCaptureApi {
+ static final String PATH_PREFIX = "/v3/shared/capture-requests/";
+ static final String UPLOAD_PATH = "/uploads/attachments";
+ // The server accepts log bundles up to 20 MB.
+ static final int MAX_LOGS_BYTES = 20 * 1024 * 1024;
+ private static final int MAX_RESPONSE_BYTES = 256 * 1024;
+
+ /**
+ * Upload progress, 0..1.
+ */
+ interface ProgressListener {
+ void onProgress(float progress);
+ }
+
+ static final class Response {
+ final int status;
+ // The JSON answer, or null.
+ final JSONObject json;
+
+ Response(int status, JSONObject json) {
+ this.status = status;
+ this.json = json;
+ }
+
+ boolean ok() {
+ return GleapHttp.isSuccess(status);
+ }
+
+ // The request is final (completed, skipped, cancelled or expired).
+ boolean gone() {
+ return status == 410;
+ }
+ }
+
+ private static volatile OkHttpClient uploadClient;
+
+ private GleapCaptureApi() {
+ }
+
+ /**
+ * POST /claim: this device works on the request now.
+ */
+ static Response claim(String requestId) throws IOException, JSONException {
+ JSONObject body = new JSONObject();
+ body.put("deviceId", GleapCapture.deviceId());
+ body.put("platform", GleapCapture.PLATFORM);
+ body.put("sdkType", GleapCapture.sdkType());
+ body.put("sdkVersion", GleapCapture.sdkVersion());
+ body.put("method", GleapCapture.METHOD_FRAMES);
+ return post(requestPath(requestId, "claim"), body.toString().getBytes(StandardCharsets.UTF_8), false);
+ }
+
+ /**
+ * POST /event: declined, unsupported, failed or released (gives the claim back).
+ */
+ static Response event(String requestId, String type, String reason) throws IOException, JSONException {
+ JSONObject body = new JSONObject();
+ body.put("type", type);
+ if (reason != null) {
+ body.put("reason", reason);
+ }
+ return post(requestPath(requestId, "event"), body.toString().getBytes(StandardCharsets.UTF_8), false);
+ }
+
+ /**
+ * POST /complete with the uploaded files.
+ */
+ static Response complete(String requestId, JSONObject body) throws IOException {
+ return post(requestPath(requestId, "complete"), body.toString().getBytes(StandardCharsets.UTF_8), false);
+ }
+
+ /**
+ * POST /logs with a gzip compressed JSON bundle.
+ */
+ static Response postLogs(String requestId, byte[] gzippedJson) throws IOException {
+ return post(requestPath(requestId, "logs"), gzippedJson, true);
+ }
+
+ static String requestPath(String requestId, String action) {
+ if (!GleapCapture.isValidRequestId(requestId)) {
+ throw new IllegalArgumentException("Invalid capture request id");
+ }
+ return PATH_PREFIX + requestId + "/" + action;
+ }
+
+ private static Response post(String path, byte[] body, boolean gzipped) throws IOException {
+ GleapSession session = currentSession();
+ HttpURLConnection conn = GleapHttp.open(GleapConfig.getInstance().getApiUrl() + path,
+ gzipped ? GleapHttp.UPLOAD_READ_TIMEOUT_MS : GleapHttp.READ_TIMEOUT_MS);
+ try {
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setDoInput(true);
+ conn.setUseCaches(false);
+ conn.setRequestProperty("Api-Token", GleapConfig.getInstance().getSdkKey());
+ conn.setRequestProperty("Accept", "application/json");
+ conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+ if (gzipped) {
+ conn.setRequestProperty("Content-Encoding", "gzip");
+ }
+ if (session != null) {
+ conn.setRequestProperty("Gleap-Id", session.getId());
+ conn.setRequestProperty("Gleap-Hash", session.getHash());
+ }
+ conn.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(body);
+ }
+ int status = conn.getResponseCode();
+ return new Response(status, readJson(conn, status));
+ } finally {
+ conn.disconnect();
+ }
+ }
+
+ private static GleapSession currentSession() {
+ GleapSessionController controller = GleapSessionController.getInstance();
+ return controller != null ? controller.getUserSession() : null;
+ }
+
+ private static JSONObject readJson(HttpURLConnection conn, int status) {
+ InputStream stream = null;
+ try {
+ stream = GleapHttp.isSuccess(status) ? conn.getInputStream() : conn.getErrorStream();
+ if (stream == null) {
+ return null;
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int read;
+ while ((read = stream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ if (out.size() > MAX_RESPONSE_BYTES) {
+ return null;
+ }
+ }
+ String text = new String(out.toByteArray(), StandardCharsets.UTF_8).trim();
+ return text.startsWith("{") ? new JSONObject(text) : null;
+ } catch (Exception e) {
+ return null;
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException ignore) {
+ }
+ }
+ }
+ }
+
+ /**
+ * Uploads a file to /uploads/attachments (multipart field "file"), streamed from disk: the file
+ * is never read into memory as a whole.
+ *
+ * @param callHolder receives the call, so it can be cancelled from another thread
+ * @return the upload answer, {@code {"fileUrls": [...]}}
+ */
+ static JSONObject uploadFile(File file, String fileName, String mimeType, ProgressListener progress,
+ AtomicReference callHolder) throws IOException {
+ GleapSession session = currentSession();
+ RequestBody fileBody = new FileBody(file, MediaType.parse(mimeType), progress);
+ MultipartBody body = new MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart("file", fileName, fileBody)
+ .build();
+ Request.Builder request = new Request.Builder()
+ .url(GleapConfig.getInstance().getApiUrl() + UPLOAD_PATH)
+ .header("api-token", GleapConfig.getInstance().getSdkKey())
+ .header("Accept", "application/json")
+ .post(body);
+ if (session != null) {
+ request.header("gleap-id", session.getId());
+ request.header("gleap-hash", session.getHash());
+ }
+
+ OkHttpClient client = uploadClient();
+ Call call = client.newCall(request.build());
+ if (callHolder != null) {
+ callHolder.set(call);
+ }
+ try (okhttp3.Response response = call.execute()) {
+ if (!response.isSuccessful()) {
+ throw new IOException("The upload failed with HTTP " + response.code());
+ }
+ ResponseBody responseBody = response.body();
+ String text = responseBody != null ? responseBody.string() : "";
+ try {
+ return new JSONObject(text);
+ } catch (JSONException e) {
+ throw new IOException("Unexpected upload answer", e);
+ }
+ } finally {
+ if (callHolder != null) {
+ callHolder.compareAndSet(call, null);
+ }
+ // No idle connection (and cleanup thread) stays behind.
+ try {
+ client.connectionPool().evictAll();
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+
+ private static OkHttpClient uploadClient() {
+ OkHttpClient client = uploadClient;
+ if (client == null) {
+ synchronized (GleapCaptureApi.class) {
+ client = uploadClient;
+ if (client == null) {
+ client = new OkHttpClient.Builder()
+ .connectTimeout(GleapHttp.CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ .writeTimeout(GleapHttp.UPLOAD_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ .readTimeout(GleapHttp.UPLOAD_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ .build();
+ uploadClient = client;
+ }
+ }
+ }
+ return client;
+ }
+
+ /**
+ * A file as request body, written in chunks with progress.
+ */
+ private static final class FileBody extends RequestBody {
+ private static final long CHUNK = 64 * 1024;
+ private final File file;
+ private final MediaType contentType;
+ private final ProgressListener progress;
+
+ FileBody(File file, MediaType contentType, ProgressListener progress) {
+ this.file = file;
+ this.contentType = contentType;
+ this.progress = progress;
+ }
+
+ @Override
+ public MediaType contentType() {
+ return contentType;
+ }
+
+ @Override
+ public long contentLength() {
+ return file.length();
+ }
+
+ @Override
+ public void writeTo(BufferedSink sink) throws IOException {
+ long total = Math.max(1, file.length());
+ long written = 0;
+ float reported = -1;
+ Buffer buffer = new Buffer();
+ try (Source source = Okio.source(file)) {
+ long read;
+ while ((read = source.read(buffer, CHUNK)) != -1) {
+ sink.write(buffer, read);
+ written += read;
+ float value = Math.min(1f, written / (float) total);
+ if (progress != null && value - reported >= 0.01f) {
+ reported = value;
+ try {
+ progress.onProgress(value);
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureBar.java b/gleap/src/main/java/io/gleap/GleapCaptureBar.java
new file mode 100644
index 00000000..97d5cb60
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureBar.java
@@ -0,0 +1,1123 @@
+package io.gleap;
+
+import static io.gleap.GleapCaptureUi.dp;
+
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Insets;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.Region;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.view.AttachedSurfaceControl;
+import android.view.DisplayCutout;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * The bar the customer uses while a capture runs: a small dark pill with a drag grip, the main
+ * action (Capture or Start recording) and a close button; while recording a pulsing red dot, the
+ * time and Stop. For a few seconds a caption above it says what to do.
+ *
+ * The pill is a small panel window of the app (no overlay permission) on the topmost window of the
+ * current activity (the activity, or the dialog / bottom sheet on top of it), moved when that
+ * changes and attached again for every activity. It never takes the focus (the app keeps the
+ * keyboard), is never part of a capture, and touches next to it reach the app. The customer can
+ * drag it anywhere in the safe area (never under the system bars or the display cutout); it keeps
+ * that place for the rest of the process, also for the next capture.
+ */
+final class GleapCaptureBar {
+ interface Listener {
+ void onCaptureTapped();
+
+ void onStartTapped();
+
+ void onStopTapped();
+
+ void onCancelTapped();
+ }
+
+ static final int MODE_SCREENSHOT = 0;
+ static final int MODE_RECORD_READY = 1;
+ static final int MODE_RECORDING = 2;
+
+ private static final long ANCHOR_CHECK_MS = 1000;
+ private static final int ATTACH_RETRIES = 30;
+ private static final long CAPTION_MS = 4000;
+ private static final long FADE_MS = 160;
+ private static final long SETTLE_MS = 180;
+ // How often the window's place is corrected after the window manager laid it out elsewhere.
+ private static final int MAX_CORRECTIONS = 3;
+ // In dp.
+ private static final float PILL_HEIGHT = 44;
+ private static final float GRIP_WIDTH = 32;
+ // Room around the pill in its window for the shadow; also the pill's distance to the edges of
+ // the safe area.
+ private static final float MARGIN = 8;
+ private static final float DEFAULT_BOTTOM = 16;
+ private static final float SNAP_DISTANCE = 24;
+ private static final float SCREEN_MARGIN = 16;
+ private static final float CAPTION_GAP = 6;
+ private static final float CAPTION_MAX_WIDTH = 280;
+
+ // Where the customer left the bar: its center as fractions of the safe area, for the rest of
+ // the process (the next capture starts there). Null: the default place, bottom center.
+ private static float[] rememberedCenter;
+
+ private final GleapCaptureRequest request;
+ private final Listener listener;
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private int mode;
+ private boolean busy;
+ private String timeText;
+ private int announcedMode = -1;
+
+ // While shown.
+ private WeakReference activity = new WeakReference<>(null);
+ private WindowManager windowManager;
+ private WindowManager.LayoutParams params;
+ private BarRoot root;
+ // Capture / Start, or Stop while recording: shows the spinner while the SDK works.
+ private GleapCaptureUi.Busy primary;
+ // Stop gets a 48 dp touch target, also a bit beyond the pill.
+ private View stop;
+ private TextView timer;
+ private View anchor;
+ // The activity's window: the safe area is computed from it.
+ private View decorView;
+ private View.OnLayoutChangeListener decorListener;
+ private ViewTreeObserver.OnWindowFocusChangeListener focusListener;
+ private int attachAttempt;
+ // The screen position params.x / params.y count from (checked once the window is laid out).
+ private int originX;
+ private int originY;
+ private Rect safeArea = new Rect();
+ // The pill's center on screen (px).
+ private float centerX;
+ private float centerY;
+ private boolean checkPending;
+ private int corrections;
+ private boolean revealed;
+ private boolean dragging;
+ private ValueAnimator settle;
+
+ // The caption ("Go to the screen you want to show, then tap Capture.").
+ private View caption;
+ private boolean captionDone;
+ private long captionUntil;
+
+ GleapCaptureBar(GleapCaptureRequest request, int mode, Listener listener) {
+ this.request = request;
+ this.mode = mode;
+ this.listener = listener;
+ }
+
+ int getMode() {
+ return mode;
+ }
+
+ boolean isShownOn(Activity candidate) {
+ return root != null && activity.get() == candidate;
+ }
+
+ /**
+ * Shows the bar on the activity (on its topmost window), moving it from where it was.
+ */
+ @SuppressLint("RtlHardcoded")
+ void attach(final Activity target) {
+ detach();
+ if (target == null || target.isFinishing()) {
+ return;
+ }
+ if (activity.get() != target) {
+ // Another screen: it gets its own attempts.
+ attachAttempt = 0;
+ }
+ activity = new WeakReference<>(target);
+ View decor = target.getWindow() != null ? target.getWindow().peekDecorView() : null;
+ if (decor == null || decor.getWindowToken() == null || decor.getWidth() <= 0) {
+ // The activity's window is not attached yet: try again shortly.
+ if (attachAttempt++ < ATTACH_RETRIES) {
+ main.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (activity.get() == target && root == null) {
+ attach(target);
+ }
+ }
+ }, 100);
+ } else {
+ // Given up on this window; the next attach (e.g. when the screen resumes) starts over.
+ attachAttempt = 0;
+ }
+ return;
+ }
+ attachAttempt = 0;
+
+ try {
+ View top = GleapWindowCapture.topWindow(target, decor, null);
+ IBinder token = top.getWindowToken();
+ if (token == null) {
+ top = decor;
+ token = decor.getWindowToken();
+ }
+
+ decorView = decor;
+ safeArea = safeArea(decor);
+ estimateOrigin(decor);
+ root = new BarRoot(target);
+ root.addView(buildPill(target), pillParams(target));
+ root.setAlpha(0f);
+ revealed = false;
+
+ params = new WindowManager.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+ | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+ | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+ | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
+ PixelFormat.TRANSLUCENT);
+ params.gravity = Gravity.TOP | Gravity.LEFT;
+ params.token = token;
+ params.setTitle(windowTitle());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ // Laid out in the safe area: never under the system bars, the cutout or the keyboard.
+ params.setFitInsetsTypes(WindowInsets.Type.systemBars() | WindowInsets.Type.ime()
+ | WindowInsets.Type.displayCutout());
+ }
+ float[] center = startCenter(target);
+ int[] size = measure(root);
+ place(center[0], center[1], size[0], size[1], false);
+
+ windowManager = target.getWindowManager();
+ checkPending = true;
+ corrections = 0;
+ root.getViewTreeObserver().addOnPreDrawListener(placeCheck);
+ GleapWindowCapture.addOwnWindow(root);
+ windowManager.addView(root, params);
+ anchor = top;
+
+ listenForChanges(decor);
+ main.postDelayed(anchorCheck, ANCHOR_CHECK_MS);
+ } catch (Throwable error) {
+ // E.g. the window went away meanwhile: the bar comes back with the next activity.
+ GleapLog.w("Could not show the capture bar", error);
+ removeCaption(false);
+ removeWindow();
+ }
+ }
+
+ /**
+ * Takes the bar off the screen (it can be attached again).
+ */
+ void detach() {
+ main.removeCallbacksAndMessages(null);
+ cancelSettle();
+ dragging = false;
+ if (decorView != null) {
+ if (focusListener != null) {
+ try {
+ ViewTreeObserver observer = decorView.getViewTreeObserver();
+ if (observer.isAlive()) {
+ observer.removeOnWindowFocusChangeListener(focusListener);
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+ if (decorListener != null) {
+ decorView.removeOnLayoutChangeListener(decorListener);
+ }
+ }
+ focusListener = null;
+ decorListener = null;
+ decorView = null;
+ removeCaption(false);
+ removeWindow();
+ anchor = null;
+ }
+
+ private void removeWindow() {
+ if (root != null) {
+ GleapWindowCapture.removeOwnWindow(root);
+ try {
+ ViewTreeObserver observer = root.getViewTreeObserver();
+ if (observer.isAlive()) {
+ observer.removeOnPreDrawListener(placeCheck);
+ }
+ } catch (Throwable ignore) {
+ }
+ if (windowManager != null) {
+ try {
+ windowManager.removeViewImmediate(root);
+ } catch (Throwable ignore) {
+ // Already gone with its parent window (a dismissed dialog).
+ }
+ }
+ }
+ root = null;
+ primary = null;
+ stop = null;
+ timer = null;
+ windowManager = null;
+ params = null;
+ }
+
+ void setMode(int newMode) {
+ if (mode == newMode) {
+ return;
+ }
+ mode = newMode;
+ busy = false;
+ // The new step gets its own caption.
+ captionDone = false;
+ captionUntil = 0;
+ if (root != null && activity.get() != null) {
+ rebuild();
+ }
+ }
+
+ /**
+ * While the SDK works (taking the screenshot, finishing the recording) the main button shows a
+ * spinner and ignores taps; nothing else moves.
+ */
+ void setBusy(boolean isBusy) {
+ busy = isBusy;
+ if (primary != null) {
+ primary.setBusy(isBusy);
+ }
+ if (isBusy) {
+ dismissCaption();
+ }
+ }
+
+ void setRecordedTime(long recordedMs, int maxSec) {
+ timeText = GleapCaptureGeometry.formatClock(recordedMs / 1000) + " / " + GleapCaptureGeometry.formatClock(maxSec);
+ if (timer != null) {
+ timer.setText(timeText);
+ timer.setContentDescription(request.label("barRecording") + ", " + timeText);
+ }
+ }
+
+ // The content changed (another step): the pill keeps its center.
+ private void rebuild() {
+ try {
+ Context context = root.getContext();
+ removeCaption(false);
+ root.removeAllViews();
+ root.addView(buildPill(context), pillParams(context));
+ int[] size = measure(root);
+ params.setTitle(windowTitle());
+ place(centerX, centerY, size[0], size[1], true);
+ checkPending = true;
+ corrections = 0;
+ if (revealed) {
+ showCaption();
+ announce();
+ }
+ } catch (Throwable error) {
+ GleapLog.w("Could not update the capture bar", error);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Place
+
+ /**
+ * The safe area on screen (px): the activity's window without the system bars, the display
+ * cutout and the keyboard.
+ */
+ private static Rect safeArea(View decor) {
+ int[] location = new int[2];
+ decor.getLocationOnScreen(location);
+ Rect area = new Rect(location[0], location[1], location[0] + decor.getWidth(), location[1] + decor.getHeight());
+ int[] insets = insets(decor);
+ Rect safe = new Rect(area.left + insets[0], area.top + insets[1], area.right - insets[2], area.bottom - insets[3]);
+ return safe.width() > 0 && safe.height() > 0 ? safe : area;
+ }
+
+ @SuppressLint({"DiscouragedApi", "InternalInsetResource"})
+ private static int[] insets(View decor) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ WindowInsets windowInsets = decor.getRootWindowInsets();
+ if (windowInsets != null) {
+ Insets insets = windowInsets.getInsets(WindowInsets.Type.systemBars()
+ | WindowInsets.Type.displayCutout() | WindowInsets.Type.ime());
+ return new int[]{insets.left, insets.top, insets.right, insets.bottom};
+ }
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ WindowInsets windowInsets = decor.getRootWindowInsets();
+ if (windowInsets != null) {
+ int[] result = new int[]{windowInsets.getSystemWindowInsetLeft(), windowInsets.getSystemWindowInsetTop(),
+ windowInsets.getSystemWindowInsetRight(), windowInsets.getSystemWindowInsetBottom()};
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && windowInsets.getDisplayCutout() != null) {
+ DisplayCutout cutout = windowInsets.getDisplayCutout();
+ result[0] = Math.max(result[0], cutout.getSafeInsetLeft());
+ result[1] = Math.max(result[1], cutout.getSafeInsetTop());
+ result[2] = Math.max(result[2], cutout.getSafeInsetRight());
+ result[3] = Math.max(result[3], cutout.getSafeInsetBottom());
+ }
+ return result;
+ }
+ }
+ // Before API 23: the status bar (the window manager keeps the bar off the navigation bar).
+ Resources resources = decor.getResources();
+ int id = resources.getIdentifier("status_bar_height", "dimen", "android");
+ int top = id > 0 ? resources.getDimensionPixelSize(id) : dp(decor.getContext(), 24);
+ return new int[]{0, top, 0, 0};
+ } catch (Throwable error) {
+ return new int[4];
+ }
+ }
+
+ // Where params.x / params.y count from: the safe area since API 30 (the window manager lays
+ // the bar out there), the activity's window before. Checked once the window is laid out.
+ private void estimateOrigin(View decor) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ originX = safeArea.left;
+ originY = safeArea.top;
+ } else {
+ int[] location = new int[2];
+ decor.getLocationOnScreen(location);
+ originX = location[0];
+ originY = location[1];
+ }
+ }
+
+ // Where the pill's center goes: where the customer left it, else bottom center, 16 dp above
+ // the navigation bar.
+ private float[] startCenter(Context context) {
+ float[] remembered = rememberedCenter;
+ if (remembered != null) {
+ return new float[]{safeArea.left + remembered[0] * safeArea.width(), safeArea.top + remembered[1] * safeArea.height()};
+ }
+ return new float[]{safeArea.exactCenterX(), safeArea.bottom - dp(context, DEFAULT_BOTTOM) - dp(context, PILL_HEIGHT) / 2f};
+ }
+
+ private int[] measure(View view) {
+ view.measure(View.MeasureSpec.makeMeasureSpec(Math.max(0, safeArea.width()), View.MeasureSpec.AT_MOST),
+ View.MeasureSpec.makeMeasureSpec(Math.max(0, safeArea.height()), View.MeasureSpec.AT_MOST));
+ return new int[]{view.getMeasuredWidth(), view.getMeasuredHeight()};
+ }
+
+ /**
+ * Puts the pill's center at (x, y) on screen, kept in the safe area.
+ */
+ private void place(float x, float y, int width, int height, boolean update) {
+ if (params == null) {
+ return;
+ }
+ int left = clamp(Math.round(x - width / 2f), safeArea.left, safeArea.right - width);
+ int top = clamp(Math.round(y - height / 2f), safeArea.top, safeArea.bottom - height);
+ centerX = left + width / 2f;
+ centerY = top + height / 2f;
+ params.x = left - originX;
+ params.y = top - originY;
+ if (update && root != null && windowManager != null) {
+ try {
+ windowManager.updateViewLayout(root, params);
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+
+ private void moveTo(float x, float y) {
+ if (root != null) {
+ place(x, y, root.getWidth(), root.getHeight(), true);
+ }
+ }
+
+ private static int clamp(int value, int min, int max) {
+ if (max < min) {
+ return min + (max - min) / 2;
+ }
+ return Math.max(min, Math.min(max, value));
+ }
+
+ // Once laid out: the window is where it should be (else the origin is corrected), then it
+ // fades in.
+ private final ViewTreeObserver.OnPreDrawListener placeCheck = new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ try {
+ checkPlace();
+ } catch (Throwable error) {
+ GleapLog.w("Could not place the capture bar", error);
+ reveal();
+ }
+ return true;
+ }
+ };
+
+ private void checkPlace() {
+ if (root == null || params == null || !checkPending || dragging) {
+ return;
+ }
+ int width = root.getWidth();
+ int height = root.getHeight();
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ int[] location = new int[2];
+ root.getLocationOnScreen(location);
+ int left = params.x + originX;
+ int top = params.y + originY;
+ boolean moved = location[0] != left || location[1] != top;
+ boolean resized = Math.abs(left + width / 2f - centerX) > 1 || Math.abs(top + height / 2f - centerY) > 1;
+ if ((moved || resized) && corrections < MAX_CORRECTIONS) {
+ corrections++;
+ if (moved) {
+ originX = location[0] - params.x;
+ originY = location[1] - params.y;
+ }
+ place(centerX, centerY, width, height, true);
+ return;
+ }
+ checkPending = false;
+ corrections = 0;
+ reveal();
+ }
+
+ private void reveal() {
+ if (revealed || root == null) {
+ return;
+ }
+ revealed = true;
+ root.setTranslationY(dp(root.getContext(), 6));
+ root.animate().alpha(1f).translationY(0f).setDuration(FADE_MS).setInterpolator(new DecelerateInterpolator()).start();
+ showCaption();
+ announce();
+ }
+
+ // The customer let go: the bar snaps to the horizontal center when near it and keeps its place.
+ private void settle() {
+ if (root == null) {
+ return;
+ }
+ final float fromX = centerX;
+ final float y = centerY;
+ float target = fromX;
+ if (Math.abs(fromX - safeArea.exactCenterX()) <= dp(root.getContext(), SNAP_DISTANCE)) {
+ target = safeArea.exactCenterX();
+ }
+ remember(target, y);
+ if (Math.abs(target - fromX) < 1) {
+ return;
+ }
+ final float toX = target;
+ cancelSettle();
+ settle = ValueAnimator.ofFloat(0f, 1f);
+ settle.setDuration(SETTLE_MS);
+ settle.setInterpolator(new DecelerateInterpolator());
+ settle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ float fraction = (float) animation.getAnimatedValue();
+ moveTo(fromX + (toX - fromX) * fraction, y);
+ }
+ });
+ settle.start();
+ }
+
+ private void cancelSettle() {
+ if (settle != null) {
+ settle.cancel();
+ settle = null;
+ }
+ }
+
+ private void remember(float x, float y) {
+ if (safeArea.width() <= 0 || safeArea.height() <= 0) {
+ return;
+ }
+ rememberedCenter = new float[]{
+ Math.max(0f, Math.min(1f, (x - safeArea.left) / safeArea.width())),
+ Math.max(0f, Math.min(1f, (y - safeArea.top) / safeArea.height()))};
+ }
+
+ // The safe area changed (rotation, a resized window, the keyboard): the bar goes to its place
+ // in the new one.
+ private void checkSafeArea() {
+ if (root == null || decorView == null || dragging) {
+ return;
+ }
+ Rect area = safeArea(decorView);
+ if (area.equals(safeArea)) {
+ return;
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ // Placed and checked again from scratch.
+ Activity current = activity.get();
+ if (current != null) {
+ attach(current);
+ }
+ return;
+ }
+ cancelSettle();
+ safeArea = area;
+ estimateOrigin(decorView);
+ removeCaption(false);
+ float[] center = startCenter(root.getContext());
+ int[] size = measure(root);
+ place(center[0], center[1], size[0], size[1], true);
+ checkPending = true;
+ corrections = 0;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Changes of the app's windows
+
+ // Moves the bar to the window on top now (a dialog was shown or dismissed).
+ private final Runnable anchorCheck = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ Activity current = activity.get();
+ if (current == null || root == null) {
+ return;
+ }
+ View decor = current.getWindow().peekDecorView();
+ if (decor == null) {
+ return;
+ }
+ View top = GleapWindowCapture.topWindow(current, decor, null);
+ if (top != anchor || anchor == null || anchor.getWindowToken() == null) {
+ attach(current);
+ return;
+ }
+ checkSafeArea();
+ } catch (Throwable ignore) {
+ }
+ main.postDelayed(this, ANCHOR_CHECK_MS);
+ }
+ };
+
+ private final Runnable safeAreaCheck = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ checkSafeArea();
+ } catch (Throwable ignore) {
+ }
+ }
+ };
+
+ private void listenForChanges(View decor) {
+ focusListener = new ViewTreeObserver.OnWindowFocusChangeListener() {
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ // A dialog took or gave back the focus: look at the top window once it settled.
+ main.removeCallbacks(anchorCheck);
+ main.postDelayed(anchorCheck, 150);
+ }
+ };
+ decorListener = new View.OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(View view, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ main.removeCallbacks(safeAreaCheck);
+ main.post(safeAreaCheck);
+ }
+ };
+ try {
+ decor.getViewTreeObserver().addOnWindowFocusChangeListener(focusListener);
+ decor.addOnLayoutChangeListener(decorListener);
+ } catch (Throwable ignore) {
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // The caption
+
+ // The windows' titles are what accessibility services may say: the labels.
+ private String windowTitle() {
+ String title = mode == MODE_RECORDING ? request.label("barRecording") : hintText();
+ return title != null && !title.isEmpty() ? title : request.label("barCapture");
+ }
+
+ private String hintText() {
+ if (mode == MODE_SCREENSHOT) {
+ return request.label("barScreenshotHint");
+ }
+ if (mode == MODE_RECORD_READY) {
+ return request.label("barRecordHint");
+ }
+ return "";
+ }
+
+ // Above the pill (below it when there is no room above) for about 4 s, or until the first
+ // drag or tap. It takes no touches.
+ @SuppressLint("RtlHardcoded")
+ private void showCaption() {
+ if (mode == MODE_RECORDING || captionDone || caption != null || root == null || params == null
+ || windowManager == null || busy) {
+ return;
+ }
+ String text = hintText();
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ long now = SystemClock.uptimeMillis();
+ if (captionUntil == 0) {
+ captionUntil = now + CAPTION_MS;
+ }
+ long remaining = captionUntil - now;
+ if (remaining < 300) {
+ captionDone = true;
+ return;
+ }
+ try {
+ Context context = root.getContext();
+ FrameLayout window = new FrameLayout(context);
+ int margin = dp(context, MARGIN);
+ window.setPadding(margin, margin, margin, margin);
+ window.setClipToPadding(false);
+ // Announced when the bar appears.
+ window.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
+
+ TextView bubble = GleapCaptureUi.text(context, text, 13, GleapCaptureUi.CAPTION_TEXT_COLOR, false);
+ bubble.setMaxLines(2);
+ bubble.setEllipsize(TextUtils.TruncateAt.END);
+ bubble.setMaxWidth(Math.min(dp(context, CAPTION_MAX_WIDTH), Math.max(0, safeArea.width() - 2 * margin)));
+ bubble.setGravity(Gravity.CENTER);
+ bubble.setLineSpacing(dp(context, 2), 1f);
+ bubble.setPadding(dp(context, 12), dp(context, 8), dp(context, 12), dp(context, 8));
+ bubble.setBackground(GleapCaptureUi.pill(context, 12));
+ bubble.setElevation(dp(context, 4));
+ window.addView(bubble, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ int[] size = measure(window);
+ float pillHalf = dp(context, PILL_HEIGHT) / 2f;
+ int gap = dp(context, CAPTION_GAP);
+ int top = Math.round(centerY - pillHalf - gap + margin - size[1]);
+ if (top < safeArea.top) {
+ top = Math.round(centerY + pillHalf + gap - margin);
+ }
+ int left = clamp(Math.round(centerX - size[0] / 2f), safeArea.left, safeArea.right - size[0]);
+ top = clamp(top, safeArea.top, safeArea.bottom - size[1]);
+
+ WindowManager.LayoutParams captionParams = new WindowManager.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL,
+ params.flags | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ PixelFormat.TRANSLUCENT);
+ captionParams.gravity = Gravity.TOP | Gravity.LEFT;
+ captionParams.token = params.token;
+ captionParams.x = left - originX;
+ captionParams.y = top - originY;
+ captionParams.setTitle(text);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ captionParams.setFitInsetsTypes(params.getFitInsetsTypes());
+ }
+ window.setAlpha(0f);
+ GleapWindowCapture.addOwnWindow(window);
+ windowManager.addView(window, captionParams);
+ caption = window;
+ window.animate().alpha(1f).setDuration(FADE_MS).start();
+ main.postDelayed(captionTimeout, remaining);
+ } catch (Throwable error) {
+ GleapLog.w("Could not show the capture hint", error);
+ captionDone = true;
+ }
+ }
+
+ private final Runnable captionTimeout = new Runnable() {
+ @Override
+ public void run() {
+ dismissCaption();
+ }
+ };
+
+ // Fades the caption out for good (this step).
+ private void dismissCaption() {
+ captionDone = true;
+ removeCaption(true);
+ }
+
+ private void removeCaption(boolean fade) {
+ main.removeCallbacks(captionTimeout);
+ final View view = caption;
+ final WindowManager manager = windowManager;
+ caption = null;
+ if (view == null) {
+ return;
+ }
+ if (fade && GleapCaptureUi.animationsEnabled()) {
+ view.animate().cancel();
+ view.animate().alpha(0f).setDuration(FADE_MS).withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ removeCaptionView(view, manager);
+ }
+ }).start();
+ } else {
+ removeCaptionView(view, manager);
+ }
+ }
+
+ private static void removeCaptionView(View view, WindowManager manager) {
+ GleapWindowCapture.removeOwnWindow(view);
+ if (manager != null) {
+ try {
+ manager.removeViewImmediate(view);
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+
+ private void announce() {
+ if (announcedMode == mode || root == null) {
+ return;
+ }
+ announcedMode = mode;
+ final String text = mode == MODE_RECORDING ? request.label("barRecording") : hintText();
+ final View view = root;
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ view.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ view.announceForAccessibility(text);
+ } catch (Throwable ignore) {
+ }
+ }
+ }, 300);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Views
+
+ private FrameLayout.LayoutParams pillParams(Context context) {
+ return new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(context, PILL_HEIGHT));
+ }
+
+ private LinearLayout buildPill(Context context) {
+ primary = null;
+ stop = null;
+ timer = null;
+ int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
+ int maxWidth = Math.min(screenWidth - 2 * dp(context, SCREEN_MARGIN), safeArea.width() - 2 * dp(context, MARGIN));
+ PillLayout pill = new PillLayout(context, Math.max(dp(context, 120), maxWidth));
+ pill.setOrientation(LinearLayout.HORIZONTAL);
+ pill.setGravity(Gravity.CENTER_VERTICAL);
+ pill.setBackground(GleapCaptureUi.pill(context, GleapCaptureUi.PILL_RADIUS_DP));
+ pill.setElevation(dp(context, 6));
+ pill.setOnTouchListener(new Drag(context));
+
+ View grip = new GleapCaptureUi.Grip(context);
+ grip.setContentDescription(request.label("barDragHint"));
+ grip.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ pill.addView(grip, new LinearLayout.LayoutParams(dp(context, GRIP_WIDTH), ViewGroup.LayoutParams.MATCH_PARENT));
+
+ int pillHeight = dp(context, PILL_HEIGHT);
+ if (mode == MODE_RECORDING) {
+ View dot = recordingDot(context);
+ LinearLayout.LayoutParams dotParams = new LinearLayout.LayoutParams(dp(context, 10), dp(context, 10));
+ dotParams.setMarginEnd(dp(context, 8));
+ pill.addView(dot, dotParams);
+
+ timer = GleapCaptureUi.text(context, "", 15, GleapCaptureUi.TIMER_COLOR, true);
+ timer.setSingleLine(true);
+ timer.setFontFeatureSettings("tnum");
+ // "elapsed / limit" reads left to right in every language (right to left the bidi
+ // algorithm would swap the two times).
+ timer.setTextDirection(View.TEXT_DIRECTION_LTR);
+ if (timeText == null) {
+ timeText = GleapCaptureGeometry.formatClock(0) + " / " + GleapCaptureGeometry.formatClock(request.maxDurationSec);
+ }
+ setRecordedTimeText();
+ // A fixed width (tabular digits): the time changes without laying the window out again.
+ int timerWidth = (int) Math.ceil(timer.getPaint().measureText(timeText)) + 1;
+ pill.addView(timer, new LinearLayout.LayoutParams(timerWidth, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ // Icon only: a red circle; its label is what accessibility services say.
+ int stopWidth = dp(context, 48);
+ GleapCaptureUi.CircleButton stopButton = GleapCaptureUi.stopButton(context, request.label("barStop"), stopWidth, pillHeight);
+ stopButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (!busy) {
+ listener.onStopTapped();
+ }
+ }
+ });
+ LinearLayout.LayoutParams stopParams = new LinearLayout.LayoutParams(stopWidth, pillHeight);
+ stopParams.setMarginStart(dp(context, 6));
+ pill.addView(stopButton, stopParams);
+ primary = stopButton;
+ stop = stopButton;
+ } else {
+ final boolean screenshot = mode == MODE_SCREENSHOT;
+ GleapCaptureUi.Button action = GleapCaptureUi.button(context,
+ screenshot ? request.label("barCapture") : request.label("barStart"),
+ screenshot ? GleapCaptureUi.Icon.CAMERA : GleapCaptureUi.Icon.RECORD, false,
+ GleapCaptureUi.primaryFill(), GleapCaptureUi.BUTTON_RADIUS_DP);
+ action.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (busy) {
+ return;
+ }
+ dismissCaption();
+ if (screenshot) {
+ listener.onCaptureTapped();
+ } else {
+ listener.onStartTapped();
+ }
+ }
+ });
+ // Takes what is left on a narrow screen (its label is cut).
+ LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+ dp(context, GleapCaptureUi.BUTTON_HEIGHT_DP));
+ actionParams.weight = 1f;
+ pill.addView(action, actionParams);
+ primary = action;
+
+ // A 36 dp circle in a 44 dp square: 4 dp to the pill's edge and to the main button.
+ GleapCaptureUi.CircleButton close = GleapCaptureUi.iconButton(context, GleapCaptureUi.Icon.CLOSE,
+ request.label("barCancel"), pillHeight, pillHeight);
+ close.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ // Also while a screenshot is taken: it is dropped.
+ listener.onCancelTapped();
+ }
+ });
+ pill.addView(close, new LinearLayout.LayoutParams(pillHeight, pillHeight));
+ }
+ if (busy && primary != null) {
+ primary.setBusy(true);
+ }
+ return pill;
+ }
+
+ private void setRecordedTimeText() {
+ if (timer != null && timeText != null) {
+ timer.setText(timeText);
+ timer.setContentDescription(request.label("barRecording") + ", " + timeText);
+ }
+ }
+
+ private static View recordingDot(Context context) {
+ View dot = new View(context);
+ GradientDrawable circle = new GradientDrawable();
+ circle.setShape(GradientDrawable.OVAL);
+ circle.setColor(GleapCaptureUi.RECORDING_DOT_COLOR);
+ dot.setBackground(circle);
+ dot.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ dot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View view) {
+ if (GleapCaptureUi.animationsEnabled()) {
+ new Pulse(view).run();
+ }
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View view) {
+ view.animate().cancel();
+ }
+ });
+ return dot;
+ }
+
+ // The recording dot pulses while it is shown.
+ private static final class Pulse implements Runnable {
+ private final WeakReference view;
+
+ Pulse(View view) {
+ this.view = new WeakReference<>(view);
+ }
+
+ @Override
+ public void run() {
+ View dot = view.get();
+ if (dot == null || !dot.isAttachedToWindow()) {
+ return;
+ }
+ dot.animate().alpha(dot.getAlpha() < 0.7f ? 1f : 0.4f).setDuration(700)
+ .setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator())
+ .withEndAction(this).start();
+ }
+ }
+
+ /**
+ * The window's content: the pill with room for its shadow. A touch on it ends the caption;
+ * touches next to the pill reach the app (API 34+, where the window can say so).
+ */
+ private final class BarRoot extends FrameLayout {
+ private final Rect touchable = new Rect();
+ private final Rect stopTarget = new Rect();
+ private final Region region = new Region();
+ private final Rect regionBounds = new Rect();
+
+ BarRoot(Context context) {
+ super(context);
+ int margin = dp(context, MARGIN);
+ setPadding(margin, margin, margin, margin);
+ setClipToPadding(false);
+ setClipChildren(false);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ dismissCaption();
+ }
+ return false;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ if (getChildCount() == 0) {
+ return;
+ }
+ View pill = getChildAt(0);
+ region.set(pill.getLeft(), pill.getTop(), pill.getRight(), pill.getBottom());
+ View stopButton = stop;
+ if (stopButton != null && stopButton.getParent() == pill) {
+ // Stop's touch target: 48 dp high, also over the pill's edge.
+ int half = dp(getContext(), 24);
+ int x = pill.getLeft() + (stopButton.getLeft() + stopButton.getRight()) / 2;
+ int y = pill.getTop() + (stopButton.getTop() + stopButton.getBottom()) / 2;
+ int halfWidth = Math.max(half, stopButton.getWidth() / 2);
+ if (stopTarget.left != x - halfWidth || stopTarget.top != y - half || getTouchDelegate() == null) {
+ stopTarget.set(x - halfWidth, y - half, x + halfWidth, y + half);
+ setTouchDelegate(new TouchDelegate(new Rect(stopTarget), stopButton));
+ }
+ region.op(stopTarget, Region.Op.UNION);
+ } else if (getTouchDelegate() != null) {
+ stopTarget.setEmpty();
+ setTouchDelegate(null);
+ }
+ region.getBounds(regionBounds);
+ if (Build.VERSION.SDK_INT >= 34 && !regionBounds.equals(touchable)) {
+ touchable.set(regionBounds);
+ try {
+ AttachedSurfaceControl surface = getRootSurfaceControl();
+ if (surface != null) {
+ surface.setTouchableRegion(region);
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+ }
+
+ // A row that is never wider than the screen allows.
+ private static final class PillLayout extends LinearLayout {
+ private final int maxWidth;
+
+ PillLayout(Context context, int maxWidth) {
+ super(context);
+ this.maxWidth = maxWidth;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int size = MeasureSpec.getSize(widthMeasureSpec);
+ if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED || size > maxWidth) {
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST);
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ // A tap on the bar itself (see Drag): nothing to do.
+ @Override
+ public boolean performClick() {
+ return super.performClick();
+ }
+ }
+
+ // Drags the bar from the grip or any part of the pill that is not a button; it follows the
+ // finger and stays in the safe area.
+ private final class Drag implements View.OnTouchListener {
+ private final int slop;
+ private float downX;
+ private float downY;
+ private float startX;
+ private float startY;
+
+ Drag(Context context) {
+ slop = ViewConfiguration.get(context).getScaledTouchSlop();
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ if (params == null || root == null || windowManager == null) {
+ return false;
+ }
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ if (!revealed) {
+ return false;
+ }
+ cancelSettle();
+ downX = event.getRawX();
+ downY = event.getRawY();
+ startX = centerX;
+ startY = centerY;
+ dragging = false;
+ return true;
+ case MotionEvent.ACTION_MOVE:
+ float dx = event.getRawX() - downX;
+ float dy = event.getRawY() - downY;
+ if (!dragging && dx * dx + dy * dy > slop * slop) {
+ dragging = true;
+ checkPending = false;
+ dismissCaption();
+ }
+ if (dragging) {
+ moveTo(startX + dx, startY + dy);
+ }
+ return true;
+ case MotionEvent.ACTION_UP:
+ if (dragging) {
+ dragging = false;
+ settle();
+ } else {
+ // A tap on the bar itself: nothing to do, but accessibility services see a click.
+ view.performClick();
+ }
+ return true;
+ case MotionEvent.ACTION_CANCEL:
+ if (dragging) {
+ dragging = false;
+ settle();
+ }
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureCoordinator.java b/gleap/src/main/java/io/gleap/GleapCaptureCoordinator.java
new file mode 100644
index 00000000..fe219043
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureCoordinator.java
@@ -0,0 +1,1220 @@
+package io.gleap;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.ComponentCallbacks2;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.util.Base64;
+import android.view.View;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import okhttp3.Call;
+
+/**
+ * Runs the screenshots and recordings the widget asks for (capture-start). The widget cannot stay
+ * open while the customer uses the app, so it is closed and the request is kept here:
+ *
+ * The capture bar is shown on the app ({@link GleapCaptureBar}), on every activity the
+ * customer goes to, until they tap Capture / Start recording (nothing is captured without that
+ * tap) or Cancel.
+ * Screenshot: the app's windows are captured ({@link GleapWindowCapture}) and the widget is
+ * opened again on the conversation; its page gets capture-image and capture-state (after its ping)
+ * and lets the customer mark up and send the screenshot.
+ * Recording (API 26+): {@link GleapFrameRecorder} records until Stop, the time limit, or low
+ * memory; the customer previews it ({@link GleapCapturePreview}) and sends, retakes or cancels it.
+ * The SDK uploads it, completes the request, and opens the widget again with capture-state done.
+ *
+ * Only one capture runs at a time. Everything here runs on the main thread; nothing is registered
+ * or running while no capture runs.
+ */
+final class GleapCaptureCoordinator implements Application.ActivityLifecycleCallbacks, ComponentCallbacks2 {
+ // When the SDK looks for the app's activity itself, in case its resume was not seen.
+ private static final long HOST_CHECK_MS = 3000;
+ // Messages for the next widget page are dropped when the widget is not opened this soon.
+ private static final long PENDING_TTL_MS = 10 * 60 * 1000;
+ private static final long TIMER_TICK_MS = 500;
+ private static final long SCREENSHOT_TIMEOUT_MS = 10000;
+ private static final int JPEG_QUALITY = 85;
+ private static final String RECORDING_NAME = "screen-recording.mp4";
+ private static final String RECORDING_TYPE = "video/mp4";
+ // The reason reported when the app turns capture off while a capture runs.
+ private static final String CAPTURE_DISABLED = "capture-disabled";
+
+ // How long the widget may take to open again after a capture before the app is told it closed.
+ private static final long REOPEN_CHECK_MS = 8000;
+ // How long the encoder may take to finish a recording before the SDK gives up on it.
+ private static final long STOP_TIMEOUT_MS = 5000;
+
+ private static final GleapCaptureCoordinator instance = new GleapCaptureCoordinator();
+ private static volatile boolean sessionActive;
+ // The widget was closed for a capture without telling the app (no WidgetClosedCallback): to
+ // the app it is one widget session until the widget opens again after the capture (no
+ // WidgetOpenedCallback either). If it does not open again, the app is told it closed.
+ private static volatile boolean widgetClosedForCapture;
+
+ private enum State {
+ IDLE,
+ // The widget closes; the bar comes up once an activity of the app is shown.
+ WAITING,
+ BAR,
+ CAPTURING,
+ RECORDING,
+ STOPPING,
+ PREVIEW,
+ UPLOADING
+ }
+
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private State state = State.IDLE;
+ private GleapCaptureRequest request;
+ private Application application;
+ private WeakReference host = new WeakReference<>(null);
+ private GleapCaptureBar bar;
+ private GleapCapturePreview preview;
+ private GleapFrameRecorder recorder;
+ private GleapFrameRecorder.Result recording;
+ private HandlerThread captureThread;
+ private final AtomicReference uploadCall = new AtomicReference<>();
+ // Counts uploads, so the result of a cancelled one is ignored.
+ private int uploadGeneration;
+ private boolean lifecycleRegistered;
+ private boolean memoryRegistered;
+
+ // Messages for the next widget page: {request id, message}.
+ private final List pending = new ArrayList<>();
+ private long pendingUntil;
+
+ private GleapCaptureCoordinator() {
+ }
+
+ static GleapCaptureCoordinator getInstance() {
+ return instance;
+ }
+
+ /**
+ * A capture is running (the widget is closed for it): the SDK's in-app messages and
+ * activation methods wait.
+ */
+ static boolean isSessionActive() {
+ return sessionActive;
+ }
+
+ static boolean isWidgetClosedForCapture() {
+ return widgetClosedForCapture;
+ }
+
+ /**
+ * The widget opens (main thread): true when it opens again after a capture closed it, which
+ * the app does not hear about.
+ */
+ boolean takeWidgetClosedForCapture() {
+ boolean closed = widgetClosedForCapture;
+ widgetClosedForCapture = false;
+ main.removeCallbacks(reopenCheck);
+ return closed;
+ }
+
+ /**
+ * The app turned capture off (setCaptureEnabled(false)) while a screenshot or recording runs:
+ * it stops, nothing of it is kept (the encoder and its surface are released, the file
+ * deleted, an upload cancelled), and the widget opens again with the failure.
+ */
+ void stopForDisabledCapture() {
+ try {
+ if (request == null || state == State.IDLE) {
+ return;
+ }
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "failed", CAPTURE_DISABLED);
+ queue(id, stateMessage(id, "failed", CAPTURE_DISABLED));
+ // Captures are off: the widget opens without the screenshot it takes for tickets.
+ finishAndReopen(false);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "stopForDisabledCapture");
+ }
+ }
+
+ /**
+ * The widget could not be opened again after a capture: the app hears now that it closed.
+ */
+ void widgetNotReopened() {
+ widgetClosedForCapture = true;
+ tellAppWidgetClosed();
+ }
+
+ // The widget did not open again after the capture (e.g. no activity was shown).
+ private final Runnable reopenCheck = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ tellAppWidgetClosed();
+ } catch (Throwable error) {
+ GleapErrors.report(error, "reopenCheck");
+ }
+ }
+ };
+
+ // The app hears now that the widget closed (held back when it closed for the capture).
+ private void tellAppWidgetClosed() {
+ main.removeCallbacks(reopenCheck);
+ if (!widgetClosedForCapture) {
+ return;
+ }
+ widgetClosedForCapture = false;
+ try {
+ if (GleapCallbacks.getInstance().getWidgetClosedCallback() != null) {
+ GleapCallbacks.getInstance().getWidgetClosedCallback().invoke();
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "widgetClosed");
+ }
+ }
+
+ /**
+ * Gleap.close() while the widget is closed for a capture (to the app it is open): ends the
+ * capture without opening the widget again, and the app hears that the widget closed.
+ */
+ void closeByApp() {
+ try {
+ if (request != null && state != State.IDLE) {
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "released", null);
+ queue(id, stateMessage(id, "cancelled", null));
+ endSession();
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "closeByApp");
+ }
+ tellAppWidgetClosed();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // The widget
+
+ /**
+ * The widget answered its ping: tells it what the SDK can capture.
+ */
+ void sendCapabilities(GleapMainActivity widget) {
+ try {
+ widget.sendMessage(GleapWidgetMessages.message("capture-capabilities", GleapCapture.currentCapabilities()));
+ } catch (Throwable error) {
+ GleapErrors.report(error, "sendCapabilities");
+ }
+ }
+
+ /**
+ * The widget answered its ping: hands it the results of a capture that ended while it was
+ * closed (capture-image, capture-state).
+ */
+ void deliverPending(GleapMainActivity widget) {
+ try {
+ if (pending.isEmpty()) {
+ return;
+ }
+ if (SystemClock.uptimeMillis() > pendingUntil) {
+ pending.clear();
+ return;
+ }
+ if (!widget.isTrustedWidgetPage()) {
+ return;
+ }
+ for (String[] message : pending) {
+ widget.sendMessage(message[1]);
+ }
+ pending.clear();
+ main.removeCallbacks(expirePending);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "deliverPending");
+ }
+ }
+
+ /**
+ * capture-start, capture-cancel, capture-done and capture-editor from the widget. Only the
+ * Gleap messenger page may send them.
+ */
+ void onWidgetMessage(GleapMainActivity widget, String name, JSONObject data) {
+ try {
+ if (!widget.isTrustedWidgetPage()) {
+ GleapLog.w("Ignored " + name + ": it did not come from the Gleap messenger");
+ return;
+ }
+ if ("capture-start".equals(name)) {
+ onCaptureStart(widget, data);
+ } else if ("capture-cancel".equals(name)) {
+ onCaptureEnded(data);
+ } else if ("capture-done".equals(name)) {
+ onCaptureEnded(data);
+ }
+ // capture-editor: the widget is shown full screen anyway.
+ } catch (Throwable error) {
+ GleapErrors.report(error, name);
+ }
+ }
+
+ private void onCaptureStart(GleapMainActivity widget, JSONObject data) {
+ GleapCaptureRequest next = GleapCaptureRequest.fromStart(data);
+ if (next == null) {
+ return;
+ }
+ if (state != State.IDLE) {
+ if (request != null && request.id.equals(next.id)) {
+ // A repeated tap.
+ return;
+ }
+ // Another request replaces the one running.
+ releaseRunning();
+ }
+ // Refused right away: the widget hears it now and the request's claim is given back.
+ if (!GleapCapture.isCaptureEnabled()) {
+ refuse(widget, next.id, "unsupported", "Screen capture is turned off in this app.");
+ return;
+ }
+ if (next.isRecording() && !GleapCapture.isRecordingSupported()) {
+ refuse(widget, next.id, "unsupported", "Screen recording needs Android 8 or newer.");
+ return;
+ }
+ Application app = GleapInitializer.getApplication();
+ if (app == null) {
+ refuse(widget, next.id, "failed", "The SDK is not initialized.");
+ return;
+ }
+
+ application = app;
+ request = next;
+ main.removeCallbacks(reopenCheck);
+ removePending(null);
+ setState(State.WAITING);
+ registerLifecycle();
+ GleapOverlayManager.getInstance().setHiddenForCapture(true);
+ GleapDetectorUtil.pauseDetectorsForCapture();
+ bar = new GleapCaptureBar(next, next.isRecording() ? GleapCaptureBar.MODE_RECORD_READY : GleapCaptureBar.MODE_SCREENSHOT, barListener);
+ sendNow(widget, stateMessage(next.id, "bar", null));
+
+ // The customer goes back to the app: the widget closes (the app is not told), the bar
+ // comes up.
+ widgetClosedForCapture = true;
+ widget.closeForCapture();
+ main.postDelayed(hostCheck, HOST_CHECK_MS);
+ }
+
+ // capture-cancel / capture-done: the widget ended the request; nothing may stay behind.
+ private void onCaptureEnded(JSONObject data) {
+ String id = data != null ? data.optString("requestId", null) : null;
+ if (id == null) {
+ return;
+ }
+ removePending(id);
+ if (request != null && request.id.equals(id) && state != State.IDLE) {
+ discardRecording();
+ endSession();
+ }
+ }
+
+ // The bar comes up when an activity of the app resumes. Should that have been missed (the
+ // activity was shown already), it comes up on the current one. Without one (the app went to
+ // the background) it waits for the next activity; a slow device is no reason to fail.
+ private final Runnable hostCheck = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (state != State.WAITING) {
+ return;
+ }
+ Activity current = ActivityUtil.getCurrentActivity();
+ if (current != null && !ActivityUtil.isGleapActivity(current) && !current.isFinishing()) {
+ onHostShown(current);
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "hostCheck");
+ }
+ }
+ };
+
+ // ---------------------------------------------------------------------------------------------
+ // Activities
+
+ @Override
+ public void onActivityResumed(Activity activity) {
+ try {
+ if (ActivityUtil.isGleapActivity(activity)) {
+ onWidgetOpenedDuringCapture();
+ } else {
+ onHostShown(activity);
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "capture onActivityResumed");
+ }
+ }
+
+ private void onHostShown(Activity activity) {
+ host = new WeakReference<>(activity);
+ switch (state) {
+ case WAITING:
+ main.removeCallbacks(hostCheck);
+ setState(State.BAR);
+ bar.attach(activity);
+ break;
+ case BAR:
+ case CAPTURING:
+ case STOPPING:
+ bar.attach(activity);
+ break;
+ case RECORDING:
+ bar.attach(activity);
+ recorder.setActivity(activity);
+ break;
+ case PREVIEW:
+ case UPLOADING:
+ if (preview != null && !preview.isShownOn(activity)) {
+ preview.show(activity);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ // The app (or the customer) opened the widget while the bar was shown: that ends the capture;
+ // the widget learns it after its ping. A running upload goes on.
+ private void onWidgetOpenedDuringCapture() {
+ if (state == State.IDLE || state == State.WAITING || state == State.UPLOADING || request == null) {
+ return;
+ }
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "released", null);
+ queue(id, stateMessage(id, "cancelled", null));
+ endSession();
+ }
+
+ @Override
+ public void onActivityPaused(Activity activity) {
+ try {
+ if (activity != host.get()) {
+ return;
+ }
+ if (bar != null && bar.isShownOn(activity)) {
+ bar.detach();
+ }
+ if (state == State.RECORDING && recorder != null) {
+ // Nothing of the app is recorded while it is not shown.
+ recorder.setActivity(null);
+ }
+ if (preview != null && preview.isShownOn(activity)) {
+ preview.dismiss();
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "capture onActivityPaused");
+ }
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity activity) {
+ try {
+ if (bar != null && bar.isShownOn(activity)) {
+ bar.detach();
+ }
+ if (preview != null && preview.isShownOn(activity)) {
+ preview.dismiss();
+ }
+ if (host.get() == activity) {
+ host = new WeakReference<>(null);
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+ }
+
+ @Override
+ public void onActivityStarted(Activity activity) {
+ }
+
+ @Override
+ public void onActivityStopped(Activity activity) {
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Memory (registered while recording)
+
+ @Override
+ public void onTrimMemory(int level) {
+ if (level == TRIM_MEMORY_RUNNING_CRITICAL || level == TRIM_MEMORY_COMPLETE) {
+ stopRecordingForMemory();
+ }
+ }
+
+ @Override
+ public void onLowMemory() {
+ stopRecordingForMemory();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration configuration) {
+ }
+
+ private void stopRecordingForMemory() {
+ try {
+ if (state == State.RECORDING) {
+ GleapLog.w("Memory is low: the recording stops and keeps what was recorded");
+ stopRecording();
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // The bar
+
+ private final GleapCaptureBar.Listener barListener = new GleapCaptureBar.Listener() {
+ @Override
+ public void onCaptureTapped() {
+ guard("capture", new Runnable() {
+ @Override
+ public void run() {
+ takeScreenshot();
+ }
+ });
+ }
+
+ @Override
+ public void onStartTapped() {
+ guard("startRecording", new Runnable() {
+ @Override
+ public void run() {
+ startRecording();
+ }
+ });
+ }
+
+ @Override
+ public void onStopTapped() {
+ guard("stopRecording", new Runnable() {
+ @Override
+ public void run() {
+ stopRecording();
+ }
+ });
+ }
+
+ @Override
+ public void onCancelTapped() {
+ guard("cancelCapture", new Runnable() {
+ @Override
+ public void run() {
+ cancelByCustomer();
+ }
+ });
+ }
+ };
+
+ private void guard(String context, Runnable action) {
+ try {
+ action.run();
+ } catch (Throwable error) {
+ GleapErrors.report(error, context);
+ if (request != null) {
+ fail("Something went wrong on the device.");
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Screenshot
+
+ private void takeScreenshot() {
+ final Activity activity = host.get();
+ if (state != State.BAR || request == null || request.isRecording() || activity == null || activity.isFinishing()) {
+ return;
+ }
+ if (!GleapCapture.isCaptureEnabled()) {
+ stopForDisabledCapture();
+ return;
+ }
+ View decor = activity.getWindow() != null ? activity.getWindow().peekDecorView() : null;
+ if (decor == null || decor.getWidth() <= 0) {
+ return;
+ }
+ setState(State.CAPTURING);
+ // The bar stays (it is never part of a capture): Capture shows a spinner meanwhile.
+ bar.setBusy(true);
+
+ final String requestId = request.id;
+ final Date capturedAt = new Date();
+ float scale = GleapCaptureGeometry.scaleForLongEdge(decor.getWidth(), decor.getHeight(), GleapCaptureGeometry.SCREENSHOT_MAX_EDGE);
+ main.postDelayed(screenshotTimeout, SCREENSHOT_TIMEOUT_MS);
+ GleapWindowCapture.capture(activity, scale, null, captureHandler(), null, null, new GleapWindowCapture.Callback() {
+ @Override
+ public void onFrame(GleapWindowCapture.Frame frame) {
+ // On the capture thread: compose and encode.
+ String dataUrl = null;
+ int width = 0;
+ int height = 0;
+ try {
+ if (frame != null && frame.hasContent()) {
+ Bitmap image = frame.render();
+ width = image.getWidth();
+ height = image.getHeight();
+ dataUrl = jpegDataUrl(image);
+ image.recycle();
+ }
+ } catch (Throwable error) {
+ GleapLog.w("Could not encode the screenshot", error);
+ } finally {
+ if (frame != null) {
+ frame.release(null);
+ }
+ }
+ final String result = dataUrl;
+ final int resultWidth = width;
+ final int resultHeight = height;
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ onScreenshotTaken(requestId, result, resultWidth, resultHeight, capturedAt);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "onScreenshotTaken");
+ }
+ }
+ });
+ }
+ });
+ }
+
+ private final Runnable screenshotTimeout = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (state == State.CAPTURING) {
+ fail("The screenshot took too long.");
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "screenshotTimeout");
+ }
+ }
+ };
+
+ private void onScreenshotTaken(String requestId, String dataUrl, int width, int height, Date capturedAt) {
+ main.removeCallbacks(screenshotTimeout);
+ if (state != State.CAPTURING || request == null || !request.id.equals(requestId)) {
+ return;
+ }
+ if (dataUrl == null) {
+ fail("The screenshot could not be taken.");
+ return;
+ }
+ try {
+ JSONObject image = new JSONObject();
+ image.put("requestId", requestId);
+ image.put("dataUrl", dataUrl);
+ image.put("width", width);
+ image.put("height", height);
+ image.put("method", GleapCapture.METHOD_FRAMES);
+ image.put("platform", GleapCapture.PLATFORM);
+ image.put("sdkType", GleapCapture.sdkType());
+ image.put("sdkVersion", GleapCapture.sdkVersion());
+ queue(requestId, GleapWidgetMessages.message("capture-image", image));
+ queue(requestId, stateMessage(requestId, "preview", null));
+ } catch (JSONException error) {
+ fail("The screenshot could not be sent to the widget.");
+ return;
+ }
+ if (request.attachLogs) {
+ GleapCaptureLogs.getInstance().sendForCapture(requestId, request.include, null, capturedAt);
+ }
+ finishAndReopen();
+ }
+
+ // JPEG, quality 85, as a data url.
+ private static String jpegDataUrl(Bitmap image) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ image.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out);
+ return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP);
+ }
+
+ private Handler captureHandler() {
+ if (captureThread == null) {
+ captureThread = new HandlerThread("gleap-capture-screenshot");
+ captureThread.start();
+ }
+ return new Handler(captureThread.getLooper());
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Recording
+
+ private void startRecording() {
+ Activity activity = host.get();
+ if (state != State.BAR || request == null || !request.isRecording() || activity == null || activity.isFinishing()) {
+ return;
+ }
+ if (!GleapCapture.isCaptureEnabled()) {
+ stopForDisabledCapture();
+ return;
+ }
+ if (!GleapCapture.isRecordingSupported()) {
+ unsupported("Screen recording is not available.");
+ return;
+ }
+ File file = new File(GleapCapture.captureDir(activity.getApplicationContext()),
+ "recording-" + request.id + "-" + System.currentTimeMillis() + ".mp4");
+ GleapCapture.useCaptureFile(file);
+ GleapFrameRecorder next = new GleapFrameRecorder(file, request.maxDurationSec, null, recorderListener);
+ if (!next.start(activity)) {
+ // The screen is not laid out yet: the customer can tap again.
+ GleapCapture.deleteCaptureFile(file);
+ return;
+ }
+ recorder = next;
+ setState(State.RECORDING);
+ recorder.setActivity(activity);
+ registerMemory();
+ bar.setMode(GleapCaptureBar.MODE_RECORDING);
+ if (!bar.isShownOn(activity)) {
+ bar.attach(activity);
+ }
+ main.post(timerTick);
+ }
+
+ private final Runnable timerTick = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (state != State.RECORDING || recorder == null || request == null) {
+ return;
+ }
+ if (bar != null) {
+ bar.setRecordedTime(recorder.recordedMs(), request.maxDurationSec);
+ }
+ main.postDelayed(this, TIMER_TICK_MS);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "timerTick");
+ }
+ }
+ };
+
+ private void stopRecording() {
+ if (state != State.RECORDING || recorder == null) {
+ return;
+ }
+ setState(State.STOPPING);
+ main.removeCallbacks(timerTick);
+ unregisterMemory();
+ if (bar != null) {
+ // Stop shows a spinner until the preview opens.
+ bar.setBusy(true);
+ }
+ main.postDelayed(stopWatchdog, STOP_TIMEOUT_MS);
+ recorder.stop();
+ }
+
+ // The encoder did not finish the recording in time: the SDK gives up on it (the encoder is
+ // released off the main thread), keeps nothing and opens the widget again with the failure.
+ private final Runnable stopWatchdog = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (state != State.STOPPING || recorder == null) {
+ return;
+ }
+ GleapFrameRecorder stuck = recorder;
+ recorder = null;
+ stuck.abandon();
+ fail("The recording could not be finished.");
+ } catch (Throwable error) {
+ GleapErrors.report(error, "stopWatchdog");
+ }
+ }
+ };
+
+ private final GleapFrameRecorder.Listener recorderListener = new GleapFrameRecorder.Listener() {
+ @Override
+ public void onRecordingFinished(GleapFrameRecorder source, GleapFrameRecorder.Result result) {
+ try {
+ if (source != recorder) {
+ // A recorder that was cancelled or given up on: nothing of it is kept.
+ delete(result.file);
+ return;
+ }
+ onRecordingFinishedInternal(result);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "onRecordingFinished");
+ }
+ }
+ };
+
+ private void onRecordingFinishedInternal(GleapFrameRecorder.Result result) {
+ main.removeCallbacks(stopWatchdog);
+ main.removeCallbacks(timerTick);
+ unregisterMemory();
+ if ((state != State.RECORDING && state != State.STOPPING) || request == null) {
+ // Cancelled meanwhile.
+ delete(result.file);
+ return;
+ }
+ recorder = null;
+ if (!result.success && result.empty) {
+ // Stopped right away: ready to record again.
+ delete(result.file);
+ setState(State.BAR);
+ bar.setMode(GleapCaptureBar.MODE_RECORD_READY);
+ Activity activity = host.get();
+ if (activity != null) {
+ bar.attach(activity);
+ }
+ return;
+ }
+ if (!result.success) {
+ delete(result.file);
+ fail(result.error != null ? result.error : "The recording failed.");
+ return;
+ }
+ recording = result;
+ setState(State.PREVIEW);
+ if (bar != null) {
+ bar.detach();
+ }
+ preview = new GleapCapturePreview(request, result.file, result.width, result.height, previewListener);
+ Activity activity = host.get();
+ if (activity != null && !activity.isFinishing()) {
+ preview.show(activity);
+ }
+ }
+
+ private final GleapCapturePreview.Listener previewListener = new GleapCapturePreview.Listener() {
+ @Override
+ public void onSendTapped() {
+ guard("sendRecording", new Runnable() {
+ @Override
+ public void run() {
+ sendRecording();
+ }
+ });
+ }
+
+ @Override
+ public void onRetakeTapped() {
+ guard("retakeRecording", new Runnable() {
+ @Override
+ public void run() {
+ retake();
+ }
+ });
+ }
+
+ @Override
+ public void onCancelTapped() {
+ guard("cancelRecording", new Runnable() {
+ @Override
+ public void run() {
+ cancelByCustomer();
+ }
+ });
+ }
+ };
+
+ private void retake() {
+ if (state != State.PREVIEW) {
+ return;
+ }
+ discardRecording();
+ if (preview != null) {
+ preview.dismiss();
+ preview = null;
+ }
+ setState(State.BAR);
+ bar.setMode(GleapCaptureBar.MODE_RECORD_READY);
+ Activity activity = host.get();
+ if (activity != null) {
+ bar.attach(activity);
+ }
+ }
+
+ private void sendRecording() {
+ if (state != State.PREVIEW || recording == null || request == null) {
+ return;
+ }
+ setState(State.UPLOADING);
+ if (preview != null) {
+ preview.setUploading(0);
+ }
+ final int generation = ++uploadGeneration;
+ final GleapFrameRecorder.Result result = recording;
+ final String requestId = request.id;
+ GleapCaptureExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ int status = 0;
+ boolean completed = false;
+ try {
+ JSONObject uploaded = GleapCaptureApi.uploadFile(result.file, RECORDING_NAME, RECORDING_TYPE,
+ new GleapCaptureApi.ProgressListener() {
+ @Override
+ public void onProgress(final float progress) {
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (generation == uploadGeneration && state == State.UPLOADING && preview != null) {
+ // The upload is most of the work; completing is the rest.
+ preview.setUploading(progress * 0.95f);
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "uploadProgress");
+ }
+ }
+ });
+ }
+ }, uploadCall);
+ JSONArray urls = uploaded.optJSONArray("fileUrls");
+ String url = urls != null ? urls.optString(0, null) : null;
+ if (url == null || url.isEmpty()) {
+ throw new java.io.IOException("The upload returned no file");
+ }
+ GleapCaptureApi.Response response = GleapCaptureApi.complete(requestId, completeBody(url, result));
+ status = response.status;
+ completed = response.ok();
+ } catch (Throwable error) {
+ GleapLog.w("Could not send the recording", error);
+ }
+ final int finalStatus = status;
+ final boolean finalCompleted = completed;
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ onRecordingSent(generation, requestId, finalCompleted, finalStatus);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "onRecordingSent");
+ }
+ }
+ });
+ }
+ });
+ }
+
+ private static JSONObject completeBody(String url, GleapFrameRecorder.Result result) throws JSONException {
+ JSONObject file = new JSONObject();
+ file.put("url", url);
+ file.put("name", RECORDING_NAME);
+ file.put("type", RECORDING_TYPE);
+ file.put("size", result.file.length());
+ file.put("width", result.width);
+ file.put("height", result.height);
+ file.put("durationMs", result.durationMs);
+
+ JSONObject body = new JSONObject();
+ body.put("files", new JSONArray().put(file));
+ body.put("method", GleapCapture.METHOD_FRAMES);
+ body.put("platform", GleapCapture.PLATFORM);
+ body.put("sdkType", GleapCapture.sdkType());
+ body.put("sdkVersion", GleapCapture.sdkVersion());
+ body.put("deviceId", GleapCapture.deviceId());
+ if (result.startedAt != null) {
+ body.put("recordingStartedAt", DateUtil.dateToString(result.startedAt));
+ }
+ if (result.endedAt != null) {
+ body.put("recordingEndedAt", DateUtil.dateToString(result.endedAt));
+ }
+ return body;
+ }
+
+ private void onRecordingSent(int generation, String requestId, boolean completed, int status) {
+ if (generation != uploadGeneration || state != State.UPLOADING || request == null || !request.id.equals(requestId)) {
+ return;
+ }
+ if (completed || status == 409 || status == 410) {
+ // Sent; or the request was answered on another device, cancelled or expired meanwhile.
+ GleapFrameRecorder.Result result = recording;
+ String message = completed
+ ? stateMessage(requestId, "done", null)
+ : stateMessage(requestId, "failed", "The request is closed.");
+ if (completed && request.attachLogs && result != null) {
+ GleapCaptureLogs.getInstance().sendForCapture(requestId, request.include, result.startedAt, result.endedAt);
+ }
+ discardRecording();
+ if (GleapMainActivity.deliverToOpenWidget(message)) {
+ endSession();
+ } else {
+ queue(requestId, message);
+ finishAndReopen();
+ }
+ return;
+ }
+ // Not sent (e.g. offline): the customer can try again or cancel.
+ setState(State.PREVIEW);
+ if (preview != null) {
+ preview.setError(request.label("failed"));
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Ending a capture
+
+ private void cancelByCustomer() {
+ if (request == null || state == State.IDLE) {
+ return;
+ }
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "released", null);
+ queue(id, stateMessage(id, "cancelled", null));
+ finishAndReopen();
+ }
+
+ private void unsupported(String reason) {
+ if (request == null) {
+ return;
+ }
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "unsupported", reason);
+ queue(id, stateMessage(id, "unsupported", reason));
+ finishAndReopen();
+ }
+
+ private void fail(String reason) {
+ if (request == null) {
+ return;
+ }
+ String id = request.id;
+ discardRecording();
+ postEvent(id, "failed", reason);
+ queue(id, stateMessage(id, "failed", reason));
+ finishAndReopen();
+ }
+
+ // A new capture-start replaced this one.
+ private void releaseRunning() {
+ if (request != null) {
+ postEvent(request.id, "released", null);
+ }
+ discardRecording();
+ endSession();
+ }
+
+ // Stops recording and uploading and deletes the recording file.
+ private void discardRecording() {
+ uploadGeneration++;
+ Call call = uploadCall.getAndSet(null);
+ if (call != null) {
+ call.cancel();
+ }
+ if (recorder != null) {
+ recorder.cancel();
+ recorder = null;
+ }
+ if (recording != null) {
+ delete(recording.file);
+ recording = null;
+ }
+ }
+
+ private void finishAndReopen() {
+ finishAndReopen(true);
+ }
+
+ /**
+ * @param withScreenshot the widget opens the usual way, with the screenshot it takes for
+ * tickets; false: without it
+ */
+ private void finishAndReopen(boolean withScreenshot) {
+ String token = request != null ? request.ticketShareToken : null;
+ endSession();
+ // Back to the conversation; its page gets the pending messages after its ping.
+ try {
+ if (!withScreenshot) {
+ GleapWidgetLauncher.openConversationWithoutScreenshot(token);
+ } else if (token != null && !token.isEmpty()) {
+ Gleap.getInstance().openConversation(token);
+ } else {
+ Gleap.getInstance().open();
+ }
+ } catch (Throwable error) {
+ GleapErrors.report(error, "reopenWidget");
+ }
+ if (widgetClosedForCapture) {
+ main.removeCallbacks(reopenCheck);
+ main.postDelayed(reopenCheck, REOPEN_CHECK_MS);
+ }
+ }
+
+ private void endSession() {
+ main.removeCallbacks(hostCheck);
+ main.removeCallbacks(stopWatchdog);
+ main.removeCallbacks(timerTick);
+ main.removeCallbacks(screenshotTimeout);
+ if (bar != null) {
+ bar.detach();
+ bar = null;
+ }
+ if (preview != null) {
+ preview.dismiss();
+ preview = null;
+ }
+ if (recorder != null) {
+ recorder.cancel();
+ recorder = null;
+ }
+ if (captureThread != null) {
+ captureThread.quitSafely();
+ captureThread = null;
+ }
+ unregisterMemory();
+ unregisterLifecycle();
+ request = null;
+ host = new WeakReference<>(null);
+ setState(State.IDLE);
+ try {
+ GleapOverlayManager.getInstance().setHiddenForCapture(false);
+ } catch (Throwable ignore) {
+ }
+ try {
+ GleapDetectorUtil.resumeDetectorsAfterCapture();
+ } catch (Throwable ignore) {
+ }
+ // In-app messages that arrived during the capture (shown once the widget is closed).
+ GleapMainThread.post(new Runnable() {
+ @Override
+ public void run() {
+ GleapEventService.getInstance().processDeferredActions();
+ }
+ });
+ }
+
+ private void setState(State next) {
+ state = next;
+ sessionActive = next != State.IDLE;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Helpers
+
+ private void registerLifecycle() {
+ if (!lifecycleRegistered && application != null) {
+ application.registerActivityLifecycleCallbacks(this);
+ lifecycleRegistered = true;
+ }
+ }
+
+ private void unregisterLifecycle() {
+ if (lifecycleRegistered && application != null) {
+ application.unregisterActivityLifecycleCallbacks(this);
+ }
+ lifecycleRegistered = false;
+ }
+
+ private void registerMemory() {
+ if (!memoryRegistered && application != null) {
+ application.registerComponentCallbacks(this);
+ memoryRegistered = true;
+ }
+ }
+
+ private void unregisterMemory() {
+ if (memoryRegistered && application != null) {
+ application.unregisterComponentCallbacks(this);
+ }
+ memoryRegistered = false;
+ }
+
+ private void queue(String requestId, String message) {
+ if (message == null) {
+ return;
+ }
+ pending.add(new String[]{requestId, message});
+ pendingUntil = SystemClock.uptimeMillis() + PENDING_TTL_MS;
+ // Not delivered in time (the widget was not opened again): the messages (a screenshot is
+ // megabytes) are dropped.
+ main.removeCallbacks(expirePending);
+ main.postDelayed(expirePending, PENDING_TTL_MS);
+ }
+
+ private final Runnable expirePending = new Runnable() {
+ @Override
+ public void run() {
+ if (SystemClock.uptimeMillis() >= pendingUntil) {
+ pending.clear();
+ }
+ }
+ };
+
+ // Drops the pending messages of a request (null: all of them).
+ private void removePending(String requestId) {
+ for (Iterator it = pending.iterator(); it.hasNext(); ) {
+ String[] message = it.next();
+ if (requestId == null || requestId.equals(message[0])) {
+ it.remove();
+ }
+ }
+ }
+
+ private static String stateMessage(String requestId, String state, String error) {
+ try {
+ JSONObject data = new JSONObject();
+ data.put("requestId", requestId);
+ data.put("state", state);
+ if (error != null) {
+ data.put("error", error);
+ }
+ return GleapWidgetMessages.message("capture-state", data);
+ } catch (JSONException e) {
+ return null;
+ }
+ }
+
+ private static void refuse(GleapMainActivity widget, String requestId, String state, String reason) {
+ postEvent(requestId, state, reason);
+ sendNow(widget, stateMessage(requestId, state, reason));
+ }
+
+ private static void sendNow(GleapMainActivity widget, String message) {
+ if (message != null) {
+ widget.sendMessage(message);
+ }
+ }
+
+ private static void postEvent(final String requestId, final String type, final String reason) {
+ GleapCaptureExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ GleapCaptureApi.event(requestId, type, reason);
+ } catch (Throwable error) {
+ GleapLog.w("Could not report the capture event " + type, error);
+ }
+ }
+ });
+ }
+
+ private static void delete(File file) {
+ GleapCapture.deleteCaptureFile(file);
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureExecutor.java b/gleap/src/main/java/io/gleap/GleapCaptureExecutor.java
new file mode 100644
index 00000000..6decdc60
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureExecutor.java
@@ -0,0 +1,45 @@
+package io.gleap;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Runs the capture work that blocks (log bundles, uploads, capture request calls) on up to two
+ * threads of its own: a long recording upload does not hold back the SDK's other requests or a
+ * log bundle. The threads only exist while there is work (they end after 30 s without).
+ */
+final class GleapCaptureExecutor {
+ private static final Executor DEFAULT = create();
+ // Tests only: runs the work right away.
+ private static volatile Executor testExecutor;
+
+ private GleapCaptureExecutor() {
+ }
+
+ static void execute(Runnable work) {
+ Executor executor = testExecutor;
+ (executor != null ? executor : DEFAULT).execute(work);
+ }
+
+ // Tests only; null restores the threads.
+ static void setExecutorForTesting(Executor executor) {
+ testExecutor = executor;
+ }
+
+ private static Executor create() {
+ ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 30, TimeUnit.SECONDS,
+ new LinkedBlockingQueue(), new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "gleap-capture");
+ thread.setDaemon(true);
+ return thread;
+ }
+ });
+ executor.allowCoreThreadTimeOut(true);
+ return executor;
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureGeometry.java b/gleap/src/main/java/io/gleap/GleapCaptureGeometry.java
new file mode 100644
index 00000000..2b0a0163
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureGeometry.java
@@ -0,0 +1,133 @@
+package io.gleap;
+
+import java.util.Locale;
+
+/**
+ * The size and timing math of screenshots and recordings (no Android types, so it is unit tested).
+ */
+final class GleapCaptureGeometry {
+ // Screenshots are sent with at most this long edge.
+ static final int SCREENSHOT_MAX_EDGE = 2560;
+ // Recordings are encoded with at most this long edge.
+ static final int RECORDING_MAX_EDGE = 1280;
+
+ // Recording frame rate: 4 fps, between 2 and 8 fps depending on what a frame costs.
+ static final long TARGET_FRAME_INTERVAL_MS = 250;
+ static final long MIN_FRAME_INTERVAL_MS = 125;
+ static final long MAX_FRAME_INTERVAL_MS = 500;
+ // A frame may take at most this share of the frame interval on the main thread.
+ static final double MAIN_THREAD_BUDGET = 0.3;
+ // Below this share a faster frame rate is tried.
+ static final double MAIN_THREAD_SPARE = 0.08;
+
+ private GleapCaptureGeometry() {
+ }
+
+ /**
+ * The scale that makes the long edge of {@code width x height} at most {@code maxEdge}; never
+ * more than 1 (no upscaling).
+ */
+ static float scaleForLongEdge(int width, int height, int maxEdge) {
+ int longEdge = Math.max(width, height);
+ if (longEdge <= 0 || longEdge <= maxEdge) {
+ return 1f;
+ }
+ return maxEdge / (float) longEdge;
+ }
+
+ /**
+ * {@code width x height} scaled so the long edge is at most {@code maxEdge}, at least 1 x 1.
+ */
+ static int[] fitLongEdge(int width, int height, int maxEdge) {
+ float scale = scaleForLongEdge(width, height, maxEdge);
+ return new int[]{Math.max(1, Math.round(width * scale)), Math.max(1, Math.round(height * scale))};
+ }
+
+ /**
+ * The encoder size for a recording of {@code width x height}: the long edge at most
+ * {@code maxEdge}, both dimensions multiples of {@code alignment} (at least 2: H.264 needs
+ * even sizes) and at least one alignment step.
+ */
+ static int[] encoderSize(int width, int height, int maxEdge, int alignment) {
+ int align = Math.max(2, alignment);
+ float scale = scaleForLongEdge(width, height, maxEdge);
+ // A hair above the product, so float rounding never costs a whole alignment step.
+ int scaledWidth = (int) Math.floor(Math.max(1, width) * (double) scale + 0.001);
+ int scaledHeight = (int) Math.floor(Math.max(1, height) * (double) scale + 0.001);
+ return new int[]{alignDown(scaledWidth, align), alignDown(scaledHeight, align)};
+ }
+
+ private static int alignDown(int value, int alignment) {
+ return Math.max(alignment, value - value % alignment);
+ }
+
+ /**
+ * Where a {@code sourceWidth x sourceHeight} frame goes in a {@code targetWidth x targetHeight}
+ * output: scaled to fit and centered, with black bars on the other sides (letterbox).
+ *
+ * @return {left, top, width, height}
+ */
+ static float[] letterbox(int sourceWidth, int sourceHeight, int targetWidth, int targetHeight) {
+ if (sourceWidth <= 0 || sourceHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
+ return new float[]{0, 0, Math.max(0, targetWidth), Math.max(0, targetHeight)};
+ }
+ float scale = Math.min(targetWidth / (float) sourceWidth, targetHeight / (float) sourceHeight);
+ float width = sourceWidth * scale;
+ float height = sourceHeight * scale;
+ return new float[]{(targetWidth - width) / 2f, (targetHeight - height) / 2f, width, height};
+ }
+
+ /**
+ * The next recording frame interval: slower while a frame costs more than
+ * {@link #MAIN_THREAD_BUDGET} of the interval on the main thread, faster while it costs less
+ * than {@link #MAIN_THREAD_SPARE} and the frames keep up, back towards 4 fps otherwise.
+ *
+ * @param mainThreadMs what the last frame cost on the main thread
+ * @param keptUp whether the last frame was encoded before the next one was due
+ */
+ static long nextFrameInterval(long currentMs, long mainThreadMs, boolean keptUp) {
+ long interval = clampInterval(currentMs);
+ if (mainThreadMs > interval * MAIN_THREAD_BUDGET || !keptUp) {
+ return clampInterval(Math.max(interval + 50, Math.round(interval * 1.5)));
+ }
+ if (mainThreadMs < interval * MAIN_THREAD_SPARE) {
+ return clampInterval(interval - 25);
+ }
+ if (interval > TARGET_FRAME_INTERVAL_MS) {
+ return clampInterval(interval - 25);
+ }
+ return interval;
+ }
+
+ /**
+ * When the next frame may start: the frame interval, or later when frames cost more than
+ * {@link #MAIN_THREAD_BUDGET} of the time on the main thread (frames are then skipped, also
+ * below 2 fps).
+ */
+ static long nextFrameDelay(long intervalMs, long mainThreadMs) {
+ long budgetDelay = (long) Math.ceil(Math.max(0, mainThreadMs) / MAIN_THREAD_BUDGET);
+ return Math.max(clampInterval(intervalMs), budgetDelay);
+ }
+
+ private static long clampInterval(long intervalMs) {
+ return Math.max(MIN_FRAME_INTERVAL_MS, Math.min(MAX_FRAME_INTERVAL_MS, intervalMs));
+ }
+
+ /**
+ * mm:ss for the recording timer.
+ */
+ static String formatClock(long seconds) {
+ long safe = Math.max(0, seconds);
+ return String.format(Locale.ROOT, "%02d:%02d", safe / 60, safe % 60);
+ }
+
+ /**
+ * The recording limit: the request's value clamped to 5..180 s, 60 s when missing.
+ */
+ static int clampDuration(int requestedSec) {
+ if (requestedSec <= 0) {
+ return GleapCapture.DEFAULT_RECORDING_SEC;
+ }
+ return Math.max(GleapCapture.MIN_RECORDING_SEC, Math.min(GleapCapture.MAX_RECORDING_SEC, requestedSec));
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureLogs.java b/gleap/src/main/java/io/gleap/GleapCaptureLogs.java
new file mode 100644
index 00000000..327fc676
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureLogs.java
@@ -0,0 +1,428 @@
+package io.gleap;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Log bundles for capture requests: the server asks for the app's logs (kind "logs", pushed on
+ * the WebSocket or returned with a ping), or a screenshot / recording asks for the logs around it
+ * (attachLogs). The bundle carries what a ticket carries (POST /bugs/v2) with the same shapes,
+ * limited to the parts the request includes, and is sent gzip compressed to
+ * POST /v3/shared/capture-requests/{id}/logs.
+ *
+ * Each request id is handled once per process; the work runs on the capture threads.
+ */
+final class GleapCaptureLogs {
+ static final long FLUSH_TIMEOUT_MS = 500;
+ static final String KIND_LOGS = "logs";
+ // A request that fails on the server's side (or the network) is tried 3 times, 2 and then 8
+ // minutes apart; then it is reported failed, so the server closes it.
+ static final int MAX_ATTEMPTS = 3;
+ static final long[] RETRY_DELAYS_MS = {2 * 60 * 1000L, 8 * 60 * 1000L};
+ private static final int MAX_REMEMBERED_REQUESTS = 200;
+
+ /**
+ * What a bundle may contain (the request's options.include): everything but the replay by
+ * default. The replay is never sent with capture logs (its frames are not masked).
+ */
+ static final class Include {
+ final boolean consoleLog;
+ final boolean networkLogs;
+ final boolean customData;
+ final boolean metaData;
+ final boolean customEventLog;
+ final boolean replays;
+
+ Include(boolean consoleLog, boolean networkLogs, boolean customData, boolean metaData,
+ boolean customEventLog, boolean replays) {
+ this.consoleLog = consoleLog;
+ this.networkLogs = networkLogs;
+ this.customData = customData;
+ this.metaData = metaData;
+ this.customEventLog = customEventLog;
+ this.replays = replays;
+ }
+
+ static Include from(JSONObject include) {
+ if (include == null) {
+ include = new JSONObject();
+ }
+ return new Include(
+ include.optBoolean("consoleLog", true),
+ include.optBoolean("networkLogs", true),
+ include.optBoolean("customData", true),
+ include.optBoolean("metaData", true),
+ include.optBoolean("customEventLog", true),
+ include.optBoolean("replays", false));
+ }
+ }
+
+ /**
+ * Where the bundle's data comes from: the collectors tickets use.
+ */
+ interface Sources {
+ // Console logs are enabled in the remote config (as for tickets).
+ boolean consoleLogsEnabled();
+
+ JSONArray consoleLog();
+
+ JSONArray networkLogs();
+
+ JSONObject customData();
+
+ // The env data; null while it is disabled.
+ JSONObject metaData() throws JSONException;
+
+ JSONArray customEventLog();
+ }
+
+ private static final Sources DEVICE = new Sources() {
+ @Override
+ public boolean consoleLogsEnabled() {
+ return GleapConfig.getInstance().isEnableConsoleLogs();
+ }
+
+ @Override
+ public JSONArray consoleLog() {
+ return GleapBug.getInstance().getLogs();
+ }
+
+ @Override
+ public JSONArray networkLogs() {
+ return GleapBug.getInstance().getNetworklogs();
+ }
+
+ @Override
+ public JSONObject customData() {
+ JSONObject customData = GleapBug.getInstance().getCustomData();
+ try {
+ return (JSONObject) GleapNetworkLogSanitizer.deepCopy(customData);
+ } catch (Exception e) {
+ return customData;
+ }
+ }
+
+ @Override
+ public JSONObject metaData() throws JSONException {
+ if (PhoneMeta.isEnvDataDisabled()) {
+ return null;
+ }
+ PhoneMeta phoneMeta = GleapBug.getInstance().getPhoneMeta();
+ return phoneMeta != null ? phoneMeta.getJSONObj() : null;
+ }
+
+ @Override
+ public JSONArray customEventLog() {
+ return GleapBug.getInstance().getCustomEventLog();
+ }
+ };
+
+ // After DEVICE: static fields are initialized in the order they are declared.
+ private static final GleapCaptureLogs instance = new GleapCaptureLogs();
+
+ // Request ids started on this device (in progress or sent). Guarded by this.
+ private final Set started = new LinkedHashSet<>();
+ private volatile Sources sources = DEVICE;
+
+ private GleapCaptureLogs() {
+ }
+
+ static GleapCaptureLogs getInstance() {
+ return instance;
+ }
+
+ // Tests only; null restores the device's collectors.
+ void setSourcesForTesting(Sources testSources) {
+ sources = testSources != null ? testSources : DEVICE;
+ }
+
+ // Tests only.
+ synchronized void resetForTesting() {
+ started.clear();
+ sources = DEVICE;
+ }
+
+ /**
+ * The capture requests of a ping answer ({@code cr}).
+ */
+ void onCaptureRequests(JSONArray requests) {
+ if (requests == null) {
+ return;
+ }
+ for (int i = 0; i < requests.length(); i++) {
+ onCaptureRequest(requests.optJSONObject(i));
+ }
+ }
+
+ /**
+ * A capture request the server pushed: {@code {id, kind, options, ticketShareToken, expiresAt}}.
+ * Only log requests come this way (screenshots and recordings start from the widget); each
+ * one is handled once.
+ */
+ void onCaptureRequest(JSONObject request) {
+ try {
+ if (request == null || !KIND_LOGS.equals(request.optString("kind"))) {
+ return;
+ }
+ final String id = request.optString("id", null);
+ if (!GleapCapture.isValidRequestId(id) || !begin(id)) {
+ return;
+ }
+ JSONObject options = request.optJSONObject("options");
+ attempt(id, Include.from(options != null ? options.optJSONObject("include") : null), 1);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "onCaptureRequest");
+ }
+ }
+
+ /**
+ * One attempt at a log request. The request stays started (a push or ping answer for it is
+ * ignored) while it is retried: the logs are never collected and sent again for every ping.
+ */
+ private void attempt(final String id, final Include include, final int number) {
+ GleapCaptureExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ String retryReason;
+ try {
+ retryReason = handleLogsRequest(id, include);
+ } catch (Throwable error) {
+ GleapLog.w("Could not send the logs for a capture request", error);
+ retryReason = error.getClass().getSimpleName();
+ }
+ if (retryReason == null) {
+ return;
+ }
+ if (number >= MAX_ATTEMPTS) {
+ // The server closes the request: no further pushes.
+ try {
+ GleapCaptureApi.event(id, "failed", "The logs could not be sent (" + retryReason + ").");
+ } catch (Throwable ignore) {
+ }
+ return;
+ }
+ GleapMainThread.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ attempt(id, include, number + 1);
+ }
+ }, RETRY_DELAYS_MS[number - 1]);
+ }
+ });
+ }
+
+ /**
+ * Claims the request, collects the logs and sends them.
+ *
+ * @return null when nothing is left to do for this request on this device, else why it
+ * should be tried again (server trouble)
+ */
+ String handleLogsRequest(String id, Include include) throws IOException, JSONException {
+ if (!GleapCapture.isRemoteLogCollectionEnabled()) {
+ GleapCaptureApi.event(id, "unsupported", "Remote log collection is disabled in the app.");
+ return null;
+ }
+
+ GleapCaptureApi.Response claim = GleapCaptureApi.claim(id);
+ if (!claim.ok()) {
+ // Gone (410) or refused for good: nothing to send.
+ return isRetryable(claim.status) ? "HTTP " + claim.status : null;
+ }
+
+ JSONObject bundle = collect(include, null, null);
+ GleapCaptureApi.Response response = send(id, bundle);
+ return !response.ok() && isRetryable(response.status) ? "HTTP " + response.status : null;
+ }
+
+ private static boolean isRetryable(int status) {
+ return status == 408 || status == 429 || status >= 500;
+ }
+
+ /**
+ * Sends the logs around a screenshot or recording the customer made (its attachLogs option):
+ * the widget claimed that request already. Does nothing while remote log collection is off.
+ */
+ void sendForCapture(final String requestId, JSONObject include, final Date windowStart, final Date windowEnd) {
+ if (!GleapCapture.isRemoteLogCollectionEnabled() || !GleapCapture.isValidRequestId(requestId)) {
+ return;
+ }
+ final Include parts = Include.from(include);
+ GleapCaptureExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ GleapCaptureApi.Response response = send(requestId, collect(parts, windowStart, windowEnd));
+ if (!response.ok()) {
+ GleapLog.w("The capture logs were not accepted (HTTP " + response.status + ")");
+ }
+ } catch (Throwable error) {
+ GleapLog.w("Could not send the capture logs", error);
+ }
+ }
+ });
+ }
+
+ private JSONObject collect(Include include, Date windowStart, Date windowEnd) throws JSONException {
+ flushWrapperLogs();
+ return buildBundle(include, sources, windowStart, windowEnd, new Date(), GleapCapture.deviceId());
+ }
+
+ /**
+ * The bundle: the included parts (absent = not collected), the collection time and window,
+ * and who sent it.
+ */
+ static JSONObject buildBundle(Include include, Sources sources, Date windowStart, Date windowEnd,
+ Date capturedAt, String deviceId) throws JSONException {
+ JSONObject bundle = new JSONObject();
+ if (include.consoleLog && sources.consoleLogsEnabled()) {
+ putIfPresent(bundle, "consoleLog", sources.consoleLog());
+ }
+ if (include.networkLogs) {
+ putIfPresent(bundle, "networkLogs", sources.networkLogs());
+ }
+ if (include.customData) {
+ putIfPresent(bundle, "customData", sources.customData());
+ }
+ if (include.metaData) {
+ putIfPresent(bundle, "metaData", sources.metaData());
+ }
+ if (include.customEventLog) {
+ putIfPresent(bundle, "customEventLog", sources.customEventLog());
+ }
+ // No replay (include.replays): its frames are screenshots taken without the capture
+ // masks (password fields, masked views, secure windows).
+
+ String captured = DateUtil.dateToString(capturedAt);
+ bundle.put("capturedAt", captured);
+ if (windowStart != null) {
+ bundle.put("windowStart", DateUtil.dateToString(windowStart));
+ } else {
+ Date oldest = oldestEntry(bundle);
+ bundle.put("windowStart", oldest != null && oldest.before(capturedAt) ? DateUtil.dateToString(oldest) : captured);
+ }
+ bundle.put("windowEnd", windowEnd != null ? DateUtil.dateToString(windowEnd) : captured);
+ bundle.put("platform", GleapCapture.PLATFORM);
+ bundle.put("sdkType", GleapCapture.sdkType());
+ bundle.put("sdkVersion", GleapCapture.sdkVersion());
+ if (deviceId != null) {
+ bundle.put("deviceId", deviceId);
+ }
+ return bundle;
+ }
+
+ private static void putIfPresent(JSONObject bundle, String key, Object value) throws JSONException {
+ if (value != null) {
+ bundle.put(key, value);
+ }
+ }
+
+ // The oldest dated entry of the logs and events, or null.
+ private static Date oldestEntry(JSONObject bundle) {
+ Date oldest = null;
+ for (String key : new String[]{"consoleLog", "networkLogs", "customEventLog"}) {
+ JSONArray entries = bundle.optJSONArray(key);
+ if (entries == null) {
+ continue;
+ }
+ for (int i = 0; i < entries.length(); i++) {
+ JSONObject entry = entries.optJSONObject(i);
+ Object date = entry != null ? entry.opt("date") : null;
+ if (!(date instanceof String)) {
+ continue;
+ }
+ try {
+ Date parsed = DateUtil.stringToDate((String) date);
+ if (oldest == null || parsed.before(oldest)) {
+ oldest = parsed;
+ }
+ } catch (Exception ignore) {
+ }
+ }
+ }
+ return oldest;
+ }
+
+ /**
+ * Sends the bundle gzip compressed, leaving out its biggest parts while it is above the
+ * server's limit.
+ */
+ static GleapCaptureApi.Response send(String id, JSONObject bundle) throws IOException {
+ byte[] body = gzip(bundle.toString());
+ for (String key : new String[]{"consoleLog", "networkLogs", "customEventLog"}) {
+ if (body.length <= GleapCaptureApi.MAX_LOGS_BYTES) {
+ break;
+ }
+ bundle.remove(key);
+ body = gzip(bundle.toString());
+ }
+ return GleapCaptureApi.postLogs(id, body);
+ }
+
+ static byte[] gzip(String json) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(out)) {
+ gzip.write(json.getBytes(StandardCharsets.UTF_8));
+ }
+ return out.toByteArray();
+ }
+
+ /**
+ * Gives the wrapper SDK (setLogFlushHandler) up to 500 ms to hand over its buffered logs.
+ * Never call it on the main thread: the handler runs there.
+ */
+ static void flushWrapperLogs() {
+ final GleapLogFlushHandler handler = GleapCapture.getLogFlushHandler();
+ if (handler == null) {
+ return;
+ }
+ final CountDownLatch flushed = new CountDownLatch(1);
+ final Runnable done = new Runnable() {
+ @Override
+ public void run() {
+ flushed.countDown();
+ }
+ };
+ try {
+ GleapMainThread.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ handler.onFlushRequested(done);
+ } catch (Throwable error) {
+ flushed.countDown();
+ GleapErrors.report(error, "logFlushHandler");
+ }
+ }
+ });
+ flushed.await(FLUSH_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private synchronized boolean begin(String id) {
+ if (started.contains(id)) {
+ return false;
+ }
+ started.add(id);
+ while (started.size() > MAX_REMEMBERED_REQUESTS) {
+ Iterator oldest = started.iterator();
+ oldest.next();
+ oldest.remove();
+ }
+ return true;
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCapturePreview.java b/gleap/src/main/java/io/gleap/GleapCapturePreview.java
new file mode 100644
index 00000000..a3ad7337
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCapturePreview.java
@@ -0,0 +1,629 @@
+package io.gleap;
+
+import static io.gleap.GleapCaptureUi.dp;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Insets;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.ColorDrawable;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.view.DisplayCutout;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.VideoView;
+
+import java.io.File;
+import java.lang.ref.WeakReference;
+import java.text.NumberFormat;
+
+/**
+ * Shows the finished recording before it is sent: full screen on a dark backdrop, the video
+ * (looping, tap to pause or play, a thin scrubber) with rounded corners, × to cancel, Retake and
+ * Send, and the upload progress on Send. A dialog on the current activity; it is shown again on
+ * the next activity after a rotation or an activity change.
+ */
+final class GleapCapturePreview {
+ interface Listener {
+ void onSendTapped();
+
+ void onRetakeTapped();
+
+ void onCancelTapped();
+ }
+
+ private static final long POSITION_TICK_MS = 100;
+ private static final int SHOW_RETRIES = 30;
+
+ private final GleapCaptureRequest request;
+ private final File video;
+ private final int videoWidth;
+ private final int videoHeight;
+ private final Listener listener;
+ private final Handler main = new Handler(Looper.getMainLooper());
+
+ private WeakReference activity = new WeakReference<>(null);
+ private int showAttempt;
+ private Dialog dialog;
+ private VideoView videoView;
+ private VideoFrame frame;
+ private TextView status;
+ private View close;
+ private GleapCaptureUi.Button retake;
+ private GleapCaptureUi.Button send;
+ // What the preview shows.
+ private boolean uploading;
+ private float progress;
+ private String error;
+
+ GleapCapturePreview(GleapCaptureRequest request, File video, int videoWidth, int videoHeight, Listener listener) {
+ this.request = request;
+ this.video = video;
+ this.videoWidth = Math.max(1, videoWidth);
+ this.videoHeight = Math.max(1, videoHeight);
+ this.listener = listener;
+ }
+
+ boolean isShownOn(Activity candidate) {
+ return dialog != null && activity.get() == candidate;
+ }
+
+ void show(final Activity target) {
+ dismiss();
+ if (target == null || target.isFinishing()) {
+ return;
+ }
+ if (activity.get() != target) {
+ showAttempt = 0;
+ }
+ activity = new WeakReference<>(target);
+ View decor = target.getWindow() != null ? target.getWindow().peekDecorView() : null;
+ if (decor == null || decor.getWindowToken() == null) {
+ // The activity's window is not attached yet (e.g. right after a rotation): shortly.
+ if (showAttempt++ < SHOW_RETRIES) {
+ main.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (activity.get() == target && dialog == null) {
+ show(target);
+ }
+ }
+ }, 100);
+ } else {
+ showAttempt = 0;
+ }
+ return;
+ }
+ showAttempt = 0;
+ try {
+ // Full screen and dark, whatever the app's theme.
+ dialog = new Dialog(target, android.R.style.Theme_Material_NoActionBar);
+ dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
+ dialog.setCancelable(false);
+ dialog.setCanceledOnTouchOutside(false);
+ dialog.setTitle(request.label("previewTitle"));
+ dialog.setContentView(buildViews(dialog.getContext()));
+ Window window = dialog.getWindow();
+ if (window != null) {
+ window.setBackgroundDrawable(new ColorDrawable(GleapCaptureUi.BACKDROP_COLOR));
+ window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
+ window.setWindowAnimations(android.R.style.Animation_Toast);
+ if (Build.VERSION.SDK_INT < 35) {
+ // Since API 35 the window draws behind the system bars itself.
+ window.setStatusBarColor(GleapCaptureUi.BACKDROP_COLOR);
+ window.setNavigationBarColor(GleapCaptureUi.BACKDROP_COLOR);
+ }
+ }
+ dialog.show();
+ if (window != null) {
+ GleapWindowCapture.addOwnWindow(window.getDecorView());
+ }
+ applyState();
+ startVideo();
+ } catch (Throwable error) {
+ GleapLog.w("Could not show the recording preview", error);
+ dismiss();
+ }
+ }
+
+ void dismiss() {
+ main.removeCallbacksAndMessages(null);
+ if (videoView != null) {
+ try {
+ videoView.stopPlayback();
+ } catch (Throwable ignore) {
+ }
+ }
+ if (dialog != null) {
+ try {
+ Window window = dialog.getWindow();
+ if (window != null) {
+ GleapWindowCapture.removeOwnWindow(window.peekDecorView());
+ }
+ dialog.dismiss();
+ } catch (Throwable ignore) {
+ // The activity is gone already.
+ }
+ }
+ dialog = null;
+ videoView = null;
+ frame = null;
+ status = null;
+ close = null;
+ retake = null;
+ send = null;
+ }
+
+ void setUploading(float value) {
+ uploading = true;
+ progress = value;
+ error = null;
+ applyState();
+ }
+
+ void setError(String message) {
+ uploading = false;
+ error = message;
+ applyState();
+ }
+
+ // While uploading Send shows a spinner and the percentage, Retake and the video wait and x
+ // cancels the upload; an error shows above the buttons and Send tries again.
+ private void applyState() {
+ if (send == null) {
+ return;
+ }
+ if (uploading) {
+ String percent = NumberFormat.getPercentInstance().format(Math.max(0f, Math.min(1f, progress)));
+ send.setBusy(true);
+ send.setText(percent);
+ send.setContentDescription(request.label("uploading") + " " + percent);
+ } else {
+ send.setBusy(false);
+ send.setText(request.label("previewSend"));
+ send.setContentDescription(null);
+ }
+ // x stays: it cancels the upload.
+ GleapCaptureUi.setEnabled(retake, !uploading);
+ if (frame != null) {
+ frame.setEnabled(!uploading);
+ }
+ if (status != null) {
+ if (!uploading && error != null) {
+ status.setText(error);
+ status.setVisibility(View.VISIBLE);
+ } else {
+ status.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ private void startVideo() {
+ final VideoView view = videoView;
+ if (view == null) {
+ return;
+ }
+ view.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
+ @Override
+ public void onPrepared(MediaPlayer player) {
+ try {
+ player.setLooping(true);
+ player.setVolume(0f, 0f);
+ } catch (Throwable ignore) {
+ }
+ if (frame == null || !frame.pausedByCustomer) {
+ view.start();
+ }
+ trackPosition();
+ }
+ });
+ view.setOnErrorListener(new MediaPlayer.OnErrorListener() {
+ @Override
+ public boolean onError(MediaPlayer player, int what, int extra) {
+ // The recording can still be sent without a preview.
+ return true;
+ }
+ });
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // The preview is silent: it never pauses the music the customer listens to.
+ view.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE);
+ }
+ view.setVideoPath(video.getAbsolutePath());
+ }
+
+ // The scrubber follows the video while the preview is shown.
+ private void trackPosition() {
+ main.removeCallbacks(positionTick);
+ main.post(positionTick);
+ }
+
+ private final Runnable positionTick = new Runnable() {
+ @Override
+ public void run() {
+ VideoView view = videoView;
+ VideoFrame target = frame;
+ if (view == null || target == null) {
+ return;
+ }
+ try {
+ int duration = view.getDuration();
+ if (duration > 0 && !target.scrubbing) {
+ target.setPosition(view.getCurrentPosition() / (float) duration, !view.isPlaying());
+ }
+ } catch (Throwable ignore) {
+ }
+ main.postDelayed(this, POSITION_TICK_MS);
+ }
+ };
+
+ private void togglePlayback() {
+ VideoView view = videoView;
+ if (view == null || frame == null) {
+ return;
+ }
+ try {
+ if (view.isPlaying()) {
+ view.pause();
+ frame.pausedByCustomer = true;
+ } else {
+ view.start();
+ frame.pausedByCustomer = false;
+ }
+ frame.setPosition(frame.position, !view.isPlaying());
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private void seekTo(float fraction) {
+ VideoView view = videoView;
+ if (view == null) {
+ return;
+ }
+ try {
+ int duration = view.getDuration();
+ if (duration > 0) {
+ view.seekTo(Math.round(fraction * duration));
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private View buildViews(Context context) {
+ FrameLayout root = new FrameLayout(context);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ root.setAccessibilityPaneTitle(request.label("previewTitle"));
+ }
+ // Drawn behind the system bars (API 35+): the content stays in the safe area.
+ root.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
+ @Override
+ public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
+ int[] safe = safeInsets(insets);
+ view.setPadding(safe[0], safe[1], safe[2], safe[3]);
+ return insets;
+ }
+ });
+
+ LinearLayout column = new LinearLayout(context);
+ column.setOrientation(LinearLayout.VERTICAL);
+ root.addView(column, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ // Top: the title as a small centered caption and × (cancel) at the end, clear of each other.
+ FrameLayout top = new FrameLayout(context);
+ column.addView(top, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(context, 60)));
+ int closeSize = dp(context, 44);
+ close = GleapCaptureUi.iconButton(context, GleapCaptureUi.Icon.CLOSE, request.label("barCancel"), closeSize, closeSize);
+ close.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ // Also while uploading: the upload is cancelled.
+ listener.onCancelTapped();
+ }
+ });
+ FrameLayout.LayoutParams closeParams = new FrameLayout.LayoutParams(closeSize, closeSize, Gravity.END | Gravity.CENTER_VERTICAL);
+ closeParams.setMarginEnd(dp(context, 8));
+ top.addView(close, closeParams);
+
+ TextView title = GleapCaptureUi.text(context, request.label("previewTitle"), 13, GleapCaptureUi.MUTED_TEXT_COLOR, false);
+ title.setSingleLine(true);
+ title.setEllipsize(TextUtils.TruncateAt.END);
+ title.setGravity(Gravity.CENTER);
+ // The window's title says it.
+ title.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ FrameLayout.LayoutParams titleParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER);
+ titleParams.leftMargin = dp(context, 60);
+ titleParams.rightMargin = dp(context, 60);
+ top.addView(title, titleParams);
+
+ // The video, centered, rounded, with a hairline.
+ frame = new VideoFrame(context, videoWidth, videoHeight);
+ videoView = new VideoView(context);
+ videoView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ frame.addView(videoView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ frame.setContentDescription(request.label("previewPause"));
+ frame.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (!uploading) {
+ togglePlayback();
+ }
+ }
+ });
+ LinearLayout.LayoutParams frameParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
+ frameParams.setMargins(dp(context, 16), dp(context, 4), dp(context, 16), dp(context, 8));
+ column.addView(frame, frameParams);
+
+ status = GleapCaptureUi.text(context, "", 14, GleapCaptureUi.ERROR_TEXT_COLOR, false);
+ status.setGravity(Gravity.CENTER);
+ status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
+ status.setVisibility(View.GONE);
+ LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ statusParams.setMargins(dp(context, 24), 0, dp(context, 24), dp(context, 12));
+ column.addView(status, statusParams);
+
+ // Retake and Send: a full-width row on phones, right-aligned on tablets.
+ boolean tablet = context.getResources().getConfiguration().smallestScreenWidthDp >= 600;
+ LinearLayout buttons = new LinearLayout(context);
+ buttons.setOrientation(LinearLayout.HORIZONTAL);
+ buttons.setGravity(tablet ? Gravity.END | Gravity.CENTER_VERTICAL : Gravity.CENTER);
+ int height = dp(context, 48);
+ // "↺ Retake" and "Send ➤", like the web and iOS SDKs.
+ retake = GleapCaptureUi.secondaryButton(context, request.label("previewRetake"), GleapCaptureUi.Icon.RETAKE, 24);
+ retake.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (!uploading) {
+ listener.onRetakeTapped();
+ }
+ }
+ });
+ // The send icon after the label (before it right to left).
+ send = GleapCaptureUi.button(context, request.label("previewSend"), GleapCaptureUi.Icon.SEND, true,
+ GleapCaptureUi.primaryFill(), 24);
+ send.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (!uploading) {
+ listener.onSendTapped();
+ }
+ }
+ });
+ LinearLayout.LayoutParams retakeParams;
+ LinearLayout.LayoutParams sendParams;
+ if (tablet) {
+ retake.setMinimumWidth(dp(context, 140));
+ send.setMinimumWidth(dp(context, 140));
+ retakeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, height);
+ sendParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, height);
+ } else {
+ retakeParams = new LinearLayout.LayoutParams(0, height, 1f);
+ sendParams = new LinearLayout.LayoutParams(0, height, 1f);
+ }
+ sendParams.setMarginStart(dp(context, 12));
+ buttons.addView(retake, retakeParams);
+ buttons.addView(send, sendParams);
+ LinearLayout.LayoutParams buttonsParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ buttonsParams.setMargins(dp(context, 16), 0, dp(context, 16), dp(context, 16));
+ column.addView(buttons, buttonsParams);
+ return root;
+ }
+
+ private static int[] safeInsets(WindowInsets insets) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Insets safe = insets.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
+ return new int[]{safe.left, safe.top, safe.right, safe.bottom};
+ }
+ int[] result = new int[]{insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(),
+ insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()};
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && insets.getDisplayCutout() != null) {
+ DisplayCutout cutout = insets.getDisplayCutout();
+ result[0] = Math.max(result[0], cutout.getSafeInsetLeft());
+ result[1] = Math.max(result[1], cutout.getSafeInsetTop());
+ result[2] = Math.max(result[2], cutout.getSafeInsetRight());
+ result[3] = Math.max(result[3], cutout.getSafeInsetBottom());
+ }
+ return result;
+ }
+
+ /**
+ * Lays the video out as large as fits, keeping its aspect ratio, with rounded corners and a
+ * hairline, the play glyph while paused and a thin scrubber under it. A tap pauses or plays.
+ */
+ private final class VideoFrame extends FrameLayout {
+ private final int sourceWidth;
+ private final int sourceHeight;
+ private final Paint mask = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint hairline = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint glyphBackground = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint track = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint played = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final GleapCaptureUi.Icon playIcon = new GleapCaptureUi.Icon(GleapCaptureUi.Icon.PLAY, GleapCaptureUi.TEXT_COLOR);
+ private final Path corners = new Path();
+ private final RectF video = new RectF();
+ private final float radius;
+ private final int scrubberHeight;
+ private final int slop;
+ boolean pausedByCustomer;
+ boolean scrubbing;
+ float position;
+ private boolean paused;
+ private float downX;
+ private float downY;
+ private boolean moved;
+
+ VideoFrame(Context context, int sourceWidth, int sourceHeight) {
+ super(context);
+ this.sourceWidth = sourceWidth;
+ this.sourceHeight = sourceHeight;
+ setWillNotDraw(false);
+ setClickable(true);
+ radius = dp(context, 12);
+ scrubberHeight = dp(context, 28);
+ slop = ViewConfiguration.get(context).getScaledTouchSlop();
+ mask.setColor(GleapCaptureUi.BACKDROP_COLOR);
+ hairline.setColor(GleapCaptureUi.HAIRLINE_COLOR);
+ hairline.setStyle(Paint.Style.STROKE);
+ hairline.setStrokeWidth(Math.max(1, dp(context, 1)));
+ glyphBackground.setColor(0x73000000);
+ track.setColor(0x33FFFFFF);
+ track.setStrokeWidth(dp(context, 3));
+ track.setStrokeCap(Paint.Cap.ROUND);
+ played.setColor(GleapCaptureUi.TEXT_COLOR);
+ played.setStrokeWidth(dp(context, 3));
+ played.setStrokeCap(Paint.Cap.ROUND);
+ }
+
+ void setPosition(float value, boolean isPaused) {
+ if (isPaused != paused) {
+ // What a tap does now.
+ setContentDescription(request.label(isPaused ? "previewPlay" : "previewPause"));
+ }
+ if (Math.abs(value - position) > 0.001f || isPaused != paused) {
+ position = Math.max(0f, Math.min(1f, value));
+ paused = isPaused;
+ invalidate();
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+ int height = MeasureSpec.getSize(heightMeasureSpec);
+ int availableHeight = Math.max(1, height - scrubberHeight);
+ float scale = Math.min(width / (float) sourceWidth, availableHeight / (float) sourceHeight);
+ int videoWidthPx = Math.max(1, Math.round(sourceWidth * scale));
+ int videoHeightPx = Math.max(1, Math.round(sourceHeight * scale));
+ for (int i = 0; i < getChildCount(); i++) {
+ getChildAt(i).measure(MeasureSpec.makeMeasureSpec(videoWidthPx, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(videoHeightPx, MeasureSpec.EXACTLY));
+ }
+ setMeasuredDimension(width, height);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ int width = right - left;
+ int height = bottom - top;
+ for (int i = 0; i < getChildCount(); i++) {
+ View child = getChildAt(i);
+ int childWidth = child.getMeasuredWidth();
+ int childHeight = child.getMeasuredHeight();
+ int childLeft = (width - childWidth) / 2;
+ int childTop = Math.max(0, (height - scrubberHeight - childHeight) / 2);
+ child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
+ video.set(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
+ }
+ corners.reset();
+ corners.setFillType(Path.FillType.EVEN_ODD);
+ corners.addRect(video, Path.Direction.CW);
+ corners.addRoundRect(video, radius, radius, Path.Direction.CW);
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ super.dispatchDraw(canvas);
+ if (video.isEmpty()) {
+ return;
+ }
+ // Rounded corners: the backdrop over the video's corners, then the hairline.
+ canvas.drawPath(corners, mask);
+ float inset = hairline.getStrokeWidth() / 2;
+ canvas.drawRoundRect(video.left + inset, video.top + inset, video.right - inset, video.bottom - inset,
+ radius, radius, hairline);
+ if (paused) {
+ float glyph = dp(getContext(), 56);
+ canvas.drawCircle(video.centerX(), video.centerY(), glyph / 2, glyphBackground);
+ int icon = dp(getContext(), 26);
+ playIcon.setBounds(Math.round(video.centerX() - icon / 2f), Math.round(video.centerY() - icon / 2f),
+ Math.round(video.centerX() + icon / 2f), Math.round(video.centerY() + icon / 2f));
+ playIcon.draw(canvas);
+ }
+ // The scrubber: a thin line under the video.
+ float y = video.bottom + scrubberHeight / 2f;
+ float start = video.left + radius / 2;
+ float end = video.right - radius / 2;
+ canvas.drawLine(start, y, end, y, track);
+ canvas.drawLine(start, y, start + (end - start) * position, y, played);
+ if (scrubbing) {
+ canvas.drawCircle(start + (end - start) * position, y, dp(getContext(), 6), played);
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (!isEnabled()) {
+ return false;
+ }
+ float x = event.getX();
+ float y = event.getY();
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ downX = x;
+ downY = y;
+ moved = false;
+ scrubbing = y >= video.bottom && y <= video.bottom + scrubberHeight
+ && x >= video.left - scrubberHeight && x <= video.right + scrubberHeight;
+ if (scrubbing) {
+ scrubTo(x);
+ }
+ return true;
+ case MotionEvent.ACTION_MOVE:
+ if (Math.abs(x - downX) > slop || Math.abs(y - downY) > slop) {
+ moved = true;
+ }
+ if (scrubbing) {
+ scrubTo(x);
+ }
+ return true;
+ case MotionEvent.ACTION_UP:
+ if (scrubbing) {
+ scrubbing = false;
+ invalidate();
+ } else if (!moved && video.contains(x, y)) {
+ performClick();
+ }
+ return true;
+ case MotionEvent.ACTION_CANCEL:
+ scrubbing = false;
+ invalidate();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean performClick() {
+ return super.performClick();
+ }
+
+ private void scrubTo(float x) {
+ float start = video.left + radius / 2;
+ float end = video.right - radius / 2;
+ float fraction = end > start ? (x - start) / (end - start) : 0f;
+ position = Math.max(0f, Math.min(1f, fraction));
+ seekTo(position);
+ invalidate();
+ }
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureRequest.java b/gleap/src/main/java/io/gleap/GleapCaptureRequest.java
new file mode 100644
index 00000000..75c2dc24
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureRequest.java
@@ -0,0 +1,121 @@
+package io.gleap;
+
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A screenshot or recording the widget asked for (capture-start): the request, its options and
+ * the labels for the SDK's own UI (localized by the widget, English when missing).
+ */
+final class GleapCaptureRequest {
+ static final String KIND_SCREENSHOT = "screenshot";
+ static final String KIND_RECORDING = "recording";
+
+ // Labels longer than this are cut: they are shown in the SDK's small bar.
+ private static final int MAX_LABEL_LENGTH = 160;
+
+ private static final Map ENGLISH = new HashMap<>();
+
+ static {
+ ENGLISH.put("barScreenshotHint", "Go to the screen you want to show, then tap Capture.");
+ ENGLISH.put("barCapture", "Capture");
+ ENGLISH.put("barCancel", "Cancel");
+ ENGLISH.put("barRecordHint", "Go to where the issue happens, then start recording.");
+ ENGLISH.put("barStart", "Start");
+ ENGLISH.put("barStop", "Stop");
+ ENGLISH.put("barRecording", "Recording");
+ ENGLISH.put("barDragHint", "Drag to move");
+ ENGLISH.put("microphone", "Microphone");
+ ENGLISH.put("previewTitle", "Send this recording?");
+ ENGLISH.put("previewSend", "Send");
+ ENGLISH.put("previewRetake", "Retake");
+ ENGLISH.put("previewPlay", "Play");
+ ENGLISH.put("previewPause", "Pause");
+ ENGLISH.put("uploading", "Uploading…");
+ ENGLISH.put("recordingInterrupted", "Recording stopped because the page changed.");
+ ENGLISH.put("recordAgain", "Record again");
+ ENGLISH.put("permissionDenied", "Screen capture was blocked. You can upload a file instead.");
+ ENGLISH.put("notSupported", "Screen capture isn't available here. You can upload a file instead.");
+ ENGLISH.put("failed", "That didn't work. Please try again or upload a file.");
+ }
+
+ final String id;
+ final String kind;
+ final String ticketShareToken;
+ final boolean annotate;
+ final boolean audio;
+ final boolean attachLogs;
+ final int maxDurationSec;
+ // options.include as sent (null: the defaults).
+ final JSONObject include;
+ private final JSONObject labels;
+
+ private GleapCaptureRequest(String id, String kind, String ticketShareToken, boolean annotate, boolean audio,
+ boolean attachLogs, int maxDurationSec, JSONObject include, JSONObject labels) {
+ this.id = id;
+ this.kind = kind;
+ this.ticketShareToken = ticketShareToken;
+ this.annotate = annotate;
+ this.audio = audio;
+ this.attachLogs = attachLogs;
+ this.maxDurationSec = maxDurationSec;
+ this.include = include;
+ this.labels = labels;
+ }
+
+ /**
+ * Reads a capture-start message's data; null when it has no valid request id or an unknown kind.
+ */
+ static GleapCaptureRequest fromStart(JSONObject data) {
+ if (data == null) {
+ return null;
+ }
+ String id = data.optString("requestId", null);
+ if (!GleapCapture.isValidRequestId(id)) {
+ return null;
+ }
+ String kind = data.optString("kind", "");
+ if (!KIND_SCREENSHOT.equals(kind) && !KIND_RECORDING.equals(kind)) {
+ // "any" is resolved by the widget before it starts a capture.
+ return null;
+ }
+ JSONObject options = data.optJSONObject("options");
+ if (options == null) {
+ options = new JSONObject();
+ }
+ String token = data.optString("ticketShareToken", "");
+ return new GleapCaptureRequest(
+ id,
+ kind,
+ token != null ? token.trim() : "",
+ options.optBoolean("annotate", true),
+ options.optBoolean("audio", false),
+ options.optBoolean("attachLogs", true),
+ GleapCaptureGeometry.clampDuration(options.optInt("maxDurationSec", GleapCapture.DEFAULT_RECORDING_SEC)),
+ options.optJSONObject("include"),
+ data.optJSONObject("labels"));
+ }
+
+ boolean isRecording() {
+ return KIND_RECORDING.equals(kind);
+ }
+
+ /**
+ * The widget's label for {@code key}, else the English default.
+ */
+ String label(String key) {
+ if (labels != null) {
+ Object value = labels.opt(key);
+ if (value instanceof String) {
+ String text = ((String) value).trim();
+ if (!text.isEmpty()) {
+ return text.length() > MAX_LABEL_LENGTH ? text.substring(0, MAX_LABEL_LENGTH) : text;
+ }
+ }
+ }
+ String english = ENGLISH.get(key);
+ return english != null ? english : "";
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapCaptureUi.java b/gleap/src/main/java/io/gleap/GleapCaptureUi.java
new file mode 100644
index 00000000..c93210bb
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapCaptureUi.java
@@ -0,0 +1,865 @@
+package io.gleap;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.InsetDrawable;
+import android.graphics.drawable.RippleDrawable;
+import android.graphics.drawable.StateListDrawable;
+import android.os.Build;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.appcompat.widget.AppCompatImageView;
+import androidx.core.view.AccessibilityDelegateCompat;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+
+/**
+ * The look of the SDK's capture UI (bar, caption and recording preview), one visual language
+ * with the web SDK's bar: a dark pill with a white hairline that reads well over any app, 36 dp
+ * buttons with a radius of 10 dp, the widget's color for the main action and red for Stop.
+ */
+final class GleapCaptureUi {
+ // #16161A at 97 %, a white 10 % hairline.
+ static final int PILL_COLOR = 0xF716161A;
+ static final int HAIRLINE_COLOR = 0x1AFFFFFF;
+ // Icon buttons and secondary buttons: white 12 %, pressed 20 %.
+ static final int SECONDARY_COLOR = 0x1FFFFFFF;
+ static final int SECONDARY_PRESSED_COLOR = 0x33FFFFFF;
+ static final int GRIP_COLOR = 0x73FFFFFF;
+ static final int TEXT_COLOR = 0xFFFFFFFF;
+ static final int TIMER_COLOR = 0xD9FFFFFF;
+ static final int CAPTION_TEXT_COLOR = 0xE6FFFFFF;
+ static final int MUTED_TEXT_COLOR = 0x99FFFFFF;
+ static final int ERROR_TEXT_COLOR = 0xFFFF9A9D;
+ static final int STOP_COLOR = 0xFFE5484D;
+ static final int STOP_PRESSED_COLOR = 0xFFC93C41;
+ static final int RECORDING_DOT_COLOR = 0xFFFF4D4F;
+ static final int BACKDROP_COLOR = 0xFF0B0B0C;
+ private static final int DARK_TEXT_COLOR = 0xFF16161A;
+
+ static final float BUTTON_HEIGHT_DP = 36;
+ // Fully rounded: the bar (44 dp high), its buttons (36 dp) and the circular icon buttons.
+ static final float BUTTON_RADIUS_DP = BUTTON_HEIGHT_DP / 2;
+ static final float PILL_RADIUS_DP = 22;
+ static final float ICON_DP = 18;
+
+ private GleapCaptureUi() {
+ }
+
+ /**
+ * The dark pill: #16161A at 97 % with a white 10 % hairline.
+ */
+ static GradientDrawable pill(Context context, float radiusDp) {
+ GradientDrawable background = new GradientDrawable();
+ background.setColor(PILL_COLOR);
+ background.setCornerRadius(dp(context, radiusDp));
+ background.setStroke(Math.max(1, dp(context, 1)), HAIRLINE_COLOR);
+ return background;
+ }
+
+ static TextView text(Context context, String value, float sizeSp, int color, boolean semibold) {
+ TextView view = new TextView(context);
+ view.setText(value);
+ view.setTextColor(color);
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp);
+ view.setIncludeFontPadding(false);
+ if (semibold) {
+ view.setTypeface(semibold());
+ }
+ return view;
+ }
+
+ static Typeface semibold() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ return Typeface.create(Typeface.DEFAULT, 600, false);
+ }
+ return Typeface.create("sans-serif-medium", Typeface.NORMAL);
+ }
+
+ /**
+ * The widget's color, once the SDK has its configuration; null before (then the main action
+ * is white with dark text).
+ */
+ static Integer configuredPrimaryColor() {
+ try {
+ GleapConfig config = GleapConfig.getInstance();
+ if (config.getThemedFlowConfig() == null) {
+ return null;
+ }
+ return Color.parseColor(config.getColor().trim());
+ } catch (Throwable ignore) {
+ return null;
+ }
+ }
+
+ static int primaryFill() {
+ Integer color = configuredPrimaryColor();
+ return color != null ? color : Color.WHITE;
+ }
+
+ // Dark text on light colors, white text otherwise (the web bar's rule).
+ static int onColor(int color) {
+ int brightness = (Color.red(color) * 299 + Color.green(color) * 587 + Color.blue(color) * 114) / 1000;
+ return brightness >= 160 ? DARK_TEXT_COLOR : Color.WHITE;
+ }
+
+ /**
+ * A rounded background that darkens or lightens while pressed.
+ */
+ static Drawable buttonBackground(int color, float radiusPx, int textColor) {
+ GradientDrawable shape = new GradientDrawable();
+ shape.setColor(color);
+ shape.setCornerRadius(radiusPx);
+ GradientDrawable mask = new GradientDrawable();
+ mask.setColor(Color.WHITE);
+ mask.setCornerRadius(radiusPx);
+ int pressed = textColor == DARK_TEXT_COLOR ? 0x1F000000 : 0x33FFFFFF;
+ return new RippleDrawable(ColorStateList.valueOf(pressed), shape, mask);
+ }
+
+ /**
+ * A fully rounded button with an icon and a semibold 15 sp label (36 dp high in the bar).
+ *
+ * @param icon one of the {@link Icon} kinds, or -1 for none
+ * @param iconTrailing the icon after the label (Send), else before it
+ */
+ static Button button(Context context, String label, int icon, boolean iconTrailing, int fill, float radiusDp) {
+ return new Button(context, label, icon, iconTrailing, fill, onColor(fill), dp(context, radiusDp));
+ }
+
+ /**
+ * A secondary button (white 12 %, white text), e.g. Retake.
+ *
+ * @param icon one of the {@link Icon} kinds before the label, or -1 for none
+ */
+ static Button secondaryButton(Context context, String label, int icon, float radiusDp) {
+ return new Button(context, label, icon, false, SECONDARY_COLOR, TEXT_COLOR, dp(context, radiusDp));
+ }
+
+ /**
+ * A circular 36 dp icon button (white 12 %, 20 % while pressed), announced with its label. Its
+ * view can be larger than the circle: a bigger touch target, the circle in the middle.
+ */
+ static CircleButton iconButton(Context context, int icon, String label, int widthPx, int heightPx) {
+ return new CircleButton(context, icon, ICON_DP, SECONDARY_COLOR, SECONDARY_PRESSED_COLOR, label, widthPx, heightPx);
+ }
+
+ /**
+ * Stop: a red 36 dp circle with a white rounded square.
+ */
+ static CircleButton stopButton(Context context, String label, int widthPx, int heightPx) {
+ return new CircleButton(context, Icon.STOP, 12, STOP_COLOR, STOP_PRESSED_COLOR, label, widthPx, heightPx);
+ }
+
+ // The circle, centered in a view of the given size, in its color and while pressed.
+ private static Drawable circle(int color, int pressedColor, int diameter, int width, int height) {
+ StateListDrawable states = new StateListDrawable();
+ states.setEnterFadeDuration(60);
+ states.setExitFadeDuration(120);
+ states.addState(new int[]{android.R.attr.state_pressed}, insetCircle(pressedColor, diameter, width, height));
+ states.addState(new int[0], insetCircle(color, diameter, width, height));
+ return states;
+ }
+
+ private static Drawable insetCircle(int color, int diameter, int width, int height) {
+ GradientDrawable oval = new GradientDrawable();
+ oval.setShape(GradientDrawable.OVAL);
+ oval.setColor(color);
+ int insetX = Math.max(0, (width - diameter) / 2);
+ int insetY = Math.max(0, (height - diameter) / 2);
+ return new InsetDrawable(oval, insetX, insetY, insetX, insetY);
+ }
+
+ // Clickable, focusable, announced as a button; taps are ignored while another app's window
+ // covers it.
+ private static void makeButton(View view) {
+ view.setClickable(true);
+ view.setFocusable(true);
+ view.setFilterTouchesWhenObscured(true);
+ ViewCompat.setAccessibilityDelegate(view, new AccessibilityDelegateCompat() {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ info.setClassName("android.widget.Button");
+ }
+ });
+ }
+
+ /**
+ * Enables or disables a button: a disabled one is dimmed.
+ */
+ static void setEnabled(View view, boolean enabled) {
+ if (view == null) {
+ return;
+ }
+ view.setEnabled(enabled);
+ view.setAlpha(enabled ? 1f : 0.5f);
+ }
+
+ static boolean animationsEnabled() {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.O || ValueAnimator.areAnimatorsEnabled();
+ }
+
+ static int dp(Context context, float value) {
+ return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
+ context.getResources().getDisplayMetrics()));
+ }
+
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * A pill or rounded button: an optional icon (the spinner while busy, in its place so nothing
+ * moves) and a single-line label, centered.
+ */
+ static final class Button extends LinearLayout implements Busy {
+ private final ImageView iconView;
+ private final TextView label;
+ private final int textColor;
+ private Icon icon;
+ private Spinner spinner;
+ private boolean busy;
+
+ Button(Context context, String text, int iconKind, boolean iconTrailing, int fill, int textColor, float radiusPx) {
+ super(context);
+ this.textColor = textColor;
+ setOrientation(HORIZONTAL);
+ setGravity(Gravity.CENTER);
+ setBackground(buttonBackground(fill, radiusPx, textColor));
+ int padding = dp(context, 16);
+ int iconSide = dp(context, 13);
+ setPaddingRelative(iconKind >= 0 && !iconTrailing ? iconSide : padding, 0,
+ iconKind >= 0 && iconTrailing ? iconSide : padding, 0);
+ makeButton(this);
+
+ iconView = new ImageView(context);
+ iconView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ int iconSize = dp(context, ICON_DP);
+ LayoutParams iconParams = new LayoutParams(iconSize, iconSize);
+ if (iconTrailing) {
+ iconParams.setMarginStart(dp(context, 6));
+ } else {
+ iconParams.setMarginEnd(dp(context, 6));
+ }
+ if (iconKind >= 0) {
+ icon = new Icon(iconKind, textColor);
+ iconView.setImageDrawable(icon);
+ } else {
+ iconView.setVisibility(GONE);
+ }
+
+ label = text(context, text, 15, textColor, true);
+ label.setSingleLine(true);
+ label.setEllipsize(TextUtils.TruncateAt.END);
+ label.setGravity(Gravity.CENTER);
+ LayoutParams labelParams = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ if (iconTrailing) {
+ addView(label, labelParams);
+ addView(iconView, iconParams);
+ } else {
+ addView(iconView, iconParams);
+ addView(label, labelParams);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ LayoutParams labelParams = (LayoutParams) label.getLayoutParams();
+ labelParams.weight = 0f;
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ int needed = getPaddingLeft() + getPaddingRight();
+ for (int i = 0; i < getChildCount(); i++) {
+ View child = getChildAt(i);
+ if (child.getVisibility() != GONE) {
+ LayoutParams params = (LayoutParams) child.getLayoutParams();
+ needed += child.getMeasuredWidth() + params.leftMargin + params.rightMargin;
+ }
+ }
+ if (needed > getMeasuredWidth()) {
+ // Too narrow: the label gives way (it is cut), never the icon.
+ labelParams.weight = 1f;
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+ }
+
+ void setText(CharSequence text) {
+ label.setText(text);
+ }
+
+ CharSequence getText() {
+ return label.getText();
+ }
+
+ /**
+ * Shows a spinner instead of the icon (or before the label) and ignores taps.
+ */
+ @Override
+ public void setBusy(boolean value) {
+ if (busy == value) {
+ return;
+ }
+ busy = value;
+ if (value) {
+ if (spinner == null) {
+ spinner = new Spinner(textColor, dp(getContext(), 2));
+ }
+ iconView.setImageDrawable(spinner);
+ iconView.setVisibility(VISIBLE);
+ spinner.start();
+ } else {
+ if (spinner != null) {
+ spinner.stop();
+ }
+ iconView.setImageDrawable(icon);
+ iconView.setVisibility(icon != null ? VISIBLE : GONE);
+ }
+ setClickable(!value);
+ }
+
+ boolean isBusy() {
+ return busy;
+ }
+
+ @Override
+ public boolean performClick() {
+ if (busy) {
+ return false;
+ }
+ return super.performClick();
+ }
+ }
+
+ /**
+ * A button that shows a spinner (and ignores taps) while the SDK works.
+ */
+ interface Busy {
+ void setBusy(boolean busy);
+ }
+
+ /**
+ * A circular icon button; while busy a spinner replaces the icon (nothing moves).
+ */
+ static final class CircleButton extends AppCompatImageView implements Busy {
+ private final Icon icon;
+ private final int iconSize;
+ private Spinner spinner;
+ private boolean busy;
+
+ CircleButton(Context context, int iconKind, float iconDp, int color, int pressedColor, String label,
+ int width, int height) {
+ super(context);
+ icon = new Icon(iconKind, TEXT_COLOR);
+ iconSize = dp(context, iconDp);
+ setImageDrawable(icon);
+ setScaleType(ScaleType.FIT_CENTER);
+ // The background first: its insets would replace the padding that sizes the icon.
+ setBackground(circle(color, pressedColor, dp(context, BUTTON_HEIGHT_DP), width, height));
+ setIconSize(iconSize, width, height);
+ setContentDescription(label);
+ makeButton(this);
+ }
+
+ private void setIconSize(int size, int width, int height) {
+ int horizontal = Math.max(0, (width - size) / 2);
+ int vertical = Math.max(0, (height - size) / 2);
+ setPadding(horizontal, vertical, horizontal, vertical);
+ }
+
+ @Override
+ public void setBusy(boolean value) {
+ if (busy == value) {
+ return;
+ }
+ busy = value;
+ int width = getLayoutParams() != null && getLayoutParams().width > 0 ? getLayoutParams().width : getWidth();
+ int height = getLayoutParams() != null && getLayoutParams().height > 0 ? getLayoutParams().height : getHeight();
+ if (value) {
+ if (spinner == null) {
+ spinner = new Spinner(TEXT_COLOR, dp(getContext(), 2));
+ }
+ setIconSize(dp(getContext(), ICON_DP), width, height);
+ setImageDrawable(spinner);
+ spinner.start();
+ } else {
+ if (spinner != null) {
+ spinner.stop();
+ }
+ setIconSize(iconSize, width, height);
+ setImageDrawable(icon);
+ }
+ setClickable(!value);
+ }
+
+ @Override
+ public boolean performClick() {
+ if (busy) {
+ return false;
+ }
+ return super.performClick();
+ }
+ }
+
+ /**
+ * A small ring that turns while the SDK works (static when the system turned animations off).
+ */
+ static final class Spinner extends Drawable implements Animatable {
+ private static final long TURN_MS = 800;
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final RectF oval = new RectF();
+ private boolean running;
+
+ Spinner(int color, float strokePx) {
+ paint.setColor(color);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(strokePx);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ Rect bounds = getBounds();
+ float size = Math.min(bounds.width(), bounds.height()) * 0.82f;
+ float inset = paint.getStrokeWidth() / 2;
+ oval.set(bounds.exactCenterX() - size / 2 + inset, bounds.exactCenterY() - size / 2 + inset,
+ bounds.exactCenterX() + size / 2 - inset, bounds.exactCenterY() + size / 2 - inset);
+ boolean turning = running && animationsEnabled();
+ float angle = turning ? (SystemClock.uptimeMillis() % TURN_MS) * 360f / TURN_MS : 0;
+ canvas.drawArc(oval, angle - 90, 270, false, paint);
+ if (turning && isVisible()) {
+ invalidateSelf();
+ }
+ }
+
+ @Override
+ public void start() {
+ running = true;
+ invalidateSelf();
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ }
+
+ @Override
+ public boolean isRunning() {
+ return running;
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter filter) {
+ paint.setColorFilter(filter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+ }
+
+ /**
+ * The capture UI's icons, drawn on a 24 x 24 grid like the web bar's.
+ */
+ static final class Icon extends Drawable {
+ static final int CAMERA = 0;
+ static final int RECORD = 1;
+ static final int STOP = 2;
+ static final int CLOSE = 3;
+ static final int PLAY = 4;
+ static final int SEND = 5;
+ static final int RETAKE = 6;
+
+ // The web SDK's icons (Lucide "send" and "rotate-ccw"), as SVG path data on a 24 x 24 grid.
+ private static final Path SEND_PATH = SvgPath.parse("M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63"
+ + "l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.11 1.11zM21.85 2.15l-10.94 10.94");
+ private static final Path RETAKE_PATH = SvgPath.parse("M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5");
+
+ private final int kind;
+ private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path path = new Path();
+ private final RectF rect = new RectF();
+
+ Icon(int kind, int color) {
+ this.kind = kind;
+ // Points the other way right to left.
+ setAutoMirrored(kind == SEND);
+ stroke.setColor(color);
+ stroke.setStyle(Paint.Style.STROKE);
+ stroke.setStrokeWidth(1.8f);
+ stroke.setStrokeCap(Paint.Cap.ROUND);
+ stroke.setStrokeJoin(Paint.Join.ROUND);
+ fill.setColor(color);
+ fill.setStyle(Paint.Style.FILL);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ Rect bounds = getBounds();
+ float scale = Math.min(bounds.width(), bounds.height()) / 24f;
+ if (scale <= 0) {
+ return;
+ }
+ int save = canvas.save();
+ if (isAutoMirrored() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ && getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
+ canvas.scale(-1f, 1f, bounds.exactCenterX(), bounds.exactCenterY());
+ }
+ canvas.translate(bounds.exactCenterX() - 12 * scale, bounds.exactCenterY() - 12 * scale);
+ canvas.scale(scale, scale);
+ switch (kind) {
+ case CAMERA:
+ path.reset();
+ path.moveTo(4, 8);
+ path.lineTo(7, 8);
+ path.lineTo(8.6f, 5.6f);
+ path.quadTo(9f, 5f, 9.9f, 5f);
+ path.lineTo(14.1f, 5f);
+ path.quadTo(15f, 5f, 15.4f, 5.6f);
+ path.lineTo(17, 8);
+ path.lineTo(20, 8);
+ path.quadTo(21, 8, 21, 9);
+ path.lineTo(21, 18);
+ path.quadTo(21, 19, 20, 19);
+ path.lineTo(4, 19);
+ path.quadTo(3, 19, 3, 18);
+ path.lineTo(3, 9);
+ path.quadTo(3, 8, 4, 8);
+ path.close();
+ canvas.drawPath(path, stroke);
+ canvas.drawCircle(12, 13, 3.4f, stroke);
+ break;
+ case RECORD:
+ canvas.drawCircle(12, 12, 8, stroke);
+ canvas.drawCircle(12, 12, 3.6f, fill);
+ break;
+ case STOP:
+ // The whole box: a rounded square (corners 2.5 / 12 of its size).
+ rect.set(0, 0, 24, 24);
+ canvas.drawRoundRect(rect, 5f, 5f, fill);
+ break;
+ case CLOSE:
+ stroke.setStrokeWidth(2f);
+ canvas.drawLine(7, 7, 17, 17, stroke);
+ canvas.drawLine(17, 7, 7, 17, stroke);
+ stroke.setStrokeWidth(1.8f);
+ break;
+ case PLAY:
+ path.reset();
+ path.moveTo(8.5f, 6.2f);
+ path.lineTo(18.2f, 12f);
+ path.lineTo(8.5f, 17.8f);
+ path.close();
+ canvas.drawPath(path, fill);
+ stroke.setStrokeWidth(1.6f);
+ canvas.drawPath(path, stroke);
+ stroke.setStrokeWidth(1.8f);
+ break;
+ case SEND:
+ canvas.drawPath(SEND_PATH, stroke);
+ break;
+ case RETAKE:
+ canvas.drawPath(RETAKE_PATH, stroke);
+ break;
+ default:
+ break;
+ }
+ canvas.restoreToCount(save);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ stroke.setAlpha(alpha);
+ fill.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter filter) {
+ stroke.setColorFilter(filter);
+ fill.setColorFilter(filter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+ }
+
+ /**
+ * The drag handle: six dots, two columns of three, white 45 %.
+ */
+ static final class Grip extends View {
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final float radius;
+ private final float spacing;
+
+ Grip(Context context) {
+ super(context);
+ paint.setColor(GRIP_COLOR);
+ radius = dp(context, 1.5f);
+ spacing = dp(context, 5.5f);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ float cx = getWidth() / 2f;
+ float cy = getHeight() / 2f;
+ for (int column = -1; column <= 1; column += 2) {
+ for (int row = -1; row <= 1; row++) {
+ canvas.drawCircle(cx + column * spacing / 2, cy + row * spacing, radius, paint);
+ }
+ }
+ }
+ }
+
+ /**
+ * Builds a {@link Path} from SVG path data: M, L, H, V, C, Q, A and Z, absolute and relative
+ * (what the capture icons use).
+ */
+ static final class SvgPath {
+ private final String data;
+ private int index;
+
+ private SvgPath(String data) {
+ this.data = data;
+ }
+
+ static Path parse(String data) {
+ Path path = new Path();
+ try {
+ new SvgPath(data).build(path);
+ } catch (Throwable error) {
+ GleapLog.w("Could not read an icon", error);
+ }
+ return path;
+ }
+
+ private void build(Path path) {
+ char command = 0;
+ float x = 0;
+ float y = 0;
+ float startX = 0;
+ float startY = 0;
+ while (true) {
+ skipSeparators();
+ if (index >= data.length()) {
+ return;
+ }
+ char next = data.charAt(index);
+ if (Character.isLetter(next)) {
+ command = next;
+ index++;
+ } else if (command == 0) {
+ return;
+ }
+ boolean relative = Character.isLowerCase(command);
+ float baseX = relative ? x : 0;
+ float baseY = relative ? y : 0;
+ switch (Character.toUpperCase(command)) {
+ case 'M':
+ x = baseX + number();
+ y = baseY + number();
+ path.moveTo(x, y);
+ startX = x;
+ startY = y;
+ // Further pairs are lines.
+ command = relative ? 'l' : 'L';
+ break;
+ case 'L':
+ x = baseX + number();
+ y = baseY + number();
+ path.lineTo(x, y);
+ break;
+ case 'H':
+ x = baseX + number();
+ path.lineTo(x, y);
+ break;
+ case 'V':
+ y = baseY + number();
+ path.lineTo(x, y);
+ break;
+ case 'C': {
+ float x1 = baseX + number();
+ float y1 = baseY + number();
+ float x2 = baseX + number();
+ float y2 = baseY + number();
+ x = baseX + number();
+ y = baseY + number();
+ path.cubicTo(x1, y1, x2, y2, x, y);
+ break;
+ }
+ case 'Q': {
+ float x1 = baseX + number();
+ float y1 = baseY + number();
+ x = baseX + number();
+ y = baseY + number();
+ path.quadTo(x1, y1, x, y);
+ break;
+ }
+ case 'A': {
+ float rx = number();
+ float ry = number();
+ float rotation = number();
+ boolean largeArc = flag();
+ boolean sweep = flag();
+ float endX = baseX + number();
+ float endY = baseY + number();
+ arcTo(path, x, y, rx, ry, rotation, largeArc, sweep, endX, endY);
+ x = endX;
+ y = endY;
+ break;
+ }
+ case 'Z':
+ path.close();
+ x = startX;
+ y = startY;
+ // Z takes no numbers: the next letter follows.
+ command = 0;
+ break;
+ default:
+ return;
+ }
+ }
+ }
+
+ private void skipSeparators() {
+ while (index < data.length()) {
+ char c = data.charAt(index);
+ if (c == ',' || Character.isWhitespace(c)) {
+ index++;
+ } else {
+ return;
+ }
+ }
+ }
+
+ private boolean flag() {
+ skipSeparators();
+ char c = data.charAt(index++);
+ return c == '1';
+ }
+
+ // A number: "-.64", ".5" (".5.5" is two numbers), "1e-3".
+ private float number() {
+ skipSeparators();
+ int start = index;
+ if (index < data.length() && (data.charAt(index) == '-' || data.charAt(index) == '+')) {
+ index++;
+ }
+ boolean dot = false;
+ while (index < data.length()) {
+ char c = data.charAt(index);
+ if (Character.isDigit(c)) {
+ index++;
+ } else if (c == '.' && !dot) {
+ dot = true;
+ index++;
+ } else if ((c == 'e' || c == 'E') && index + 1 < data.length()) {
+ index++;
+ if (data.charAt(index) == '-' || data.charAt(index) == '+') {
+ index++;
+ }
+ } else {
+ break;
+ }
+ }
+ return Float.parseFloat(data.substring(start, index));
+ }
+
+ // An elliptical arc (SVG implementation notes, F.6.5) as cubic curves of at most 90°.
+ private static void arcTo(Path path, float fromX, float fromY, float rx, float ry, float rotation,
+ boolean largeArc, boolean sweep, float toX, float toY) {
+ if (fromX == toX && fromY == toY) {
+ return;
+ }
+ if (rx == 0 || ry == 0) {
+ path.lineTo(toX, toY);
+ return;
+ }
+ double radiusX = Math.abs(rx);
+ double radiusY = Math.abs(ry);
+ double phi = Math.toRadians(rotation % 360);
+ double cos = Math.cos(phi);
+ double sin = Math.sin(phi);
+ double halfX = (fromX - toX) / 2.0;
+ double halfY = (fromY - toY) / 2.0;
+ double x1 = cos * halfX + sin * halfY;
+ double y1 = -sin * halfX + cos * halfY;
+ double lambda = (x1 * x1) / (radiusX * radiusX) + (y1 * y1) / (radiusY * radiusY);
+ if (lambda > 1) {
+ radiusX *= Math.sqrt(lambda);
+ radiusY *= Math.sqrt(lambda);
+ }
+ double rx2 = radiusX * radiusX;
+ double ry2 = radiusY * radiusY;
+ double numerator = rx2 * ry2 - rx2 * y1 * y1 - ry2 * x1 * x1;
+ double denominator = rx2 * y1 * y1 + ry2 * x1 * x1;
+ double coefficient = (largeArc == sweep ? -1 : 1) * Math.sqrt(Math.max(0, numerator / denominator));
+ double centerX1 = coefficient * radiusX * y1 / radiusY;
+ double centerY1 = -coefficient * radiusY * x1 / radiusX;
+ double centerX = cos * centerX1 - sin * centerY1 + (fromX + toX) / 2.0;
+ double centerY = sin * centerX1 + cos * centerY1 + (fromY + toY) / 2.0;
+ double ux = (x1 - centerX1) / radiusX;
+ double uy = (y1 - centerY1) / radiusY;
+ double vx = (-x1 - centerX1) / radiusX;
+ double vy = (-y1 - centerY1) / radiusY;
+ double start = Math.atan2(uy, ux);
+ double sweepAngle = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
+ if (!sweep && sweepAngle > 0) {
+ sweepAngle -= 2 * Math.PI;
+ } else if (sweep && sweepAngle < 0) {
+ sweepAngle += 2 * Math.PI;
+ }
+ int segments = Math.max(1, (int) Math.ceil(Math.abs(sweepAngle) / (Math.PI / 2) - 1e-9));
+ double step = sweepAngle / segments;
+ double handle = 4.0 / 3.0 * Math.tan(step / 4);
+ double angle = start;
+ for (int i = 0; i < segments; i++) {
+ double cos1 = Math.cos(angle);
+ double sin1 = Math.sin(angle);
+ double next = angle + step;
+ double cos2 = Math.cos(next);
+ double sin2 = Math.sin(next);
+ double[] c1 = point(cos1 - handle * sin1, sin1 + handle * cos1, radiusX, radiusY, cos, sin, centerX, centerY);
+ double[] c2 = point(cos2 + handle * sin2, sin2 - handle * cos2, radiusX, radiusY, cos, sin, centerX, centerY);
+ double[] end = i == segments - 1 ? new double[]{toX, toY}
+ : point(cos2, sin2, radiusX, radiusY, cos, sin, centerX, centerY);
+ path.cubicTo((float) c1[0], (float) c1[1], (float) c2[0], (float) c2[1], (float) end[0], (float) end[1]);
+ angle = next;
+ }
+ }
+
+ // A point of the unit circle on the ellipse.
+ private static double[] point(double ux, double uy, double radiusX, double radiusY, double cos, double sin,
+ double centerX, double centerY) {
+ double x = radiusX * ux;
+ double y = radiusY * uy;
+ return new double[]{cos * x - sin * y + centerX, sin * x + cos * y + centerY};
+ }
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapDetectorUtil.java b/gleap/src/main/java/io/gleap/GleapDetectorUtil.java
index 6c7bc0f9..c7378111 100644
--- a/gleap/src/main/java/io/gleap/GleapDetectorUtil.java
+++ b/gleap/src/main/java/io/gleap/GleapDetectorUtil.java
@@ -14,6 +14,32 @@ class GleapDetectorUtil {
public static void resumeAllDetectors() {
widgetOpen = false;
+ if (GleapCaptureCoordinator.isSessionActive()) {
+ // The widget closed for a capture: the activation methods wait until it is done.
+ return;
+ }
+ for (GleapDetector detector : GleapConfig.getInstance().getGestureDetectors()) {
+ detector.resume();
+ }
+ }
+
+ /**
+ * Pauses the activation methods (and replays) while a capture runs, without marking the
+ * widget open.
+ */
+ static void pauseDetectorsForCapture() {
+ for (GleapDetector detector : GleapConfig.getInstance().getGestureDetectors()) {
+ detector.pause();
+ }
+ }
+
+ /**
+ * Resumes them after a capture, unless the widget is open.
+ */
+ static void resumeDetectorsAfterCapture() {
+ if (widgetOpen) {
+ return;
+ }
for (GleapDetector detector : GleapConfig.getInstance().getGestureDetectors()) {
detector.resume();
}
diff --git a/gleap/src/main/java/io/gleap/GleapEventService.java b/gleap/src/main/java/io/gleap/GleapEventService.java
index 566cbcc7..fd597270 100644
--- a/gleap/src/main/java/io/gleap/GleapEventService.java
+++ b/gleap/src/main/java/io/gleap/GleapEventService.java
@@ -12,6 +12,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -28,12 +30,20 @@ class GleapEventService {
// One ping carries the oldest events up to these limits; the rest follows right after.
static final int MAX_EVENTS_PER_PING = 100;
static final int MAX_PING_BYTES = 256 * 1024;
+ // A ping answer bigger than this is not read for capture requests.
+ private static final int MAX_PING_ANSWER_BYTES = 64 * 1024;
+ // In-app messages that arrive while the customer captures the app wait (at most this many,
+ // this long) until the capture is over and the widget is closed.
+ private static final int MAX_DEFERRED_ACTIONS = 20;
+ private static final long DEFERRED_ACTIONS_TTL_MS = 10 * 60 * 1000;
// Created with the class: getInstance() is called from several threads.
private static volatile GleapEventService instance = new GleapEventService();
private static GleapWebSocketListener webSocketListener;
private boolean disableInAppNotifications = false;
private final GleapEventQueue eventQueue = new GleapEventQueue();
+ // {received at (elapsed ms), actions}: in-app messages held back during a capture. Guarded by itself.
+ private final List deferredActions = new ArrayList<>();
private final GleapPingBackoff backoff = new GleapPingBackoff();
// A ping is waiting for the request thread or in flight: never more than one.
private final AtomicBoolean pingInFlight = new AtomicBoolean();
@@ -220,6 +230,46 @@ private void clearWebsocketListener() {
}
}
+ private void deferActions(JSONArray actions) {
+ synchronized (deferredActions) {
+ while (deferredActions.size() >= MAX_DEFERRED_ACTIONS) {
+ deferredActions.remove(0);
+ }
+ deferredActions.add(new Object[]{SystemClock.elapsedRealtime(), actions});
+ }
+ }
+
+ /**
+ * Shows the in-app messages held back during a capture, once no capture runs and the widget
+ * is closed (main thread). Messages older than 10 minutes are dropped.
+ */
+ void processDeferredActions() {
+ try {
+ if (GleapCaptureCoordinator.isSessionActive() || GleapCaptureCoordinator.isWidgetClosedForCapture()
+ || Gleap.getInstance().isOpened()) {
+ // Later: when the capture ends or the widget closes.
+ return;
+ }
+ List ready;
+ synchronized (deferredActions) {
+ if (deferredActions.isEmpty()) {
+ return;
+ }
+ ready = new ArrayList<>(deferredActions);
+ deferredActions.clear();
+ }
+ long now = SystemClock.elapsedRealtime();
+ for (Object[] entry : ready) {
+ if (now - (Long) entry[0] > DEFERRED_ACTIONS_TTL_MS) {
+ continue;
+ }
+ processEventData(new JSONObject().put("a", entry[1]));
+ }
+ } catch (Exception error) {
+ GleapLog.w("Could not show the held back in-app messages", error);
+ }
+ }
+
public void addEvent(JSONObject event) {
eventQueue.add(event);
}
@@ -407,17 +457,59 @@ static PingResponse postEvents(GleapSession session, JSONArray events) throws IO
body.put("opened", Gleap.getInstance().isOpened());
body.put("ws", true);
body.put("sdkVersion", BuildConfig.VERSION_NAME);
+ // What the SDK can capture for capture requests (screenshot, recording, logs).
+ body.put("caps", GleapCapture.currentCapsJson());
GleapHttp.writeJson(conn, body);
int status = conn.getResponseCode();
String retryAfter = conn.getHeaderField("Retry-After");
- closeBody(conn, status);
+ if (GleapHttp.isSuccess(status)) {
+ readCaptureRequests(conn);
+ } else {
+ closeBody(conn, status);
+ }
return new PingResponse(status, retryAfter);
} finally {
conn.disconnect();
}
}
+ // Without a WebSocket the log requests come with the ping answer: {"cr": [...]}.
+ private static void readCaptureRequests(HttpURLConnection conn) {
+ InputStream stream = null;
+ try {
+ stream = conn.getInputStream();
+ if (stream == null) {
+ return;
+ }
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int read;
+ while ((read = stream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ if (out.size() > MAX_PING_ANSWER_BYTES) {
+ return;
+ }
+ }
+ String text = new String(out.toByteArray(), java.nio.charset.StandardCharsets.UTF_8).trim();
+ if (!text.startsWith("{")) {
+ return;
+ }
+ JSONArray requests = new JSONObject(text).optJSONArray("cr");
+ if (requests != null && requests.length() > 0) {
+ GleapCaptureLogs.getInstance().onCaptureRequests(requests);
+ }
+ } catch (Exception ignore) {
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (Exception ignore) {
+ }
+ }
+ }
+ }
+
private static void closeBody(HttpURLConnection conn, int status) {
try {
InputStream body = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
@@ -544,6 +636,12 @@ public void run() {
}
if (data.has("a") && data.get("a") instanceof JSONArray) {
+ // Nothing pops up while the customer captures the app: the messages wait until the
+ // capture is over (the server marked them sent).
+ if (GleapCaptureCoordinator.isSessionActive() || GleapCaptureCoordinator.isWidgetClosedForCapture()) {
+ deferActions(data.getJSONArray("a"));
+ return;
+ }
if (Gleap.getInstance().isOpened()) {
return;
}
diff --git a/gleap/src/main/java/io/gleap/GleapFrameRecorder.java b/gleap/src/main/java/io/gleap/GleapFrameRecorder.java
new file mode 100644
index 00000000..cd48c776
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapFrameRecorder.java
@@ -0,0 +1,819 @@
+package io.gleap;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.Surface;
+import android.view.View;
+
+import java.io.File;
+import java.lang.ref.WeakReference;
+import java.nio.ByteBuffer;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Records the app's windows into an H.264 MP4 (API 26+): the main thread captures a frame
+ * about 4 times a second (2 to 8, see {@link GleapCaptureGeometry#nextFrameInterval}) with
+ * {@link GleapWindowCapture} at the recording's scale; the frames are drawn onto the encoder's
+ * input surface and muxed on a background thread. No audio.
+ *
+ * The output size is fixed when the recording starts (long edge at most 1280, even); after a
+ * rotation or a window resize the frames are letterboxed into it. While no activity of the app
+ * is shown the recording pauses, and the pause is left out of the video.
+ */
+@TargetApi(Build.VERSION_CODES.O)
+final class GleapFrameRecorder {
+ interface Listener {
+ /**
+ * The recording ended (main thread).
+ *
+ * @param recorder the recorder it came from (results of an earlier one are stale)
+ */
+ void onRecordingFinished(GleapFrameRecorder recorder, Result result);
+ }
+
+ static final class Result {
+ final File file;
+ final boolean success;
+ final long durationMs;
+ final int width;
+ final int height;
+ final Date startedAt;
+ final Date endedAt;
+ final String error;
+ // Stopped before the first frame was encoded (nothing went wrong).
+ final boolean empty;
+
+ Result(File file, boolean success, long durationMs, int width, int height, Date startedAt, Date endedAt,
+ String error, boolean empty) {
+ this.file = file;
+ this.success = success;
+ this.durationMs = durationMs;
+ this.width = width;
+ this.height = height;
+ this.startedAt = startedAt;
+ this.endedAt = endedAt;
+ this.error = error;
+ this.empty = empty;
+ }
+ }
+
+ private static final String MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC;
+ private static final int BIT_RATE = 1000000;
+ // The fastest adaptive rate: the bit budget per frame never exceeds ~1 Mbps.
+ private static final int FRAME_RATE = 8;
+ // The SDK asks for a key frame every 2 s of video (at any frame rate); the encoder's own
+ // interval is only a backstop, so the two do not double up.
+ private static final long KEY_FRAME_INTERVAL_US = 2000000L;
+ private static final int ENCODER_KEY_FRAME_INTERVAL_SEC = 10;
+ // One frame at a time; a second one only when the first seems lost (its copies did not come
+ // back for this long), and never more: a stuck encoder never piles up frames.
+ private static final long FRAME_STALL_MS = 3000;
+ private static final int MAX_PENDING_FRAMES = 2;
+ private static final long DRAIN_TIMEOUT_US = 10000;
+ private static final long END_OF_STREAM_TIMEOUT_MS = 3000;
+ // Encoder timestamps further off the clock than this are replaced by the SDK's own.
+ private static final long CLOCK_TOLERANCE_US = 5000000;
+ // Frames the encoder may hold before it outputs them (it never holds this many).
+ private static final int MAX_PENDING_TIMESTAMPS = 60;
+
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final GleapWindowCapture.BitmapPool pool = new GleapWindowCapture.BitmapPool();
+ private final GleapWindowCapture.MaskHistory maskHistory = new GleapWindowCapture.MaskHistory();
+ private final File file;
+ private final long maxDurationMs;
+ private final Listener listener;
+ private final Set excludedRoots;
+
+ // Main thread.
+ private WeakReference activity = new WeakReference<>(null);
+ private HandlerThread thread;
+ private Handler encoderHandler;
+ private boolean running;
+ private boolean paused;
+ private long intervalMs = GleapCaptureGeometry.TARGET_FRAME_INTERVAL_MS;
+ private long recordedMs;
+ private long lastTickAt;
+ // Frames captured and not encoded yet, and when the last one was started.
+ private int framesPending;
+ private long lastFrameStartedAt;
+ // What the last finished frame cost on the main thread (including a redraw after a failed copy).
+ private long lastFrameMainThreadMs;
+ private boolean keptUp = true;
+ private Date startedAt;
+ private volatile int outputWidth;
+ private volatile int outputHeight;
+
+ // Encoder thread.
+ private MediaCodec codec;
+ private Surface inputSurface;
+ private MediaMuxer muxer;
+ private int track = -1;
+ private boolean muxerStarted;
+ private boolean softwareCanvas;
+ private boolean ownClock;
+ private boolean clockChecked;
+ private long firstTimeUs = -1;
+ private long lastTimeUs = -1;
+ private long previousTimeUs = -1;
+ private long lastKeyFrameUs = Long.MIN_VALUE;
+ // A key frame was asked for and has not come out yet: not asked for again meanwhile.
+ private boolean keyFrameRequested;
+ private int framesWritten;
+ private volatile boolean released;
+ // Given up (the encoder did not finish in time): its result never reaches the listener.
+ private volatile boolean abandoned;
+ // stop() queued finishing the file on the encoder thread (main thread only).
+ private boolean finishQueued;
+ private boolean muxerStopped;
+ private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+ // When each submitted frame was posted (µs, monotonic): the timestamps when the encoder's are off.
+ private final ArrayDeque submittedAt = new ArrayDeque<>();
+ // Pauses as {start, end} in µs of the monotonic clock. Guarded by itself.
+ private final List pauses = new ArrayList<>();
+ private long pauseStartedUs = -1;
+
+ GleapFrameRecorder(File file, int maxDurationSec, Set excludedRoots, Listener listener) {
+ this.file = file;
+ this.maxDurationMs = maxDurationSec * 1000L;
+ this.excludedRoots = excludedRoots;
+ this.listener = listener;
+ }
+
+ /**
+ * Starts recording the activity (main thread).
+ *
+ * @return false when there is nothing to record (no laid out window)
+ */
+ boolean start(Activity current) {
+ View decor = current != null && current.getWindow() != null ? current.getWindow().peekDecorView() : null;
+ if (decor == null || decor.getWidth() <= 0 || decor.getHeight() <= 0) {
+ return false;
+ }
+ final int areaWidth = decor.getWidth();
+ final int areaHeight = decor.getHeight();
+ int[] planned = GleapCaptureGeometry.encoderSize(areaWidth, areaHeight, GleapCaptureGeometry.RECORDING_MAX_EDGE, 2);
+ outputWidth = planned[0];
+ outputHeight = planned[1];
+
+ activity = new WeakReference<>(current);
+ thread = new HandlerThread("gleap-capture-encoder");
+ thread.start();
+ encoderHandler = new Handler(thread.getLooper());
+ encoderHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ prepareEncoder(areaWidth, areaHeight);
+ }
+ });
+
+ startedAt = new Date();
+ running = true;
+ lastTickAt = SystemClock.uptimeMillis();
+ main.post(tick);
+ return true;
+ }
+
+ /**
+ * The activity to record; null while none is shown (the recording pauses).
+ */
+ void setActivity(Activity current) {
+ activity = new WeakReference<>(current);
+ }
+
+ /**
+ * The recorded time so far, without pauses.
+ */
+ long recordedMs() {
+ return recordedMs;
+ }
+
+ boolean isRunning() {
+ return running;
+ }
+
+ /**
+ * Stops and finishes the file; the listener gets the result (main thread).
+ */
+ void stop() {
+ if (!running) {
+ return;
+ }
+ running = false;
+ main.removeCallbacks(tick);
+ endPause();
+ final long duration = recordedMs;
+ final Date endedAt = new Date();
+ final Date started = startedAt;
+ finishQueued = true;
+ encoderHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ final Result result = finish(duration, started, endedAt);
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ if (abandoned) {
+ deleteFile();
+ return;
+ }
+ try {
+ listener.onRecordingFinished(GleapFrameRecorder.this, result);
+ } catch (Throwable error) {
+ GleapErrors.report(error, "onRecordingFinished");
+ }
+ }
+ });
+ }
+ });
+ thread.quitSafely();
+ }
+
+ /**
+ * Gives up on a recording whose encoder did not finish in time (main thread): the file is
+ * deleted now and no result follows. The encoder, its surface and the muxer are released on the
+ * encoder thread when it gets to them: never on the main thread, and never while that thread
+ * may still use them (a release from another thread could crash in native code).
+ */
+ void abandon() {
+ abandoned = true;
+ running = false;
+ main.removeCallbacks(tick);
+ // The muxer may still write to the file: deleted, its data is freed once it is closed.
+ deleteFile();
+ if (encoderHandler != null && !finishQueued) {
+ // Not stopped (nothing is queued to finish the file): released like a cancel.
+ cancel();
+ }
+ }
+
+ /**
+ * Stops without a result and deletes the file (main thread).
+ */
+ void cancel() {
+ running = false;
+ main.removeCallbacks(tick);
+ if (encoderHandler == null) {
+ return;
+ }
+ encoderHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ releaseEncoder();
+ releaseMuxer();
+ deleteFile();
+ pool.clear();
+ }
+ });
+ thread.quitSafely();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Main thread: capturing
+
+ private final Runnable tick = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ onTick();
+ } catch (Throwable error) {
+ GleapLog.w("A recording frame failed", error);
+ if (running) {
+ main.postDelayed(tick, intervalMs);
+ }
+ }
+ }
+ };
+
+ private void onTick() {
+ if (!running) {
+ return;
+ }
+ long now = SystemClock.uptimeMillis();
+ Activity current = activity.get();
+ if (current == null || current.isFinishing()) {
+ // The app is in the background (or between two activities): pause.
+ if (!paused) {
+ paused = true;
+ recordedMs += now - lastTickAt;
+ startPause();
+ }
+ lastTickAt = now;
+ main.postDelayed(tick, intervalMs);
+ return;
+ }
+ if (paused) {
+ paused = false;
+ endPause();
+ } else {
+ recordedMs += now - lastTickAt;
+ }
+ lastTickAt = now;
+
+ if (recordedMs >= maxDurationMs) {
+ stop();
+ return;
+ }
+
+ if (framesPending > 0 && (now - lastFrameStartedAt < FRAME_STALL_MS || framesPending >= MAX_PENDING_FRAMES)) {
+ // The last frame is still being copied or encoded: skip this one.
+ keptUp = false;
+ main.postDelayed(tick, intervalMs);
+ return;
+ }
+
+ framesPending++;
+ lastFrameStartedAt = now;
+ GleapWindowCapture.capture(current, captureScale(current), excludedRoots, encoderHandler, pool, maskHistory,
+ new GleapWindowCapture.Callback() {
+ @Override
+ public void onFrame(GleapWindowCapture.Frame frame) {
+ onFrameCaptured(frame);
+ }
+ });
+ // What frames cost on the main thread: this one so far, or the last finished one with a
+ // redraw after a failed copy.
+ long mainThreadMs = Math.max(SystemClock.uptimeMillis() - now, lastFrameMainThreadMs);
+ intervalMs = GleapCaptureGeometry.nextFrameInterval(intervalMs, mainThreadMs, keptUp);
+ keptUp = true;
+ // Hard cap: frames never take more than 30 % of the main thread. Below 2 fps a costly
+ // frame pushes the next one further out (frames are skipped).
+ main.postDelayed(tick, GleapCaptureGeometry.nextFrameDelay(intervalMs, mainThreadMs));
+ }
+
+ // Frames are captured at the size they are encoded at.
+ private float captureScale(Activity current) {
+ View decor = current.getWindow() != null ? current.getWindow().peekDecorView() : null;
+ if (decor == null || decor.getWidth() <= 0 || decor.getHeight() <= 0) {
+ return 1f;
+ }
+ float scale = Math.min(outputWidth / (float) decor.getWidth(), outputHeight / (float) decor.getHeight());
+ return Math.max(0.05f, Math.min(1f, scale));
+ }
+
+ private void startPause() {
+ synchronized (pauses) {
+ pauseStartedUs = nowUs();
+ }
+ }
+
+ private void endPause() {
+ synchronized (pauses) {
+ if (pauseStartedUs >= 0) {
+ pauses.add(new long[]{pauseStartedUs, nowUs()});
+ pauseStartedUs = -1;
+ }
+ }
+ }
+
+ private static long nowUs() {
+ return System.nanoTime() / 1000;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Encoder thread
+
+ private void onFrameCaptured(GleapWindowCapture.Frame frame) {
+ final long frameMainThreadMs = frame != null ? frame.mainThreadMs : -1;
+ try {
+ if (frame != null && !released && codec != null) {
+ encode(frame);
+ }
+ } catch (Throwable error) {
+ GleapLog.w("Could not encode a recording frame", error);
+ } finally {
+ if (frame != null) {
+ frame.release(released ? null : pool);
+ }
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ framesPending = Math.max(0, framesPending - 1);
+ if (frameMainThreadMs >= 0) {
+ lastFrameMainThreadMs = frameMainThreadMs;
+ }
+ }
+ });
+ }
+ }
+
+ private void prepareEncoder(int areaWidth, int areaHeight) {
+ try {
+ codec = createEncoder(areaWidth, areaHeight);
+ muxer = new MediaMuxer(file.getAbsolutePath(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+ softwareCanvas = prefersSoftwareCanvas();
+ } catch (Throwable error) {
+ GleapLog.w("Could not start the recording encoder", error);
+ releaseEncoder();
+ main.post(new Runnable() {
+ @Override
+ public void run() {
+ failEarly("The video encoder could not be started.");
+ }
+ });
+ }
+ }
+
+ // The encoder did not start: the recording ends right away.
+ private void failEarly(final String error) {
+ if (!running) {
+ return;
+ }
+ running = false;
+ main.removeCallbacks(tick);
+ final Date started = startedAt;
+ encoderHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ released = true;
+ releaseMuxer();
+ deleteFile();
+ pool.clear();
+ }
+ });
+ thread.quitSafely();
+ try {
+ listener.onRecordingFinished(this, new Result(file, false, 0, outputWidth, outputHeight, started, new Date(), error, false));
+ } catch (Throwable reportError) {
+ GleapErrors.report(reportError, "onRecordingFinished");
+ }
+ }
+
+ /**
+ * The device's default H.264 encoder, else any other (software ones first) that accepts
+ * the recording's size.
+ */
+ private MediaCodec createEncoder(int areaWidth, int areaHeight) throws Exception {
+ String triedName = null;
+ try {
+ MediaCodec encoder = MediaCodec.createEncoderByType(MIME_TYPE);
+ triedName = encoder.getName();
+ if (startEncoder(encoder, areaWidth, areaHeight)) {
+ return encoder;
+ }
+ } catch (Throwable error) {
+ GleapLog.w("The default video encoder is not available", error);
+ }
+
+ List candidates = new ArrayList<>();
+ for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.REGULAR_CODECS).getCodecInfos()) {
+ if (info.isEncoder() && supports(info, MIME_TYPE) && !info.getName().equals(triedName)) {
+ candidates.add(info);
+ }
+ }
+ // Software encoders first: the hardware one (the default) already failed.
+ List ordered = new ArrayList<>();
+ for (MediaCodecInfo info : candidates) {
+ if (isSoftware(info)) {
+ ordered.add(info);
+ }
+ }
+ for (MediaCodecInfo info : candidates) {
+ if (!isSoftware(info)) {
+ ordered.add(info);
+ }
+ }
+ for (MediaCodecInfo info : ordered) {
+ try {
+ MediaCodec encoder = MediaCodec.createByCodecName(info.getName());
+ if (startEncoder(encoder, areaWidth, areaHeight)) {
+ return encoder;
+ }
+ } catch (Throwable error) {
+ GleapLog.w("The video encoder " + info.getName() + " is not available", error);
+ }
+ }
+ throw new IllegalStateException("No H.264 encoder could be started");
+ }
+
+ private boolean startEncoder(MediaCodec encoder, int areaWidth, int areaHeight) {
+ Surface surface = null;
+ try {
+ int[] size = encoderSize(encoder, areaWidth, areaHeight);
+ MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, size[0], size[1]);
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
+ format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
+ format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, ENCODER_KEY_FRAME_INTERVAL_SEC);
+ encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ surface = encoder.createInputSurface();
+ encoder.start();
+ inputSurface = surface;
+ outputWidth = size[0];
+ outputHeight = size[1];
+ return true;
+ } catch (Throwable error) {
+ GleapLog.w("Could not configure the video encoder " + safeName(encoder), error);
+ if (surface != null) {
+ try {
+ surface.release();
+ } catch (Throwable ignore) {
+ }
+ }
+ try {
+ encoder.release();
+ } catch (Throwable ignore) {
+ }
+ return false;
+ }
+ }
+
+ // The size the encoder takes: its alignment, and a smaller size if it cannot do the planned one.
+ private static int[] encoderSize(MediaCodec encoder, int areaWidth, int areaHeight) {
+ int alignment = 2;
+ MediaCodecInfo.VideoCapabilities video = null;
+ try {
+ video = encoder.getCodecInfo().getCapabilitiesForType(MIME_TYPE).getVideoCapabilities();
+ alignment = Math.max(alignment, Math.max(video.getWidthAlignment(), video.getHeightAlignment()));
+ } catch (Throwable ignore) {
+ }
+ int[] size = GleapCaptureGeometry.encoderSize(areaWidth, areaHeight, GleapCaptureGeometry.RECORDING_MAX_EDGE, alignment);
+ if (video == null) {
+ return size;
+ }
+ int[] limits = {GleapCaptureGeometry.RECORDING_MAX_EDGE, 960, 720, 640};
+ for (int limit : limits) {
+ for (int align : new int[]{alignment, 16}) {
+ int[] candidate = GleapCaptureGeometry.encoderSize(areaWidth, areaHeight, limit, align);
+ try {
+ if (video.isSizeSupported(candidate[0], candidate[1])) {
+ return candidate;
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+ }
+ return size;
+ }
+
+ private static boolean supports(MediaCodecInfo info, String mimeType) {
+ for (String type : info.getSupportedTypes()) {
+ if (type.equalsIgnoreCase(mimeType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isSoftware(MediaCodecInfo info) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return info.isSoftwareOnly();
+ }
+ String name = info.getName().toLowerCase(Locale.ROOT);
+ return name.startsWith("omx.google.") || name.startsWith("c2.android.");
+ }
+
+ private static String safeName(MediaCodec encoder) {
+ try {
+ return encoder.getName();
+ } catch (Throwable error) {
+ return "";
+ }
+ }
+
+ // The hardware canvas draws garbage on the encoder surface of some devices.
+ private static boolean prefersSoftwareCanvas() {
+ String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toLowerCase(Locale.ROOT) : "";
+ if (manufacturer.contains("xiaomi") || manufacturer.contains("motorola")) {
+ return true;
+ }
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && "T606".equalsIgnoreCase(Build.SOC_MODEL);
+ }
+
+ private void encode(GleapWindowCapture.Frame frame) {
+ if (inputSurface == null || !frame.hasContent()) {
+ return;
+ }
+ requestKeyFrameIfDue();
+ Canvas canvas = lockCanvas();
+ if (canvas == null) {
+ return;
+ }
+ try {
+ canvas.drawColor(Color.BLACK);
+ float[] box = GleapCaptureGeometry.letterbox(frame.width, frame.height, outputWidth, outputHeight);
+ frame.draw(canvas, box[0], box[1], box[2] / frame.width);
+ } finally {
+ long postedAt = nowUs();
+ inputSurface.unlockCanvasAndPost(canvas);
+ submittedAt.addLast(postedAt);
+ while (submittedAt.size() > MAX_PENDING_TIMESTAMPS) {
+ submittedAt.pollFirst();
+ }
+ }
+ drain(false);
+ }
+
+ private Canvas lockCanvas() {
+ if (!softwareCanvas) {
+ try {
+ return inputSurface.lockHardwareCanvas();
+ } catch (Throwable error) {
+ GleapLog.w("The hardware canvas is not available for the recording", error);
+ softwareCanvas = true;
+ }
+ }
+ try {
+ return inputSurface.lockCanvas(null);
+ } catch (Throwable error) {
+ GleapLog.w("Could not draw a recording frame", error);
+ return null;
+ }
+ }
+
+ // A key frame at least every 2 s of video, also at 2 fps.
+ private void requestKeyFrameIfDue() {
+ if (keyFrameRequested || lastKeyFrameUs == Long.MIN_VALUE || lastTimeUs < 0) {
+ return;
+ }
+ // Asked for one frame ahead: the frame drawn now comes out about one interval later.
+ long frameIntervalUs = previousTimeUs >= 0 ? Math.max(0, lastTimeUs - previousTimeUs) : 0;
+ if (lastTimeUs + frameIntervalUs - lastKeyFrameUs < KEY_FRAME_INTERVAL_US) {
+ return;
+ }
+ try {
+ Bundle parameters = new Bundle();
+ parameters.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ codec.setParameters(parameters);
+ keyFrameRequested = true;
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private void drain(boolean endOfStream) {
+ long deadline = SystemClock.uptimeMillis() + END_OF_STREAM_TIMEOUT_MS;
+ while (true) {
+ if (abandoned) {
+ return;
+ }
+ int index = codec.dequeueOutputBuffer(bufferInfo, endOfStream ? DRAIN_TIMEOUT_US : 0);
+ if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ if (!endOfStream || SystemClock.uptimeMillis() > deadline) {
+ return;
+ }
+ continue;
+ }
+ if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ if (!muxerStarted) {
+ track = muxer.addTrack(codec.getOutputFormat());
+ muxer.start();
+ muxerStarted = true;
+ }
+ continue;
+ }
+ if (index < 0) {
+ continue;
+ }
+ ByteBuffer data = codec.getOutputBuffer(index);
+ boolean codecConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
+ if (data != null && bufferInfo.size > 0 && !codecConfig && muxerStarted) {
+ long timeUs = videoTimeUs(bufferInfo.presentationTimeUs);
+ bufferInfo.presentationTimeUs = timeUs;
+ data.position(bufferInfo.offset);
+ data.limit(bufferInfo.offset + bufferInfo.size);
+ muxer.writeSampleData(track, data, bufferInfo);
+ framesWritten++;
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ lastKeyFrameUs = timeUs;
+ keyFrameRequested = false;
+ }
+ }
+ codec.releaseOutputBuffer(index, false);
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ return;
+ }
+ }
+ }
+
+ /**
+ * The video time of an encoded frame: its capture time since the first frame, minus the
+ * pauses before it; strictly increasing.
+ */
+ private long videoTimeUs(long encoderTimeUs) {
+ Long submitted = submittedAt.pollFirst();
+ if (!clockChecked) {
+ clockChecked = true;
+ // The input surface stamps frames with the monotonic clock; if this device does not,
+ // the SDK's own submit times are used.
+ ownClock = submitted == null || Math.abs(encoderTimeUs - submitted) > CLOCK_TOLERANCE_US;
+ }
+ long captureUs = ownClock && submitted != null ? submitted : encoderTimeUs;
+ if (firstTimeUs < 0) {
+ firstTimeUs = captureUs;
+ }
+ long pausedUs = 0;
+ synchronized (pauses) {
+ for (long[] pause : pauses) {
+ if (pause[1] <= captureUs && pause[0] >= firstTimeUs) {
+ pausedUs += pause[1] - pause[0];
+ }
+ }
+ }
+ long timeUs = Math.max(0, captureUs - firstTimeUs - pausedUs);
+ if (timeUs <= lastTimeUs) {
+ timeUs = lastTimeUs + 1;
+ }
+ previousTimeUs = lastTimeUs;
+ lastTimeUs = timeUs;
+ return timeUs;
+ }
+
+ private Result finish(long durationMs, Date started, Date endedAt) {
+ boolean success = false;
+ String error = null;
+ try {
+ if (codec != null && !abandoned) {
+ codec.signalEndOfInputStream();
+ drain(true);
+ }
+ } catch (Throwable drainError) {
+ GleapLog.w("Could not finish the recording", drainError);
+ }
+ releaseEncoder();
+ try {
+ if (abandoned) {
+ error = "The recording was given up.";
+ } else if (muxer != null && muxerStarted && framesWritten > 0) {
+ muxerStopped = true;
+ muxer.stop();
+ success = true;
+ } else {
+ error = "No frame was recorded.";
+ }
+ } catch (Throwable stopError) {
+ error = "The recording could not be finished.";
+ GleapLog.w(error, stopError);
+ }
+ releaseMuxer();
+ pool.clear();
+ if (!success) {
+ deleteFile();
+ }
+ return new Result(file, success, durationMs, outputWidth, outputHeight, started, endedAt, error,
+ !success && framesWritten == 0);
+ }
+
+ private void releaseEncoder() {
+ released = true;
+ if (codec != null) {
+ try {
+ codec.stop();
+ } catch (Throwable ignore) {
+ }
+ try {
+ codec.release();
+ } catch (Throwable ignore) {
+ }
+ codec = null;
+ }
+ if (inputSurface != null) {
+ try {
+ inputSurface.release();
+ } catch (Throwable ignore) {
+ }
+ inputSurface = null;
+ }
+ }
+
+ // A started muxer is stopped before it is released (also without samples, where stop()
+ // throws): release() would stop it itself, throw, and leave the muxer unreleased.
+ private void releaseMuxer() {
+ MediaMuxer current = muxer;
+ muxer = null;
+ if (current == null) {
+ return;
+ }
+ if (muxerStarted && !muxerStopped) {
+ muxerStopped = true;
+ try {
+ current.stop();
+ } catch (Throwable ignore) {
+ }
+ }
+ try {
+ current.release();
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private void deleteFile() {
+ GleapCapture.deleteCaptureFile(file);
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/GleapInitializer.java b/gleap/src/main/java/io/gleap/GleapInitializer.java
index 30f663f1..88855143 100644
--- a/gleap/src/main/java/io/gleap/GleapInitializer.java
+++ b/gleap/src/main/java/io/gleap/GleapInitializer.java
@@ -62,6 +62,15 @@ static void initialize(String sdkKey, Application app) {
*/
static void startLoading(OnHttpResponseListener configListener) {
try {
+ // A recording left behind by a process that ended while it was recorded or sent.
+ final Application app = application;
+ GleapExecutor.SERIAL.execute(new Runnable() {
+ @Override
+ public void run() {
+ GleapCapture.deleteCaptureFiles(app);
+ }
+ });
+
new ConfigLoader(configListener).executeOnExecutor(GleapExecutor.SERIAL, GleapBug.getInstance());
GleapBaseSessionService sessionLoader = new GleapBaseSessionService();
diff --git a/gleap/src/main/java/io/gleap/GleapLogFlushHandler.java b/gleap/src/main/java/io/gleap/GleapLogFlushHandler.java
new file mode 100644
index 00000000..c4f94257
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapLogFlushHandler.java
@@ -0,0 +1,19 @@
+package io.gleap;
+
+/**
+ * Lets a wrapper SDK (React Native, Flutter, Capacitor) hand over the logs it buffers itself
+ * before the native SDK collects the logs for a capture request (see
+ * {@link Gleap#setLogFlushHandler(GleapLogFlushHandler)}).
+ */
+public interface GleapLogFlushHandler {
+ /**
+ * Called on the main thread right before the SDK collects the logs for a capture request.
+ * Push the buffered logs to the SDK (e.g. {@link Gleap#attachConsoleLogs} and
+ * {@link Gleap#attachNetworkLogs}), then call {@code done} from any thread. The SDK waits at
+ * most 500 ms for {@code done} and collects the logs without waiting any longer; a later call
+ * of {@code done} does nothing.
+ *
+ * @param done call it once the logs were handed over
+ */
+ void onFlushRequested(Runnable done);
+}
diff --git a/gleap/src/main/java/io/gleap/GleapMainActivity.java b/gleap/src/main/java/io/gleap/GleapMainActivity.java
index 61393858..249a757d 100644
--- a/gleap/src/main/java/io/gleap/GleapMainActivity.java
+++ b/gleap/src/main/java/io/gleap/GleapMainActivity.java
@@ -68,6 +68,9 @@ public class GleapMainActivity extends AppCompatActivity implements OnHttpRespon
private int lockedScrollY = 0;
// The widget answered its first ping, so it listens for config updates.
private boolean widgetPinged = false;
+ // Closed so the customer can capture the app: to the app the widget stays open (no
+ // WidgetClosedCallback), see GleapCaptureCoordinator.
+ private boolean closedForCapture = false;
private final GleapWebPermissions webPermissions = new GleapWebPermissions(this);
private final GleapFileChooser fileChooser = new GleapFileChooser();
@@ -135,6 +138,15 @@ public void closeMainGleapActivity() {
}
}
+ /**
+ * Closes the widget for a capture: like {@link #closeMainGleapActivity()}, without telling
+ * the app.
+ */
+ void closeForCapture() {
+ closedForCapture = true;
+ closeMainGleapActivity();
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
isActive = true;
@@ -462,13 +474,22 @@ protected void onDestroy() {
GleapConfig.getInstance().setFileUploadCallback(null);
if (!recreating) {
GleapDetectorUtil.resumeAllDetectors();
- if (GleapCallbacks.getInstance().getWidgetClosedCallback() != null) {
+ if (!closedForCapture && GleapCallbacks.getInstance().getWidgetClosedCallback() != null) {
GleapCallbacks.getInstance().getWidgetClosedCallback().invoke();
}
GleapOverlayManager.getInstance().setShowFab(true);
GleapOverlayManager.getInstance().clearMessages();
isActive = false;
+ if (!closedForCapture) {
+ // In-app messages held back during a capture show once the widget is closed.
+ GleapMainThread.post(new Runnable() {
+ @Override
+ public void run() {
+ GleapEventService.getInstance().processDeferredActions();
+ }
+ });
+ }
}
} catch (Error | Exception ignore) {
}
@@ -648,4 +669,32 @@ public void sendMessage(String message) {
webView.evaluateJavascript("sendMessage(" + message + ");", null);
}
}
+
+ /**
+ * Whether the WebView shows the Gleap messenger (the widget url's scheme, host and port).
+ * Capture commands are only taken from it. Main thread only.
+ */
+ boolean isTrustedWidgetPage() {
+ try {
+ return webView != null && !isFinishing()
+ && GleapCapture.sameOrigin(webView.getUrl(), GleapConfig.getInstance().getiFrameUrl());
+ } catch (Throwable error) {
+ return false;
+ }
+ }
+
+ /**
+ * Sends a message to the open widget, if one is open, answered its ping and shows the
+ * messenger. Main thread only.
+ *
+ * @return whether it was sent
+ */
+ static boolean deliverToOpenWidget(String message) {
+ GleapMainActivity activity = openInstance != null ? openInstance.get() : null;
+ if (activity == null || !activity.widgetPinged || !activity.isTrustedWidgetPage()) {
+ return false;
+ }
+ activity.sendMessage(message);
+ return true;
+ }
}
diff --git a/gleap/src/main/java/io/gleap/GleapOverlayManager.java b/gleap/src/main/java/io/gleap/GleapOverlayManager.java
index f84e2cc5..54bd8316 100644
--- a/gleap/src/main/java/io/gleap/GleapOverlayManager.java
+++ b/gleap/src/main/java/io/gleap/GleapOverlayManager.java
@@ -44,6 +44,9 @@ class GleapOverlayManager {
private GleapModal modal;
private JSONObject modalData;
private int originalVisibility = 0;
+ // While a capture runs the overlay (button, notifications, banner, modal) is hidden: it is
+ // not part of the app the customer shows.
+ private boolean hiddenForCapture = false;
private GleapOverlayManager() {
}
@@ -91,6 +94,16 @@ public void setInvisible() {
}
}
+ /**
+ * Hides the whole overlay while a capture runs (main thread).
+ */
+ void setHiddenForCapture(boolean hidden) {
+ hiddenForCapture = hidden;
+ if (layout != null) {
+ layout.setVisibility(hidden ? View.INVISIBLE : View.VISIBLE);
+ }
+ }
+
public void setVisible() {
if (button.container != null && !GleapConfig.getInstance().isHideFeedbackButton()) {
button.container.setVisibility(View.VISIBLE);
@@ -311,6 +324,9 @@ public void addLayoutToActivity(Activity activity) {
LayoutInflater inflater = activity.getLayoutInflater();
this.layout = (ConstraintLayout) inflater.inflate(R.layout.activity_gleap_fab, null);
this.layout.setLayoutParams(new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ if (hiddenForCapture) {
+ this.layout.setVisibility(View.INVISIBLE);
+ }
addLocalLayoutToActivity(activity);
}
diff --git a/gleap/src/main/java/io/gleap/GleapWebSocketListener.java b/gleap/src/main/java/io/gleap/GleapWebSocketListener.java
index 0ee7dbd2..6356c6bf 100644
--- a/gleap/src/main/java/io/gleap/GleapWebSocketListener.java
+++ b/gleap/src/main/java/io/gleap/GleapWebSocketListener.java
@@ -41,11 +41,18 @@ public boolean connect() {
String wsApiUrl = GleapConfig.getInstance().getWsApiUrl();
String sdkKey = GleapConfig.getInstance().getSdkKey();
- internallyConnect(wsApiUrl + "?gleapId=" + gleapSession.getId() + "&gleapHash=" + gleapSession.getHash() + "&apiKey=" + sdkKey + "&sdkVersion=" + BuildConfig.VERSION_NAME);
+ internallyConnect(wsApiUrl + "?gleapId=" + gleapSession.getId() + "&gleapHash=" + gleapSession.getHash() + "&apiKey=" + sdkKey + "&sdkVersion=" + BuildConfig.VERSION_NAME
+ + capsParameter());
return true;
}
+ // What the SDK can capture for capture requests, e.g. "&caps=capture.screenshot,capture.logs".
+ static String capsParameter() {
+ String caps = GleapCapture.capsQueryValue(GleapCapture.currentCaps());
+ return caps.isEmpty() ? "" : "&caps=" + caps;
+ }
+
private void internallyConnect(String url) {
currentUrl = url;
webSocket = openWebSocket(url);
@@ -104,6 +111,9 @@ public void onMessage(WebSocket webSocket, String text) {
if (eventName != null && eventName.equalsIgnoreCase("update")) {
JSONObject data = jsonObject.getJSONObject("data");
GleapEventService.getInstance().processEventData(data);
+ } else if ("capture-request".equals(eventName)) {
+ // The server asks for the app's logs; handled whether the widget is open or not.
+ GleapCaptureLogs.getInstance().onCaptureRequest(jsonObject.optJSONObject("data"));
}
} catch (Exception exp) {}
}
diff --git a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java
index 09b79639..4d097847 100644
--- a/gleap/src/main/java/io/gleap/GleapWidgetBridge.java
+++ b/gleap/src/main/java/io/gleap/GleapWidgetBridge.java
@@ -43,9 +43,12 @@ public void run() {
case "ping":
sendConfigUpdate(activity);
sendSessionUpdate(activity);
+ GleapCaptureCoordinator.getInstance().sendCapabilities(activity);
sendPrefillData(activity);
sendScreenshotUpdate(activity);
sendPendingActions(activity);
+ // The result of a capture that ended while the widget was closed.
+ GleapCaptureCoordinator.getInstance().deliverPending(activity);
GleapMainThread.postDelayed(new Runnable() {
@Override
@@ -116,6 +119,15 @@ public void run() {
case "send-feedback":
sendFeedback(gleapCallback);
break;
+ case "capture-start":
+ case "capture-cancel":
+ case "capture-done":
+ case "capture-editor":
+ // Only from the messenger page; nothing is captured before the
+ // customer taps the SDK's own bar.
+ GleapCaptureCoordinator.getInstance().onWidgetMessage(activity, command,
+ gleapCallback.optJSONObject("data"));
+ break;
}
} catch (Exception err) {
}
diff --git a/gleap/src/main/java/io/gleap/GleapWidgetLauncher.java b/gleap/src/main/java/io/gleap/GleapWidgetLauncher.java
index 6ef4debf..0deb831a 100644
--- a/gleap/src/main/java/io/gleap/GleapWidgetLauncher.java
+++ b/gleap/src/main/java/io/gleap/GleapWidgetLauncher.java
@@ -83,6 +83,35 @@ public void run() throws RuntimeException {
}
}
+ /**
+ * Opens the widget on a conversation (its home screen without a share token) without taking
+ * the screenshot for tickets first: after the app turned captures off.
+ */
+ static void openConversationWithoutScreenshot(final String shareToken) {
+ try {
+ GleapMainThread.postWithActivity(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (GleapDetectorUtil.isWidgetOpen() || !isGleapReady() || screenshotTaker == null) {
+ return;
+ }
+ if (shareToken != null && !shareToken.isEmpty()) {
+ GleapActionQueueHandler.getInstance().addActionMessage(new GleapAction("open-conversation",
+ new JSONObject().put("hideBackButton", false).put("shareToken", shareToken)));
+ }
+ GleapDetectorUtil.stopAllDetectors();
+ screenshotTaker.openScreenshot(null, SurveyType.NONE);
+ } catch (Error | Exception e) {
+ GleapErrors.report(e, "openConversationWithoutScreenshot");
+ }
+ }
+ });
+ } catch (Error | Exception e) {
+ GleapErrors.report(e, "openConversationWithoutScreenshot");
+ }
+ }
+
/**
* Opens the widget without a command (its home screen, or a survey: the survey command is
* queued by the caller). Only the home screen restarts the session when Gleap is not ready.
diff --git a/gleap/src/main/java/io/gleap/GleapWindowCapture.java b/gleap/src/main/java/io/gleap/GleapWindowCapture.java
new file mode 100644
index 00000000..f460ef29
--- /dev/null
+++ b/gleap/src/main/java/io/gleap/GleapWindowCapture.java
@@ -0,0 +1,920 @@
+package io.gleap;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.text.InputType;
+import android.text.method.PasswordTransformationMethod;
+import android.view.PixelCopy;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.inspector.WindowInspector;
+import android.widget.TextView;
+
+import java.lang.reflect.Field;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.gleap.callbacks.GetBitmapCallback;
+
+/**
+ * Captures what the app shows in the current activity: its window and the windows above it
+ * that belong to it (dialogs, bottom sheets, popups), in window order. For screenshots and the
+ * frames of recordings.
+ *
+ * API 26+: every window with PixelCopy (off the main thread); SurfaceViews (video, maps,
+ * GL, Flutter) are copied on their own and drawn underneath their window, which only has a
+ * hole there.
+ * Below API 26 (or a window that is not hardware accelerated): {@link View#draw} into a
+ * bitmap on the main thread; SurfaceViews are still copied with PixelCopy on API 24+.
+ * Windows with FLAG_SECURE, password fields and the views masked with
+ * {@link Gleap#maskView} are painted black.
+ * A wrapper SDK that renders its own content (Flutter's GetBitmapCallback) provides the
+ * picture instead; the masks above are drawn over it all the same.
+ *
+ * {@link #capture} must be called on the main thread; the frame is delivered on the given
+ * handler's thread.
+ */
+final class GleapWindowCapture {
+ // Views scanned per window at most (for SurfaceViews and masked views). A bigger window
+ // cannot be checked for masks: its frame is dropped.
+ private static final int MAX_SCANNED_VIEWS = 50000;
+ // Masks are bigger than the view, against rounding and a scroll between the scan and the copy.
+ private static final float MASK_PADDING_DP = 8f;
+
+ // Tests only: copy windows the way API 26-33 does (their Window) on API 34+ as well.
+ static volatile boolean forceWindowCopy = false;
+
+ // The SDK's own windows (the capture bar, the recording preview): never captured and never
+ // the window the bar is attached to. Weak, guarded by itself.
+ private static final Set ownWindows = Collections.newSetFromMap(new WeakHashMap());
+
+ private static volatile Field decorWindowField;
+ private static volatile boolean decorWindowFieldLooked;
+ private static volatile Field globalViewsField;
+ private static volatile Object windowManagerGlobal;
+
+ private GleapWindowCapture() {
+ }
+
+ interface Callback {
+ /**
+ * @param frame the captured frame, or null when there was nothing to capture
+ */
+ void onFrame(Frame frame);
+ }
+
+ /**
+ * One layer of a frame, in frame pixels.
+ */
+ static final class Layer {
+ static final int DIM = 0;
+ static final int BITMAP = 1;
+ static final int BLACK = 2;
+
+ final int type;
+ final RectF rect;
+ final float alpha;
+ // BITMAP only: null while it is copied, or when the copy failed (the layer is left out).
+ volatile Bitmap bitmap;
+ // The bitmap came from the app (GetBitmapCallback): never recycled or pooled by the SDK.
+ boolean external;
+
+ private Layer(int type, RectF rect, float alpha) {
+ this.type = type;
+ this.rect = rect;
+ this.alpha = alpha;
+ }
+ }
+
+ /**
+ * The captured windows as layers, bottom first. Its size is the activity's size times the scale.
+ */
+ static final class Frame {
+ final int areaWidth;
+ final int areaHeight;
+ final float scale;
+ final int width;
+ final int height;
+ final List layers = Collections.synchronizedList(new ArrayList());
+ // What capturing cost on the main thread, including a redraw after a failed copy.
+ volatile long mainThreadMs;
+ // The masks could not be worked out: the frame is dropped, never delivered.
+ volatile boolean failed;
+
+ Frame(int areaWidth, int areaHeight, float scale) {
+ this.areaWidth = areaWidth;
+ this.areaHeight = areaHeight;
+ this.scale = scale;
+ this.width = Math.max(1, Math.round(areaWidth * scale));
+ this.height = Math.max(1, Math.round(areaHeight * scale));
+ }
+
+ /**
+ * Draws the layers at {@code (left, top)}, scaled by {@code drawScale}.
+ */
+ void draw(Canvas canvas, float left, float top, float drawScale) {
+ Paint bitmapPaint = new Paint(Paint.FILTER_BITMAP_FLAG);
+ Paint black = new Paint();
+ black.setColor(Color.BLACK);
+ canvas.save();
+ canvas.translate(left, top);
+ canvas.scale(drawScale, drawScale);
+ canvas.clipRect(0, 0, width, height);
+ synchronized (layers) {
+ for (Layer layer : layers) {
+ if (layer.type == Layer.DIM) {
+ Paint dim = new Paint();
+ dim.setColor(Color.argb(Math.round(Math.max(0f, Math.min(1f, layer.alpha)) * 255), 0, 0, 0));
+ canvas.drawRect(0, 0, width, height, dim);
+ } else if (layer.type == Layer.BLACK) {
+ canvas.drawRect(layer.rect, black);
+ } else {
+ Bitmap bitmap = layer.bitmap;
+ if (bitmap != null && !bitmap.isRecycled()) {
+ canvas.drawBitmap(bitmap, null, layer.rect, bitmapPaint);
+ }
+ }
+ }
+ }
+ canvas.restore();
+ }
+
+ /**
+ * The frame as one bitmap of {@code width x height}.
+ */
+ Bitmap render() {
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+ canvas.drawColor(Color.BLACK);
+ draw(canvas, 0, 0, 1f);
+ return output;
+ }
+
+ /**
+ * Gives the layers' bitmaps back to the pool (or recycles them without one).
+ */
+ void release(BitmapPool pool) {
+ synchronized (layers) {
+ for (Layer layer : layers) {
+ Bitmap bitmap = layer.bitmap;
+ layer.bitmap = null;
+ if (bitmap == null || layer.external) {
+ continue;
+ }
+ if (pool != null) {
+ pool.release(bitmap);
+ } else {
+ bitmap.recycle();
+ }
+ }
+ }
+ }
+
+ boolean hasContent() {
+ synchronized (layers) {
+ for (Layer layer : layers) {
+ if (layer.type == Layer.BITMAP && layer.bitmap != null) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Reuses the bitmaps of recording frames (they have the same sizes from frame to frame).
+ */
+ static final class BitmapPool {
+ private static final int MAX_FREE = 8;
+ private final ArrayList free = new ArrayList<>();
+
+ synchronized Bitmap obtain(int width, int height) {
+ for (int i = 0; i < free.size(); i++) {
+ Bitmap bitmap = free.get(i);
+ if (bitmap.getWidth() == width && bitmap.getHeight() == height) {
+ free.remove(i);
+ return bitmap;
+ }
+ }
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ }
+
+ synchronized void release(Bitmap bitmap) {
+ if (bitmap == null || bitmap.isRecycled()) {
+ return;
+ }
+ if (free.size() >= MAX_FREE) {
+ Bitmap oldest = free.remove(0);
+ oldest.recycle();
+ }
+ free.add(bitmap);
+ }
+
+ synchronized void clear() {
+ for (Bitmap bitmap : free) {
+ bitmap.recycle();
+ }
+ free.clear();
+ }
+ }
+
+ /**
+ * Counts the copies of a frame that are still running; delivers the frame after the last one.
+ */
+ private static final class Pending {
+ private final AtomicInteger outstanding = new AtomicInteger(1);
+ private final Frame frame;
+ private final Handler handler;
+ private final Callback callback;
+ private final BitmapPool pool;
+
+ Pending(Frame frame, Handler handler, Callback callback, BitmapPool pool) {
+ this.frame = frame;
+ this.handler = handler;
+ this.callback = callback;
+ this.pool = pool;
+ }
+
+ void add() {
+ outstanding.incrementAndGet();
+ }
+
+ void done() {
+ if (outstanding.decrementAndGet() == 0) {
+ if (frame.failed) {
+ frame.release(pool);
+ deliver(handler, callback, null);
+ } else {
+ deliver(handler, callback, frame);
+ }
+ }
+ }
+ }
+
+ /**
+ * The mask rects of the previous recording frame, per view: a mask covers where the view is
+ * and where it was, since the copied pixels can be a frame older than the view's position
+ * (fast scrolling). Main thread only.
+ */
+ static final class MaskHistory {
+ private Map previous = new WeakHashMap<>();
+ private float scale = -1f;
+ }
+
+ /**
+ * Captures the activity's windows. Main thread only.
+ *
+ * Every frame is masked (FLAG_SECURE windows, sensitive fields, masked views); when the masks
+ * cannot be worked out the frame is dropped (the callback gets null): no unmasked pixels.
+ *
+ * @param scale frame pixels per window pixel (1 = full resolution)
+ * @param excludedRoots the root views of windows to leave out (the SDK's own bar)
+ * @param resultHandler where the copies finish and the frame is delivered (not the main thread)
+ * @param pool bitmaps to reuse, or null
+ * @param history the masks of the previous frame of a recording, or null
+ */
+ static void capture(Activity activity, float scale, Set excludedRoots, Handler resultHandler,
+ BitmapPool pool, MaskHistory history, Callback callback) {
+ long startedAt = SystemClock.uptimeMillis();
+ View decor = null;
+ try {
+ decor = activity != null && activity.getWindow() != null ? activity.getWindow().peekDecorView() : null;
+ } catch (Throwable ignore) {
+ }
+ if (decor == null || decor.getWidth() <= 0 || decor.getHeight() <= 0 || decor.getWindowToken() == null) {
+ deliver(resultHandler, callback, null);
+ return;
+ }
+
+ int[] origin = new int[2];
+ decor.getLocationOnScreen(origin);
+ Frame frame = new Frame(decor.getWidth(), decor.getHeight(), scale);
+ Pending pending = new Pending(frame, resultHandler, callback, pool);
+ try {
+ Set masked = GleapCapture.maskedViews();
+ List roots = windowRoots(activity, decor, excludedRoots);
+ Map masks = new WeakHashMap<>();
+ float padding = MASK_PADDING_DP * decor.getResources().getDisplayMetrics().density * scale;
+ // Below API 24 the content of a SurfaceView (Flutter) cannot be copied: a wrapper that
+ // renders its own content hands over its picture of the activity instead.
+ Bitmap hostBitmap = Build.VERSION.SDK_INT < Build.VERSION_CODES.N ? hostBitmap() : null;
+ if (hostBitmap != null) {
+ Layer layer = new Layer(Layer.BITMAP, new RectF(0, 0, frame.width, frame.height), 1f);
+ layer.bitmap = hostBitmap;
+ layer.external = true;
+ frame.layers.add(layer);
+ for (View root : roots) {
+ addMasks(root, origin, frame, masked, padding, history, masks);
+ }
+ } else {
+ for (View root : roots) {
+ addWindow(activity, decor, root, origin, frame, masked, padding, history, masks, pool, pending, resultHandler);
+ }
+ }
+ if (history != null) {
+ history.previous = masks;
+ history.scale = scale;
+ }
+ } catch (Throwable error) {
+ // Without its masks the frame must not leave the device.
+ GleapLog.w("Could not mask a capture: the frame is dropped", error);
+ frame.failed = true;
+ }
+ frame.mainThreadMs = SystemClock.uptimeMillis() - startedAt;
+ pending.done();
+ }
+
+ private static void addWindow(Activity activity, View decor, View root, int[] origin, Frame frame,
+ Set masked, float padding, MaskHistory history, Map masks,
+ BitmapPool pool, Pending pending, Handler resultHandler) {
+ WindowManager.LayoutParams params = windowParams(root);
+ int[] location = new int[2];
+ root.getLocationOnScreen(location);
+ float scale = frame.scale;
+ RectF windowRect = scaled(location[0] - origin[0], location[1] - origin[1], root.getWidth(), root.getHeight(), scale);
+
+ if (root != decor && params != null && (params.flags & WindowManager.LayoutParams.FLAG_DIM_BEHIND) != 0) {
+ frame.layers.add(new Layer(Layer.DIM, null, params.dimAmount));
+ }
+ if (params != null && (params.flags & WindowManager.LayoutParams.FLAG_SECURE) != 0) {
+ // The app keeps this window out of screenshots: so does the SDK.
+ frame.layers.add(new Layer(Layer.BLACK, windowRect, 1f));
+ return;
+ }
+
+ List surfaces = new ArrayList<>();
+ List sensitive = new ArrayList<>();
+ scan(root, masked, surfaces, sensitive);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ for (SurfaceView surface : surfaces) {
+ addSurface(surface, origin, frame, pool, pending, resultHandler);
+ }
+ }
+
+ Layer windowLayer = new Layer(Layer.BITMAP, windowRect, 1f);
+ frame.layers.add(windowLayer);
+ copyWindow(activity, decor, root, windowLayer, pool, pending, resultHandler);
+
+ addMaskLayers(sensitive, location, origin, frame, padding, history, masks);
+ }
+
+ /**
+ * Black boxes for a window over a picture the SDK did not take itself (GetBitmapCallback,
+ * drawn over the whole activity): its sensitive views, or all of it with FLAG_SECURE.
+ */
+ private static void addMasks(View root, int[] origin, Frame frame, Set masked, float padding,
+ MaskHistory history, Map masks) {
+ WindowManager.LayoutParams params = windowParams(root);
+ int[] location = new int[2];
+ root.getLocationOnScreen(location);
+ if (params != null && (params.flags & WindowManager.LayoutParams.FLAG_SECURE) != 0) {
+ frame.layers.add(new Layer(Layer.BLACK, scaled(location[0] - origin[0], location[1] - origin[1],
+ root.getWidth(), root.getHeight(), frame.scale), 1f));
+ return;
+ }
+ List surfaces = new ArrayList<>();
+ List sensitive = new ArrayList<>();
+ scan(root, masked, surfaces, sensitive);
+ addMaskLayers(sensitive, location, origin, frame, padding, history, masks);
+ }
+
+ /**
+ * The visible part of each view, from window to frame coordinates, padded, joined with where
+ * the view was in the previous frame. Throws when a mask cannot be worked out (the frame is
+ * then dropped).
+ */
+ private static void addMaskLayers(List views, int[] windowLocation, int[] origin, Frame frame, float padding,
+ MaskHistory history, Map masks) {
+ Rect visible = new Rect();
+ for (View view : views) {
+ if (!view.getGlobalVisibleRect(visible)) {
+ // Nothing of it is on screen.
+ continue;
+ }
+ RectF mask = scaled(visible.left + windowLocation[0] - origin[0], visible.top + windowLocation[1] - origin[1],
+ visible.width(), visible.height(), frame.scale);
+ mask.inset(-padding, -padding);
+ masks.put(view, new RectF(mask));
+ if (history != null && history.scale == frame.scale) {
+ RectF previous = history.previous.get(view);
+ if (previous != null) {
+ mask.union(previous);
+ }
+ }
+ frame.layers.add(new Layer(Layer.BLACK, mask, 1f));
+ }
+ }
+
+ private static RectF scaled(float left, float top, float width, float height, float scale) {
+ return new RectF(left * scale, top * scale, (left + width) * scale, (top + height) * scale);
+ }
+
+ private static int scaledSize(int size, float scale) {
+ return Math.max(1, Math.round(size * scale));
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Copying
+
+ private static void copyWindow(final Activity activity, final View decor, final View root, final Layer layer,
+ final BitmapPool pool, final Pending pending, final Handler resultHandler) {
+ final float scale = layer.rect.width() / Math.max(1, root.getWidth());
+ final Bitmap bitmap = obtain(pool, scaledSize(root.getWidth(), scale), scaledSize(root.getHeight(), scale));
+ if (bitmap == null) {
+ return;
+ }
+ boolean hardware = root.isHardwareAccelerated();
+ boolean started = false;
+ try {
+ CopyResult result = new CopyResult() {
+ @Override
+ public void onResult(boolean success) {
+ finishWindowCopy(root, layer, bitmap, scale, success, pool, pending);
+ }
+ };
+ if (Build.VERSION.SDK_INT >= 34 && hardware && !forceWindowCopy) {
+ pending.add();
+ started = true;
+ requestWindowCopy(root, bitmap, result, resultHandler);
+ return;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hardware) {
+ Window window = windowOf(activity, decor, root);
+ if (window != null) {
+ pending.add();
+ started = true;
+ requestWindowCopy(window, bitmap, result, resultHandler);
+ return;
+ }
+ }
+ } catch (Throwable error) {
+ // E.g. the window has no surface (anymore): draw it instead. The copy did not start.
+ if (started) {
+ pending.done();
+ }
+ }
+ drawWindow(root, layer, bitmap, scale, pool);
+ }
+
+ interface CopyResult {
+ void onResult(boolean success);
+ }
+
+ @TargetApi(34)
+ private static void requestWindowCopy(View root, Bitmap bitmap, final CopyResult result, final Handler handler) {
+ // The window's own area: without a source rect ofWindow(View) takes the surface insets
+ // (a dialog's shadow) as the source rect, and the whole surface is copied, scaled down.
+ PixelCopy.Request request = PixelCopy.Request.Builder.ofWindow(root)
+ .setSourceRect(new Rect(0, 0, root.getWidth(), root.getHeight()))
+ .setDestinationBitmap(bitmap)
+ .build();
+ PixelCopy.request(request, executor(handler), new java.util.function.Consumer() {
+ @Override
+ public void accept(PixelCopy.Result copy) {
+ result.onResult(copy.getStatus() == PixelCopy.SUCCESS);
+ }
+ });
+ }
+
+ @TargetApi(Build.VERSION_CODES.O)
+ private static void requestWindowCopy(Window window, Bitmap bitmap, final CopyResult result, Handler handler) {
+ PixelCopy.request(window, bitmap, new PixelCopy.OnPixelCopyFinishedListener() {
+ @Override
+ public void onPixelCopyFinished(int copyResult) {
+ result.onResult(copyResult == PixelCopy.SUCCESS);
+ }
+ }, handler);
+ }
+
+ // On the result thread: a failed copy is drawn on the main thread instead.
+ private static void finishWindowCopy(final View root, final Layer layer, final Bitmap bitmap, final float scale,
+ boolean success, final BitmapPool pool, final Pending pending) {
+ if (success) {
+ layer.bitmap = bitmap;
+ pending.done();
+ return;
+ }
+ GleapMainThread.post(new Runnable() {
+ @Override
+ public void run() {
+ long startedAt = SystemClock.uptimeMillis();
+ try {
+ drawWindow(root, layer, bitmap, scale, pool);
+ } finally {
+ pending.frame.mainThreadMs += SystemClock.uptimeMillis() - startedAt;
+ pending.done();
+ }
+ }
+ });
+ }
+
+ /**
+ * Draws the window's views into the bitmap (main thread). Views that cannot be drawn in
+ * software (e.g. hardware bitmaps) leave the window out.
+ */
+ private static void drawWindow(View root, Layer layer, Bitmap bitmap, float scale, BitmapPool pool) {
+ try {
+ bitmap.eraseColor(Color.TRANSPARENT);
+ Canvas canvas = new Canvas(bitmap);
+ canvas.scale(bitmap.getWidth() / (float) Math.max(1, root.getWidth()),
+ bitmap.getHeight() / (float) Math.max(1, root.getHeight()));
+ root.draw(canvas);
+ layer.bitmap = bitmap;
+ } catch (Throwable error) {
+ GleapLog.w("Could not draw a window for the capture", error);
+ release(pool, bitmap);
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ private static void addSurface(SurfaceView surface, int[] origin, Frame frame, final BitmapPool pool,
+ final Pending pending, Handler resultHandler) {
+ try {
+ if (surface.getWidth() <= 0 || surface.getHeight() <= 0 || !surface.getHolder().getSurface().isValid()) {
+ return;
+ }
+ int[] location = new int[2];
+ surface.getLocationOnScreen(location);
+ final Layer layer = new Layer(Layer.BITMAP,
+ scaled(location[0] - origin[0], location[1] - origin[1], surface.getWidth(), surface.getHeight(), frame.scale), 1f);
+ final Bitmap bitmap = obtain(pool, scaledSize(surface.getWidth(), frame.scale), scaledSize(surface.getHeight(), frame.scale));
+ if (bitmap == null) {
+ return;
+ }
+ // Below the window: the window only has a hole where the surface shows.
+ frame.layers.add(layer);
+ pending.add();
+ try {
+ PixelCopy.request(surface, bitmap, new PixelCopy.OnPixelCopyFinishedListener() {
+ @Override
+ public void onPixelCopyFinished(int copyResult) {
+ if (copyResult == PixelCopy.SUCCESS) {
+ layer.bitmap = bitmap;
+ } else {
+ release(pool, bitmap);
+ }
+ pending.done();
+ }
+ }, resultHandler);
+ } catch (Throwable error) {
+ release(pool, bitmap);
+ pending.done();
+ }
+ } catch (Throwable ignore) {
+ }
+ }
+
+ private static Bitmap obtain(BitmapPool pool, int width, int height) {
+ try {
+ return pool != null ? pool.obtain(width, height) : Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ } catch (Throwable error) {
+ // Out of memory: the window is left out.
+ GleapLog.w("No memory for a capture bitmap", error);
+ return null;
+ }
+ }
+
+ private static void release(BitmapPool pool, Bitmap bitmap) {
+ if (pool != null) {
+ pool.release(bitmap);
+ } else if (bitmap != null) {
+ bitmap.recycle();
+ }
+ }
+
+ private static Executor executor(final Handler handler) {
+ return new Executor() {
+ @Override
+ public void execute(Runnable command) {
+ handler.post(command);
+ }
+ };
+ }
+
+ private static void deliver(Handler handler, final Callback callback, final Frame frame) {
+ Runnable delivery = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ callback.onFrame(frame);
+ } catch (Throwable error) {
+ GleapLog.w("Could not handle a captured frame", error);
+ }
+ }
+ };
+ if (handler != null) {
+ handler.post(delivery);
+ } else {
+ new Handler(Looper.getMainLooper()).post(delivery);
+ }
+ }
+
+ private static Bitmap hostBitmap() {
+ try {
+ GetBitmapCallback bitmapCallback = GleapCallbacks.getInstance().getGetBitmapCallback();
+ if (bitmapCallback == null) {
+ return null;
+ }
+ Bitmap bitmap = bitmapCallback.getBitmap();
+ return bitmap != null && !bitmap.isRecycled() && bitmap.getWidth() > 0 ? bitmap : null;
+ } catch (Throwable error) {
+ GleapLog.w("The app's bitmap callback failed", error);
+ return null;
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Windows
+
+ /**
+ * The windows of the activity to capture, bottom first: the activity's window, then its
+ * dialogs in the order they were shown, each followed by its panels (popups, menus).
+ */
+ static List windowRoots(Activity activity, View decor, Set excludedRoots) {
+ List ordered = new ArrayList<>();
+ ordered.add(decor);
+ List all = globalWindowViews();
+ if (all.isEmpty()) {
+ return ordered;
+ }
+
+ IBinder appToken = null;
+ try {
+ appToken = activity.getWindow().getAttributes().token;
+ } catch (Throwable ignore) {
+ }
+
+ List topLevel = new ArrayList<>();
+ topLevel.add(decor);
+ for (View view : all) {
+ if (view == decor || !isCapturable(view, excludedRoots)) {
+ continue;
+ }
+ WindowManager.LayoutParams params = windowParams(view);
+ if (params == null || appToken == null || params.token != appToken) {
+ continue;
+ }
+ if (params.type > WindowManager.LayoutParams.FIRST_APPLICATION_WINDOW
+ && params.type <= WindowManager.LayoutParams.LAST_APPLICATION_WINDOW
+ && params.type != WindowManager.LayoutParams.TYPE_APPLICATION_STARTING) {
+ topLevel.add(view);
+ }
+ }
+
+ ordered.clear();
+ for (View window : topLevel) {
+ ordered.add(window);
+ IBinder parentToken = window.getWindowToken();
+ if (parentToken == null) {
+ continue;
+ }
+ for (View view : all) {
+ if (view == window || !isCapturable(view, excludedRoots)) {
+ continue;
+ }
+ WindowManager.LayoutParams params = windowParams(view);
+ if (params != null && params.token == parentToken
+ && params.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW
+ && params.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
+ ordered.add(view);
+ }
+ }
+ }
+ return ordered;
+ }
+
+ /**
+ * The window the SDK's bar belongs on: the topmost dialog of the activity, else the
+ * activity's own window.
+ */
+ static View topWindow(Activity activity, View decor, Set excludedRoots) {
+ View top = decor;
+ IBinder appToken;
+ try {
+ appToken = activity.getWindow().getAttributes().token;
+ } catch (Throwable error) {
+ return decor;
+ }
+ for (View view : globalWindowViews()) {
+ if (view == decor || !isCapturable(view, excludedRoots)) {
+ continue;
+ }
+ WindowManager.LayoutParams params = windowParams(view);
+ if (params != null && appToken != null && params.token == appToken
+ && params.type > WindowManager.LayoutParams.FIRST_APPLICATION_WINDOW
+ && params.type <= WindowManager.LayoutParams.LAST_APPLICATION_WINDOW
+ && params.type != WindowManager.LayoutParams.TYPE_APPLICATION_STARTING
+ && view.getWindowToken() != null) {
+ top = view;
+ }
+ }
+ return top;
+ }
+
+ static void addOwnWindow(View root) {
+ if (root != null) {
+ synchronized (ownWindows) {
+ ownWindows.add(root);
+ }
+ }
+ }
+
+ static void removeOwnWindow(View root) {
+ if (root != null) {
+ synchronized (ownWindows) {
+ ownWindows.remove(root);
+ }
+ }
+ }
+
+ private static boolean isOwnWindow(View root) {
+ synchronized (ownWindows) {
+ return ownWindows.contains(root);
+ }
+ }
+
+ private static boolean isCapturable(View view, Set excludedRoots) {
+ return view != null
+ && !isOwnWindow(view)
+ && (excludedRoots == null || !excludedRoots.contains(view))
+ && view.getWindowToken() != null
+ && view.getVisibility() == View.VISIBLE
+ && view.getWidth() > 0
+ && view.getHeight() > 0;
+ }
+
+ static WindowManager.LayoutParams windowParams(View root) {
+ ViewGroup.LayoutParams params = root.getLayoutParams();
+ return params instanceof WindowManager.LayoutParams ? (WindowManager.LayoutParams) params : null;
+ }
+
+ /**
+ * The root views of all windows of the process (WindowInspector on API 29+, WindowManagerGlobal
+ * below); empty when they cannot be read.
+ */
+ @SuppressWarnings("unchecked")
+ static List globalWindowViews() {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return new ArrayList<>(WindowInspector.getGlobalWindowViews());
+ }
+ Field field = globalViewsField;
+ Object global = windowManagerGlobal;
+ if (field == null || global == null) {
+ Class> globalClass = Class.forName("android.view.WindowManagerGlobal");
+ global = globalClass.getMethod("getInstance").invoke(null);
+ field = globalClass.getDeclaredField("mViews");
+ field.setAccessible(true);
+ windowManagerGlobal = global;
+ globalViewsField = field;
+ }
+ Object views = field.get(global);
+ if (views instanceof List) {
+ return new ArrayList<>((List) views);
+ }
+ } catch (Throwable ignore) {
+ }
+ return new ArrayList<>();
+ }
+
+ /**
+ * The Window of a root view: the activity's, or a dialog's (its DecorView knows it).
+ */
+ private static Window windowOf(Activity activity, View decor, View root) {
+ if (root == decor) {
+ return activity.getWindow();
+ }
+ try {
+ if (!decorWindowFieldLooked) {
+ Field field = null;
+ for (Class> type = root.getClass(); type != null && type != Object.class; type = type.getSuperclass()) {
+ if (type.getName().endsWith("DecorView")) {
+ field = type.getDeclaredField("mWindow");
+ field.setAccessible(true);
+ break;
+ }
+ }
+ decorWindowField = field;
+ decorWindowFieldLooked = field != null;
+ }
+ Field field = decorWindowField;
+ if (field != null && field.getDeclaringClass().isInstance(root)) {
+ Object window = field.get(root);
+ return window instanceof Window ? (Window) window : null;
+ }
+ } catch (Throwable ignore) {
+ }
+ return null;
+ }
+
+ /**
+ * Finds the SurfaceViews and the views to paint black (password fields, masked views) in a
+ * window. Hidden subtrees are skipped; a masked view's children are covered by its mask.
+ */
+ private static void scan(View root, Set masked, List surfaces, List masks) {
+ ArrayDeque stack = new ArrayDeque<>();
+ stack.push(root);
+ int budget = MAX_SCANNED_VIEWS;
+ while (!stack.isEmpty()) {
+ if (budget-- <= 0) {
+ throw new IllegalStateException("Too many views to check for masks");
+ }
+ View view = stack.pop();
+ if (view.getVisibility() != View.VISIBLE) {
+ continue;
+ }
+ if (masked.contains(view) || isSensitive(view)) {
+ masks.add(view);
+ continue;
+ }
+ if (view instanceof SurfaceView) {
+ surfaces.add((SurfaceView) view);
+ }
+ if (view instanceof ViewGroup) {
+ ViewGroup group = (ViewGroup) view;
+ for (int i = group.getChildCount() - 1; i >= 0; i--) {
+ View child = group.getChildAt(i);
+ if (child != null) {
+ stack.push(child);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Fields painted black in every capture: passwords (text, number and web passwords, also
+ * while shown), and fields the app marked for autofill as a password, a payment card or a
+ * one-time code.
+ */
+ static boolean isSensitive(View view) {
+ if (view instanceof TextView) {
+ TextView text = (TextView) view;
+ if (isPasswordInputType(text.getInputType())
+ || text.getTransformationMethod() instanceof PasswordTransformationMethod) {
+ return true;
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ String[] hints = view.getAutofillHints();
+ if (hints != null) {
+ for (String hint : hints) {
+ if (isSensitiveAutofillHint(hint)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Autofill hints of passwords, payment cards and one-time codes (the framework's View
+ * AUTOFILL_HINT_* and androidx HintConstants names, e.g. password, newPassword,
+ * creditCardNumber, creditCardSecurityCode, creditCardExpirationDate, smsOTPCode).
+ */
+ static boolean isSensitiveAutofillHint(String hint) {
+ if (hint == null) {
+ return false;
+ }
+ String value = hint.toLowerCase(Locale.ROOT).replace("_", "").replace("-", "");
+ return value.contains("password") || value.contains("creditcard") || value.contains("otp")
+ || value.contains("onetimecode") || value.contains("securitycode") || value.contains("cvc")
+ || value.contains("cvv") || value.equals("pin");
+ }
+
+ static boolean isPasswordInputType(int inputType) {
+ int inputClass = inputType & InputType.TYPE_MASK_CLASS;
+ int variation = inputType & InputType.TYPE_MASK_VARIATION;
+ if (inputClass == InputType.TYPE_CLASS_TEXT) {
+ return variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
+ || variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ || variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD;
+ }
+ return inputClass == InputType.TYPE_CLASS_NUMBER && variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD;
+ }
+}
diff --git a/gleap/src/main/java/io/gleap/ScreenshotTaker.java b/gleap/src/main/java/io/gleap/ScreenshotTaker.java
index 9fdd0e38..23370c2d 100644
--- a/gleap/src/main/java/io/gleap/ScreenshotTaker.java
+++ b/gleap/src/main/java/io/gleap/ScreenshotTaker.java
@@ -41,6 +41,8 @@ public void getImage(Bitmap bitmap) {
public void openScreenshot(Bitmap imageFile, SurveyType type) {
boolean opened = false;
+ // Opened again after a capture closed it: to the app it never closed.
+ boolean afterCapture = false;
try {
GleapOverlayManager.getInstance().setInvisible();
Activity activity = ActivityUtil.getCurrentActivity();
@@ -82,14 +84,20 @@ public void openScreenshot(Bitmap imageFile, SurveyType type) {
GleapOverlayManager.getInstance().clearMessages();
- if(GleapCallbacks.getInstance().getWidgetOpenedCallback() != null) {
+ afterCapture = GleapCaptureCoordinator.getInstance().takeWidgetClosedForCapture();
+ final boolean reopenedAfterCapture = afterCapture;
+ if(!afterCapture && GleapCallbacks.getInstance().getWidgetOpenedCallback() != null) {
GleapCallbacks.getInstance().getWidgetOpenedCallback().invoke();
}
GleapMainThread.post(new Runnable() {
@Override
public void run() {
- GleapOverlayManager.getInstance().setMessageCounter(0);
+ // After a capture only a real change reaches the app (a message that
+ // arrived meanwhile); the count was reset when the widget first opened.
+ if (!reopenedAfterCapture || GleapOverlayManager.getInstance().messageCounter != 0) {
+ GleapOverlayManager.getInstance().setMessageCounter(0);
+ }
}
});
@@ -105,6 +113,10 @@ public void run() {
// paused, and show the feedback button again.
GleapDetectorUtil.resumeAllDetectors();
GleapOverlayManager.getInstance().setVisible();
+ if (afterCapture) {
+ // The app still thinks the widget is open from before the capture.
+ GleapCaptureCoordinator.getInstance().widgetNotReopened();
+ }
}
}
}
diff --git a/gleap/src/main/java/io/gleap/ScreenshotUtil.java b/gleap/src/main/java/io/gleap/ScreenshotUtil.java
index d5c4b1e2..01cf5b0a 100644
--- a/gleap/src/main/java/io/gleap/ScreenshotUtil.java
+++ b/gleap/src/main/java/io/gleap/ScreenshotUtil.java
@@ -84,7 +84,10 @@ public void invoke(Bitmap bitmap) {
getImageCallback.getImage(bitmap != null ? getResizedBitmap(bitmap) : null);
}
});
- } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
+ } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ // Below API 26 there is no PixelCopy of a window: draw it. API 25 went to
+ // generateBitmap, which skips hardware accelerated windows (all of them by default),
+ // so hardware accelerated apps got no screenshot at all.
bitmap = Bitmap.createBitmap(view.getWidth(),
view.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
diff --git a/gleap/src/test/java/io/gleap/GleapCaptureGeometryTest.java b/gleap/src/test/java/io/gleap/GleapCaptureGeometryTest.java
new file mode 100644
index 00000000..6d0e0d48
--- /dev/null
+++ b/gleap/src/test/java/io/gleap/GleapCaptureGeometryTest.java
@@ -0,0 +1,114 @@
+package io.gleap;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * The sizes of screenshots and recordings: the long edge limits, the even (aligned) encoder sizes
+ * H.264 needs, the letterbox after a rotation, and the frame rate limits.
+ */
+public class GleapCaptureGeometryTest {
+ @Test
+ public void screenshotsKeepTheirSizeUpToTheLimitAndShrinkAboveIt() {
+ assertArrayEquals(new int[]{1080, 2400}, GleapCaptureGeometry.fitLongEdge(1080, 2400, 2560));
+ assertArrayEquals(new int[]{2560, 1600}, GleapCaptureGeometry.fitLongEdge(2560, 1600, 2560));
+ // A 12.9" tablet: 2732 x 2048 → long edge 2560.
+ assertArrayEquals(new int[]{2560, 1919}, GleapCaptureGeometry.fitLongEdge(2732, 2048, 2560));
+ assertArrayEquals(new int[]{1, 1}, GleapCaptureGeometry.fitLongEdge(0, 0, 2560));
+ }
+
+ @Test
+ public void encoderSizesAreEvenAlignedAndAtMost1280() {
+ int[] phone = GleapCaptureGeometry.encoderSize(1080, 2400, 1280, 2);
+ assertArrayEquals(new int[]{576, 1280}, phone);
+
+ // Odd results are rounded down to even sizes.
+ int[] odd = GleapCaptureGeometry.encoderSize(1081, 2399, 1280, 2);
+ assertEquals(0, odd[0] % 2);
+ assertEquals(0, odd[1] % 2);
+ assertTrue(odd[1] <= 1280);
+
+ // An encoder that needs multiples of 16.
+ int[] aligned = GleapCaptureGeometry.encoderSize(1080, 2400, 1280, 16);
+ assertArrayEquals(new int[]{576, 1280}, aligned);
+ int[] tablet = GleapCaptureGeometry.encoderSize(2560, 1600, 1280, 16);
+ assertArrayEquals(new int[]{1280, 800}, tablet);
+ int[] foldable = GleapCaptureGeometry.encoderSize(2208, 1840, 1280, 16);
+ assertEquals(0, foldable[0] % 16);
+ assertEquals(0, foldable[1] % 16);
+ assertTrue(foldable[0] <= 1280 && foldable[1] <= 1280);
+
+ // Small windows are never upscaled and never 0.
+ assertArrayEquals(new int[]{640, 360}, GleapCaptureGeometry.encoderSize(640, 360, 1280, 2));
+ assertArrayEquals(new int[]{16, 16}, GleapCaptureGeometry.encoderSize(3, 3, 1280, 16));
+ }
+
+ @Test
+ public void aRotatedFrameIsLetterboxedIntoTheFixedOutput() {
+ // Portrait recording (576 x 1280), the phone turned to landscape (2400 x 1080).
+ float[] box = GleapCaptureGeometry.letterbox(2400, 1080, 576, 1280);
+ assertEquals(576f, box[2], 0.01f);
+ assertEquals(259.2f, box[3], 0.01f);
+ assertEquals(0f, box[0], 0.01f);
+ // Centered vertically.
+ assertEquals((1280 - 259.2f) / 2, box[1], 0.01f);
+
+ // Same aspect: the frame fills the output.
+ float[] same = GleapCaptureGeometry.letterbox(1080, 2400, 576, 1280);
+ assertArrayEquals(new float[]{0f, 0f, 576f, 1280f}, same, 0.01f);
+
+ // A narrower window (multi-window) gets bars left and right.
+ float[] narrow = GleapCaptureGeometry.letterbox(540, 2400, 576, 1280);
+ assertEquals(1280f, narrow[3], 0.01f);
+ assertEquals((576 - narrow[2]) / 2, narrow[0], 0.01f);
+ }
+
+ @Test
+ public void theFrameRateStaysBetweenTwoAndEightFramesASecond() {
+ long interval = GleapCaptureGeometry.TARGET_FRAME_INTERVAL_MS;
+ // Expensive frames (more than 30 % of the interval on the main thread) slow down to 2 fps.
+ for (int i = 0; i < 10; i++) {
+ interval = GleapCaptureGeometry.nextFrameInterval(interval, 200, true);
+ }
+ assertEquals(GleapCaptureGeometry.MAX_FRAME_INTERVAL_MS, interval);
+
+ // Frames that do not keep up slow down as well.
+ assertTrue(GleapCaptureGeometry.nextFrameInterval(250, 10, false) > 250);
+
+ // Cheap frames speed up to at most 8 fps.
+ interval = GleapCaptureGeometry.TARGET_FRAME_INTERVAL_MS;
+ for (int i = 0; i < 20; i++) {
+ interval = GleapCaptureGeometry.nextFrameInterval(interval, 1, true);
+ }
+ assertEquals(GleapCaptureGeometry.MIN_FRAME_INTERVAL_MS, interval);
+
+ // Moderate cost after a slow phase: back towards 4 fps.
+ assertTrue(GleapCaptureGeometry.nextFrameInterval(500, 60, true) < 500);
+ assertEquals(250, GleapCaptureGeometry.nextFrameInterval(250, 40, true));
+ }
+
+ @Test
+ public void framesNeverTakeMoreThanAThirdOfTheMainThread() {
+ // Cheap frames: the interval decides.
+ assertEquals(250, GleapCaptureGeometry.nextFrameDelay(250, 10));
+ // At 2 fps a frame that costs 300 ms on the main thread pushes the next one to 1 s
+ // (frames are skipped): at most 30 % of the main thread.
+ assertEquals(1000, GleapCaptureGeometry.nextFrameDelay(500, 300));
+ assertTrue(GleapCaptureGeometry.nextFrameDelay(125, 100) >= 334);
+ }
+
+ @Test
+ public void theTimerAndTheDurationLimit() {
+ assertEquals("00:00", GleapCaptureGeometry.formatClock(0));
+ assertEquals("01:05", GleapCaptureGeometry.formatClock(65));
+ assertEquals("03:00", GleapCaptureGeometry.formatClock(180));
+
+ assertEquals(60, GleapCaptureGeometry.clampDuration(0));
+ assertEquals(5, GleapCaptureGeometry.clampDuration(1));
+ assertEquals(180, GleapCaptureGeometry.clampDuration(600));
+ assertEquals(45, GleapCaptureGeometry.clampDuration(45));
+ }
+}
diff --git a/gleap/src/test/java/io/gleap/GleapCaptureLogsTest.java b/gleap/src/test/java/io/gleap/GleapCaptureLogsTest.java
new file mode 100644
index 00000000..2e367988
--- /dev/null
+++ b/gleap/src/test/java/io/gleap/GleapCaptureLogsTest.java
@@ -0,0 +1,342 @@
+package io.gleap;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Log bundles for capture requests: only what the request includes and the app allows, sent
+ * gzip compressed with the session, once per request, and never while remote log collection is
+ * turned off.
+ */
+public class GleapCaptureLogsTest {
+ private static final String ID = "65f0c0ffee0123456789abcd";
+ private static final String PATH = "/v3/shared/capture-requests/" + ID;
+
+ private SdkTestEnvironment sdk;
+ private FakeSources sources;
+
+ @Before
+ public void setUp() {
+ sdk = new SdkTestEnvironment();
+ sdk.storeSession("id-1", "hash-1");
+ sdk.controller.setSessionLoaded(true);
+ sources = new FakeSources();
+ GleapCaptureLogs.getInstance().resetForTesting();
+ GleapCaptureLogs.getInstance().setSourcesForTesting(sources);
+ GleapCaptureExecutor.setExecutorForTesting(new java.util.concurrent.Executor() {
+ @Override
+ public void execute(Runnable command) {
+ command.run();
+ }
+ });
+ GleapCapture.setRemoteLogCollectionEnabled(true);
+ GleapCapture.setLogFlushHandler(null);
+ }
+
+ @After
+ public void tearDown() {
+ GleapCaptureExecutor.setExecutorForTesting(null);
+ GleapCaptureLogs.getInstance().resetForTesting();
+ GleapCapture.setRemoteLogCollectionEnabled(true);
+ sdk.tearDown();
+ }
+
+ @Test
+ public void aLogsRequestIsClaimedAndAnsweredWithAGzippedBundle() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{\"claimedElsewhere\":false}");
+ sdk.server.respond(PATH + "/logs", 200, "{}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+
+ FakeGleapServer.Request claim = sdk.server.last(PATH + "/claim");
+ JSONObject claimBody = claim.bodyJson();
+ assertEquals("android", claimBody.getString("platform"));
+ assertEquals("NATIVE", claimBody.getString("sdkType"));
+ assertEquals(gleap.io.gleap.BuildConfig.VERSION_NAME, claimBody.getString("sdkVersion"));
+
+ FakeGleapServer.Request logs = sdk.server.last(PATH + "/logs");
+ assertEquals("gzip", logs.headers.get("Content-Encoding"));
+ assertEquals(SdkTestEnvironment.SDK_KEY, logs.headers.get("Api-Token"));
+ assertEquals("id-1", logs.headers.get("Gleap-Id"));
+ assertEquals("hash-1", logs.headers.get("Gleap-Hash"));
+ JSONObject bundle = new JSONObject(gunzip(logs.body.toByteArray()));
+ assertEquals("boom", bundle.getJSONArray("consoleLog").getJSONObject(0).getString("log"));
+ assertEquals(1, bundle.getJSONArray("networkLogs").length());
+ assertEquals("gold", bundle.getJSONObject("customData").getString("plan"));
+ assertEquals("Pixel", bundle.getJSONObject("metaData").getString("deviceModel"));
+ assertEquals("checkout", bundle.getJSONArray("customEventLog").getJSONObject(0).getString("name"));
+ // The replay is only sent when the request asks for it.
+ assertFalse(bundle.has("replay"));
+ assertEquals("android", bundle.getString("platform"));
+ // The window starts with the oldest entry.
+ assertEquals("2026-09-30T10:00:00.000Z", bundle.getString("windowStart"));
+ assertTrue(bundle.has("capturedAt"));
+ assertTrue(bundle.has("windowEnd"));
+ }
+
+ @Test
+ public void onlyTheIncludedPartsAreSent() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{}");
+ sdk.server.respond(PATH + "/logs", 200, "{}");
+ JSONObject include = new JSONObject()
+ .put("consoleLog", false)
+ .put("networkLogs", true)
+ .put("customData", false)
+ .put("metaData", false)
+ .put("customEventLog", false)
+ .put("replays", true);
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(include));
+
+ JSONObject bundle = new JSONObject(gunzip(sdk.server.last(PATH + "/logs").body.toByteArray()));
+ assertFalse(bundle.has("consoleLog"));
+ assertFalse(bundle.has("customData"));
+ assertFalse(bundle.has("metaData"));
+ assertFalse(bundle.has("customEventLog"));
+ assertTrue(bundle.has("networkLogs"));
+ // The replay's frames are not masked: never sent with capture logs, also when asked for.
+ assertFalse(bundle.has("replay"));
+ }
+
+ @Test
+ public void theAppsSettingsApply() throws Exception {
+ // Console logs off in the remote config, env data disabled.
+ sources.consoleLogsEnabled = false;
+ sources.metaData = null;
+ JSONObject bundle = GleapCaptureLogs.buildBundle(
+ GleapCaptureLogs.Include.from(new JSONObject().put("replays", true)), sources, null, null,
+ new Date(), "device-1");
+
+ assertFalse(bundle.has("consoleLog"));
+ assertFalse(bundle.has("metaData"));
+ assertFalse(bundle.has("replay"));
+ assertEquals("device-1", bundle.getString("deviceId"));
+ }
+
+ @Test
+ public void eachRequestIsSentOnce() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{}");
+ sdk.server.respond(PATH + "/logs", 200, "{}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+ // The same request again, e.g. with a ping answer or after a reconnect.
+ GleapCaptureLogs.getInstance().onCaptureRequests(new JSONArray().put(logsRequest(null)));
+
+ assertEquals(1, sdk.server.requestsTo(PATH + "/claim").size());
+ assertEquals(1, sdk.server.requestsTo(PATH + "/logs").size());
+ }
+
+ @Test
+ public void aRequestThatFailsOnTheServerSideIsTriedThreeTimesThenReportedFailed() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{}");
+ sdk.server.respond(PATH + "/logs", 503, "{}");
+ sdk.server.respond(PATH + "/event", 200, "{}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+ // Pushed again (a ping answer, a reconnect) while it waits for its retry: ignored, the
+ // logs are not collected and sent again.
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+ assertEquals(1, sdk.server.requestsTo(PATH + "/logs").size());
+ assertTrue(sdk.server.requestsTo(PATH + "/event").isEmpty());
+
+ // The retry delays pass (posted to the main thread).
+ sdk.runMainThreadTasks();
+
+ assertEquals(GleapCaptureLogs.MAX_ATTEMPTS, sdk.server.requestsTo(PATH + "/logs").size());
+ JSONObject event = sdk.server.last(PATH + "/event").bodyJson();
+ assertEquals("failed", event.getString("type"));
+ assertTrue(event.getString("reason").contains("503"));
+
+ // Closed for this device: a later push is ignored as well.
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+ sdk.runMainThreadTasks();
+ assertEquals(GleapCaptureLogs.MAX_ATTEMPTS, sdk.server.requestsTo(PATH + "/logs").size());
+ assertEquals(1, sdk.server.requestsTo(PATH + "/event").size());
+ }
+
+ @Test
+ public void aRetryThatSucceedsEndsTheRequest() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{}");
+ sdk.server.respond(PATH + "/logs", 503, "{}");
+ sdk.server.respond(PATH + "/logs", 200, "{}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+ sdk.runMainThreadTasks();
+
+ assertEquals(2, sdk.server.requestsTo(PATH + "/logs").size());
+ assertTrue(sdk.server.requestsTo(PATH + "/event").isEmpty());
+ }
+
+ @Test
+ public void withRemoteLogCollectionOffTheRequestIsAnsweredUnsupported() throws Exception {
+ sdk.server.respond(PATH + "/event", 200, "{}");
+ GleapCapture.setRemoteLogCollectionEnabled(false);
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+
+ assertTrue(sdk.server.requestsTo(PATH + "/claim").isEmpty());
+ assertTrue(sdk.server.requestsTo(PATH + "/logs").isEmpty());
+ JSONObject event = sdk.server.last(PATH + "/event").bodyJson();
+ assertEquals("unsupported", event.getString("type"));
+
+ // Captures do not bring logs either.
+ GleapCaptureLogs.getInstance().sendForCapture(ID, null, null, new Date());
+ assertTrue(sdk.server.requestsTo(PATH + "/logs").isEmpty());
+ }
+
+ @Test
+ public void aClosedRequestGetsNoLogs() throws Exception {
+ sdk.server.respond(PATH + "/claim", 410, "{\"status\":\"expired\"}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+
+ assertTrue(sdk.server.requestsTo(PATH + "/logs").isEmpty());
+ }
+
+ @Test
+ public void onlyLogRequestsWithAValidIdAreHandled() throws Exception {
+ sdk.server.respond("/v3/shared/capture-requests/", 200, "{}");
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(new JSONObject().put("id", ID).put("kind", "screenshot"));
+ GleapCaptureLogs.getInstance().onCaptureRequest(new JSONObject().put("id", "../../bugs").put("kind", "logs"));
+ GleapCaptureLogs.getInstance().onCaptureRequest(new JSONObject().put("kind", "logs"));
+ GleapCaptureLogs.getInstance().onCaptureRequest(null);
+
+ assertTrue(sdk.server.requests.isEmpty());
+ }
+
+ @Test
+ public void theFlushHandlerRunsOnTheMainThreadBeforeTheLogsAreRead() throws Exception {
+ sdk.server.respond(PATH + "/claim", 200, "{}");
+ sdk.server.respond(PATH + "/logs", 200, "{}");
+ final boolean[] flushed = {false};
+ GleapCapture.setLogFlushHandler(new GleapLogFlushHandler() {
+ @Override
+ public void onFlushRequested(Runnable done) {
+ flushed[0] = true;
+ done.run();
+ }
+ });
+ // The main thread runs the posted work right away here.
+ GleapMainThread.setTestExecutor(new java.util.concurrent.Executor() {
+ @Override
+ public void execute(Runnable command) {
+ command.run();
+ }
+ });
+
+ GleapCaptureLogs.getInstance().onCaptureRequest(logsRequest(null));
+
+ assertTrue(flushed[0]);
+ assertEquals(1, sdk.server.requestsTo(PATH + "/logs").size());
+ }
+
+ @Test
+ public void gzipRoundTrip() throws Exception {
+ String json = "{\"consoleLog\":[{\"log\":\"é ✓\"}]}";
+ assertEquals(json, gunzip(GleapCaptureLogs.gzip(json)));
+ }
+
+ private static JSONObject logsRequest(JSONObject include) throws Exception {
+ JSONObject options = new JSONObject();
+ if (include != null) {
+ options.put("include", include);
+ }
+ return new JSONObject()
+ .put("id", ID)
+ .put("kind", "logs")
+ .put("options", options)
+ .put("ticketShareToken", "share-1")
+ .put("expiresAt", "2026-10-01T10:00:00.000Z");
+ }
+
+ private static String gunzip(byte[] data) throws Exception {
+ try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static final class FakeSources implements GleapCaptureLogs.Sources {
+ boolean consoleLogsEnabled = true;
+ JSONObject metaData;
+
+ FakeSources() {
+ try {
+ metaData = new JSONObject().put("deviceModel", "Pixel");
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public boolean consoleLogsEnabled() {
+ return consoleLogsEnabled;
+ }
+
+ @Override
+ public JSONArray consoleLog() {
+ try {
+ return new JSONArray().put(new JSONObject()
+ .put("date", "2026-09-30T10:00:00.000Z").put("priority", "ERROR").put("log", "boom"));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public JSONArray networkLogs() {
+ try {
+ return new JSONArray().put(new JSONObject()
+ .put("date", "2026-09-30T10:01:00.000Z").put("url", "https://api.example.com").put("success", false));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public JSONObject customData() {
+ try {
+ return new JSONObject().put("plan", "gold");
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public JSONObject metaData() {
+ return metaData;
+ }
+
+ @Override
+ public JSONArray customEventLog() {
+ try {
+ return new JSONArray().put(new JSONObject()
+ .put("name", "checkout").put("date", "2026-09-30T10:02:00.000Z"));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ }
+}
diff --git a/gleap/src/test/java/io/gleap/GleapCaptureTest.java b/gleap/src/test/java/io/gleap/GleapCaptureTest.java
new file mode 100644
index 00000000..eef23e8e
--- /dev/null
+++ b/gleap/src/test/java/io/gleap/GleapCaptureTest.java
@@ -0,0 +1,167 @@
+package io.gleap;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONObject;
+import org.junit.Test;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * Who may start a capture and what the SDK announces: capture commands are only taken from the
+ * messenger's own origin, request ids never leave the id format, and the capabilities follow the
+ * app's switches.
+ */
+public class GleapCaptureTest {
+ private static final String WIDGET = "https://messenger-app.gleap.io/appnew";
+
+ @Test
+ public void onlyTheMessengerOriginMayStartACapture() {
+ assertTrue(GleapCapture.sameOrigin("https://messenger-app.gleap.io/appnew?lang=en&gleapId=1", WIDGET));
+ assertTrue(GleapCapture.sameOrigin("https://MESSENGER-APP.gleap.io:443/other", WIDGET));
+
+ assertFalse(GleapCapture.sameOrigin("http://messenger-app.gleap.io/appnew", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("https://messenger-app.gleap.io.evil.com/appnew", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("https://evil.com/?u=https://messenger-app.gleap.io/appnew", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("https://messenger-app.gleap.io@evil.com/appnew", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("https://messenger-app.gleap.io:8443/appnew", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("javascript:alert(1)", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("file:///android_asset/appnew.html", WIDGET));
+ assertFalse(GleapCapture.sameOrigin("about:blank", WIDGET));
+ assertFalse(GleapCapture.sameOrigin(null, WIDGET));
+ assertFalse(GleapCapture.sameOrigin(WIDGET, null));
+
+ // A custom frame url (setFrameUrl), e.g. a local server.
+ assertTrue(GleapCapture.sameOrigin("http://10.0.2.2:8787/widget/appnew?x=1", "http://10.0.2.2:8787/widget/appnew"));
+ assertFalse(GleapCapture.sameOrigin("http://10.0.2.2:8788/widget/appnew", "http://10.0.2.2:8787/widget/appnew"));
+ }
+
+ @Test
+ public void passwordsPaymentCardsAndOneTimeCodesAreMasked() {
+ int text = android.text.InputType.TYPE_CLASS_TEXT;
+ int number = android.text.InputType.TYPE_CLASS_NUMBER;
+ assertTrue(GleapWindowCapture.isPasswordInputType(text | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD));
+ assertTrue(GleapWindowCapture.isPasswordInputType(text | android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD));
+ assertTrue(GleapWindowCapture.isPasswordInputType(text | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD));
+ assertTrue(GleapWindowCapture.isPasswordInputType(number | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD));
+ assertFalse(GleapWindowCapture.isPasswordInputType(text | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS));
+ assertFalse(GleapWindowCapture.isPasswordInputType(number));
+
+ // Autofill hints: the framework's View.AUTOFILL_HINT_* and androidx HintConstants names.
+ for (String hint : new String[]{"password", "newPassword", "creditCardNumber", "creditCardSecurityCode",
+ "creditCardExpirationDate", "smsOTPCode", "2faAppOTPCode", "emailOTPCode", "one-time-code"}) {
+ assertTrue(hint, GleapWindowCapture.isSensitiveAutofillHint(hint));
+ }
+ for (String hint : new String[]{"emailAddress", "username", "phone", "postalCode", "name", null}) {
+ assertFalse(String.valueOf(hint), GleapWindowCapture.isSensitiveAutofillHint(hint));
+ }
+ }
+
+ @Test
+ public void requestIdsStayInsideTheirPath() {
+ assertTrue(GleapCapture.isValidRequestId("65f0c0ffee0123456789abcd"));
+ assertFalse(GleapCapture.isValidRequestId("../../bugs/v2"));
+ assertFalse(GleapCapture.isValidRequestId("id/claim"));
+ assertFalse(GleapCapture.isValidRequestId("id?x=1"));
+ assertFalse(GleapCapture.isValidRequestId(""));
+ assertFalse(GleapCapture.isValidRequestId(null));
+ assertEquals("/v3/shared/capture-requests/abc/logs", GleapCaptureApi.requestPath("abc", "logs"));
+ try {
+ GleapCaptureApi.requestPath("a/b", "logs");
+ throw new AssertionError("An invalid id was accepted");
+ } catch (IllegalArgumentException expected) {
+ }
+ }
+
+ @Test
+ public void theCapabilitiesFollowTheAppsSwitches() throws Exception {
+ assertEquals(Arrays.asList("capture.screenshot", "capture.recording", "capture.logs"),
+ GleapCapture.caps(true, true, true));
+ // Below API 26: no recordings.
+ assertEquals(Arrays.asList("capture.screenshot", "capture.logs"), GleapCapture.caps(true, false, true));
+ assertEquals(Collections.singletonList("capture.logs"), GleapCapture.caps(false, true, true));
+ assertEquals(Collections.emptyList(), GleapCapture.caps(false, true, false));
+ assertEquals("capture.screenshot,capture.logs",
+ GleapCapture.capsQueryValue(GleapCapture.caps(true, false, true)));
+
+ JSONObject enabled = GleapCapture.capabilities(true, true, "FLUTTER", "19.0.0");
+ assertEquals(1, enabled.getInt("version"));
+ assertEquals("android", enabled.getString("platform"));
+ assertEquals("FLUTTER", enabled.getString("sdkType"));
+ assertTrue(enabled.getBoolean("screenshot"));
+ assertTrue(enabled.getBoolean("recording"));
+ assertEquals("frames", enabled.getString("recordingMethod"));
+ assertFalse(enabled.getBoolean("microphone"));
+
+ // setCaptureEnabled(false): nothing is offered, the widget falls back to the upload.
+ JSONObject disabled = GleapCapture.capabilities(false, true, "NATIVE", "19.0.0");
+ assertFalse(disabled.getBoolean("screenshot"));
+ assertFalse(disabled.getBoolean("recording"));
+ assertFalse(disabled.has("recordingMethod"));
+
+ JSONObject oldDevice = GleapCapture.capabilities(true, false, "NATIVE", "19.0.0");
+ assertTrue(oldDevice.getBoolean("screenshot"));
+ assertFalse(oldDevice.getBoolean("recording"));
+ }
+
+ @Test
+ public void aCaptureStartIsReadWithItsLimitsAndLabels() throws Exception {
+ JSONObject start = new JSONObject()
+ .put("requestId", "65f0c0ffee0123456789abcd")
+ .put("kind", "recording")
+ .put("ticketShareToken", "share-1")
+ .put("options", new JSONObject().put("maxDurationSec", 900).put("attachLogs", false))
+ .put("labels", new JSONObject().put("barStop", "Stopp").put("barStart", " "));
+
+ GleapCaptureRequest request = GleapCaptureRequest.fromStart(start);
+
+ assertEquals("recording", request.kind);
+ assertTrue(request.isRecording());
+ assertEquals("share-1", request.ticketShareToken);
+ assertEquals(180, request.maxDurationSec);
+ assertFalse(request.attachLogs);
+ assertEquals("Stopp", request.label("barStop"));
+ // Blank or missing labels fall back to English.
+ assertEquals("Start", request.label("barStart"));
+ assertEquals("Cancel", request.label("barCancel"));
+
+ assertNull(GleapCaptureRequest.fromStart(new JSONObject(start.toString()).put("kind", "any")));
+ assertNull(GleapCaptureRequest.fromStart(new JSONObject(start.toString()).put("requestId", "../x")));
+ assertNull(GleapCaptureRequest.fromStart(null));
+ }
+
+ @Test
+ public void leftoverRecordingsAreDeletedButNotTheRunningCapturesOne() throws Exception {
+ File dir = java.nio.file.Files.createTempDirectory("gleap-capture").toFile();
+ File leftover = new File(dir, "recording-old.mp4");
+ File current = new File(dir, "recording-new.mp4");
+ try {
+ assertTrue(leftover.createNewFile());
+ assertTrue(current.createNewFile());
+ GleapCapture.useCaptureFile(current);
+
+ // At start: what an earlier process left goes, the running capture's recording stays.
+ assertEquals(1, GleapCapture.deleteLeftovers(dir));
+ assertFalse(leftover.exists());
+ assertTrue(current.exists());
+
+ // Sent, retaken, cancelled or failed: it is deleted and no longer protected.
+ GleapCapture.deleteCaptureFile(current);
+ assertFalse(current.exists());
+ assertTrue(current.createNewFile());
+ assertEquals(1, GleapCapture.deleteLeftovers(dir));
+ assertFalse(current.exists());
+
+ assertEquals(0, GleapCapture.deleteLeftovers(new File(dir, "missing")));
+ } finally {
+ GleapCapture.deleteCaptureFile(current);
+ leftover.delete();
+ dir.delete();
+ }
+ }
+}