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
89 changes: 89 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPA2 CI — migration contract (issue abandonrules/sifry#8)
#
# Every push/PR validates the Phase-1 tiered test suite:
# Tier 1+2 (JVM + Robolectric unit tests) run here on linux.
# Tier 3 (instrumented, NDK/PCRE on-device) intentionally stays on the
# physical Pixel locally (serial 52141FDAS001QF) — see README "Testing".
#
# Toolchain tracked here: JDK 17 (temurin), platform android-35,
# build-tools 36.1.0, CMake 3.22.1, NDK 27.0.12077973, Gradle 9.7.1
# (wrapper). Robolectric 4.17-beta-4 is the AGP/JDK-sensitive pin that must
# advance with the migration (see issue notes).

name: build

on:
push:
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-and-unit-test:
name: Assemble + JVM/Robolectric tests (JDK ${{ matrix.jdk }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
jdk: ['17']
env:
ANDROID_HOME: /usr/local/lib/android/sdk
steps:
- uses: actions/checkout@v4

- name: Set up JDK ${{ matrix.jdk }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ matrix.jdk }}

- name: Set up Gradle (wrapper + dependency cache)
uses: gradle/actions/setup-gradle@v4

- name: Cache Android NDK and CMake
uses: actions/cache@v4
with:
path: |
${{ env.ANDROID_HOME }}/ndk/27.0.12077973
${{ env.ANDROID_HOME }}/cmake/3.22.1
key: android-${{ runner.os }}-ndk27-cmake3221

- name: Accept Android SDK licenses
run: |
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
echo "sdkmanager not found at $SDKMANAGER" >&2
exit 1
fi
yes | "$SDKMANAGER" --licenses > /dev/null

- name: Install Android SDK packages
run: |
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
"$SDKMANAGER" \
"platforms;android-35" \
"build-tools;36.1.0" \
"ndk;27.0.12077973" \
"cmake;3.22.1"

- name: Tier 1+2 — JVM + Robolectric unit tests
run: ./gradlew :spa2:testDebugUnitTest

- name: Assemble debug APK (incl. native libregrep)
run: ./gradlew :spa2:assembleDebug

- name: Upload unit test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-reports-jdk${{ matrix.jdk }}
path: |
spa2/build/reports/tests/*
spa2/build/test-results/**/TEST-*.xml
if-no-files-found: ignore
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPA2

SPA2 is a self-hosted collection of cipher and code tools for Android
(substitution ciphers, Morse code, Braille, and more). Native UI in Java with
a C/C++ core (PCRE-based search).

## Building

Requirements:
- JDK 17+ (build validated on JDK 17, 24 and 26)
- Android SDK with: platform android-35, build-tools 36.1.0, CMake 3.22.1,
NDK 27.0.12077973

Point Gradle at your SDK one of these ways:

1. Set `ANDROID_HOME` (recommended, no repo changes needed):

```sh
export ANDROID_HOME=$HOME/Android/Sdk
./gradlew :spa2:assembleDebug
```

2. Or create `local.properties` in the repo root (see
`local.properties.example`):

```properties
sdk.dir=/path/to/Android/Sdk
```

`local.properties` is machine-specific and intentionally not tracked.

The output APK is `spa2/build/outputs/apk/debug/spa2-debug.apk`.

## Testing

The migration relies on a 3-tier test suite (the "migration contract",
issue #7). Tiers 1 and 2 run on any JVM anywhere; Tier 3 needs a device.

| Tier | Where it runs | Command |
|---|---|---|
| 1. Pure JVM unit tests | CI + local | `./gradlew :spa2:testDebugUnitTest` |
| 2. Robolectric (resources/prefs/XML) | CI + local | `./gradlew :spa2:testDebugUnitTest` |
| 3. Instrumented (native `libregrep`/PCRE, app smoke) | **physical Pixel, local only** | `./gradlew :spa2:connectedDebugAndroidTest` |

Tier 3 — the NDK/PCRE safety net (`RegExpNativeTest` + `AppSmokeTest`) —
intentionally stays on the attached Pixel (`adb -s 52141FDAS001QF`):
GitHub Actions runners cannot host physical devices, and Managed/Firebase
Test Lab is deferred (issue #8). When a step touches native code or JNI,
run Tier 3 locally before closing the issue.

## CI

`.github/workflows/build.yml` runs Tier 1+2 and `assembleDebug` on every
push/PR (JDK 17, platform android-35, build-tools 36.1.0, CMake 3.22.1,
NDK 27.0.12077973, Gradle 9.7.1 from the wrapper). Robolectric is pinned to
4.17-beta-4 — an AGP/JDK-sensitive choice that must advance with the
toolchain (unit tests were validated on JDK 17/26).

## Versioning

`versionCode`/`versionName` live in `spa2/src/main/AndroidManifest.xml`.
Codes use the scheme `major*10000 + minor*1000 + patch*10 + sub`, so
4.0.0 = 40000. Codes only ever increase (Android requires it for sideloaded
upgrades).
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.7.1' apply false
id 'com.android.application' version '9.4.0' apply false
}
3 changes: 1 addition & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,4 @@ android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.enableJetifier=true
android.nonTransitiveRClass=true
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
3 changes: 3 additions & 0 deletions local.properties.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copy to local.properties and set your SDK path.
# Gradle also honors the ANDROID_HOME environment variable instead.
sdk.dir=/path/to/Android/Sdk
46 changes: 36 additions & 10 deletions spa2/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ plugins {
}

android {
namespace 'cz.absolutno.sifry'
compileSdk 35
namespace = 'cz.absolutno.sifry'
compileSdk = 35

defaultConfig {
applicationId 'cz.absolutno.sifry'
minSdk 21
targetSdk 35
applicationId = 'cz.absolutno.sifry'
minSdk = 21
targetSdk = 35
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
externalNativeBuild {
cmake {
arguments '-DANDROID_STL=c++_shared'
Expand All @@ -19,24 +20,49 @@ android {

buildTypes {
release {
minifyEnabled false
minifyEnabled = false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}

externalNativeBuild {
cmake {
version '3.22.1'
path 'src/main/cpp/CMakeLists.txt'
version = '3.22.1'
path = 'src/main/cpp/CMakeLists.txt'
}
}

buildToolsVersion = '36.1.0'
ndkVersion = '27.0.12077973'

packagingOptions {
jniLibs {
useLegacyPackaging = false
}
}

testOptions {
unitTests {
includeAndroidResources = true
all {
maxHeapSize = '2g'
}
}
}
}

dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.preference:preference:1.2.1'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.17-beta-4'

androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:core:1.6.1'
}
85 changes: 85 additions & 0 deletions spa2/src/androidTest/java/cz/absolutno/sifry/AppSmokeTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cz.absolutno.sifry;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;

import androidx.lifecycle.Lifecycle;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

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

import cz.absolutno.sifry.braille.BrailleActivity;
import cz.absolutno.sifry.cisla.CislaActivity;
import cz.absolutno.sifry.frekvence.FrekvActivity;
import cz.absolutno.sifry.kalendar.KalendarActivity;
import cz.absolutno.sifry.mainscreen.MainActivity;
import cz.absolutno.sifry.mainscreen.SplashActivity;
import cz.absolutno.sifry.morse.MorseActivity;
import cz.absolutno.sifry.regexp.RegExpActivity;
import cz.absolutno.sifry.semafor.SemaforActivity;
import cz.absolutno.sifry.substituce.SubstActivity;
import cz.absolutno.sifry.tabulky.TabulkyActivity;
import cz.absolutno.sifry.transpozice.TransActivity;
import cz.absolutno.sifry.vlajky.VlajkyActivity;
import cz.absolutno.sifry.zapisnik.ZapisnikActivity;

@RunWith(AndroidJUnit4.class)
public class AppSmokeTest {

private static final Class<?>[] CIPHER_ACTIVITIES = {
MorseActivity.class,
BrailleActivity.class,
CislaActivity.class,
SemaforActivity.class,
TabulkyActivity.class,
VlajkyActivity.class,
SubstActivity.class,
TransActivity.class,
FrekvActivity.class,
KalendarActivity.class,
ZapisnikActivity.class,
RegExpActivity.class,
};

@Test
public void splashToMain() throws Exception {
Instrumentation inst = InstrumentationRegistry.getInstrumentation();
Context ctx = ApplicationProvider.getApplicationContext();
Instrumentation.ActivityMonitor monitor =
inst.addMonitor(MainActivity.class.getName(), null, false);
try {
Intent splashIntent = new Intent(ctx, SplashActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Activity splash = inst.startActivitySync(splashIntent);
assertEquals("SplashActivity did not resume", "mainscreen.SplashActivity",
splash.getLocalClassName());
Activity main = inst.waitForMonitorWithTimeout(monitor, 15000);
assertNotNull("MainActivity did not open after SplashActivity", main);
main.finish();
splash.finish();
} finally {
inst.removeMonitor(monitor);
}
}

@Test
public void eachCipherActivityRenders() {
Context ctx = ApplicationProvider.getApplicationContext();
for (Class<?> cls : CIPHER_ACTIVITIES) {
try (ActivityScenario<?> scenario = ActivityScenario.launch(new Intent(ctx, cls))) {
scenario.moveToState(Lifecycle.State.RESUMED);
assertEquals(cls.getSimpleName() + " did not reach RESUMED",
Lifecycle.State.RESUMED, scenario.getState());
}
}
}
}
Loading
Loading