Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/scripts-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,27 @@ jobs:
- name: Build Android port
run: ./scripts/build-android-port.sh -q -DskipTests
- name: Build Hello Codename One Android app
id: build-android-app
run: ./scripts/build-android-app.sh -q -DskipTests
- name: Enable KVM for Android emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Android instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
arch: x86_64
target: google_apis
script: |
./scripts/run-android-instrumentation-tests.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}"
- name: Upload emulator screenshot
if: always() # still collect it if tests fail
uses: actions/upload-artifact@v4
with:
name: emulator-screenshot
path: artifacts/*.png
if-no-files-found: warn
retention-days: 14
compression-level: 6
252 changes: 251 additions & 1 deletion scripts/build-android-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ xmlstarlet sel -N "$NS" -t -c "/mvn:project/mvn:build/mvn:plugins" -n "$ROOT_POM
[ -f "$APP_DIR/build.sh" ] && chmod +x "$APP_DIR/build.sh"

SETTINGS_FILE="$APP_DIR/common/codenameone_settings.properties"
echo "codename1.arg.android.useAndroidX=true" >> "$SETTINGS_FILE"
[ -f "$SETTINGS_FILE" ] || { ba_log "codenameone_settings.properties not found at $SETTINGS_FILE" >&2; exit 1; }

# --- Read settings ---
Expand Down Expand Up @@ -265,6 +266,246 @@ if [ -z "$GRADLE_PROJECT_DIR" ]; then
exit 1
fi

ba_log "Configuring instrumentation test sources in $GRADLE_PROJECT_DIR"

# Ensure AndroidX flags in gradle.properties
# --- BEGIN: robust Gradle patch for AndroidX tests ---
GRADLE_PROPS="$GRADLE_PROJECT_DIR/gradle.properties"
grep -q '^android.useAndroidX=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.useAndroidX=true' >> "$GRADLE_PROPS"
grep -q '^android.enableJetifier=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.enableJetifier=true' >> "$GRADLE_PROPS"

APP_BUILD_GRADLE="$GRADLE_PROJECT_DIR/app/build.gradle"
ROOT_BUILD_GRADLE="$GRADLE_PROJECT_DIR/build.gradle"

# Ensure repos in both root and app
for F in "$ROOT_BUILD_GRADLE" "$APP_BUILD_GRADLE"; do
if [ -f "$F" ]; then
if ! grep -qE '^\s*repositories\s*{' "$F"; then
cat >> "$F" <<'EOS'

repositories {
google()
mavenCentral()
}
EOS
else
grep -q 'google()' "$F" || sed -E -i '0,/repositories[[:space:]]*\{/s//repositories {\n google()\n mavenCentral()/' "$F"
grep -q 'mavenCentral()' "$F" || sed -E -i '0,/repositories[[:space:]]*\{/s//repositories {\n google()\n mavenCentral()/' "$F"
fi
fi
done

# Edit app/build.gradle
python3 - "$APP_BUILD_GRADLE" <<'PY'
import sys, re, pathlib
p = pathlib.Path(sys.argv[1]); txt = p.read_text(); orig = txt; changed = False

def strip_block(name, s):
return re.sub(rf'(?ms)^\s*{name}\s*\{{.*?\}}\s*', '', s)

module_view = strip_block('buildscript', strip_block('pluginManagement', txt))

# 1) android { compileSdkVersion/targetSdkVersion }
def ensure_sdk(body):
# If android { ... } exists, update/insert inside defaultConfig and the android block
if re.search(r'(?m)^\s*android\s*\{', body):
# compileSdkVersion
if re.search(r'(?m)^\s*compileSdkVersion\s+\d+', body) is None:
body = re.sub(r'(?m)(^\s*android\s*\{)', r'\1\n compileSdkVersion 33', body, count=1)
else:
body = re.sub(r'(?m)^\s*compileSdkVersion\s+\d+', ' compileSdkVersion 33', body)
# targetSdkVersion
if re.search(r'(?ms)^\s*defaultConfig\s*\{.*?^\s*\}', body):
dc = re.search(r'(?ms)^\s*defaultConfig\s*\{.*?^\s*\}', body)
block = dc.group(0)
if re.search(r'(?m)^\s*targetSdkVersion\s+\d+', block):
block2 = re.sub(r'(?m)^\s*targetSdkVersion\s+\d+', ' targetSdkVersion 33', block)
else:
block2 = re.sub(r'(\{\s*)', r'\1\n targetSdkVersion 33', block, count=1)
body = body[:dc.start()] + block2 + body[dc.end():]
else:
body = re.sub(r'(?m)(^\s*android\s*\{)', r'\1\n defaultConfig {\n targetSdkVersion 33\n }', body, count=1)
else:
# No android block at all: add minimal
body += '\n\nandroid {\n compileSdkVersion 33\n defaultConfig { targetSdkVersion 33 }\n}\n'
return body

txt2 = ensure_sdk(txt)
if txt2 != txt: txt = txt2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True

# 2) testInstrumentationRunner -> AndroidX
if "androidx.test.runner.AndroidJUnitRunner" not in module_view:
t2, n = re.subn(r'(?m)^\s*testInstrumentationRunner\s*".*?"\s*$', ' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"', txt)
if n == 0:
t2, n = re.subn(r'(?m)(^\s*defaultConfig\s*\{)', r'\1\n testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"', txt, count=1)
if n == 0:
t2, n = re.subn(r'(?ms)(^\s*android\s*\{)', r'\1\n defaultConfig {\n testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"\n }', txt, count=1)
if n: txt = t2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True

# 3) remove legacy useLibrary lines
t2, n = re.subn(r'(?m)^\s*useLibrary\s+\'android\.test\.(base|mock|runner)\'\s*$', '', txt)
if n: txt = t2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True

# 4) deps: choose androidTestImplementation vs androidTestCompile
uses_modern = re.search(r'(?m)^\s*(implementation|api|testImplementation|androidTestImplementation)\b', module_view) is not None
conf = "androidTestImplementation" if uses_modern else "androidTestCompile"
need = [
("androidx.test.ext:junit:1.1.5", conf), # AndroidJUnit4
("androidx.test:runner:1.5.2", conf),
("androidx.test:core:1.5.0", conf),
("androidx.test.services:storage:1.4.2", conf),
]
to_add = [(c, k) for (c, k) in need if c not in module_view]

if to_add:
block = "\n\ndependencies {\n" + "".join([f" {k} \"{c}\"\n" for c, k in to_add]) + "}\n"
txt = txt.rstrip() + block
changed = True

if changed and txt != orig:
if not txt.endswith("\n"): txt += "\n"
p.write_text(txt)
print(f"Patched app/build.gradle (SDK=33; deps via {conf})")
else:
print("No changes needed in app/build.gradle")
PY
# --- END: robust Gradle patch ---

echo "----- app/build.gradle tail -----"
tail -n 80 "$APP_BUILD_GRADLE" | sed 's/^/| /'
echo "---------------------------------"

TEST_SRC_DIR="$GRADLE_PROJECT_DIR/app/src/androidTest/java/${PACKAGE_PATH}"
mkdir -p "$TEST_SRC_DIR"
TEST_CLASS="$TEST_SRC_DIR/HelloCodenameOneInstrumentedTest.java"
cat >"$TEST_CLASS" <<'EOF'
package @PACKAGE@;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.util.Base64;
import android.util.DisplayMetrics;
import android.view.View;

import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.ByteArrayOutputStream;

@RunWith(AndroidJUnit4.class)
public class HelloCodenameOneInstrumentedTest {

private static void println(String s) { System.out.println(s); }

@Test
public void testUseAppContext_andEmitScreenshot() throws Exception {
Context ctx = ApplicationProvider.getApplicationContext();
String pkg = "@PACKAGE@";
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());

// Resolve real launcher intent (don’t hard-code activity)
Intent launch = ctx.getPackageManager().getLaunchIntentForPackage(pkg);
if (launch == null) {
// Fallback MAIN/LAUNCHER inside this package
Intent q = new Intent(Intent.ACTION_MAIN);
q.addCategory(Intent.CATEGORY_LAUNCHER);
q.setPackage(pkg);
launch = q;
}
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

println("CN1SS:INFO: about to launch Activity");
byte[] pngBytes = null;

try (ActivityScenario<Activity> scenario = ActivityScenario.launch(launch)) {
// give the activity a tiny moment to layout
Thread.sleep(750);

println("CN1SS:INFO: activity launched");

final byte[][] holder = new byte[1][];
scenario.onActivity(activity -> {
try {
View root = activity.getWindow().getDecorView().getRootView();
int w = root.getWidth();
int h = root.getHeight();
if (w <= 0 || h <= 0) {
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
w = Math.max(1, dm.widthPixels);
h = Math.max(1, dm.heightPixels);
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
root.measure(sw, sh);
root.layout(0, 0, w, h);
println("CN1SS:INFO: forced layout to " + w + "x" + h);
} else {
println("CN1SS:INFO: natural layout " + w + "x" + h);
}

Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(bmp);
root.draw(c);

ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
boolean ok = bmp.compress(Bitmap.CompressFormat.PNG, 100, baos);
if (!ok) throw new RuntimeException("Bitmap.compress returned false");
holder[0] = baos.toByteArray();
println("CN1SS:INFO: png_bytes=" + holder[0].length);
} catch (Throwable t) {
println("CN1SS:ERR: onActivity " + t);
t.printStackTrace(System.out);
}
});

pngBytes = holder[0];
} catch (Throwable t) {
println("CN1SS:ERR: launch " + t);
t.printStackTrace(System.out);
}

if (pngBytes == null || pngBytes.length == 0) {
println("CN1SS:END"); // terminator for the runner parser
Assert.fail("Screenshot capture produced 0 bytes");
return;
}

// Chunk & emit (safe for Gradle/logcat capture)
String b64 = Base64.encodeToString(pngBytes, Base64.NO_WRAP);
final int CHUNK = 2000;
int count = 0;
for (int pos = 0; pos < b64.length(); pos += CHUNK) {
int end = Math.min(pos + CHUNK, b64.length());
System.out.println("CN1SS:" + String.format("%06d", pos) + ":" + b64.substring(pos, end));
count++;
}
println("CN1SS:INFO: chunks=" + count + " total_b64_len=" + b64.length());
System.out.println("CN1SS:END");
System.out.flush();
}
}
EOF
sed -i "s|@PACKAGE@|$PACKAGE_NAME|g" "$TEST_CLASS"
ba_log "Created instrumentation test at $TEST_CLASS"

DEFAULT_ANDROID_TEST="$GRADLE_PROJECT_DIR/app/src/androidTest/java/com/example/myapplication2/ExampleInstrumentedTest.java"
if [ -f "$DEFAULT_ANDROID_TEST" ]; then
rm -f "$DEFAULT_ANDROID_TEST"
ba_log "Removed default instrumentation stub at $DEFAULT_ANDROID_TEST"
DEFAULT_ANDROID_TEST_DIR="$(dirname "$DEFAULT_ANDROID_TEST")"
DEFAULT_ANDROID_TEST_PARENT="$(dirname "$DEFAULT_ANDROID_TEST_DIR")"
rmdir "$DEFAULT_ANDROID_TEST_DIR" 2>/dev/null || true
rmdir "$DEFAULT_ANDROID_TEST_PARENT" 2>/dev/null || true
rmdir "$(dirname "$DEFAULT_ANDROID_TEST_PARENT")" 2>/dev/null || true
fi

ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR"
chmod +x "$GRADLE_PROJECT_DIR/gradlew"
ORIGINAL_JAVA_HOME="$JAVA_HOME"
Expand All @@ -282,4 +523,13 @@ export JAVA_HOME="$ORIGINAL_JAVA_HOME"

APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n 1 || true)
[ -n "$APK_PATH" ] || { ba_log "Gradle build completed but no APK was found" >&2; exit 1; }
ba_log "Successfully built Android APK at $APK_PATH"
ba_log "Successfully built Android APK at $APK_PATH"

if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "gradle_project_dir=$GRADLE_PROJECT_DIR"
echo "apk_path=$APK_PATH"
echo "instrumentation_test_class=$PACKAGE_NAME.HelloCodenameOneInstrumentedTest"
} >> "$GITHUB_OUTPUT"
ba_log "Published GitHub Actions outputs for downstream steps"
fi
Loading
Loading