diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index c1edb9f64a..dd97842cd5 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -25,6 +25,9 @@ Here is the current list of supported arguments. Build hints change over time, s |android.release |true/false defaults to true - indicates whether to include the release version in the build +|android.onDeviceDebug +|Boolean true/false defaults to false. When `true`, the generated `AndroidManifest.xml` is marked `android:debuggable="true"`, R8/proguard is disabled, and the build is pinned to debug-only (`android.release` is forced off and `android.debug` is forced on) so a stray hint can't ship a release-signed APK that's `debuggable="true"`. Pair with the `cn1:android-on-device-debugging` Maven goal (or the bundled IntelliJ run configs) to install, launch, forward JDWP, and stream logcat through adb. Has no effect on builds that don't carry it — release builds are unaffected. See the link:#_ondevice_debugging_android[On-Device Debugging (Android) chapter] for the full flow. + |android.installLocation |Maps to android:installLocation manifest entry defaults to auto. Can also be set to internalOnly or preferExternal. diff --git a/docs/developer-guide/On-Device-Debugging-Android.asciidoc b/docs/developer-guide/On-Device-Debugging-Android.asciidoc new file mode 100644 index 0000000000..ea933b9c2f --- /dev/null +++ b/docs/developer-guide/On-Device-Debugging-Android.asciidoc @@ -0,0 +1,327 @@ +== On-Device Debugging (Android) + +The companion to the iOS chapter, for the Android side. The motivation +is the same — bugs that only reproduce on a real device or on the +Android emulator — but the implementation is much simpler. The +Dalvik/ART runtime already exposes a JDWP socket per debuggable +process, so there is no desktop proxy and no custom protocol. The +Codename One Maven plugin just orchestrates `adb`: install the APK, +mark it as the debug-app, launch the activity, forward the JDWP +socket onto `localhost`, and tail logcat. + +Everything below assumes you have the Android platform-tools on your +machine (Android Studio installs them, or +https://developer.android.com/tools/releases/platform-tools[the +standalone package]) and `adb` is reachable through `ANDROID_HOME`, +`ANDROID_SDK_ROOT`, or `$PATH`. + +=== When to use it + +* A bug only reproduces on a physical Android device or in the Android + emulator and not in the Codename One simulator. +* You want to single-step through your code while it runs on the + device, with full access to locals, fields, and the + `Display.getInstance()...` accessor chains. +* You want to attach without a USB cable — the wireless flow below + covers both the legacy `adb tcpip` path and the Android 11+ + `adb pair` path. + +If the bug reproduces in the simulator, stay in the simulator — its +debugger is faster and has zero device-side moving parts. + +=== Quick start (IntelliJ IDEA) + +Projects generated from the `cn1app-archetype` ship with two run +configurations under the *On-Device Debug* folder: *CN1 Android +On-Device Debug* and *CN1 Attach Android*. The flow is: + +==== 1. Enable the build hint + +In `common/codenameone_settings.properties`, uncomment the line the +archetype generated: + +[source,properties] +---- +codename1.arg.android.onDeviceDebug=true +---- + +This flips the generated `AndroidManifest.xml` to +`debuggable="true"` and disables R8/proguard so symbols and locals +survive the build. Release builds (anything without this hint) are +unaffected. + +==== 2. Build the APK + +Either the cloud build (`cn1:buildAndroidOnDeviceDebug` — wraps +`cn1:buildAndroid` and force-sets the hint above) or your usual +`cn1:buildAndroid` once the hint is set. Both produce a signed, +debuggable APK in the project's `target/` directory. + +For a fully local build, use `cn1:buildAndroidGradleProject` to +generate the Gradle project under `target/...-android-source/` and +run `./gradlew assembleDebug` from there; the Mojo will autodetect +the resulting APK under `build/outputs/apk/`. + +==== 3. Connect the device + +Plug a device in over USB with USB debugging enabled, *or* connect +wirelessly (see <> +below). + +==== 4. Run the debug session from IntelliJ + +Select *CN1 Android On-Device Debug* from the Run-config dropdown +and click ▶ *Run* (the green play icon — not the bug icon). The Run +tool window prints: + +---- +Using adb: /Users/you/Library/Android/sdk/platform-tools/adb +Target device: emulator-5554 +Installing my-app-1.0.apk on emulator-5554 +Marking com.example.myapp as the debug app (waits for debugger). +Launching com.example.myapp.MyAppStub +App PID on device: 12345 + +================================================================== + JDWP forwarded: localhost:5005 -> device pid 12345 + Attach IntelliJ: Run -> 'CN1 Attach Android' (Remote JVM Debug) +================================================================== +---- + +After that banner, logcat output (filtered to the app's PID) streams +through the Run window prefixed with `[device]`. + +==== 5. Attach the debugger + +Switch the Run-config dropdown to *CN1 Attach Android* and click 🐞 +*Debug*. IntelliJ connects to `localhost:5005` and opens a Debug tool +window. The app is paused inside `Debug.waitForDebugger()` at this +point — set your breakpoints, then resume from the IDE to let it +proceed past boot. + +Set breakpoints, step, inspect, evaluate expressions — all the +normal remote-attach features work. Unlike the iOS path, there is +no method-invocation or static-field limitation here; the JVM is +the real Android runtime. + +[#on-device-debug-android-wireless] +=== Wireless debugging + +==== Android 11 and newer (recommended) + +. On the device: *Settings → Developer options → Wireless debugging → + Pair device with pairing code*. Note the *IP & port* (for example + `192.168.1.42:37051`) and the *six-digit pairing code*. +. Pair from your laptop (this only has to be done once per network): ++ +[source,bash] +---- +adb pair 192.168.1.42:37051 +# When prompted, enter the 6-digit code shown on the device. +---- +. Connect: ++ +[source,bash] +---- +adb connect 192.168.1.42:5555 +---- ++ +The connect port is *not* the pairing port — it's the one shown on +the *Wireless debugging* screen above the *Pair device* button. +. Run the debug session as normal. Pass the IP and port through the + Mojo if you prefer to do the `adb connect` in one step: ++ +[source,bash] +---- +mvn cn1:android-on-device-debugging \ + -Dcn1.android.onDeviceDebug.wireless=192.168.1.42:5555 +---- + +==== Android 10 and older + +. Plug the device in over USB. +. Switch adb to TCP/IP mode and grab the device's IP from + *Settings → About phone → Status*: ++ +[source,bash] +---- +adb tcpip 5555 +adb connect 192.168.1.42:5555 +---- +. Unplug the cable, then run the debug session as normal. + +In either flow, the JDWP forward, app launch, and logcat stream all +happen over the same Wi-Fi link. + +=== Quick start (Maven from the command line) + +Without the IntelliJ run configs, the same flow is two terminals: + +[source,bash] +---- +# Terminal 1 — build the APK once +mvn cn1:buildAndroidOnDeviceDebug + +# Terminal 2 — install, launch, forward JDWP, tail logcat +mvn cn1:android-on-device-debugging +---- + +Attach jdb (or another JDWP client) to the forwarded port: + +[source,bash] +---- +jdb -attach localhost:5005 \ + -sourcepath src/main/java:$HOME/.m2/repository/com/codenameone/codenameone-core/8.0-SNAPSHOT/codenameone-core-8.0-SNAPSHOT-sources.jar +---- + +For VS Code (Debugger for Java extension), add a launch +configuration of type `java` with `"request": "attach"`, +`"hostName": "localhost"`, `"port": 5005`. + +=== Useful command-line flags + +The Mojo's defaults match what the archetype's run config does. The +flags below exist for unusual setups: + +[cols="1,2", options="header"] +|=== +|Property +|Meaning + +|`-Dcn1.android.onDeviceDebug.adb=` +|Use a specific adb executable (default: search `ANDROID_HOME`, +`ANDROID_SDK_ROOT`, the standard Android Studio SDK locations, and +`$PATH`). + +|`-Dcn1.android.onDeviceDebug.deviceSerial=` +|Force a target device when more than one is online +(`adb devices`). + +|`-Dcn1.android.onDeviceDebug.wireless=` +|Run `adb connect ` before everything else (covers both the +Android 11 wireless-debug path and the legacy `adb tcpip` path). + +|`-Dcn1.android.onDeviceDebug.jdwpPort=` +|Local TCP port for `adb forward`. Default `5005`. Match this with +the *CN1 Attach Android* run config if you change it. + +|`-Dcn1.android.onDeviceDebug.apk=` +|Skip APK autodetection and install this APK instead. + +|`-Dcn1.android.onDeviceDebug.skipInstall=true` +|Skip the install step — the app is already on the device. + +|`-Dcn1.android.onDeviceDebug.waitForAttach=false` +|Don't run `am set-debug-app -w`. The app launches normally and you +attach the debugger afterwards. Default is `true`. +|=== + +=== What you can step through + +Everything runs in one Dalvik/ART process, so the JDWP attach sees +every class loaded by the app. In practical terms: + +* *Your common-module Java* (`com...*`, anything + under the `-common` module's `src/main/java`). Breakpoints work + out of the box — this is the module the *CN1 Attach Android* run + config is scoped to, so IntelliJ resolves the source pane and the + variables view from your common-module classpath. +* *Your android-module Java or Kotlin* (`src/main/java` of the + `-android` module — native interface implementations, custom + Activities, Android-only helpers). Open the file and set a + breakpoint; the IDE's *Remote JVM Debug* config doesn't restrict + which module breakpoints live in, the `` setting only + decides which module's classpath is used to *display* state. If + the variables view ever looks empty when you stop inside + `-android` code, switch the run config's *Use classpath of module* + to the `-android` module and reattach. +* *Codename One framework code* in `codenameone-core` and the + Android port (`com.codename1.impl.android.*` such as + `AndroidImplementation` and `CodenameOneActivity`). Both need + source resolution — see the next section. +* *Native C/C++ via the NDK* is *not* debuggable through this path. + JDWP only speaks JVM. If you've added native sources through the + Android NDK, attach Android Studio's LLDB to the same process for + C/C++ debugging — the two attaches are independent and can run + side-by-side against one device. + +=== Pointing the IDE at the Codename One sources + +The Android runtime serves real `.class` files, so the IDE only needs +the matching `.java` files to render the source pane. The same two +options cover both `codenameone-core` and the Android port +(`codenameone-android`): + +* *Maven sources jars (recommended).* IntelliJ resolves + `codenameone-core--sources.jar` *and* + `codenameone-android--sources.jar` automatically once you + run *Maven → Reimport* with "Sources" enabled in the Maven + settings. +* *Local clone of the + https://github.com/codenameone/CodenameOne[Codename One GitHub + repository].* In IntelliJ: *Run → Edit Configurations… → CN1 + Attach Android → Configuration → Source roots → +*. Add two + entries: the clone's `CodenameOne/src` directory for the + framework core, and `Ports/Android/src` for `AndroidImplementation` + and the rest of the Android port. + +Without a source path, breakpoints in framework classes still trigger +and locals / fields still read — you'll just see "Sources not found" +in the editor when stepping into framework code. + +=== Troubleshooting + +==== "No Android device is online" + +`adb devices` returns nothing useful. Common causes: + +* The device's *USB debugging* toggle is off, or the per-laptop + RSA fingerprint prompt is still pending on-device. +* The cable is power-only (some short USB-C cables are charge-only). +* For wireless: pairing expired (Android 11+) or the laptop is on a + different Wi-Fi network. + +==== "Multiple devices online" + +Pass `-Dcn1.android.onDeviceDebug.deviceSerial=` (run +`adb devices` to see serials). The emulator's serial looks like +`emulator-5554`; a USB-attached phone is its hardware ID; a wireless +device is `:`. + +==== App installs but won't pause for the debugger + +`waitForAttach` only takes effect when the APK is built with +`android.onDeviceDebug=true` (or `android.xapplication_attr` +containing `android:debuggable="true"`). Verify by running +`adb shell dumpsys package | grep flags` — `DEBUGGABLE` +must be in the list. If it isn't, rebuild with +`mvn cn1:buildAndroidOnDeviceDebug`. + +==== Breakpoint never fires + +Check the *CN1 Attach Android* run config: + +* *Host* is `localhost`, *Port* matches the `jdwpPort` printed by the + debug session. +* The *Use module classpath* dropdown is the `-common` module, so + IntelliJ can resolve your `.java` files. + +If a class loaded on the device doesn't match the class IntelliJ +thinks is current, breakpoints stop firing with no error message. +Rebuild and reinstall (`cn1:buildAndroidOnDeviceDebug` + re-run +*CN1 Android On-Device Debug*). + +==== logcat shows the app exiting with `Debug.waitForDebugger`-like noise + +The system killed the process for taking too long to attach. Set +`-Dcn1.android.onDeviceDebug.waitForAttach=false` and trigger the +code path you want to debug manually after attach — Android's debug-app +wait isn't bound to the breakpoint, only to process start. + +==== Wireless connection drops mid-session + +Wi-Fi connections to debug-mode devices are sensitive to power-saving. +On the device, keep the screen on (or set *Settings → Developer +options → Stay awake* while charging). For long sessions, plug into +USB and use `adb -s ` to keep the wireless TCP socket +alive without depending on the radio. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 7a2d34b5e9..6b87b1785a 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -92,6 +92,8 @@ include::Working-With-iOS.asciidoc[] include::On-Device-Debugging.asciidoc[] +include::On-Device-Debugging-Android.asciidoc[] + include::Working-With-Javascript.asciidoc[] include::Working-with-Mac-OS-X.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 86b6ee1870..d987416484 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -491,3 +491,11 @@ jdb loopback rethrow rethrows + +# ----------------------------------------------------------------------------- +# Android tooling — names from the Android SDK / platform-tools that the +# English dictionary doesn't recognise. +# ----------------------------------------------------------------------------- +adb +logcat +pidof diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Android_OnDeviceDebug.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Android_OnDeviceDebug.xml new file mode 100644 index 0000000000..c60769443d --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Android_OnDeviceDebug.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Attach_Android.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Attach_Android.xml new file mode 100644 index 0000000000..42cc5dca33 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_Attach_Android.xml @@ -0,0 +1,17 @@ + + + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/codenameone_settings.properties b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/codenameone_settings.properties index 5caf691923..f75b2331d1 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/codenameone_settings.properties +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/codenameone_settings.properties @@ -34,6 +34,15 @@ codename1.ios.release.provision= #codename1.arg.ios.onDeviceDebug.proxyPort=55333 # Block the app at boot until the debugger attaches (default: false). #codename1.arg.ios.onDeviceDebug.waitForAttach=true +# On-device debugging for Android apps. When set, the Android build +# marks the APK debuggable and skips R8/proguard so the Dalvik/ART +# JDWP socket is reachable through adb. Pair with the IntelliJ +# "CN1 Android On-Device Debug" and "CN1 Attach Android" run configs +# bundled with this project, or with the cn1:android-on-device-debugging +# Maven goal. See the "On-Device Debugging (Android)" chapter of the +# developer guide for the wireless-debugging instructions and the full +# adb flow. +#codename1.arg.android.onDeviceDebug=true codename1.j2me.nativeTheme=nbproject/nativej2me.res codename1.kotlin=false codename1.languageLevel=5 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 69c278d3f2..6891e7ab95 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 @@ -538,6 +538,23 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // R8 configuration - disable full mode by default to prevent issues with reflection disableR8 = request.getArg("android.disableR8", "false").equals("true"); disableR8FullMode = request.getArg("android.disableR8FullMode", "true").equals("true"); + + // On-device debugging: when set, mark the APK debuggable so Dalvik/ART exposes + // a JDWP socket the cn1:android-on-device-debugging Mojo can forward through adb. + // Forcing debuggable also flips off R8 and ProGuard so symbols, method names, + // and line numbers survive the build — we may be invoked from the android-device + // cloud target which would otherwise run full optimisation. Also force the build + // down to debug-only — Android otherwise produces both a release and a debug + // APK from the same manifest, so without this a stray hint could ship a + // release-signed, debuggable APK. Release builds and projects that don't opt + // in see no change. + boolean onDeviceDebug = request.getArg("android.onDeviceDebug", "false").equals("true"); + if (onDeviceDebug) { + disableR8 = true; + request.putArgument("android.enableProguard", "false"); + request.putArgument("android.release", "false"); + request.putArgument("android.debug", "true"); + } if (useGradle8) { getGradleJavaHome(); // will throw build exception if JAVA17_HOME is not set MIN_GRADLE_VERSION = 8; @@ -2483,6 +2500,14 @@ public void usesClassMethod(String cls, String method) { allowBackup = ""; } + // On-device debugging needs the application to be marked debuggable so the + // runtime hands out a JDWP socket per-process. Idempotent: skip if the user + // already declared android:debuggable via android.xapplication_attr. + String debuggableAttr = ""; + if (onDeviceDebug && !applicationAttr.contains("android:debuggable")) { + debuggableAttr = " android:debuggable=\"true\" "; + } + String applicationNode = " } for wireless devices. + * 2. (optional) {@code adb install -r } when an APK is found in + * the build output or passed via -Dapk. + * 3. {@code adb shell am set-debug-app -w --persistent } when + * waitForAttach is true, so the app blocks at boot for the IDE. + * 4. {@code adb shell am start -n /Stub} to launch. + * 5. Polls {@code adb shell pidof } for the running PID. + * 6. {@code adb forward tcp: jdwp:} exposes the device + * JDWP socket on localhost. + * 7. Streams {@code adb logcat --pid=} to this console with a + * {@code [device]} prefix. + * + * The Mojo blocks until interrupted (Ctrl-C) or the device process exits. + * + * Properties: + * -Dcn1.android.onDeviceDebug.adb=path/to/adb adb override + * -Dcn1.android.onDeviceDebug.deviceSerial= target one device + * -Dcn1.android.onDeviceDebug.wireless=192.168.1.5:5555 adb connect first + * -Dcn1.android.onDeviceDebug.jdwpPort=5005 local forward port + * -Dcn1.android.onDeviceDebug.apk=path/to/app.apk APK override + * -Dcn1.android.onDeviceDebug.packageName=... override package + * -Dcn1.android.onDeviceDebug.mainClass=... override main class + * -Dcn1.android.onDeviceDebug.waitForAttach=true|false default true + * -Dcn1.android.onDeviceDebug.skipInstall=true don't reinstall APK + */ +@Mojo(name = "android-on-device-debugging") +public class AndroidOnDeviceDebuggingMojo extends AbstractCN1Mojo { + + @Parameter(property = "cn1.android.onDeviceDebug.adb") + private String adbPath; + + @Parameter(property = "cn1.android.onDeviceDebug.deviceSerial") + private String deviceSerial; + + @Parameter(property = "cn1.android.onDeviceDebug.wireless") + private String wireless; + + @Parameter(property = "cn1.android.onDeviceDebug.jdwpPort", defaultValue = "5005") + private int jdwpPort; + + @Parameter(property = "cn1.android.onDeviceDebug.apk") + private String apkPath; + + @Parameter(property = "cn1.android.onDeviceDebug.packageName") + private String packageNameOverride; + + @Parameter(property = "cn1.android.onDeviceDebug.mainClass") + private String mainClassOverride; + + @Parameter(property = "cn1.android.onDeviceDebug.waitForAttach", defaultValue = "true") + private boolean waitForAttach; + + @Parameter(property = "cn1.android.onDeviceDebug.skipInstall", defaultValue = "false") + private boolean skipInstall; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + File commonDir = getCN1ProjectDir(); + if (commonDir == null) { + throw new MojoFailureException("Could not locate Codename One project root"); + } + File rootProjectDir = commonDir.getParentFile(); + if (rootProjectDir == null) rootProjectDir = commonDir; + + Properties cn1Settings = readCn1Settings(commonDir); + String packageName = firstNonEmpty(packageNameOverride, + cn1Settings.getProperty("codename1.packageName")); + String mainName = firstNonEmpty(mainClassOverride, + cn1Settings.getProperty("codename1.mainName")); + if (packageName == null || mainName == null) { + throw new MojoFailureException("Could not resolve package / main class from " + + new File(commonDir, "codenameone_settings.properties")); + } + String launcherActivity = packageName + "." + mainName + "Stub"; + + File adb = resolveAdb(); + getLog().info("Using adb: " + adb); + + if (wireless != null && !wireless.isEmpty()) { + getLog().info("adb connect " + wireless); + runAdb(adb, null, "connect", wireless); + } + + String serial = resolveDevice(adb); + getLog().info("Target device: " + serial); + + if (!skipInstall) { + File apk = resolveApk(rootProjectDir); + if (apk != null) { + getLog().info("Installing " + apk.getName() + " on " + serial); + CommandResult install = runAdb(adb, serial, "install", "-r", "-t", apk.getAbsolutePath()); + if (install.exitCode != 0) { + throw new MojoFailureException("adb install failed:\n" + install.stdout); + } + } else { + getLog().warn("No APK found under target/ — skipping install. " + + "Pass -Dcn1.android.onDeviceDebug.apk= or run " + + "'mvn cn1:buildAndroidOnDeviceDebug' first."); + } + } + + if (waitForAttach) { + getLog().info("Marking " + packageName + " as the debug app (waits for debugger)."); + runAdb(adb, serial, "shell", "am", "set-debug-app", "-w", "--persistent", packageName); + } else { + // Clear any stale debug-app flag from a previous run. + runAdb(adb, serial, "shell", "am", "clear-debug-app"); + } + + getLog().info("Launching " + launcherActivity); + CommandResult launch = runAdb(adb, serial, "shell", "am", "start", "-n", + packageName + "/" + launcherActivity); + if (launch.exitCode != 0) { + throw new MojoFailureException("adb am start failed:\n" + launch.stdout); + } + + String pid = waitForPid(adb, serial, packageName); + if (pid == null) { + throw new MojoFailureException("Timed out waiting for process " + packageName + + " on device " + serial + ". Did the app crash on launch?"); + } + getLog().info("App PID on device: " + pid); + + // Map the device JDWP socket onto a local TCP port. + runAdb(adb, serial, "forward", "tcp:" + jdwpPort, "jdwp:" + pid); + + getLog().info(""); + getLog().info("=================================================================="); + getLog().info(" JDWP forwarded: localhost:" + jdwpPort + " -> device pid " + pid); + getLog().info(" Attach IntelliJ: Run -> 'CN1 Attach Android' (Remote JVM Debug)"); + getLog().info(" Or from a shell: jdb -attach localhost:" + jdwpPort); + if (waitForAttach) { + getLog().info(""); + getLog().info(" The app is paused in waitForDebugger() — attach now to resume."); + } + getLog().info("=================================================================="); + getLog().info(""); + + // Stream logcat for the lifetime of the session. Logcat tee'd to our + // own stdout so it lands in the IDE's Run tool window alongside the + // [adb] lines above. --pid filters out unrelated chatter. + ProcessBuilder logcat = new ProcessBuilder( + buildAdbCommand(adb, serial, "logcat", "-v", "threadtime", "--pid=" + pid)); + logcat.redirectErrorStream(true); + Process logcatProc; + try { + logcatProc = logcat.start(); + } catch (IOException e) { + throw new MojoExecutionException("Failed to start adb logcat: " + e.getMessage(), e); + } + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logcatProc.destroy(); + try { + // Best-effort cleanup so a re-run doesn't trip over a stale forward. + runAdb(adb, serial, "forward", "--remove", "tcp:" + jdwpPort); + } catch (MojoFailureException e) { + System.err.println("[adb] failed to remove port forward on shutdown: " + + e.getMessage()); + } + })); + + try (BufferedReader r = new BufferedReader( + new InputStreamReader(logcatProc.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + getLog().info("[device] " + line); + } + } catch (IOException e) { + getLog().warn("logcat stream interrupted: " + e.getMessage()); + } + + try { + int rc = logcatProc.waitFor(); + if (rc != 0) { + getLog().warn("logcat exited with code " + rc); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private Properties readCn1Settings(File commonDir) throws MojoFailureException { + File f = new File(commonDir, "codenameone_settings.properties"); + if (!f.isFile()) { + throw new MojoFailureException("Missing " + f); + } + Properties p = new Properties(); + try (FileInputStream in = new FileInputStream(f)) { + p.load(in); + } catch (IOException e) { + throw new MojoFailureException("Failed to read " + f + ": " + e.getMessage()); + } + return p; + } + + private File resolveAdb() throws MojoFailureException { + if (adbPath != null && !adbPath.isEmpty()) { + File f = new File(adbPath); + if (!f.isFile()) throw new MojoFailureException("Configured adb not found: " + f); + return f; + } + List candidates = new ArrayList<>(); + String androidHome = System.getenv("ANDROID_HOME"); + String androidSdkRoot = System.getenv("ANDROID_SDK_ROOT"); + if (androidHome != null) candidates.add(androidHome + "/platform-tools/adb"); + if (androidSdkRoot != null) candidates.add(androidSdkRoot + "/platform-tools/adb"); + String home = System.getProperty("user.home"); + if (home != null) { + // macOS, Linux, and Windows default Android Studio SDK locations. + candidates.add(home + "/Library/Android/sdk/platform-tools/adb"); + candidates.add(home + "/Android/Sdk/platform-tools/adb"); + candidates.add(home + "/AppData/Local/Android/Sdk/platform-tools/adb.exe"); + } + for (String c : candidates) { + File f = new File(c); + if (f.isFile()) return f; + } + // PATH lookup as a last resort. + String pathEnv = System.getenv("PATH"); + if (pathEnv != null) { + String exe = isWindows() ? "adb.exe" : "adb"; + for (String dir : pathEnv.split(File.pathSeparator)) { + File f = new File(dir, exe); + if (f.isFile()) return f; + } + } + throw new MojoFailureException( + "Could not locate the Android adb executable. Install the Android SDK " + + "platform-tools (or Android Studio) and set ANDROID_HOME, or pass " + + "-Dcn1.android.onDeviceDebug.adb=."); + } + + private String resolveDevice(File adb) throws MojoFailureException { + if (deviceSerial != null && !deviceSerial.isEmpty()) return deviceSerial; + CommandResult res = runAdb(adb, null, "devices"); + // adb devices output: header line then `\t` per device. + List online = new ArrayList<>(); + for (String line : res.stdout.split("\\r?\\n")) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("List of devices")) continue; + String[] parts = line.split("\\s+"); + if (parts.length >= 2 && "device".equals(parts[1])) { + online.add(parts[0]); + } + } + if (online.isEmpty()) { + throw new MojoFailureException( + "No Android device is online. Plug a device in with USB debugging enabled, " + + "or pass -Dcn1.android.onDeviceDebug.wireless= to dial a " + + "wireless device first. (adb devices output: " + res.stdout.trim() + ")"); + } + if (online.size() > 1) { + throw new MojoFailureException( + "Multiple devices online (" + String.join(", ", online) + "); pick one with " + + "-Dcn1.android.onDeviceDebug.deviceSerial=."); + } + return online.get(0); + } + + private File resolveApk(File rootProjectDir) { + if (apkPath != null && !apkPath.isEmpty()) { + File f = new File(apkPath); + return f.isFile() ? f : null; + } + // Locally-built Gradle source path (from `cn1:buildAndroidGradleProject`). + // Cloud-built APK lands directly in /target/*.apk. + List roots = new ArrayList<>(); + File androidModule = new File(rootProjectDir, "android"); + if (androidModule.isDirectory()) roots.add(new File(androidModule, "target")); + roots.add(new File(rootProjectDir, "target")); + roots.add(rootProjectDir); + + List hits = new ArrayList<>(); + for (File root : roots) { + if (!root.isDirectory()) continue; + try (Stream walk = Files.walk(root.toPath())) { + walk.filter(p -> { + String n = p.getFileName().toString().toLowerCase(Locale.ROOT); + return n.endsWith(".apk") + && !p.toString().contains("/.gradle/") + && !p.toString().contains("/intermediates/"); + }).forEach(p -> hits.add(p.toFile())); + } catch (IOException ignored) { + } + } + if (hits.isEmpty()) return null; + hits.sort(Comparator.comparingLong(File::lastModified).reversed()); + return hits.get(0); + } + + private String waitForPid(File adb, String serial, String packageName) throws MojoFailureException { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20); + while (System.currentTimeMillis() < deadline) { + CommandResult r = runAdbQuiet(adb, serial, "shell", "pidof", packageName); + String pid = r.stdout.trim(); + // pidof prints space-separated pids when multiple matches exist; take the first. + int sp = pid.indexOf(' '); + if (sp > 0) pid = pid.substring(0, sp); + if (!pid.isEmpty() && pid.chars().allMatch(Character::isDigit)) { + return pid; + } + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + return null; + } + + private List buildAdbCommand(File adb, String serial, String... args) { + List cmd = new ArrayList<>(); + cmd.add(adb.getAbsolutePath()); + if (serial != null) { + cmd.add("-s"); + cmd.add(serial); + } + cmd.addAll(Arrays.asList(args)); + return cmd; + } + + private CommandResult runAdb(File adb, String serial, String... args) throws MojoFailureException { + CommandResult r = runAdbQuiet(adb, serial, args); + if (r.exitCode != 0 && getLog().isDebugEnabled()) { + getLog().debug("adb " + String.join(" ", args) + " exited " + r.exitCode + ": " + r.stdout); + } + return r; + } + + private CommandResult runAdbQuiet(File adb, String serial, String... args) throws MojoFailureException { + ProcessBuilder pb = new ProcessBuilder(buildAdbCommand(adb, serial, args)); + pb.redirectErrorStream(true); + try { + Process p = pb.start(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream in = p.getInputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + int rc = p.waitFor(); + return new CommandResult(rc, out.toString(StandardCharsets.UTF_8.name())); + } catch (IOException | InterruptedException e) { + throw new MojoFailureException("adb invocation failed: " + e.getMessage()); + } + } + + private static String firstNonEmpty(String... values) { + for (String v : values) { + if (v != null && !v.isEmpty()) return v; + } + return null; + } + + private static boolean isWindows() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + return os.contains("win"); + } + + private static final class CommandResult { + final int exitCode; + final String stdout; + + CommandResult(int exitCode, String stdout) { + this.exitCode = exitCode; + this.stdout = stdout; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildAndroidOnDeviceDebugMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildAndroidOnDeviceDebugMojo.java new file mode 100644 index 0000000000..85de0f79f0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildAndroidOnDeviceDebugMojo.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2012, 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.maven.buildWrappers; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.invoker.*; + +import java.io.File; +import java.util.Collections; +import java.util.Properties; + +/** + * Builds an Android APK with the on-device-debug flag flipped on and + * submits it through the normal Codename One Android build flow. + * + * Pair with {@code mvn cn1:android-on-device-debugging} to install the + * APK on a connected device, forward JDWP, and stream logcat — see + * {@code docs/developer-guide/On-Device-Debugging.asciidoc}. + * + * Forces {@code codename1.arg.android.onDeviceDebug=true} so the + * generated manifest is {@code debuggable="true"} and R8/proguard + * is disabled, regardless of what + * {@code codenameone_settings.properties} contains. + */ +@Mojo(name = "buildAndroidOnDeviceDebug", + requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +public class BuildAndroidOnDeviceDebugMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (!project.isExecutionRoot()) { + getLog().info("Skipping execution for non-root project"); + return; + } + InvocationRequest request = new DefaultInvocationRequest(); + request.setPomFile(new File("pom.xml")); + request.setGoals(Collections.singletonList("package")); + + Properties properties = new Properties(); + properties.setProperty("skipTests", "true"); + properties.setProperty("codename1.platform", "android"); + properties.setProperty("codename1.buildTarget", "android-device"); + // Self-contained from the IDE menu: do not depend on what the + // project's codenameone_settings.properties currently has. + properties.setProperty("codename1.arg.android.onDeviceDebug", "true"); + request.setProperties(properties); + + Invoker invoker = new DefaultInvoker(); + try { + InvocationResult result = invoker.execute(request); + if (result.getExitCode() != 0) { + throw new MojoFailureException("Failed to build project with exit code " + result.getExitCode()); + } + } catch (MavenInvocationException e) { + throw new MojoExecutionException("Failed to invoke Maven", e); + } + } +}