diff --git a/.github/workflows/build-aar.yml b/.github/workflows/build-aar.yml new file mode 100644 index 0000000..61d95eb --- /dev/null +++ b/.github/workflows/build-aar.yml @@ -0,0 +1,71 @@ +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: 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" + "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 1ca15eb..0f05c87 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,125 @@ -# 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. + +## 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 + +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 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`: ```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/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..6aa12ee --- /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 {} \; | while IFS= read -r version_dir; do dirname "$version_dir"; done | sort -u) + +echo "Uploaded $count files to $repo_url." 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;