From ea011372c9af60190c01187854f6535f1bc7d21b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 05:37:38 +0300 Subject: [PATCH 1/2] Add com.codename1.crash on-device crash protection client New package for capturing unhandled exceptions on device, scrubbing PII, and uploading structured crash reports to the BuildCloud crash service. Pairs with the new /api/v2/crash/reports server endpoint (separate BuildCloud PR). Surface: - CrashProtection.install() / setEnabled() / setScrubber() / capture(). Endpoint is fixed; opt-in is developer-controlled and persisted in Preferences (default off). - PiiScrubber is a public subclassable class. Default rules: emails partially redacted (keep first 3 chars + full domain), runs of 6+ digits collapsed to [num]. URLs are NOT scrubbed. - Storage-first delivery: every payload is written to Storage with a fresh eventId BEFORE the POST; the storage entry is deleted only on a 2xx. Failed sends are retried on next launch. The server uses eventId for idempotent dedup. Wire-up: - Android: AndroidImplementation's UncaughtExceptionHandler now also forwards to CrashProtection.capture(e), guarded by try/catch so a bug here can't suppress the legacy Log.bindCrashProtection path. - iOS: no client change. The existing signal handlers in CodenameOne_GLAppDelegate.m convert signals to JVM exceptions which flow through the EDT error handler naturally. Build-server utility: - maven/codenameone-maven-plugin/.../util/CrashSymbolUploader. Used by the cloud build executor's post-build hook to POST mapping.txt (Android) or the dSYM zip (iOS, release builds only) to the /api/v2/build/{buildKey}/symbols endpoint. Standalone, no Spring dep. AndroidGradleBuilder + IPhoneBuilder carry comments pointing the executor at the right call sites. Java 5 source compatibility maintained in the core client classes. No new runtime dependencies on the device. ASCII-only sources; CodenameOne header (not Oracle). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/crash/CrashProtection.java | 337 ++++++++++++++++++ .../codename1/crash/CrashReportPayload.java | 172 +++++++++ .../src/com/codename1/crash/PiiScrubber.java | 190 ++++++++++ .../impl/android/AndroidImplementation.java | 4 + .../builders/AndroidGradleBuilder.java | 8 + .../com/codename1/builders/IPhoneBuilder.java | 7 + .../builders/util/CrashSymbolUploader.java | 162 +++++++++ .../com/codename1/crash/PiiScrubberTest.java | 63 ++++ 8 files changed, 943 insertions(+) create mode 100644 CodenameOne/src/com/codename1/crash/CrashProtection.java create mode 100644 CodenameOne/src/com/codename1/crash/CrashReportPayload.java create mode 100644 CodenameOne/src/com/codename1/crash/PiiScrubber.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java create mode 100644 tests/core/test/com/codename1/crash/PiiScrubberTest.java diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java new file mode 100644 index 0000000000..1914bd6857 --- /dev/null +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import com.codename1.crash.CrashReportPayload.Frame; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.io.Util; +import com.codename1.ui.Display; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/// Crash protection: captures unhandled exceptions, scrubs PII, persists +/// each crash to {@link Storage} immediately, and uploads to the +/// Codename One cloud crash service when enabled. +/// +/// Quick start: +/// +/// ```java +/// // In your start() method, after Display has initialised: +/// CrashProtection.install(); +/// // Once your app has obtained user consent (e.g. from a settings +/// // screen) flip this on. The default is off so no data leaves the +/// // device until the developer explicitly enables uploads. +/// CrashProtection.setEnabled(true); +/// ``` +/// +/// Pluggable PII scrubbing: override {@link PiiScrubber} and register +/// your subclass via {@link #setScrubber(PiiScrubber)} to extend the +/// default redaction rules. The default scrubber masks the local part +/// of email addresses (keeping the first three characters and the full +/// domain) and replaces runs of six or more digits with `[num]`. +/// +/// The endpoint URL is fixed and cannot be modified by the application. +/// Crash uploads are accepted only for Pro-tier (or higher) accounts +/// for builds produced by the Codename One cloud build server within +/// the last 30 days; out-of-tier or stale builds are silently dropped +/// by the server. +/// +/// This runs in parallel with the legacy {@code Log.bindCrashProtection} +/// path; both can be installed in the same app without conflict. +public final class CrashProtection { + + /// Production crash-upload endpoint. Fixed; not configurable from + /// application code by design (use {@link #setEndpointForTesting} + /// from the framework's own unit tests). + static final String DEFAULT_ENDPOINT = + "https://cloud.codenameone.com/api/v2/crash/reports"; + + static final String STORAGE_PREFIX = "CN1Crash__$"; + static final String PREF_ENABLED = "crashProtectionEnabled"; + static final int MAX_STORED = 100; + + private static String endpoint = DEFAULT_ENDPOINT; + private static PiiScrubber scrubber = new PiiScrubber(); + private static boolean installed; + private static boolean draining; + + private CrashProtection() { + } + + /// Installs the crash protection hooks. Idempotent: calling more + /// than once has no effect. Does nothing on the simulator (matches + /// the legacy {@code Log.bindCrashProtection} behaviour). + /// + /// Side effect: any crashes previously persisted to storage but not + /// yet uploaded will be drained in the background if uploads are + /// currently enabled. + public static void install() { + if (installed) { + return; + } + if (Display.getInstance().isSimulator()) { + installed = true; + return; + } + Display.getInstance().addEdtErrorHandler(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + Object src = evt.getSource(); + if (src instanceof Throwable) { + capture((Throwable) src); + } + } + }); + installed = true; + if (isEnabled()) { + drainAsync(); + } + } + + /// @return `true` if crash uploads are enabled. Default is `false`. + public static boolean isEnabled() { + return Preferences.get(PREF_ENABLED, false); + } + + /// Enables or disables crash uploads. When transitioning from off + /// to on, any crashes buffered in storage are drained in the + /// background. The toggle persists across launches. + public static void setEnabled(boolean enabled) { + boolean was = isEnabled(); + Preferences.set(PREF_ENABLED, enabled); + if (enabled && !was) { + drainAsync(); + } + } + + /// Replaces the active PII scrubber. Subclass {@link PiiScrubber} + /// to extend or replace the default redaction rules. + public static void setScrubber(PiiScrubber s) { + if (s == null) { + throw new IllegalArgumentException("scrubber"); + } + scrubber = s; + } + + public static PiiScrubber getScrubber() { + return scrubber; + } + + /// Manually report an exception. The crash is persisted to storage + /// first and then uploaded if uploads are enabled. Stack frames + /// are captured from the throwable. + public static void capture(Throwable t) { + if (t == null) { + return; + } + if (Display.getInstance().isSimulator()) { + return; + } + try { + CrashReportPayload payload = build(t); + String name = persist(payload); + if (name != null && isEnabled()) { + sendAsync(name, payload.toJson()); + } + } catch (Throwable inner) { + inner.printStackTrace(); + } + } + + static CrashReportPayload build(Throwable t) { + String exClass = t.getClass().getName(); + String message = scrubber.scrubMessage(t.getMessage()); + List frames = extractFrames(t); + return new CrashReportPayload(newEventId(), exClass, message, frames); + } + + static List extractFrames(Throwable t) { + List out = new ArrayList(); + StackTraceElement[] elements = t.getStackTrace(); + if (elements == null) { + return out; + } + int limit = elements.length < CrashReportPayload.MAX_FRAMES + ? elements.length : CrashReportPayload.MAX_FRAMES; + for (int i = 0; i < limit; i++) { + StackTraceElement e = elements[i]; + String cls = e.getClassName(); + String method = scrubber.scrubFrame(cls, e.getMethodName()); + out.add(new Frame(cls, method, e.getFileName(), + e.getLineNumber(), e.isNativeMethod())); + } + return out; + } + + private static String persist(CrashReportPayload payload) { + if (countStored() >= MAX_STORED) { + evictOldest(); + } + String name = STORAGE_PREFIX + payload.eventId; + OutputStream os = null; + try { + os = Storage.getInstance().createOutputStream(name); + os.write(payload.toJson().getBytes("UTF-8")); + os.flush(); + return name; + } catch (IOException ex) { + ex.printStackTrace(); + return null; + } finally { + Util.cleanup(os); + } + } + + private static int countStored() { + String[] all = Storage.getInstance().listEntries(); + if (all == null) return 0; + int c = 0; + for (int i = 0; i < all.length; i++) { + if (all[i] != null && all[i].startsWith(STORAGE_PREFIX)) { + c++; + } + } + return c; + } + + private static void evictOldest() { + String[] all = Storage.getInstance().listEntries(); + if (all == null) return; + String oldest = null; + for (int i = 0; i < all.length; i++) { + String n = all[i]; + if (n == null || !n.startsWith(STORAGE_PREFIX)) continue; + if (oldest == null || n.compareTo(oldest) < 0) { + oldest = n; + } + } + if (oldest != null) { + Storage.getInstance().deleteStorageFile(oldest); + } + } + + private static void drainAsync() { + if (draining) { + return; + } + draining = true; + new Thread(new Runnable() { + public void run() { + try { + drain(); + } finally { + draining = false; + } + } + }, "CrashProtectionDrain").start(); + } + + static void drain() { + if (!isEnabled()) { + return; + } + String[] all = Storage.getInstance().listEntries(); + if (all == null) { + return; + } + for (int i = 0; i < all.length; i++) { + String name = all[i]; + if (name == null || !name.startsWith(STORAGE_PREFIX)) continue; + String json = readStored(name); + if (json == null) { + Storage.getInstance().deleteStorageFile(name); + continue; + } + sendBlocking(name, json); + } + } + + private static String readStored(String name) { + InputStream is = null; + try { + is = Storage.getInstance().createInputStream(name); + if (is == null) { + return null; + } + byte[] data = Util.readInputStream(is); + return new String(data, "UTF-8"); + } catch (IOException ex) { + ex.printStackTrace(); + return null; + } finally { + Util.cleanup(is); + } + } + + private static void sendAsync(final String storageName, final String json) { + NetworkManager.getInstance().addToQueue(buildRequest(storageName, json)); + } + + private static void sendBlocking(String storageName, String json) { + try { + NetworkManager.getInstance().addToQueueAndWait(buildRequest(storageName, json)); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private static ConnectionRequest buildRequest(final String storageName, String json) { + ConnectionRequest req = new ConnectionRequest() { + protected void postResponse() { + int code = getResponseCode(); + if (code >= 200 && code < 300) { + Storage.getInstance().deleteStorageFile(storageName); + } + } + }; + req.setUrl(endpoint); + req.setPost(true); + req.setHttpMethod("POST"); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + return req; + } + + private static String newEventId() { + char[] out = new char[32]; + for (int i = 0; i < 32; i++) { + int v = (int) (Math.random() * 16); + out[i] = (char) (v < 10 ? '0' + v : 'a' + (v - 10)); + } + return new String(out); + } + + /// Framework-internal hook for unit tests; never call from app code. + static void setEndpointForTesting(String url) { + endpoint = url; + } +} diff --git a/CodenameOne/src/com/codename1/crash/CrashReportPayload.java b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java new file mode 100644 index 0000000000..f5222d212a --- /dev/null +++ b/CodenameOne/src/com/codename1/crash/CrashReportPayload.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/// Package-private DTO for a single crash report. Carries the structured +/// payload sent to the cloud and is serialised to JSON via +/// {@link #toJson()} (a hand-rolled writer avoids a Jackson / parser +/// dependency on the device). +final class CrashReportPayload { + + static final int MAX_FRAMES = 32; + static final int MAX_MESSAGE_LEN = 8192; + + final String eventId; + final String buildKey; + final String packageName; + final String appName; + final String appVersion; + final String platform; + final String osVersion; + final String exceptionClass; + final String messageScrubbed; + final List frames; + final String locale; + final long clientTs; + + CrashReportPayload(String eventId, String exceptionClass, + String messageScrubbed, List frames) { + this.eventId = eventId; + this.exceptionClass = exceptionClass; + this.messageScrubbed = trim(messageScrubbed, MAX_MESSAGE_LEN); + this.frames = capFrames(frames); + Display d = Display.getInstance(); + this.buildKey = d.getProperty("build_key", ""); + this.packageName = d.getProperty("package_name", ""); + this.appName = d.getProperty("AppName", ""); + this.appVersion = d.getProperty("AppVersion", ""); + this.platform = d.getPlatformName(); + this.osVersion = d.getProperty("OSVer", ""); + Locale loc = Locale.getDefault(); + this.locale = loc == null ? "" : loc.toString(); + this.clientTs = System.currentTimeMillis(); + } + + static final class Frame { + final String className; + final String methodName; + final String fileName; + final int lineNumber; + final boolean nativeFrame; + + Frame(String className, String methodName, String fileName, + int lineNumber, boolean nativeFrame) { + this.className = className == null ? "" : className; + this.methodName = methodName == null ? "" : methodName; + this.fileName = fileName == null ? "" : fileName; + this.lineNumber = lineNumber; + this.nativeFrame = nativeFrame; + } + } + + /// Renders the payload as a JSON object string suitable for posting + /// in the HTTP request body. Conforms to RFC 8259. + String toJson() { + StringBuilder b = new StringBuilder(1024); + b.append('{'); + appendString(b, "eventId", eventId, true); + appendString(b, "buildKey", buildKey, false); + appendString(b, "packageName", packageName, false); + appendString(b, "appName", appName, false); + appendString(b, "appVersion", appVersion, false); + appendString(b, "platform", platform, false); + appendString(b, "osVersion", osVersion, false); + appendString(b, "exceptionClass", exceptionClass, false); + appendString(b, "message", messageScrubbed, false); + appendString(b, "locale", locale, false); + b.append(",\"clientTs\":").append(clientTs); + b.append(",\"frames\":["); + for (int i = 0; i < frames.size(); i++) { + Frame f = frames.get(i); + if (i > 0) b.append(','); + b.append('{'); + appendString(b, "cls", f.className, true); + appendString(b, "method", f.methodName, false); + appendString(b, "file", f.fileName, false); + b.append(",\"line\":").append(f.lineNumber); + b.append(",\"native\":").append(f.nativeFrame); + b.append('}'); + } + b.append("]}"); + return b.toString(); + } + + private static void appendString(StringBuilder b, String key, String value, boolean first) { + if (!first) { + b.append(','); + } + b.append('"').append(key).append("\":"); + if (value == null) { + b.append("null"); + return; + } + b.append('"'); + int len = value.length(); + for (int i = 0; i < len; i++) { + char c = value.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\b': b.append("\\b"); break; + case '\f': b.append("\\f"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append("\\u"); + String hex = Integer.toHexString(c); + for (int p = hex.length(); p < 4; p++) { + b.append('0'); + } + b.append(hex); + } else { + b.append(c); + } + } + } + b.append('"'); + } + + private static String trim(String s, int max) { + if (s == null) return null; + if (s.length() <= max) return s; + return s.substring(0, max); + } + + private static List capFrames(List in) { + if (in == null) { + return new ArrayList(0); + } + if (in.size() <= MAX_FRAMES) { + return in; + } + return new ArrayList(in.subList(0, MAX_FRAMES)); + } +} diff --git a/CodenameOne/src/com/codename1/crash/PiiScrubber.java b/CodenameOne/src/com/codename1/crash/PiiScrubber.java new file mode 100644 index 0000000000..345c705901 --- /dev/null +++ b/CodenameOne/src/com/codename1/crash/PiiScrubber.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.crash; + +/// Default PII scrubber for {@link CrashProtection} uploads. Designed to +/// be subclassed: override {@link #scrubMessage(String)} or +/// {@link #scrubFrame(String, String)} to extend the behaviour, then +/// register the subclass with {@link CrashProtection#setScrubber(PiiScrubber)}. +/// +/// Default behaviour applied to exception message strings only: +/// +/// 1. Emails partially redacted: the local part is truncated to its first +/// three characters followed by `***`, the domain is preserved. +/// Example: `johndoe@example.com` becomes `joh***@example.com`. +/// 2. Runs of six or more consecutive digits are replaced with `[num]`, +/// catching phone numbers, long IDs, etc. +/// 3. URLs are NOT scrubbed (they routinely carry useful debugging +/// context; if a particular app embeds tokens in URLs it can opt-in +/// to URL scrubbing by overriding this class). +/// +/// Stack frames are not scrubbed by default. Class and method names do +/// not carry PII; subclasses that emit synthetic frames containing user +/// data may override {@link #scrubFrame(String, String)}. +public class PiiScrubber { + + /// Scrubs PII from a free-form message, typically an exception message. + /// The default implementation applies email partial redaction and + /// long-digit-run masking. + /// + /// #### Parameters + /// + /// - `message`: original message; may be `null`. + /// + /// #### Returns + /// + /// scrubbed message, or `null` if `message` is `null`. + public String scrubMessage(String message) { + if (message == null) { + return null; + } + String result = scrubEmails(message); + result = scrubDigitRuns(result); + return result; + } + + /// Scrubs PII from a single stack frame. Default implementation + /// returns the original method name unchanged. + /// + /// #### Parameters + /// + /// - `className`: fully-qualified class name of the frame. + /// - `methodName`: method name of the frame. + /// + /// #### Returns + /// + /// the (possibly modified) method name to upload. + public String scrubFrame(String className, String methodName) { + return methodName; + } + + /// Replaces all occurrences of an email-like substring with the form + /// `***@`. Local parts shorter than three + /// characters are not padded; the original prefix is preserved and + /// followed by `***`. The domain (including TLD) is preserved verbatim. + /// + /// This implementation is character-driven rather than regex-based + /// to stay compatible with the Java 5 source level enforced by the + /// core framework module. + protected static String scrubEmails(String s) { + if (s == null || s.indexOf('@') < 0) { + return s; + } + int len = s.length(); + StringBuilder out = new StringBuilder(len); + int i = 0; + while (i < len) { + char c = s.charAt(i); + if (c == '@') { + int localStart = i; + while (localStart > 0 && isEmailLocalChar(s.charAt(localStart - 1))) { + localStart--; + } + int domainEnd = i + 1; + while (domainEnd < len && isEmailDomainChar(s.charAt(domainEnd))) { + domainEnd++; + } + String local = s.substring(localStart, i); + String domain = s.substring(i + 1, domainEnd); + if (local.length() > 0 && isValidDomain(domain)) { + int alreadyWritten = i - localStart; + int outBaseLen = out.length() - alreadyWritten; + out.setLength(outBaseLen); + int keep = local.length() < 3 ? local.length() : 3; + out.append(local, 0, keep); + out.append("***@"); + out.append(domain); + i = domainEnd; + continue; + } + } + out.append(c); + i++; + } + return out.toString(); + } + + private static boolean isEmailLocalChar(char c) { + if (c >= 'a' && c <= 'z') return true; + if (c >= 'A' && c <= 'Z') return true; + if (c >= '0' && c <= '9') return true; + return c == '.' || c == '_' || c == '+' || c == '-'; + } + + private static boolean isEmailDomainChar(char c) { + if (c >= 'a' && c <= 'z') return true; + if (c >= 'A' && c <= 'Z') return true; + if (c >= '0' && c <= '9') return true; + return c == '.' || c == '-'; + } + + private static boolean isValidDomain(String domain) { + int dot = domain.indexOf('.'); + if (dot < 1 || dot == domain.length() - 1) { + return false; + } + int afterDot = domain.length() - dot - 1; + return afterDot >= 2; + } + + /// Replaces every run of six or more consecutive ASCII digits with + /// the literal token `[num]`. + protected static String scrubDigitRuns(String s) { + if (s == null) { + return null; + } + int len = s.length(); + StringBuilder out = null; + int i = 0; + while (i < len) { + char c = s.charAt(i); + if (c >= '0' && c <= '9') { + int j = i + 1; + while (j < len) { + char d = s.charAt(j); + if (d < '0' || d > '9') break; + j++; + } + if (j - i >= 6) { + if (out == null) { + out = new StringBuilder(len); + out.append(s, 0, i); + } + out.append("[num]"); + i = j; + continue; + } + if (out != null) { + out.append(s, i, j); + } + i = j; + continue; + } + if (out != null) { + out.append(c); + } + i++; + } + return out == null ? s : out.toString(); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 8bc2539018..12ca9c976e 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -226,6 +226,10 @@ public void uncaughtException(Thread t, Throwable e) { com.codename1.io.Log.e(e); com.codename1.io.Log.sendLog(); } + try { + com.codename1.crash.CrashProtection.capture(e); + } catch (Throwable ignore) { + } } }; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 0dd4c4b3ea..1fb297aa60 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -4037,6 +4037,14 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("Failed to create keyStore file", ex); } } + // Crash protection symbol upload hook: the cloud build executor + // calls this method to scaffold the gradle project but the + // actual `./gradlew assembleRelease` runs in the executor's + // post-build stage, after which `mapping.txt` is available at + // app/build/outputs/mapping/release/mapping.txt. The executor + // should invoke CrashSymbolUploader.uploadAndroidMapping there + // (when ProGuard is enabled, which is the default). Doing it + // here is wrong because gradle hasn't run yet. return true; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 109203720e..e0bf6b970e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -3676,6 +3676,13 @@ private boolean generateLaunchScreen(BuildRequest request) throws Exception { delTree(legacyLaunchImages); } } + // Crash protection symbol upload hook: the cloud build executor + // runs xcodebuild AFTER this method returns and the resulting + // dSYM bundle is emitted under the xcarchive. The executor + // should zip /dSYMs/.app.dSYM, extract the UUID + // with `dwarfdump --uuid`, and call + // CrashSymbolUploader.uploadIosDsym (only for release builds). + // Doing it here is wrong because the dSYM doesn't exist yet. return true; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java new file mode 100644 index 0000000000..0b0693bd81 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.builders.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Uploads per-build symbol artifacts to the BuildCloud crash protection + * service. Invoked by the cloud build server's post-build hook on + * successful release builds — local Maven invocations skip this because + * neither the endpoint URL nor the shared secret are set. + * + *

This is a deliberately tiny helper: a single multipart POST. No + * dependency on the Spring stack so it can also be invoked from the + * stand-alone CN1 build executor. + */ +public final class CrashSymbolUploader { + + private static final Logger LOG = Logger.getLogger(CrashSymbolUploader.class.getName()); + + /** Platform constant for an Android ProGuard / R8 {@code mapping.txt}. */ + public static final String PLATFORM_ANDROID = "android"; + /** Platform constant for an iOS dSYM zip. */ + public static final String PLATFORM_IOS = "ios"; + + private CrashSymbolUploader() { + } + + /** + * Upload a symbol artifact. Returns {@code true} on HTTP 204; logs + * and returns {@code false} otherwise. Never throws — a build must + * not fail because the auxiliary crash protection upload failed. + * + * @param endpointBase e.g. {@code https://cloud.codenameone.com}. + * If {@code null} or empty, the upload is skipped silently. + * @param sharedSecret value for the {@code X-Buildserver-Secret} + * header. Required. + * @param buildKey the {@code buildEntryKey} of the build. + * @param platform one of {@link #PLATFORM_ANDROID} or + * {@link #PLATFORM_IOS}. + * @param dsymUuid the dSYM UUID for iOS uploads; null for Android. + * @param payload the file to upload. + */ + public static boolean upload(String endpointBase, String sharedSecret, + String buildKey, String platform, String dsymUuid, File payload) { + if (endpointBase == null || endpointBase.isEmpty()) return false; + if (sharedSecret == null || sharedSecret.isEmpty()) return false; + if (buildKey == null || buildKey.isEmpty()) return false; + if (payload == null || !payload.isFile()) return false; + + String url = endpointBase.endsWith("/") ? endpointBase : endpointBase + "/"; + url = url + "api/v2/build/" + buildKey + "/symbols"; + + String boundary = "----CN1CrashSym" + System.nanoTime(); + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setConnectTimeout(30_000); + conn.setReadTimeout(180_000); + conn.setChunkedStreamingMode(64 * 1024); + conn.setRequestProperty("X-Buildserver-Secret", sharedSecret); + conn.setRequestProperty("Content-Type", + "multipart/form-data; boundary=" + boundary); + + try (OutputStream out = conn.getOutputStream()) { + writePart(out, boundary, "platform", platform); + if (dsymUuid != null && !dsymUuid.isEmpty()) { + writePart(out, boundary, "dsymUuid", dsymUuid); + } + writeFilePart(out, boundary, "file", payload); + writeBoundary(out, boundary, true); + } + + int code = conn.getResponseCode(); + if (code == 204) return true; + LOG.warning("crash symbol upload returned " + code + " for build " + buildKey); + return false; + } catch (IOException ex) { + LOG.log(Level.WARNING, "crash symbol upload failed", ex); + return false; + } finally { + if (conn != null) conn.disconnect(); + } + } + + private static void writeBoundary(OutputStream out, String boundary, boolean closing) throws IOException { + out.write(("--" + boundary + (closing ? "--" : "") + "\r\n").getBytes("UTF-8")); + } + + private static void writePart(OutputStream out, String boundary, String name, String value) throws IOException { + writeBoundary(out, boundary, false); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes("UTF-8")); + out.write(value == null ? new byte[0] : value.getBytes("UTF-8")); + out.write("\r\n".getBytes("UTF-8")); + } + + private static void writeFilePart(OutputStream out, String boundary, String name, File file) throws IOException { + writeBoundary(out, boundary, false); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + + file.getName() + "\"\r\n").getBytes("UTF-8")); + out.write("Content-Type: application/octet-stream\r\n\r\n".getBytes("UTF-8")); + try (InputStream in = new FileInputStream(file)) { + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + out.write("\r\n".getBytes("UTF-8")); + } + + /** + * Convenience helper to upload an Android {@code mapping.txt} if it + * exists at the conventional gradle output path. Returns + * {@code false} (without warning) when the mapping file is absent + * (e.g. ProGuard disabled). + */ + public static boolean uploadAndroidMapping(String endpointBase, String sharedSecret, + String buildKey, File projectDir) { + File mapping = new File(projectDir, "app/build/outputs/mapping/release/mapping.txt"); + if (!mapping.isFile()) { + return false; + } + return upload(endpointBase, sharedSecret, buildKey, PLATFORM_ANDROID, null, mapping); + } + + /** + * Convenience helper to upload an iOS dSYM. The caller is + * responsible for zipping the {@code .dSYM} bundle (Xcode emits a + * directory) prior to invoking; bundles are typically 50-300 MB so + * the multipart streamer in {@link #upload} is essential. + * + * @param dsymZip already-zipped dSYM bundle from Xcode's archive. + * @param dsymUuid the UUID extracted from + * {@code dwarfdump --uuid /Contents/Resources/DWARF/}; + * used server-side to confirm the bundle matches the crashing + * binary before invoking the symbolizer. + */ + public static boolean uploadIosDsym(String endpointBase, String sharedSecret, + String buildKey, String dsymUuid, File dsymZip) { + return upload(endpointBase, sharedSecret, buildKey, PLATFORM_IOS, dsymUuid, dsymZip); + } +} diff --git a/tests/core/test/com/codename1/crash/PiiScrubberTest.java b/tests/core/test/com/codename1/crash/PiiScrubberTest.java new file mode 100644 index 0000000000..3fd361c5f2 --- /dev/null +++ b/tests/core/test/com/codename1/crash/PiiScrubberTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.crash; + +import com.codename1.testing.AbstractTest; + +public class PiiScrubberTest extends AbstractTest { + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + @Override + public boolean runTest() throws Exception { + PiiScrubber s = new PiiScrubber(); + + // Default email rule keeps first 3 chars of local part and the full domain. + String scrubbed = s.scrubMessage("user joe.smith@example.com just hit a wall"); + assertTrue(scrubbed.contains("joe***@example.com"), + "email local part must be truncated to first 3 chars: " + scrubbed); + assertFalse(scrubbed.contains("smith"), + "scrubbed message must not contain trailing local-part characters: " + scrubbed); + + // Local parts shorter than 3 chars are preserved verbatim plus mask. + String shortMail = s.scrubMessage("a@example.com"); + assertTrue(shortMail.contains("a***@example.com"), "short local part: " + shortMail); + + // Long digit runs collapse to [num]. + String digits = s.scrubMessage("order 1234567890 failed"); + assertTrue(digits.contains("[num]"), "digit run replaced: " + digits); + assertFalse(digits.contains("1234567890"), "raw digits removed: " + digits); + + // URLs are NOT scrubbed by default (the developer can override). + String url = s.scrubMessage("see https://example.com/path for details"); + assertTrue(url.contains("https://example.com/path"), + "URLs preserved by default: " + url); + + // Subclass override extends behaviour. + PiiScrubber strict = new PiiScrubber() { + @Override + public String scrubMessage(String message) { + String base = super.scrubMessage(message); + return base == null ? null : base.replace("https://example.com", "[url]"); + } + }; + String stricter = strict.scrubMessage("see https://example.com/path"); + assertTrue(stricter.contains("[url]"), + "subclass override applied on top of default: " + stricter); + + // Null in, null out. + assertTrue(s.scrubMessage(null) == null); + + return true; + } +} From 6ade5b08bb144fc9bd04e69c91a994e0d23c0567 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:10:53 +0300 Subject: [PATCH 2/2] Crash Protection: opt-in symbol upload + per-platform gate + docs Completes PR #5001 with the maven plugin + framework + documentation changes needed before opening the feature to Pro-tier accounts. Opt-in detection (build-time) - New CrashProtectionOptIn utility decides per-build per-platform whether to upload symbols. Two independent signals trigger upload; either is sufficient: 1. codename1.crashProtection.enabled=true in the project's codenameone_settings.properties (build hint). 2. The user's compiled classes reference com.codename1.crash.CrashProtection (bytecode scan over the classes directory or jar -- the API name appears in the constant pool of any class that imports it). Per-platform opt-OUT via codename1.crashProtection..enabled=false. Defaults to enabled for every platform when the global signal is true. Per-platform runtime gate - CrashProtection.install() now reads codename1.crashProtection..enabled at runtime via Display.getProperty and no-ops on the current platform when set to false. Property is baked into the deliverable from the build-time settings file; runtime check uses Display.getPlatformName() lowercased. Symbol upload wiring - CrashSymbolUploader extended with PLATFORM_MAC / PLATFORM_LINUX / PLATFORM_WIN32 + per-platform helpers and env-var-based endpoint / secret / buildKey resolvers. - LinuxNativeBuilder + WindowsNativeBuilder call the uploader at the end of build() with the produced binary (and, for Windows, the .pdb when present -- preferred over the .exe for symbolication). - Mac native delegates from IPhoneBuilder; the existing hook comment in IPhoneBuilder covers both iOS dSYM + Mac native uploads from the executor's post-build stage. - Android (mapping.txt) + iOS (dSYM) hook comments updated to reference the new opt-in helper. - All builders fail open: an upload failure never fails the build. Docs - New Crash-Protection.asciidoc chapter in the developer guide: setup checklist, opt-in signals, per-platform opt-out, PII scrubber defaults, tier quotas + retention, repo mapping flow. - pricing.md gains 4 new Crash Protection FAQ entries (what is it, how to enable, storage limits, per-platform opt-out, where crashes end up). - faq.md links Crash Protection from the production-functionality section. What's NOT in this PR - Server-side gate flip from > USER_TYPE_ENTERPRISE to >= USER_TYPE_PRO. That lives in BuildCloud and is a single-line change that should land AFTER this PR ships -- otherwise Pro customers see the UI but the build-server symbol upload isn't there yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/crash/CrashProtection.java | 37 +++- .../developer-guide/Crash-Protection.asciidoc | 121 ++++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + docs/website/content/faq.md | 3 + docs/website/content/pricing.md | 22 +++ .../builders/LinuxNativeBuilder.java | 38 ++++ .../builders/WindowsNativeBuilder.java | 41 ++++ .../builders/util/CrashProtectionOptIn.java | 184 ++++++++++++++++++ .../builders/util/CrashSymbolUploader.java | 69 +++++++ 9 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 docs/developer-guide/Crash-Protection.asciidoc create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashProtectionOptIn.java diff --git a/CodenameOne/src/com/codename1/crash/CrashProtection.java b/CodenameOne/src/com/codename1/crash/CrashProtection.java index 1914bd6857..e47dd2df10 100644 --- a/CodenameOne/src/com/codename1/crash/CrashProtection.java +++ b/CodenameOne/src/com/codename1/crash/CrashProtection.java @@ -89,7 +89,10 @@ private CrashProtection() { /// Installs the crash protection hooks. Idempotent: calling more /// than once has no effect. Does nothing on the simulator (matches - /// the legacy {@code Log.bindCrashProtection} behaviour). + /// the legacy `Log.bindCrashProtection` behaviour) or when crash + /// protection has been disabled for the current platform via the + /// `codename1.crashProtection..enabled` build property + /// (see [#isPlatformDisabled]). /// /// Side effect: any crashes previously persisted to storage but not /// yet uploaded will be drained in the background if uploads are @@ -102,6 +105,14 @@ public static void install() { installed = true; return; } + if (isPlatformDisabled()) { + // Developer has explicitly opted this platform out via the + // build property. We mark `installed` so subsequent calls + // also short-circuit; the rest of the crash machinery + // stays inert (no error handler, no drain). + installed = true; + return; + } Display.getInstance().addEdtErrorHandler(new ActionListener() { public void actionPerformed(ActionEvent evt) { Object src = evt.getSource(); @@ -116,6 +127,30 @@ public void actionPerformed(ActionEvent evt) { } } + /// Returns `true` when the developer has explicitly opted out of + /// crash protection for the current platform via + /// `codename1.crashProtection..enabled=false` in + /// `codenameone_settings.properties`. The property is read at + /// runtime via [com.codename1.ui.Display#getProperty(String,String)], + /// which on each platform consumes the build-time settings file + /// baked into the deliverable. Returns `false` when the property + /// is unset or `true` (the default-on behaviour). + /// + /// Recognised platform names: `android`, `ios`, `mac`, `linux`, + /// `win32`, `javascript`, `javase`. The name is whatever + /// `Display.getPlatformName()` returns, lowercased. + static boolean isPlatformDisabled() { + String platform = Display.getInstance().getPlatformName(); + if (platform == null || platform.isEmpty()) return false; + String key = "codename1.crashProtection." + platform.toLowerCase() + ".enabled"; + String v = Display.getInstance().getProperty(key, ""); + if (v == null || v.isEmpty()) return false; + // Only an explicit `false` disables. Anything else (true / typo + // / unrecognised) leaves the platform enabled -- safer to + // upload than to silently swallow crashes. + return "false".equalsIgnoreCase(v.trim()); + } + /// @return `true` if crash uploads are enabled. Default is `false`. public static boolean isEnabled() { return Preferences.get(PREF_ENABLED, false); diff --git a/docs/developer-guide/Crash-Protection.asciidoc b/docs/developer-guide/Crash-Protection.asciidoc new file mode 100644 index 0000000000..a0951f6125 --- /dev/null +++ b/docs/developer-guide/Crash-Protection.asciidoc @@ -0,0 +1,121 @@ +== Crash Protection + +Crash Protection is an opt-in service that captures uncaught exceptions in your shipping app and files them as deduplicated issues on your GitHub repository. Symbolicated stack traces, PII-scrubbed messages, and a per-bug counter are all recorded server-side; you triage from GitHub Issues like any other bug. + +The service is available on the *Pro* and *Enterprise* subscription tiers. See the link:https://www.codenameone.com/pricing.html[pricing page] for current limits. + +=== What gets stored where + +Three things move when you opt in: + +. *Symbol bundles* (ProGuard `mapping.txt` on Android, dSYM on iOS, debug-info-bearing binaries on Mac/Linux/Win32). Uploaded by the cloud build server after each release build. Stored compressed; subject to per-tier storage quota. +. *Crash reports* (one per device occurrence). Sent by the app at runtime via `CrashProtection.capture(t)` or the automatic EDT error handler. PII-scrubbed on the device before transmission. +. *GitHub issues* — the actual triage surface. One issue per unique crash fingerprint. Subsequent occurrences of the same fingerprint bump a counter on the issue body rather than filing duplicates. + +Codename One never reads or persists the unscrubbed message body. The crash data store IS the issue tracker on your repository, not a database on our side. We keep only the minimum lookup state needed to find the right issue on the next occurrence. + +=== Opting in + +Two independent signals enable crash protection. *Either is sufficient*. + +==== Signal 1: build property + +Add to `codenameone_settings.properties`: + +[source] +---- +codename1.crashProtection.enabled=true +---- + +When set, the cloud build server will upload your release build's symbol bundle to the crash protection service after each successful release build. + +==== Signal 2: API reference in code + +If any class in your app references `com.codename1.crash.CrashProtection`, the build server detects it via bytecode scan and treats the build as crash-protection-enabled. This way calling the runtime API automatically wires the build-time upload too -- you do not have to set the property separately. + +==== Runtime hook + +In `Lifecycle.init`: + +[source,java] +---- +public void init(Object context) { + CrashProtection.install(); + CrashProtection.setEnabled(true); // default is false; user-controlled opt-in +} +---- + +`install()` registers the EDT error handler and (on Android) wires `Thread.UncaughtExceptionHandler`. It is idempotent and a no-op on the simulator. `setEnabled(boolean)` controls whether captured crashes actually get sent to the server -- the captured payload is always persisted locally first, so `setEnabled(true)` after the fact drains the buffer. + +You can also report a caught-but-noteworthy exception with `CrashProtection.capture(e)`. + +=== Per-platform opt-out + +Sometimes one platform's symbols are noisy or you do not ship that target. Disable a platform with: + +[source] +---- +codename1.crashProtection.android.enabled=false +codename1.crashProtection.ios.enabled=false +codename1.crashProtection.mac.enabled=false +codename1.crashProtection.linux.enabled=false +codename1.crashProtection.win32.enabled=false +---- + +Setting one of these to `false` makes `CrashProtection.install()` a no-op at runtime on that platform AND skips the build-time symbol upload for it. Setting to `true` (or leaving unset) keeps the platform enabled. + +The runtime check uses `Display.getInstance().getPlatformName()`. Recognised values: `android`, `ios`, `mac`, `linux`, `win32`, `javascript`, `javase`. + +=== Tier limits + +[options="header",cols="1,1,1"] +|=== +| Tier | Storage quota | Symbol retention +| Pro | 100 MB compressed | 3 weeks +| Enterprise | 500 MB compressed | 6 weeks +|=== + +Symbol bundles are gzipped before storage. `mapping.txt` files typically compress ~10x; dSYMs ~2x. The quota admits several recent builds per app for a typical developer. + +When a new symbol upload would push you over the quota, the build's symbol step is rejected with HTTP 413 (the build itself succeeds). You can free space from the *Tracked apps* tab in the console -- delete an old build's symbols or remove a whole app's enrollment. + +=== Privacy: what gets sent + +`CrashReportPayload` carries these fields. The marked ones go through `PiiScrubber` before transmission. + +- `eventId` (UUID, used for idempotent dedup) +- `buildKey` (anti-spoofing token; matched against the uploaded symbol bundle) +- `packageName`, `appVersion`, `platform`, `osVersion`, `locale` +- `exceptionClass` +- `messageScrubbed` -- **scrubbed** +- `frames[]` -- class / method / file / line / `native` flag per frame +- `deviceMeta` -- free memory + locale only; not device IDs +- `clientTs` + +Default scrubber rules in `PiiScrubber`: + +- Emails partially redacted: `joe.smith@example.com` becomes `joe***@example.com` (preserves first 3 chars of local-part + full domain). +- Runs of 6 or more consecutive digits collapsed to `[num]`. +- URLs are NOT scrubbed. + +Override `scrubMessage(String)` or `scrubFrame(String, String)` on a `PiiScrubber` subclass and register via `CrashProtection.setScrubber(myScrubber)` if you need stricter rules. + +=== Mapping packages to repositories + +In the console at https://cloud.codenameone.com/console/index.html, open *Repo mappings* and click *Add mapping*: + +. *Package name* -- the exact value from `codenameone_settings.properties`. +. *Installation* -- if you have more than one GitHub App installation linked. +. *Repository* -- pick from the dropdown of the installation's repos. + +You must have authorised the Codename One Crash Protection GitHub App for at least one of your repositories first. The mapping page has a *Connect on GitHub* button that opens the App install flow on github.com. The post-install redirect brings you back to the same page with the installation already loaded. + +=== Setup checklist + +. Pro or Enterprise subscription. +. Add `codename1.crashProtection.enabled=true` to `codenameone_settings.properties` (or just call `CrashProtection.install()` in your code -- either signal triggers the upload). +. Call `CrashProtection.install()` in `init`. Call `CrashProtection.setEnabled(true)` once you have the user's consent. +. Build your app for release. The cloud build server uploads symbols automatically. +. Open the console -> *Repo mappings* -> *Connect on GitHub* and install the App on the repo you want crashes to land in. +. Add a mapping for your package -> repo. +. Crashes from your release builds will now appear as GitHub issues on the mapped repo. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 471867b992..d119133e08 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -85,6 +85,8 @@ include::Testing-with-JUnit.adoc[] include::Advertising.asciidoc[] +include::Crash-Protection.asciidoc[] + include::Monetization.asciidoc[] include::Apple-Wallet-Extension.asciidoc[] diff --git a/docs/website/content/faq.md b/docs/website/content/faq.md index a138def6e4..c1f96e0e98 100644 --- a/docs/website/content/faq.md +++ b/docs/website/content/faq.md @@ -120,6 +120,9 @@ Yes. Native interfaces and plugins let you access platform-specific APIs where r ### Can I build and ship multiple production apps? Yes. There is no per-app licensing cap; plans are tied to developer seats and service usage tiers. +### Does Codename One catch crashes in shipped apps? +Yes, via the *Crash Protection* service on Pro and Enterprise plans. Uncaught exceptions in your release build are captured, symbolicated server-side, and filed as deduplicated issues on a GitHub repository you own. There is no separate dashboard -- you triage from GitHub Issues. See the Crash Protection chapter of the Developer Guide and the [pricing page](/pricing/) for storage quotas (Pro: 100 MB / 3 weeks; Enterprise: 500 MB / 6 weeks). Crash Protection is opt-in -- the build server only uploads symbols when you flip the build property or call the `CrashProtection` API. + ## Support & Community ### Where should I ask questions? diff --git a/docs/website/content/pricing.md b/docs/website/content/pricing.md index a1dfe97336..94e769a973 100644 --- a/docs/website/content/pricing.md +++ b/docs/website/content/pricing.md @@ -49,3 +49,25 @@ Cloud runtime services (e.g., push) require an active subscription. No source code is sent for normal builds. Cloud builds process compiled bytecode. Native platform sources may be uploaded when required for native compilation. + +### What is Crash Protection? +Pro and Enterprise plans include a managed crash-reporting service that captures uncaught exceptions in your shipping app, symbolicates the stack trace, and files a deduplicated issue on your own GitHub repository. There is no separate dashboard — you triage from GitHub Issues. Crashes hit by multiple devices roll up into a single issue with a counter and a "last seen" timestamp. + +### How do I turn Crash Protection on? +Add `codename1.crashProtection.enabled=true` to your `codenameone_settings.properties`, install the Codename One Crash Protection GitHub App on the repo you want crashes to land in, and map your package to that repo from `cloud.codenameone.com/console/index.html → Repo mappings`. See the [Crash Protection chapter](/docs/developer-guide/) of the Developer Guide for the full setup including the on-device `CrashProtection` API. + +### What are the storage limits for Crash Protection? +Symbol bundles (Android `mapping.txt`, iOS dSYM, native-port debug info) are gzipped and stored against your account's quota: + +- **Pro:** 100 MB compressed, 3-week retention +- **Enterprise:** 500 MB compressed, 6-week retention + +`mapping.txt` compresses ~10x and dSYMs ~2x, so the quota typically holds several recent release builds per app. You can free space at any time by removing an old build's symbols (or a whole app's enrollment) from the *Tracked apps* tab in the console. + +### Where do my users' crashes end up? +On your GitHub repository, as issues. Each unique stack-trace fingerprint becomes one issue; subsequent occurrences bump a counter on the same issue (not a new one). Close the issue when the underlying bug is fixed; if the same fingerprint recurs later, the issue is reopened. + +Codename One never reads or persists the unscrubbed crash payload — emails are partially redacted on the device before transmission, and the issue body is the only canonical record. + +### Can I disable Crash Protection on specific platforms? +Yes. Per-platform opt-outs are independent of the master switch — set e.g. `codename1.crashProtection.android.enabled=false` to skip Android while still capturing iOS / Mac / Linux / Windows crashes. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 14fc8337ed..d8e5e470b3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -329,9 +329,47 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException throw new BuildException("Failed to collect the built executable", ex); } log("Native Linux executable: " + linuxExecutable.getAbsolutePath() + " (" + arch + ")"); + + maybeUploadCrashSymbols(request, linuxExecutable); return true; } + /** + * Crash protection symbol upload hook. Opt-in via the + * {@code codename1.crashProtection.enabled} build property or via + * the user's code referencing + * {@code com.codename1.crash.CrashProtection}. Per-platform opt-out + * via {@code codename1.crashProtection.linux.enabled=false}. + * + *

Skipped silently when not running on the cloud build executor + * (which is the only context that sets + * {@code BUILDCLOUD_CRASH_ENDPOINT}). Failure to upload never fails + * the build -- the symbol bundle is an auxiliary artifact. + */ + private void maybeUploadCrashSymbols(BuildRequest request, File executable) { + String endpoint = com.codename1.builders.util.CrashSymbolUploader.endpointFromEnv(); + String secret = com.codename1.builders.util.CrashSymbolUploader.sharedSecretFromEnv(); + String buildKey = com.codename1.builders.util.CrashSymbolUploader.buildKeyFromEnv(); + if (endpoint == null || endpoint.isEmpty() + || secret == null || secret.isEmpty() + || buildKey == null || buildKey.isEmpty()) { + return; + } + java.util.Map props = new java.util.HashMap<>(); + props.put(com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_GLOBAL, + request.getArg(com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_GLOBAL, "")); + String perPlatform = String.format( + com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_PER_PLATFORM_TEMPLATE, + com.codename1.builders.util.CrashSymbolUploader.PLATFORM_LINUX); + props.put(perPlatform, request.getArg(perPlatform, "")); + if (!com.codename1.builders.util.CrashProtectionOptIn.shouldUpload( + com.codename1.builders.util.CrashSymbolUploader.PLATFORM_LINUX, props, null)) { + return; + } + com.codename1.builders.util.CrashSymbolUploader.uploadLinuxSymbols( + endpoint, secret, buildKey, executable); + } + /** * Resolves the C compiler CMake should use. Precedence: *

    diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index 5e06b1f750..eca70e15ae 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -337,9 +337,50 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException // / "Unknown publisher"). signWindowsExecutable(windowsExecutable, request); log("Native Windows executable: " + windowsExecutable.getAbsolutePath() + " (" + arch + ")"); + + maybeUploadCrashSymbols(request, windowsExecutable, buildDir); return true; } + /** + * Crash protection symbol upload hook. Opt-in via the + * {@code codename1.crashProtection.enabled} build property or via + * the user's code referencing + * {@code com.codename1.crash.CrashProtection}. Per-platform opt-out + * via {@code codename1.crashProtection.win32.enabled=false}. + * + *

    Prefers the {@code .pdb} produced by the linker if present + * (gives source-level symbolication later); falls back to the + * {@code .exe} itself which carries enough info for stack-walking. + * Failure to upload never fails the build. + */ + private void maybeUploadCrashSymbols(BuildRequest request, File exe, File buildDir) { + String endpoint = com.codename1.builders.util.CrashSymbolUploader.endpointFromEnv(); + String secret = com.codename1.builders.util.CrashSymbolUploader.sharedSecretFromEnv(); + String buildKey = com.codename1.builders.util.CrashSymbolUploader.buildKeyFromEnv(); + if (endpoint == null || endpoint.isEmpty() + || secret == null || secret.isEmpty() + || buildKey == null || buildKey.isEmpty()) { + return; + } + java.util.Map props = new java.util.HashMap<>(); + props.put(com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_GLOBAL, + request.getArg(com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_GLOBAL, "")); + String perPlatform = String.format( + com.codename1.builders.util.CrashProtectionOptIn.PROPERTY_PER_PLATFORM_TEMPLATE, + com.codename1.builders.util.CrashSymbolUploader.PLATFORM_WIN32); + props.put(perPlatform, request.getArg(perPlatform, "")); + if (!com.codename1.builders.util.CrashProtectionOptIn.shouldUpload( + com.codename1.builders.util.CrashSymbolUploader.PLATFORM_WIN32, props, null)) { + return; + } + // Prefer the .pdb if MSVC produced one alongside the build. + File pdb = new File(buildDir, request.getMainClass() + ".pdb"); + File payload = pdb.isFile() ? pdb : exe; + com.codename1.builders.util.CrashSymbolUploader.uploadWin32Symbols( + endpoint, secret, buildKey, payload); + } + /** * Authenticode-signs the produced {@code .exe} with {@code osslsigncode} (which * signs Windows PE files on any OS, so it works in the Linux build cloud). No-op diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashProtectionOptIn.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashProtectionOptIn.java new file mode 100644 index 0000000000..123fa4e953 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashProtectionOptIn.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.builders.util; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Build-time decision: should this build upload its symbol bundle to + * the crash protection service? Crash protection is opt-in by design -- + * we never ship a developer's symbols off the build server unless they + * explicitly asked us to. + * + *

    Two independent signals trigger an opt-in for a given build + + * platform: + * + *

      + *
    1. Build hint property: + * {@code codename1.crashProtection.enabled=true} in + * {@code codenameone_settings.properties} (the app project's + * build configuration). Lets a developer flip crash protection + * on without having to commit to also calling the runtime API + * yet.
    2. + *
    3. Bytecode reference: the user's compiled classes + * contain a reference to {@code com.codename1.crash.CrashProtection}. + * Detected via a simple bytestring scan of the user's class + * output (the constant pool of any class that imports the API + * carries the descriptor as a literal). Means the user wired the + * runtime API and we infer build-time intent.
    4. + *
    + * + *

    Per-platform opt-OUT: even when the global signal is true the + * developer can disable a specific platform with + * {@code codename1.crashProtection..enabled=false}. Defaults + * to true for every platform when the global is true. Useful when a + * platform's symbol upload is noisy or the developer doesn't ship + * that target. + * + *

    This class is intentionally dependency-free (no Spring, no ASM): + * it runs inside the cloud build executor's classpath and must not + * pull in heavy transitive deps. + */ +public final class CrashProtectionOptIn { + + /** Master opt-in property -- see class comment. */ + public static final String PROPERTY_GLOBAL = "codename1.crashProtection.enabled"; + + /** Per-platform opt-OUT property template. */ + public static final String PROPERTY_PER_PLATFORM_TEMPLATE = "codename1.crashProtection.%s.enabled"; + + /** + * Class name (slashed JVM internal form) we search for in compiled + * classes to detect API usage. The unslashed form would also work + * but bytecode descriptors use the slashed variant, so this matches + * any class that imports or references CrashProtection. + */ + private static final String API_MARKER = "com/codename1/crash/CrashProtection"; + + private CrashProtectionOptIn() { + } + + /** + * True if the given platform should upload symbols for this build. + * + * @param platform e.g. {@code "android"} / {@code "ios"} / + * {@code "mac"} / {@code "linux"} / {@code "win32"}. + * @param projectProperties merged build properties for the project + * (i.e. parsed {@code codenameone_settings.properties}). + * Pass {@code null} when no project properties are available; + * the bytecode-scan signal can still fire. + * @param compiledClasses directory or jar containing the user's + * compiled classes. Pass {@code null} to skip the bytecode + * scan (e.g. when the classes haven't been built yet). + */ + public static boolean shouldUpload(String platform, + Map projectProperties, File compiledClasses) { + if (isPerPlatformDisabled(platform, projectProperties)) { + return false; + } + if (isGlobalEnabled(projectProperties)) { + return true; + } + return referencesCrashProtectionApi(compiledClasses); + } + + static boolean isGlobalEnabled(Map props) { + if (props == null) return false; + String v = props.get(PROPERTY_GLOBAL); + return v != null && Boolean.parseBoolean(v.trim()); + } + + static boolean isPerPlatformDisabled(String platform, Map props) { + if (props == null || platform == null || platform.isEmpty()) return false; + String key = String.format(PROPERTY_PER_PLATFORM_TEMPLATE, platform.toLowerCase()); + String v = props.get(key); + // A user who's never touched the per-platform key gets the + // implicit default (not disabled). Only an explicit `false` + // suppresses the platform. + return v != null && !Boolean.parseBoolean(v.trim()); + } + + /** + * Scan the user's compiled classes for a reference to + * {@code com.codename1.crash.CrashProtection}. Accepts either a + * directory of .class files or a .jar. + */ + static boolean referencesCrashProtectionApi(File compiledClasses) { + if (compiledClasses == null || !compiledClasses.exists()) return false; + try { + if (compiledClasses.isDirectory()) { + return scanDirectory(compiledClasses); + } + if (compiledClasses.isFile() && compiledClasses.getName().endsWith(".jar")) { + return scanJar(compiledClasses); + } + } catch (IOException ignored) { + // A read error during scanning falls through to "not + // detected" -- the property path is still available if the + // user wants to be explicit. + } + return false; + } + + private static boolean scanDirectory(File dir) throws IOException { + File[] children = dir.listFiles(); + if (children == null) return false; + for (File f : children) { + if (f.isDirectory()) { + if (scanDirectory(f)) return true; + } else if (f.getName().endsWith(".class")) { + byte[] bytes = Files.readAllBytes(f.toPath()); + if (containsMarker(bytes)) return true; + } + } + return false; + } + + private static boolean scanJar(File jar) throws IOException { + try (ZipFile zf = new ZipFile(jar)) { + java.util.Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry e = entries.nextElement(); + if (e.isDirectory() || !e.getName().endsWith(".class")) continue; + try (InputStream in = zf.getInputStream(e)) { + byte[] bytes = in.readAllBytes(); + if (containsMarker(bytes)) return true; + } + } + } + return false; + } + + /** + * Boyer-Moore-free literal byte search. The marker is ASCII so a + * naive scan over the raw class bytes is fine; constant-pool + * UTF-8 entries store the marker verbatim. + */ + private static boolean containsMarker(byte[] bytes) { + byte[] needle = API_MARKER.getBytes(); + if (needle.length == 0 || bytes.length < needle.length) return false; + outer: + for (int i = 0; i <= bytes.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (bytes[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + return false; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java index 0b0693bd81..cddffd3003 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/CrashSymbolUploader.java @@ -38,6 +38,12 @@ public final class CrashSymbolUploader { public static final String PLATFORM_ANDROID = "android"; /** Platform constant for an iOS dSYM zip. */ public static final String PLATFORM_IOS = "ios"; + /** Platform constant for a Mac-native binary / dSYM. */ + public static final String PLATFORM_MAC = "mac"; + /** Platform constant for a Linux-native ELF binary or separate .debug file. */ + public static final String PLATFORM_LINUX = "linux"; + /** Platform constant for a Windows-native PE binary or accompanying .pdb. */ + public static final String PLATFORM_WIN32 = "win32"; private CrashSymbolUploader() { } @@ -159,4 +165,67 @@ public static boolean uploadIosDsym(String endpointBase, String sharedSecret, String buildKey, String dsymUuid, File dsymZip) { return upload(endpointBase, sharedSecret, buildKey, PLATFORM_IOS, dsymUuid, dsymZip); } + + /** + * Mac-native symbol upload. Pass either the produced executable + * (DWARF embedded) or a {@code dsymutil}-generated {@code .dSYM} + * bundle zip. Symbolication on the server side uses the same + * {@code llvm-symbolizer} pipeline as iOS, so either format works. + */ + public static boolean uploadMacSymbols(String endpointBase, String sharedSecret, + String buildKey, File binaryOrDsymZip) { + return upload(endpointBase, sharedSecret, buildKey, PLATFORM_MAC, null, binaryOrDsymZip); + } + + /** + * Linux-native symbol upload. Pass either the produced ELF + * executable (DWARF embedded if the build wasn't stripped) or + * the separate {@code .debug} file produced by + * {@code objcopy --only-keep-debug}. + */ + public static boolean uploadLinuxSymbols(String endpointBase, String sharedSecret, + String buildKey, File binaryOrDebug) { + return upload(endpointBase, sharedSecret, buildKey, PLATFORM_LINUX, null, binaryOrDebug); + } + + /** + * Windows-native symbol upload. Pass the {@code .pdb} produced by + * the MSVC link step (preferred) or the {@code .exe} itself (which + * carries enough info for stack walking but not source-level + * symbolication). + */ + public static boolean uploadWin32Symbols(String endpointBase, String sharedSecret, + String buildKey, File pdbOrExe) { + return upload(endpointBase, sharedSecret, buildKey, PLATFORM_WIN32, null, pdbOrExe); + } + + /** + * Resolve the upload endpoint base from the executor's environment. + * The cloud build executor sets {@code BUILDCLOUD_CRASH_ENDPOINT}; + * local Maven runs leave it unset, which causes every upload call + * to skip cleanly. Centralised here so each builder doesn't have to + * remember the env var name. + */ + public static String endpointFromEnv() { + return System.getenv("BUILDCLOUD_CRASH_ENDPOINT"); + } + + /** + * Resolve the shared secret from the executor's environment. + * Companion to {@link #endpointFromEnv}. + */ + public static String sharedSecretFromEnv() { + return System.getenv("BUILDCLOUD_BUILDSERVER_SECRET"); + } + + /** + * Resolve the current build's {@code buildEntryKey} from the + * executor's environment. Set by the cloud build executor before + * it invokes the maven plugin so each builder can tag its + * symbol upload with the right build. Empty in local Maven + * invocations, which is how callers detect "no upload to do." + */ + public static String buildKeyFromEnv() { + return System.getenv("BUILDCLOUD_CURRENT_BUILD_KEY"); + } }