From 1f38c8a034a82f13e74731e179e14544760bce4b Mon Sep 17 00:00:00 2001 From: Uros Simic Date: Fri, 23 Oct 2026 16:44:45 +0200 Subject: [PATCH 1/4] ENG-58732 Prepare isolated Incode JPEG 2000 decoder --- README.md | 56 ++++++++++- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- library/CMakeLists.txt | 13 ++- library/build.gradle | 93 ++++++++++++------- library/consumer-rules.pro | 3 + library/exports.map | 12 +++ .../jp2/TestJp2Decoder.java | 2 +- .../jp2/TestJp2Encoder.java | 2 +- .../com/{gemalto => incode}/jp2/Util.java | 2 +- library/src/main/cpp/openjpg.cpp | 14 +-- .../{gemalto => incode}/jp2/JP2Decoder.java | 4 +- .../{gemalto => incode}/jp2/JP2Encoder.java | 4 +- testapp/build.gradle | 33 +++++-- .../jp2/test/TestDecoderCoexistence.java | 91 ++++++++++++++++++ .../jp2/test/MainActivity.java | 4 +- 16 files changed, 276 insertions(+), 61 deletions(-) create mode 100644 library/consumer-rules.pro create mode 100644 library/exports.map rename library/src/androidTest/java/com/{gemalto => incode}/jp2/TestJp2Decoder.java (97%) rename library/src/androidTest/java/com/{gemalto => incode}/jp2/TestJp2Encoder.java (97%) rename library/src/androidTest/java/com/{gemalto => incode}/jp2/Util.java (97%) rename library/src/main/java/com/{gemalto => incode}/jp2/JP2Decoder.java (97%) rename library/src/main/java/com/{gemalto => incode}/jp2/JP2Encoder.java (97%) create mode 100644 testapp/src/androidTest/java/com/incode/jp2/test/TestDecoderCoexistence.java rename testapp/src/main/java/com/{gemalto => incode}/jp2/test/MainActivity.java (96%) diff --git a/README.md b/README.md index 1ca15eb..b294e33 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,73 @@ -# JP2 for Android +# Incode JP2 for Android --------------------------- An open-source JPEG-2000 image encoder/decoder for Android based on [OpenJPEG](http://www.openjpeg.org/) v2.5.4. +This fork starts from [Thales JP2ForAndroid 1.1.0](https://github.com/ThalesGroup/JP2ForAndroid/tree/1.1.0), commit `8ad43abb5d8e2caf2147162d3bfe5569581b5d1f`. +It uses the Java package `com.incode.jp2` and the native library `libincode-openjpeg.so`. +The native library exports only its JNI entry points and preserves 16 KB page alignment on all four Android ABIs. + +## Build the Incode artifact + +Use JDK 17, Android SDK 36, NDK `23.2.8568313`, and CMake `3.22.1`. +The library supports Android API 16 and later. The test app uses API 23 and later. + +```sh +./gradlew :jp2-android:assembleRelease +./gradlew :jp2-android:publishReleasePublicationToStagingRepository +``` + +The release AAR is `library/build/outputs/aar/jp2-android-release.aar`. +The second command creates a local Maven repository under `build/staging-repo`. It does not upload artifacts. +Remote publication to Nexus and GitHub Packages requires a separate CI workflow. + +## Native tests + +Connect an Android device or start an emulator, then run the existing codec tests: + +```sh +./gradlew :jp2-android:connectedDebugAndroidTest +./gradlew :testapp:connectedDebugAndroidTest +``` + +The test app also contains keiji 1.0.5 to exercise coexistence with a real second decoder. +That dependency does not ship in the release AAR or its Maven metadata. +Run the coexistence test in separate processes for each library load order: + +```sh +./gradlew :testapp:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.incode.jp2.test.TestDecoderCoexistence#testBothDecodersPreservePixels -Pandroid.testInstrumentationRunnerArguments.decoderLoadOrder=incodeFirst +./gradlew :testapp:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.incode.jp2.test.TestDecoderCoexistence#testBothDecodersPreservePixels -Pandroid.testInstrumentationRunnerArguments.decoderLoadOrder=originalFirst +``` + +To test the release AAR as a Maven dependency, first create the local Maven repository with the publication command above. +Then add `-PcodecFromStaging=true` to either test app command. + +Local verification on 2026-10-23 passed all 26 upstream codec tests and both coexistence tests on ARM64 emulators: + +- Android 14 with 4 KB pages. +- Android 15 with 16 KB pages. + +The staged release AAR passed both decoder loading orders on both emulators. +ELF alignment passed for all four ABIs, and the test app APK passed `zipalign -c -P 16 4`. +SDK integration, API 23 runtime tests, and protected SDK release tests remain pending. + ## Set up Add dependency to your `build.gradle`: ```groovy -implementation 'io.github.michaldvorak-gemalto:jp2-android:1.1.0' +implementation 'com.incode.sdk:jp2-android:1.1.0-incode.1' ``` ## Basic Usage Decoding an image: ```java +import com.incode.jp2.JP2Decoder; + Bitmap bmp = new JP2Decoder(jp2data).decode(); imgView.setImageBitmap(bmp); ``` Encoding an image: ```java +import com.incode.jp2.JP2Encoder; + Bitmap bmp = ...; //lossless encode byte[] jp2data = new JP2Encoder(bmp).encode(); diff --git a/build.gradle b/build.gradle index 7383062..6b391d7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:9.3.1' + classpath 'com.android.tools.build:gradle:8.9.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 69dd0d0..8ff605c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/library/CMakeLists.txt b/library/CMakeLists.txt index 4cda26a..4cea288 100644 --- a/library/CMakeLists.txt +++ b/library/CMakeLists.txt @@ -13,7 +13,7 @@ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DUSE_JPIP") # Gradle automatically packages shared libraries with your APK. add_library( # Sets the name of the library. - openjpeg + incode-openjpeg # Sets the library as a shared library. SHARED @@ -51,7 +51,7 @@ add_library( # Sets the name of the library. src/main/cpp/openjpg.cpp ) -target_include_directories(openjpeg PRIVATE src/main/cpp src/main/cpp/openjpeg/src/lib/openjp2 src/main/cpp/openjpeg/src/bin/common) +target_include_directories(incode-openjpeg PRIVATE src/main/cpp src/main/cpp/openjpeg/src/lib/openjp2 src/main/cpp/openjpeg/src/bin/common) # Searches for a specified prebuilt library and stores the path as a # variable. Because CMake includes system libraries in the search path by @@ -70,8 +70,13 @@ find_library( # Sets the name of the path variable. # can link multiple libraries, such as libraries you define in this # build script, prebuilt third-party libraries, or system libraries. -target_link_libraries(openjpeg) +target_link_libraries(incode-openjpeg ${log-lib}) # Use the following linker flags to support compiling 16 KB-aligned # shared libraries with Android NDK version r27 or lower. -target_link_options(openjpeg PRIVATE ${log-lib} "-Wl,-z,max-page-size=16384,-z,common-page-size=16384") \ No newline at end of file +target_link_options(incode-openjpeg PRIVATE + "-Wl,-z,max-page-size=16384,-z,common-page-size=16384" + "-Wl,--exclude-libs,ALL" + "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exports.map" +) +set_property(TARGET incode-openjpeg APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/exports.map") diff --git a/library/build.gradle b/library/build.gradle index 15c700b..1ecdfb8 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,16 +1,21 @@ plugins { - id "com.vanniktech.maven.publish" version "0.37.0" apply(true) + id 'com.android.library' + id 'maven-publish' } -apply plugin: 'com.android.library' + +group = 'com.incode.sdk' +version = '1.1.0-incode.1' android { - compileSdk = 37 - namespace = 'com.gemalto.jp2' + compileSdk = 36 + namespace = 'com.incode.jp2' defaultConfig { minSdk 16 - targetSdk 37 - versionName "1.1.0" - versionCode 6 + targetSdk 33 + consumerProguardFiles 'consumer-rules.pro' + aarMetadata { + minCompileSdk = 36 + } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { cmake { @@ -26,13 +31,22 @@ android { externalNativeBuild { cmake { path "CMakeLists.txt" + version '3.22.1' } } buildFeatures { buildConfig = true } ndkVersion = '23.2.8568313' - enableKotlin = false + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + publishing { + singleVariant('release') { + withSourcesJar() + } + } } dependencies { @@ -45,32 +59,49 @@ dependencies { /*************************************************************/ /******************* PUBLISHING STUFF ************************/ /*************************************************************/ -mavenPublishing { - publishToMavenCentral() - signAllPublications() - coordinates("io.github.michaldvorak-gemalto", "jp2-android", "${android.defaultConfig.versionName}") - - pom { - name = "JP2ForAndroid" - description = "A JPEG-2000 image encoder/decoder for Android" - inceptionYear = "2019" - url = "https://github.com/ThalesGroup/JP2ForAndroid" - licenses { - license { - name = "The 2-Clause BSD License" - url = "https://opensource.org/licenses/BSD-2-Clause" - distribution = "repo" +publishing { + publications { + release(MavenPublication) { + groupId = project.group + artifactId = 'jp2-android' + version = project.version + afterEvaluate { + from components.release } - } - developers { - developer { - name = "Michal Dvorak" - url = "https://github.com/michaldvorak-gemalto" + pom { + name = 'Incode JP2ForAndroid' + description = 'Incode fork of Thales JP2ForAndroid 1.1.0 with OpenJPEG 2.5.4' + inceptionYear = '2019' + url = 'https://github.com/Incode-Technologies-Example-Repos/JP2ForAndroid' + licenses { + license { + name = 'The 2-Clause BSD License' + url = 'https://opensource.org/licenses/BSD-2-Clause' + distribution = 'repo' + } + } + developers { + developer { + name = 'Michal Dvorak' + url = 'https://github.com/michaldvorak-gemalto' + } + developer { + name = 'Incode Android SDK Team' + organization = 'Incode' + organizationUrl = 'https://incode.com' + } + } + scm { + url = 'https://github.com/Incode-Technologies-Example-Repos/JP2ForAndroid' + connection = 'scm:git:https://github.com/Incode-Technologies-Example-Repos/JP2ForAndroid.git' + } } } - scm { - url = "https://github.com/ThalesGroup/JP2ForAndroid" - connection = "scm:git:https://github.com/ThalesGroup/JP2ForAndroid.git" + } + repositories { + maven { + name = 'staging' + url = rootProject.layout.buildDirectory.dir('staging-repo') } } } diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro new file mode 100644 index 0000000..1cd885f --- /dev/null +++ b/library/consumer-rules.pro @@ -0,0 +1,3 @@ +-keepclasseswithmembernames,includedescriptorclasses class com.incode.jp2.** { + native ; +} diff --git a/library/exports.map b/library/exports.map new file mode 100644 index 0000000..6a767b8 --- /dev/null +++ b/library/exports.map @@ -0,0 +1,12 @@ +{ + global: + JNI_OnLoad; + Java_com_incode_jp2_JP2Decoder_decodeJP2File; + Java_com_incode_jp2_JP2Decoder_decodeJP2ByteArray; + Java_com_incode_jp2_JP2Decoder_readJP2HeaderFile; + Java_com_incode_jp2_JP2Decoder_readJP2HeaderByteArray; + Java_com_incode_jp2_JP2Encoder_encodeJP2File; + Java_com_incode_jp2_JP2Encoder_encodeJP2ByteArray; + local: + *; +}; diff --git a/library/src/androidTest/java/com/gemalto/jp2/TestJp2Decoder.java b/library/src/androidTest/java/com/incode/jp2/TestJp2Decoder.java similarity index 97% rename from library/src/androidTest/java/com/gemalto/jp2/TestJp2Decoder.java rename to library/src/androidTest/java/com/incode/jp2/TestJp2Decoder.java index d815197..24d5b98 100644 --- a/library/src/androidTest/java/com/gemalto/jp2/TestJp2Decoder.java +++ b/library/src/androidTest/java/com/incode/jp2/TestJp2Decoder.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2; +package com.incode.jp2; import android.content.Context; import android.graphics.Bitmap; diff --git a/library/src/androidTest/java/com/gemalto/jp2/TestJp2Encoder.java b/library/src/androidTest/java/com/incode/jp2/TestJp2Encoder.java similarity index 97% rename from library/src/androidTest/java/com/gemalto/jp2/TestJp2Encoder.java rename to library/src/androidTest/java/com/incode/jp2/TestJp2Encoder.java index 3812c1f..b24b6da 100644 --- a/library/src/androidTest/java/com/gemalto/jp2/TestJp2Encoder.java +++ b/library/src/androidTest/java/com/incode/jp2/TestJp2Encoder.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2; +package com.incode.jp2; import android.content.Context; import android.graphics.Bitmap; diff --git a/library/src/androidTest/java/com/gemalto/jp2/Util.java b/library/src/androidTest/java/com/incode/jp2/Util.java similarity index 97% rename from library/src/androidTest/java/com/gemalto/jp2/Util.java rename to library/src/androidTest/java/com/incode/jp2/Util.java index 167bd4c..96202d1 100644 --- a/library/src/androidTest/java/com/gemalto/jp2/Util.java +++ b/library/src/androidTest/java/com/incode/jp2/Util.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2; +package com.incode.jp2; import android.content.Context; import android.graphics.Bitmap; diff --git a/library/src/main/cpp/openjpg.cpp b/library/src/main/cpp/openjpg.cpp index ec745be..f775a45 100644 --- a/library/src/main/cpp/openjpg.cpp +++ b/library/src/main/cpp/openjpg.cpp @@ -940,7 +940,7 @@ int encodeJP2(opj_cparameters_t *parameters, opj_image_t *image, opj_byte_array_ } //encode a raw bitmap into JPEG-2000, return the result in a byte array -JNIEXPORT jbyteArray JNICALL Java_com_gemalto_jp2_JP2Encoder_encodeJP2ByteArray(JNIEnv *env, jclass thiz, jintArray pixels, jboolean hasAlpha, jint width, jint height, +JNIEXPORT jbyteArray JNICALL Java_com_incode_jp2_JP2Encoder_encodeJP2ByteArray(JNIEnv *env, jclass thiz, jintArray pixels, jboolean hasAlpha, jint width, jint height, jint fileFormat, jint numResolutions, jfloatArray compressionRates, jfloatArray qualityValues) { opj_byte_array_source * jp2data = NULL; opj_cparameters_t parameters; /* compression parameters */ @@ -974,7 +974,7 @@ JNIEXPORT jbyteArray JNICALL Java_com_gemalto_jp2_JP2Encoder_encodeJP2ByteArray( } //encode a raw bitmap into JPEG-2000, store the result into a file, return success/failure -JNIEXPORT jint JNICALL Java_com_gemalto_jp2_JP2Encoder_encodeJP2File(JNIEnv *env, jclass thiz, jstring fileName, jintArray pixels, jboolean hasAlpha, jint width, jint height, +JNIEXPORT jint JNICALL Java_com_incode_jp2_JP2Encoder_encodeJP2File(JNIEnv *env, jclass thiz, jstring fileName, jintArray pixels, jboolean hasAlpha, jint width, jint height, jint fileFormat, jint numResolutions, jfloatArray compressionRates, jfloatArray qualityValues) { opj_cparameters_t parameters; /* compression parameters */ opj_image_t *image = NULL; @@ -1017,7 +1017,7 @@ jintArray prepareReturnHeaderData(JNIEnv *env, image_header_t *outHeader) { } //decode a JPEG-2000 encoded file, return in 32-bit raw RGBA pixels -JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_decodeJP2File(JNIEnv *env, jclass thiz, jstring fileName, jint reduce, jint layers, +JNIEXPORT jintArray JNICALL Java_com_incode_jp2_JP2Decoder_decodeJP2File(JNIEnv *env, jclass thiz, jstring fileName, jint reduce, jint layers, jint left, jint top, jint right, jint bottom) { opj_stream_t *l_stream = NULL; /* Stream */ opj_dparameters_t parameters; /* decompression parameters */ @@ -1065,7 +1065,7 @@ JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_decodeJP2File(JNIEnv } //decode a JPEG-2000 encoded byte array, return in 32-bit raw RGBA pixels -JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_decodeJP2ByteArray(JNIEnv *env, jclass thiz, jbyteArray data, +JNIEXPORT jintArray JNICALL Java_com_incode_jp2_JP2Decoder_decodeJP2ByteArray(JNIEnv *env, jclass thiz, jbyteArray data, jint reduce, jint layers, jint left, jint top, jint right, jint bottom) { opj_stream_t *l_stream = NULL; /* Stream */ opj_dparameters_t parameters; /* decompression parameters */ @@ -1124,7 +1124,7 @@ JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_decodeJP2ByteArray(J } //read meta-data information from a JPEG-2000 encoded file, return in an integer array (image_header_t representation) -JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_readJP2HeaderFile(JNIEnv *env, jclass thiz, jstring fileName) { +JNIEXPORT jintArray JNICALL Java_com_incode_jp2_JP2Decoder_readJP2HeaderFile(JNIEnv *env, jclass thiz, jstring fileName) { opj_stream_t *l_stream = NULL; /* Stream */ opj_dparameters_t parameters; /* decompression parameters */ image_header_t outHeader; //output data @@ -1165,7 +1165,7 @@ JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_readJP2HeaderFile(JN } //read meta-data information from a JPEG-2000 encoded byte array, return in an integer array (image_header_t representation) -JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_readJP2HeaderByteArray(JNIEnv *env, jclass thiz, jbyteArray data) { +JNIEXPORT jintArray JNICALL Java_com_incode_jp2_JP2Decoder_readJP2HeaderByteArray(JNIEnv *env, jclass thiz, jbyteArray data) { opj_stream_t *l_stream = NULL; /* Stream */ opj_dparameters_t parameters; /* decompression parameters */ char *imgData; @@ -1219,4 +1219,4 @@ JNIEXPORT jintArray JNICALL Java_com_gemalto_jp2_JP2Decoder_readJP2HeaderByteArr #ifdef __cplusplus } -#endif \ No newline at end of file +#endif diff --git a/library/src/main/java/com/gemalto/jp2/JP2Decoder.java b/library/src/main/java/com/incode/jp2/JP2Decoder.java similarity index 97% rename from library/src/main/java/com/gemalto/jp2/JP2Decoder.java rename to library/src/main/java/com/incode/jp2/JP2Decoder.java index 390d999..3a9ac9c 100644 --- a/library/src/main/java/com/gemalto/jp2/JP2Decoder.java +++ b/library/src/main/java/com/incode/jp2/JP2Decoder.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2; +package com.incode.jp2; import android.annotation.SuppressLint; import android.graphics.Bitmap; @@ -28,7 +28,7 @@ public class JP2Decoder { private static final String TAG = "JP2Decoder"; static { - System.loadLibrary("openjpeg"); + System.loadLibrary("incode-openjpeg"); } public static class Header { diff --git a/library/src/main/java/com/gemalto/jp2/JP2Encoder.java b/library/src/main/java/com/incode/jp2/JP2Encoder.java similarity index 97% rename from library/src/main/java/com/gemalto/jp2/JP2Encoder.java rename to library/src/main/java/com/incode/jp2/JP2Encoder.java index 10f30fc..48a47fb 100644 --- a/library/src/main/java/com/gemalto/jp2/JP2Encoder.java +++ b/library/src/main/java/com/incode/jp2/JP2Encoder.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2; +package com.incode.jp2; import android.graphics.Bitmap; import android.util.Log; @@ -47,7 +47,7 @@ public class JP2Encoder { private int outputFormat = FORMAT_JP2; static { - System.loadLibrary("openjpeg"); + System.loadLibrary("incode-openjpeg"); } private final Bitmap bmp; diff --git a/testapp/build.gradle b/testapp/build.gradle index a61c5fd..58097ae 100644 --- a/testapp/build.gradle +++ b/testapp/build.gradle @@ -1,15 +1,29 @@ apply plugin: 'com.android.application' +def codecFromStaging = providers.gradleProperty('codecFromStaging').map { it.toBoolean() }.getOrElse(false) + +if (codecFromStaging) { + repositories { + maven { + url = rootProject.layout.buildDirectory.dir('staging-repo') + content { + includeGroup 'com.incode.sdk' + } + } + } +} + android { - compileSdk = 37 - namespace = "com.gemalto.jp2.test" + compileSdk = 36 + namespace = "com.incode.jp2.test" defaultConfig { - applicationId "com.gemalto.openjpeg.test" - minSdk 16 - targetSdk 37 + applicationId "com.incode.jp2.testapp" + minSdk 23 + targetSdk 33 versionCode 1 versionName "1.0" + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } signingConfigs { @@ -45,7 +59,14 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation project(':jp2-android') + if (codecFromStaging) { + implementation "com.incode.sdk:jp2-android:${project(':jp2-android').version}" + } else { + implementation project(':jp2-android') + } + implementation 'dev.keiji.jp2:jp2-android:1.0.5' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test:runner:1.5.2' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core:1.12.0' diff --git a/testapp/src/androidTest/java/com/incode/jp2/test/TestDecoderCoexistence.java b/testapp/src/androidTest/java/com/incode/jp2/test/TestDecoderCoexistence.java new file mode 100644 index 0000000..c542c04 --- /dev/null +++ b/testapp/src/androidTest/java/com/incode/jp2/test/TestDecoderCoexistence.java @@ -0,0 +1,91 @@ +package com.incode.jp2.test; + +import android.graphics.Bitmap; +import android.graphics.Color; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.incode.jp2.JP2Decoder; +import com.incode.jp2.JP2Encoder; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@RunWith(AndroidJUnit4.class) +public class TestDecoderCoexistence { + @Test + public void testBothDecodersPreservePixels() throws Exception { + String loadOrder = InstrumentationRegistry.getArguments().getString("decoderLoadOrder", "incodeFirst"); + String firstDecoder = "originalFirst".equals(loadOrder) ? "com.gemalto.jp2.JP2Decoder" : "com.incode.jp2.JP2Decoder"; + String secondDecoder = "originalFirst".equals(loadOrder) ? "com.incode.jp2.JP2Decoder" : "com.gemalto.jp2.JP2Decoder"; + Class.forName(firstDecoder); + Class.forName(secondDecoder); + Bitmap expected = createBitmap(); + + try { + for (int format : new int[]{JP2Encoder.FORMAT_JP2, JP2Encoder.FORMAT_J2K}) { + byte[] incodeData = new JP2Encoder(expected).setOutputFormat(format).encode(); + byte[] originalData = new com.gemalto.jp2.JP2Encoder(expected).setOutputFormat(format).encode(); + assertNotNull(incodeData); + assertNotNull(originalData); + + for (byte[] data : new byte[][]{incodeData, originalData}) { + assertPixelsEqual(expected, new JP2Decoder(data).decode()); + assertPixelsEqual(expected, new com.gemalto.jp2.JP2Decoder(data).decode()); + JP2Decoder.Header header = new JP2Decoder(data).readHeader(); + assertNotNull(header); + assertEquals(expected.getWidth(), header.width); + assertEquals(expected.getHeight(), header.height); + } + } + } finally { + expected.recycle(); + } + } + + @Test + public void testTruncatedImageReturnsNull() { + Bitmap source = createBitmap(); + try { + byte[] encoded = new JP2Encoder(source).encode(); + assertNotNull(encoded); + + assertNull(new JP2Decoder(Arrays.copyOf(encoded, 12)).decode()); + } finally { + source.recycle(); + } + } + + private static Bitmap createBitmap() { + Bitmap bitmap = Bitmap.createBitmap(32, 16, Bitmap.Config.ARGB_8888); + bitmap.setHasAlpha(false); + for (int row = 0; row < bitmap.getHeight(); row++) { + for (int column = 0; column < bitmap.getWidth(); column++) { + bitmap.setPixel(column, row, Color.rgb(column * 7, row * 13, (column + row) * 5)); + } + } + return bitmap; + } + + private static void assertPixelsEqual(Bitmap expected, Bitmap actual) { + assertNotNull(actual); + try { + assertEquals(expected.getWidth(), actual.getWidth()); + assertEquals(expected.getHeight(), actual.getHeight()); + for (int row = 0; row < expected.getHeight(); row++) { + for (int column = 0; column < expected.getWidth(); column++) { + assertEquals(expected.getPixel(column, row), actual.getPixel(column, row)); + } + } + } finally { + actual.recycle(); + } + } +} diff --git a/testapp/src/main/java/com/gemalto/jp2/test/MainActivity.java b/testapp/src/main/java/com/incode/jp2/test/MainActivity.java similarity index 96% rename from testapp/src/main/java/com/gemalto/jp2/test/MainActivity.java rename to testapp/src/main/java/com/incode/jp2/test/MainActivity.java index 969d03d..7c394de 100644 --- a/testapp/src/main/java/com/gemalto/jp2/test/MainActivity.java +++ b/testapp/src/main/java/com/incode/jp2/test/MainActivity.java @@ -1,4 +1,4 @@ -package com.gemalto.jp2.test; +package com.incode.jp2.test; import android.graphics.Bitmap; import android.os.Build; @@ -16,7 +16,7 @@ import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; -import com.gemalto.jp2.JP2Decoder; +import com.incode.jp2.JP2Decoder; import java.io.Closeable; import java.io.IOException; From 25a2885ff342f34671f29f93d41ff8f57037a409 Mon Sep 17 00:00:00 2001 From: Uros Simic Date: Mon, 26 Oct 2026 13:18:57 +0100 Subject: [PATCH 2/4] ENG-58732 Add local publication and GitHub Actions AAR builds --- .github/workflows/build-aar.yml | 68 ++++++++++ README.md | 56 +++++++- scripts/mavenRepoCredentials.sh | 21 +++ scripts/uploadStagedRepo.sh | 229 ++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/build-aar.yml create mode 100644 scripts/mavenRepoCredentials.sh create mode 100644 scripts/uploadStagedRepo.sh diff --git a/.github/workflows/build-aar.yml b/.github/workflows/build-aar.yml new file mode 100644 index 0000000..f5f7ae5 --- /dev/null +++ b/.github/workflows/build-aar.yml @@ -0,0 +1,68 @@ +name: Build decoder AAR + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: decoder-aar-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Java 17 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '17' + cache: gradle + cache-dependency-path: | + **/*.gradle + gradle/wrapper/gradle-wrapper.properties + + - name: Install Android build tools + run: >- + sdkmanager "platforms;android-36" "build-tools;35.0.0" + "ndk;23.2.8568313" "cmake;3.22.1" + + - name: Build, lint, and stage the release AAR + run: >- + ./gradlew --no-daemon :jp2-android:assembleRelease + :jp2-android:lintRelease :jp2-android:publishReleasePublicationToStagingRepository + + - name: Record artifact checksum and source commit + run: | + sha256sum library/build/outputs/aar/jp2-android-release.aar > build/aar-sha256.txt + git rev-parse HEAD > build/source-commit.txt + + - name: Upload the AAR and Maven files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: jp2-android-${{ github.sha }} + if-no-files-found: error + retention-days: 30 + path: | + library/build/outputs/aar/jp2-android-release.aar + build/staging-repo/ + build/aar-sha256.txt + build/source-commit.txt + + - name: Upload lint reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: jp2-lint-${{ github.sha }} + retention-days: 30 + path: library/build/reports/lint-results-release.* diff --git a/README.md b/README.md index b294e33..0f05c87 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,58 @@ The library supports Android API 16 and later. The test app uses API 23 and late The release AAR is `library/build/outputs/aar/jp2-android-release.aar`. The second command creates a local Maven repository under `build/staging-repo`. It does not upload artifacts. -Remote publication to Nexus and GitHub Packages requires a separate CI workflow. + +## Build with GitHub Actions + +The **Build decoder AAR** workflow runs on pushes and pull requests. +It uses the same Java, Android SDK, NDK, and CMake versions as the local build. +It builds the release AAR, runs Android lint, and creates the Maven staging repository. + +Open the workflow run in the repository's **Actions** tab. +Download `jp2-android-` from **Artifacts**. +The download contains the AAR, Maven metadata, sources JAR, SHA-256 checksum, and source commit. +After the workflow reaches the default branch, **Run workflow** also starts it manually. +If Actions is disabled for the fork, a repository administrator must enable it first. + +The workflow needs no Maven credentials. It uploads build results to GitHub Actions only. +It does not publish to Nexus or GitHub Packages. Native device tests run separately with the commands below. +To publish a downloaded build, use its `build/staging-repo` directory with the publication commands below. +Published versions are immutable. If a rebuilt artifact has different bytes, publish it with a new version. + +## Publish locally + +Store these credentials in `~/.gradle/gradle.properties` or environment variables with the same names: + +| Repository | Username key | Password key | +| --- | --- | --- | +| Nexus | `nexus_username` | `nexus_password` | +| GitHub Packages | `github_username` | `github_pat` | + +The GitHub token needs `write:packages` scope and write access to `Incode-Technologies-Example-Repos/android-omni-packages`. +Keep credential values outside this repository. + +Create the staging repository with the command above. +For a new version, first run `./gradlew clean` to remove earlier staged versions. +Before an upload, run both conflict checks: + +Set `JP2_NEXUS_URL` to the internal Maven releases repository URL before running these commands. + +```sh +bash scripts/uploadStagedRepo.sh build/staging-repo "$JP2_NEXUS_URL" nexus_username nexus_password --check-only +bash scripts/uploadStagedRepo.sh build/staging-repo https://maven.pkg.github.com/incode-technologies-example-repos/android-omni-packages github_username github_pat --check-only +``` + +After both checks pass, upload the same staged files to both repositories: + +```sh +bash scripts/uploadStagedRepo.sh build/staging-repo "$JP2_NEXUS_URL" nexus_username nexus_password --immutable +bash scripts/uploadStagedRepo.sh build/staging-repo https://maven.pkg.github.com/incode-technologies-example-repos/android-omni-packages github_username github_pat --immutable +``` + +The uploader rejects existing version files with different bytes and downloads each uploaded file to verify its contents. +If an upload stops partway, rerun it with the same staging directory. +The scripts use the staged-file publication process from the Incode Android SDK. +Local publication does not require Bitrise. ## Native tests @@ -48,7 +99,8 @@ Local verification on 2026-10-23 passed all 26 upstream codec tests and both coe The staged release AAR passed both decoder loading orders on both emulators. ELF alignment passed for all four ABIs, and the test app APK passed `zipalign -c -P 16 4`. -SDK integration, API 23 runtime tests, and protected SDK release tests remain pending. +SDK integration tests passed with synthetic JP2, raw J2K, and JPEG portraits on a 16 KB emulator. +API 23 runtime tests and protected SDK release tests remain pending. ## Set up Add dependency to your `build.gradle`: diff --git a/scripts/mavenRepoCredentials.sh b/scripts/mavenRepoCredentials.sh new file mode 100644 index 0000000..18da039 --- /dev/null +++ b/scripts/mavenRepoCredentials.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Reads a Maven repo credential. The value comes from the environment variable with the key name. +# If that variable is empty, the value comes from the same key in ~/.gradle/gradle.properties. +# Source this file, then call: read_credential +read_credential() { + local key="$1" + local value="${!key}" + if [ -z "$value" ] && [ -f "$HOME/.gradle/gradle.properties" ]; then + value=$(grep "^$key=" "$HOME/.gradle/gradle.properties" | head -1 | cut -d'=' -f2-) + fi + echo "$value" +} + +# Runs curl with the credentials in $user and $password. +# curl reads them from stdin, so they are not in the command line that ps shows. +auth_curl() { + local credentials="$user:$password" + credentials=${credentials//\\/\\\\} + credentials=${credentials//\"/\\\"} + printf 'user = "%s"\n' "$credentials" | curl --config - "$@" +} diff --git a/scripts/uploadStagedRepo.sh b/scripts/uploadStagedRepo.sh new file mode 100644 index 0000000..0f7427d --- /dev/null +++ b/scripts/uploadStagedRepo.sh @@ -0,0 +1,229 @@ +#!/bin/bash +set -eo pipefail + +# Uploads every file of a staged Maven repo to a remote Maven repo with HTTP PUT. +# It does not build anything, so the remote repo gets the exact staged files. +# +# Usage: scripts/uploadStagedRepo.sh [--immutable | --check-only] +# The user and the password come from the environment variable with that key name. +# If the variable is empty, they come from the same key in ~/.gradle/gradle.properties. +# --immutable is for an RC or a release: a file that already exists on the remote repo must keep its bytes. +# --check-only runs the --immutable check and uploads nothing, so a job can check every repo before the first upload. + +if [ $# -lt 4 ] || [ $# -gt 5 ] || { [ $# -eq 5 ] && [ "$5" != "--immutable" ] && [ "$5" != "--check-only" ]; }; then + echo "Usage: $0 [--immutable | --check-only]" + exit 1 +fi + +repo_dir="${1%/}" +repo_url="${2%/}" +user_key="$3" +password_key="$4" +immutable=false +check_only=false +[ "${5:-}" == "--immutable" ] && immutable=true +[ "${5:-}" == "--check-only" ] && immutable=true && check_only=true + +source "$(dirname "$0")/mavenRepoCredentials.sh" + +user=$(read_credential "$user_key") +password=$(read_credential "$password_key") +if [ -z "$user" ] || [ -z "$password" ]; then + echo "Error: there are no credentials for $repo_url. Set $user_key and $password_key." + exit 1 +fi + +if [ ! -d "$repo_dir" ]; then + echo "Error: the staged repo $repo_dir does not exist." + exit 1 +fi + +# Writes the .md5, .sha1, .sha256, and .sha512 files of one file, in the format that Gradle uses. +write_checksums() { + local file="$1" + local algorithm + for algorithm in md5 sha1 sha256 sha512; do + openssl dgst -"$algorithm" -r "$file" | cut -d' ' -f1 | tr -d '\n' > "$file.$algorithm" + done +} + +upload() { + local file="$1" + local path="${file#"$repo_dir"/}" + if ! auth_curl --fail --silent --show-error --retry 3 --upload-file "$file" "$repo_url/$path" > /dev/null; then + echo "Error: the upload of $path to $repo_url failed after $count files." + exit 1 + fi + count=$((count + 1)) +} + +# The staged maven-metadata.xml files list only the new versions, so the script merges them below. +staged_files=$(find "$repo_dir" -type f ! -name 'maven-metadata.xml*' | sort) + +# An RC or a release version never changes after its first upload. +# Before any upload, the script compares each staged file with the file on the remote repo. +# An identical file is skipped, so a rerun after a broken upload can continue. +# A file with other bytes stops the job before any upload. +skipped="" +if [[ "$immutable" == "true" ]]; then + remote_copy=$(mktemp) + differ=false + while IFS= read -r file; do + path="${file#"$repo_dir"/}" + status=$(auth_curl --silent --show-error --location --retry 3 --output "$remote_copy" \ + --write-out '%{http_code}' "$repo_url/$path") || status=000 + if [[ "$status" == "200" ]]; then + if cmp -s "$file" "$remote_copy"; then + skipped="$skipped$path"$'\n' + else + echo "Error: $repo_url already has $path with other content." + differ=true + fi + elif [[ "$status" != "404" ]]; then + rm -f "$remote_copy" + echo "Error: the check of $path on $repo_url failed with HTTP $status." + exit 1 + fi + done <<< "$staged_files" + rm -f "$remote_copy" + if [[ "$differ" == "true" ]]; then + echo "Error: an RC or a release file must not change. The script did not upload anything to $repo_url." + exit 1 + fi +fi +if [[ "$check_only" == "true" ]]; then + echo "Checked $repo_url: no staged file differs from a file that it already has." + exit 0 +fi + +count=0 +while IFS= read -r file; do + if [ -n "$skipped" ] && grep -qxF "${file#"$repo_dir"/}" <<< "$skipped"; then + continue + fi + upload "$file" +done <<< "$staged_files" +[ -n "$skipped" ] && echo "Skipped $(printf '%s' "$skipped" | grep -c .) files that $repo_url already has with the same content." + +# The check before the upload cannot stop a second job that uploads the same RC or release at the same moment. +# So after the upload, the script downloads each file again. If another job replaced a file, the job fails. +if [[ "$immutable" == "true" ]]; then + remote_copy=$(mktemp) + while IFS= read -r file; do + path="${file#"$repo_dir"/}" + if ! auth_curl --fail --silent --show-error --location --retry 3 --output "$remote_copy" "$repo_url/$path" \ + || ! cmp -s "$file" "$remote_copy"; then + rm -f "$remote_copy" + echo "Error: $path on $repo_url is not the staged file after the upload. Another job can have uploaded the same version." + exit 1 + fi + done <<< "$staged_files" + rm -f "$remote_copy" + echo "Checked all $(printf '%s\n' "$staged_files" | grep -c .) files on $repo_url after the upload." +fi + +# The maven-metadata.xml file of an artifact lists all its versions on the remote repo. +# Gradle reads it for dynamic versions (for example "+" or "latest.release"). +# As Gradle does, the script adds the staged versions to the remote list and uploads the merged file. + +# Downloads the maven-metadata.xml file of an artifact to . Removes if the file does not exist. +download_metadata() { + local artifact_path="$1" + local target="$2" + local status + status=$(auth_curl --silent --show-error --location --retry 3 --output "$target" \ + --write-out '%{http_code}' "$repo_url/$artifact_path/maven-metadata.xml") || status=000 + if [[ "$status" == "404" ]]; then + rm -f "$target" + elif [[ "$status" != "200" ]]; then + echo "Error: the download of $artifact_path/maven-metadata.xml from $repo_url failed with HTTP $status." + exit 1 + fi +} + +# Writes with the versions of plus the new versions. Without , it lists only the new versions. +merge_metadata() { + python3 - "$@" <<'PY' +import os, sys, time +import xml.etree.ElementTree as ET +from xml.sax.saxutils import escape + +remote, out, artifact_path, *new_versions = sys.argv[1:] +parts = artifact_path.split("/") +versions = [] +if os.path.exists(remote): + versions = [v.text for v in ET.parse(remote).getroot().findall("versioning/versions/version")] +for version in new_versions: + if version not in versions: + versions.append(version) +newest = new_versions[-1] +lines = [ + '', + "", + " %s" % escape(".".join(parts[:-1])), + " %s" % escape(parts[-1]), + " ", + " %s" % escape(newest), + " %s" % escape(newest), + " ", +] + [" %s" % escape(v) for v in versions] + [ + " ", + " %s" % time.strftime("%Y%m%d%H%M%S", time.gmtime()), + " ", + "", +] +with open(out, "w") as f: + f.write("\n".join(lines) + "\n") +PY +} + +# Prints each new version that does not list. +missing_versions() { + python3 - "$@" <<'PY' +import os, sys +import xml.etree.ElementTree as ET + +remote, *new_versions = sys.argv[1:] +listed = set() +if os.path.exists(remote): + listed = {v.text for v in ET.parse(remote).getroot().findall("versioning/versions/version")} +print(" ".join(v for v in new_versions if v not in listed)) +PY +} + +# Two publish jobs at the same time can overwrite each other's merged file, as with Gradle. +# So after the upload, the script reads the remote file again. If a new version is missing, it merges again. +# The downloaded file stays outside the staged repo, so a rerun after a failed upload never uploads it. +remote_metadata=$(mktemp) +trap 'rm -f "$remote_metadata"' EXIT +while IFS= read -r artifact_dir; do + artifact_path="${artifact_dir#"$repo_dir"/}" + metadata="$artifact_dir/maven-metadata.xml" + versions=() + while IFS= read -r version_dir; do + versions+=("$(basename "$version_dir")") + done < <(find "$artifact_dir" -mindepth 1 -maxdepth 1 -type d | sort) + + attempt=1 + while :; do + download_metadata "$artifact_path" "$remote_metadata" + merge_metadata "$remote_metadata" "$metadata" "$artifact_path" "${versions[@]}" + write_checksums "$metadata" + for file in "$metadata" "$metadata".md5 "$metadata".sha1 "$metadata".sha256 "$metadata".sha512; do + upload "$file" + done + + download_metadata "$artifact_path" "$remote_metadata" + missing=$(missing_versions "$remote_metadata" "${versions[@]}") + rm -f "$remote_metadata" + [ -z "$missing" ] && break + if [ "$attempt" -ge 3 ]; then + echo "Error: $artifact_path/maven-metadata.xml on $repo_url still misses: $missing" + exit 1 + fi + echo "Another job changed $artifact_path/maven-metadata.xml. The script merges it again." + attempt=$((attempt + 1)) + done +done < <(find "$repo_dir" -name '*.pom' -exec dirname {} \; | xargs -n1 dirname | sort -u) + +echo "Uploaded $count files to $repo_url." From b11b72213ade3e67b03fcf8b7dd518f494b97e4f Mon Sep 17 00:00:00 2001 From: Uros Simic Date: Mon, 26 Oct 2026 13:21:35 +0100 Subject: [PATCH 3/4] ENG-58732 Set up Android SDK tools in GitHub Actions --- .github/workflows/build-aar.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-aar.yml b/.github/workflows/build-aar.yml index f5f7ae5..61d95eb 100644 --- a/.github/workflows/build-aar.yml +++ b/.github/workflows/build-aar.yml @@ -32,6 +32,9 @@ jobs: **/*.gradle gradle/wrapper/gradle-wrapper.properties + - name: Set up Android SDK + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 + - name: Install Android build tools run: >- sdkmanager "platforms;android-36" "build-tools;35.0.0" From 79495be66406a169573795d42a5a108755465397 Mon Sep 17 00:00:00 2001 From: Uros Simic Date: Mon, 26 Oct 2026 14:20:09 +0100 Subject: [PATCH 4/4] ENG-58732 Preserve spaces in Maven staging paths --- scripts/uploadStagedRepo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/uploadStagedRepo.sh b/scripts/uploadStagedRepo.sh index 0f7427d..6aa12ee 100644 --- a/scripts/uploadStagedRepo.sh +++ b/scripts/uploadStagedRepo.sh @@ -224,6 +224,6 @@ while IFS= read -r artifact_dir; do echo "Another job changed $artifact_path/maven-metadata.xml. The script merges it again." attempt=$((attempt + 1)) done -done < <(find "$repo_dir" -name '*.pom' -exec dirname {} \; | xargs -n1 dirname | sort -u) +done < <(find "$repo_dir" -name '*.pom' -exec dirname {} \; | while IFS= read -r version_dir; do dirname "$version_dir"; done | sort -u) echo "Uploaded $count files to $repo_url."