Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f21bbcb
fix: take screenshots on Android 7.1 with hardware acceleration
boehlerlukas Oct 1, 2026
075314a
fix: copy attachment uploads in chunks instead of whole files
boehlerlukas Oct 1, 2026
a147630
feat: background log collection for capture requests
boehlerlukas Oct 1, 2026
3ba1c0b
feat: in-app screenshots and screen recordings for capture requests
boehlerlukas Oct 1, 2026
aeb0ebe
feat: capture demo screen and local stub mode in the example app
boehlerlukas Oct 1, 2026
ca3c76f
fix: tell the server in pings whether the WebSocket is connected
boehlerlukas Oct 1, 2026
3bfe95d
fix: no widget closed / opened callbacks for a capture's own close an…
boehlerlukas Oct 1, 2026
51c5a2e
chore: log the widget callbacks and call Gleap.close() in the example…
boehlerlukas Oct 1, 2026
885ef6f
fix: mask app-provided pictures like the SDK's own captures
boehlerlukas Oct 1, 2026
d349918
fix: stop a running capture when capture is turned off
boehlerlukas Oct 1, 2026
7ba9090
chore: app bitmap callback switch and a secure dialog in the example app
boehlerlukas Oct 1, 2026
29e3b2c
fix: send ws: true in every ping again
boehlerlukas Oct 1, 2026
c3b6420
fix: captures are masked or dropped, and recordings stay bounded
boehlerlukas Oct 1, 2026
805346a
fix: logs requests retry three times, then report failed; no replay f…
boehlerlukas Oct 1, 2026
187b837
fix: in-app messages that arrive during a capture show after it
boehlerlukas Oct 1, 2026
613a9ef
fix: capture edge cases from the stability review
boehlerlukas Oct 1, 2026
a5481fb
chore: capture demo shows a card number and a one-time code field
boehlerlukas Oct 1, 2026
0ecbf11
fix: a recording that is given up is released on its encoder thread
boehlerlukas Oct 1, 2026
fccdf16
feat: minimal draggable capture bar and full-screen recording preview
boehlerlukas Oct 1, 2026
77c9f63
fix: the recording time reads left to right in RTL layouts
boehlerlukas Oct 1, 2026
4c31af8
fix: the recording preview comes back after a rotation
boehlerlukas Oct 1, 2026
1ea0bdb
fix: keep recordings out of the cache and delete every one the SDK le…
boehlerlukas Oct 1, 2026
6860010
fix: x in the recording preview cancels a running upload
boehlerlukas Oct 1, 2026
42fec9f
feat: Retake and Send icons like iOS and the web SDK
boehlerlukas Oct 1, 2026
6c4a925
docs: release notes for capture requests
boehlerlukas Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased
Capture requests ("show me the issue"): when a workflow, an AI agent or a teammate asks the customer for a screenshot or a screen recording in a conversation, the customer answers from the card in the widget and the SDK captures the app itself: the activity, its dialogs and bottom sheets and SurfaceView content such as maps or video, without MediaProjection, a foreground service or any new permission. A small dark bar appears over the app (the customer can drag it anywhere; it remembers where it was put) with Capture or Start, and while recording only a red dot, the time and a round Stop. Screenshots open in the widget's markup editor. Recordings are H.264 MP4 videos at 2 to 8 frames per second, up to 3 minutes, leave out the time the app spent in the background and are shown in a dark preview (Retake, Send) before they are uploaded. Password fields, card-number and one-time-code fields (Android 8+), windows with `FLAG_SECURE` and views passed to `Gleap.getInstance().maskView(View)` are blacked out in every screenshot and frame; a frame whose masks can't be worked out is dropped instead of being sent. In-app messages that arrive during a capture are shown after it. Every text in the bar and the preview comes from the widget's translations.
Logs requests: workflows, AI agents and teammates can collect the app's console logs, network logs, custom data, environment data and events in the background, the same data a bug report carries. The SDK takes the request from its socket or the ping answer, uploads the logs once (gzipped) and tries a failing upload three times before reporting it failed.
New: `setCaptureEnabled(boolean)` (off: the widget only offers a file upload, and a running capture stops), `setRemoteLogCollectionEnabled(boolean)` (off: logs requests are answered as unsupported and captures are sent without logs), `maskView(View)` / `unmaskView(View)` and `setLogFlushHandler(GleapLogFlushHandler)`, which the React Native, Flutter and Capacitor SDKs use to hand over their buffered logs first.
Screenshots work again on Android 7.1 with hardware acceleration (the SDK returned no screenshot there). Attachment uploads are copied in chunks instead of reading whole files into memory.

## 19.0.0

### Added
Expand Down
7 changes: 7 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only: cleartext to a local stub server (see MainApplication, debug.gleap.stub). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application android:networkSecurityConfig="@xml/gleap_stub_network_security_config" />

</manifest>
11 changes: 11 additions & 0 deletions app/src/debug/res/xml/gleap_stub_network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only: cleartext to the emulator's host (10.0.2.2) and the device itself, for a
local stub server; everything else keeps the platform default (no cleartext). -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
android:exported="false"
android:launchMode="singleInstance"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".CaptureDemo"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<activity
android:name=".MainActivity2"
android:launchMode="singleInstance"
Expand Down
176 changes: 176 additions & 0 deletions app/src/main/java/gleap/io/gleap_android_sdk/CaptureDemo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package gleap.io.gleap_android_sdk;

import android.content.Intent;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.text.InputType;
import android.widget.EditText;
import android.widget.TextView;

import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;

import java.util.Locale;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import io.gleap.Gleap;

/**
* A screen to try capture requests on: a GL surface (SurfaceView content), a password field
* and a masked view (painted black in captures), a dialog, a running clock (motion for
* recordings) and a way to change the activity while the capture bar is shown.
*/
public class CaptureDemo extends AppCompatActivity {
private final Handler handler = new Handler(Looper.getMainLooper());
private GLSurfaceView glView;
private TextView clock;
private final long startedAt = SystemClock.uptimeMillis();

private final Runnable tick = new Runnable() {
@Override
public void run() {
long ms = SystemClock.uptimeMillis() - startedAt;
clock.setText(String.format(Locale.ROOT, "Clock %02d:%02d.%d", ms / 60000, (ms / 1000) % 60, (ms / 100) % 10));
handler.postDelayed(this, 100);
}
};

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_capture_demo);

glView = findViewById(R.id.capture_gl);
glView.setEGLContextClientVersion(2);
glView.setRenderer(new MovingSquareRenderer());

clock = findViewById(R.id.capture_clock);
Gleap.getInstance().maskView(findViewById(R.id.capture_masked));

findViewById(R.id.capture_open_widget).setOnClickListener(view -> Gleap.getInstance().open());
findViewById(R.id.capture_dialog).setOnClickListener(view -> showDialog());
findViewById(R.id.capture_close_widget).setOnClickListener(view -> Gleap.getInstance().close());
findViewById(R.id.capture_secure_dialog).setOnClickListener(view -> showSecureDialog());

// What a wrapper that renders its own content does (Flutter): it hands the SDK the picture.
TextView bitmapSwitch = findViewById(R.id.capture_bitmap_switch);
bitmapSwitch.setOnClickListener(view -> {
appBitmap = !appBitmap;
Gleap.getInstance().setBitmapCallback(appBitmap ? () -> renderScreen() : null);
bitmapSwitch.setText(appBitmap ? "App bitmap callback: on" : "App bitmap callback: off");
});
findViewById(R.id.capture_next).setOnClickListener(view ->
startActivity(new Intent(CaptureDemo.this, CaptureDemo.class)));
findViewById(R.id.capture_log).setOnClickListener(view -> {
Gleap.getInstance().log("Capture demo: a log line");
Gleap.getInstance().trackEvent("capture-demo-tap");
});

TextView captureSwitch = findViewById(R.id.capture_switch);
captureSwitch.setOnClickListener(view -> {
captureEnabled = !captureEnabled;
Gleap.getInstance().setCaptureEnabled(captureEnabled);
captureSwitch.setText(captureEnabled ? "Screen capture: on" : "Screen capture: off");
});
TextView logsSwitch = findViewById(R.id.capture_logs_switch);
logsSwitch.setOnClickListener(view -> {
remoteLogs = !remoteLogs;
Gleap.getInstance().setRemoteLogCollectionEnabled(remoteLogs);
logsSwitch.setText(remoteLogs ? "Remote logs: on" : "Remote logs: off");
});
}

// Process-wide like the SDK's switches (a new screen starts with them on again).
private static boolean captureEnabled = true;
private static boolean remoteLogs = true;
private static boolean appBitmap = false;

// The screen drawn in software (no GL content), as a wrapper's own renderer would provide it.
private android.graphics.Bitmap renderScreen() {
android.view.View decor = getWindow().getDecorView();
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
Math.max(1, decor.getWidth()), Math.max(1, decor.getHeight()), android.graphics.Bitmap.Config.ARGB_8888);
decor.draw(new android.graphics.Canvas(bitmap));
return bitmap;
}

// A dialog the app keeps out of screenshots (FLAG_SECURE).
private void showSecureDialog() {
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("A secure dialog")
.setMessage("This window has FLAG_SECURE: it is black in captures.")
.setPositiveButton("Close", null)
.create();
if (dialog.getWindow() != null) {
dialog.getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
dialog.show();
}

private void showDialog() {
EditText secret = new EditText(this);
secret.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
secret.setText("dialog-secret");
new AlertDialog.Builder(this)
.setTitle("A dialog to capture")
.setMessage("The capture bar moves above this dialog. Its password field is painted black.")
.setView(secret)
.setPositiveButton("Close", null)
.show();
}

@Override
protected void onResume() {
super.onResume();
glView.onResume();
handler.post(tick);
}

@Override
protected void onPause() {
handler.removeCallbacks(tick);
glView.onPause();
super.onPause();
}

/**
* A changing background with a yellow square moving left and right.
*/
private static final class MovingSquareRenderer implements GLSurfaceView.Renderer {
private final long start = SystemClock.uptimeMillis();
private int width;
private int height;

@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
}

@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
this.width = width;
this.height = height;
GLES20.glViewport(0, 0, width, height);
}

@Override
public void onDrawFrame(GL10 gl) {
float t = (SystemClock.uptimeMillis() - start) / 1000f;
GLES20.glDisable(GLES20.GL_SCISSOR_TEST);
GLES20.glClearColor(0.1f, 0.45f + 0.35f * (float) Math.sin(t), 0.35f, 1f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
int size = Math.max(1, height / 2);
int x = (int) ((width - size) * (0.5 + 0.5 * Math.sin(t * 1.3)));
GLES20.glEnable(GLES20.GL_SCISSOR_TEST);
GLES20.glScissor(x, height / 4, size, size);
GLES20.glClearColor(1f, 0.85f, 0f, 1f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glDisable(GLES20.GL_SCISSOR_TEST);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ public void onClick(View view) {
});


findViewById(R.id.capture).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
startActivity(new Intent(MainActivity.this, CaptureDemo.class));
}
});

findViewById(R.id.network).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
import io.gleap.callbacks.UnRegisterPushMessageGroupCallback;

public class MainApplication extends Application {
private static String systemProperty(String key) {
try {
Process process = new ProcessBuilder("getprop", key).start();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()));
String value = reader.readLine();
reader.close();
return value != null ? value.trim() : "";
} catch (Exception e) {
return "";
}
}

@Override
public void onCreate() {
super.onCreate();
Expand All @@ -27,10 +39,47 @@ public void onCreate() {
@Override
public void onError(Throwable error, String context) {
System.out.println(context);
if (error != null) {
error.printStackTrace();
}
}
});

Gleap.initialize("ogWhNhuiZcGWrva5nlDS8l7a78OfaLlV", this);
String sdkKey = "ogWhNhuiZcGWrva5nlDS8l7a78OfaLlV";
// Debug builds only: every request goes to a local stub server instead of Gleap, e.g.
// adb shell setprop debug.gleap.stub http://10.0.2.2:8787 (then restart the app).
String stub = BuildConfig.DEBUG ? systemProperty("debug.gleap.stub") : "";
if (stub.startsWith("http://") || stub.startsWith("https://")) {
Gleap.getInstance().setApiUrl(stub);
Gleap.getInstance().setWSApiUrl(stub.replaceFirst("^http", "ws") + "/ws");
Gleap.getInstance().setFrameUrl(stub + "/widget/appnew");
Gleap.getInstance().setBannerUrl(stub + "/outbound");
Gleap.getInstance().setModalUrl(stub + "/outbound/modal");
Gleap.getInstance().setRealtimeHost(android.net.Uri.parse(stub).getAuthority());
sdkKey = "stub-sdk-key";
}

Gleap.initialize(sdkKey, this);

// The widget callbacks: a capture closes and reopens the widget, which the app does not
// hear about (one widget session).
Gleap.getInstance().setWidgetOpenedCallback(() -> android.util.Log.i("GleapDemo", "widget opened"));
Gleap.getInstance().setWidgetClosedCallback(() -> android.util.Log.i("GleapDemo", "widget closed"));
Gleap.getInstance().setNotificationUnreadCountUpdatedCallback(count ->
android.util.Log.i("GleapDemo", "unread count " + count));

// What a wrapper SDK does: hand over its buffered logs before a capture request's logs
// are collected, then call done.
Gleap.getInstance().setLogFlushHandler(done -> {
try {
Gleap.getInstance().attachConsoleLogs(new org.json.JSONArray().put(new JSONObject()
.put("date", "2026-09-30T00:00:00.000Z")
.put("priority", "INFO")
.put("log", "Flushed by the app's log flush handler")));
} catch (JSONException ignore) {
}
done.run();
});
Gleap.getInstance().setTags(new String[] {
"Android",
"Tags",
Expand Down
Loading
Loading