diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2ddb12..81c52570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..dc525375 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/debug/res/xml/gleap_stub_network_security_config.xml b/app/src/debug/res/xml/gleap_stub_network_security_config.xml new file mode 100644 index 00000000..6503c516 --- /dev/null +++ b/app/src/debug/res/xml/gleap_stub_network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f1d47fb6..3d9066b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,6 +62,10 @@ android:exported="false" android:launchMode="singleInstance" android:theme="@style/AppTheme.NoActionBar" /> + 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); + } + } +} diff --git a/app/src/main/java/gleap/io/gleap_android_sdk/MainActivity.java b/app/src/main/java/gleap/io/gleap_android_sdk/MainActivity.java index 2dc813eb..0bad3027 100644 --- a/app/src/main/java/gleap/io/gleap_android_sdk/MainActivity.java +++ b/app/src/main/java/gleap/io/gleap_android_sdk/MainActivity.java @@ -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) { diff --git a/app/src/main/java/gleap/io/gleap_android_sdk/MainApplication.java b/app/src/main/java/gleap/io/gleap_android_sdk/MainApplication.java index e65c40d5..8d020917 100644 --- a/app/src/main/java/gleap/io/gleap_android_sdk/MainApplication.java +++ b/app/src/main/java/gleap/io/gleap_android_sdk/MainApplication.java @@ -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(); @@ -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", diff --git a/app/src/main/res/layout/activity_capture_demo.xml b/app/src/main/res/layout/activity_capture_demo.xml new file mode 100644 index 00000000..0e03fedc --- /dev/null +++ b/app/src/main/res/layout/activity_capture_demo.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + +