Skip to content
Draft
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
38 changes: 37 additions & 1 deletion .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,34 @@ jobs:
- name: Check public API compatibility
run: npm run check:api-compat

android-test:
android-unit-test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: jdx/mise-action@v4

- name: Cache npm packages
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json', 'test/test.ts') }}
restore-keys: |
${{ runner.os }}-npm-

- name: Install dependencies
run: npm install

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

- name: Run Android unit tests
working-directory: android
run: ./gradlew :app:test

android-test:
needs: [lint, android-unit-test]
runs-on: bitrise-react-native-code-push-linux-runner
strategy:
matrix:
Expand All @@ -49,6 +75,16 @@ jobs:
- name: Install dependencies
run: npm install

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
# This job's Gradle build lives in a test app generated at runtime by the test harness,
# not a project checked into this repo, so there's nothing meaningful for it to
# contribute back to the cache. Read-only avoids each matrix arm (bare/expo) writing
# its own redundant entry; it still reuses whatever android-unit-test wrote, since
# setup-gradle shares its cache across jobs in the same workflow run.
cache-read-only: true

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
Expand Down
18 changes: 15 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ React Native CodePush is a native module that enables over-the-air updates for R
## Development Commands

### Testing
- `npm test` - Run all tests with TypeScript compilation

#### Unit tests

- `cd android && ./gradlew :app:test`
- iOS: no unit tests yet.

#### E2E Tests
- `npm run test:android` - Run Android-specific tests
- `npm run test:ios` - Run iOS-specific tests
- `npm run test:setup-android` - Set up Android emulator for testing
Expand All @@ -34,7 +40,7 @@ React Native CodePush is a native module that enables over-the-air updates for R

### Platform Structure
- **iOS**: `ios/` - Objective-C implementation with CocoaPods integration
- **Android**: `android/` - Java implementation with Gradle plugin
- **Android**: `android/` - Java implementation with Gradle plugin (Kotlin supported for unit tests and new prod code, see "Android Unit Testing & Kotlin" below)
- **Windows**: `windows/` - C++ implementation for Windows React Native
- **JavaScript**: Root level - TypeScript definitions and bridge code

Expand All @@ -48,7 +54,7 @@ React Native CodePush is a native module that enables over-the-air updates for R
- **Custom Test Runner**: TypeScript-based test framework in `test/`
- **Real App Testing**: Creates actual React Native apps for integration testing
- **Scenario Testing**: Update, rollback, and error scenarios
- **No unit test infra yet**: this repo only has the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first.
- **No unit test infra for JS/iOS yet** (Android now has some — see "Android Unit Testing & Kotlin" below): JS/iOS only have the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first.
- **Templates**: `test/template/` holds native files (Podfile, AppDelegate, Android app files) and JS scenarios copied over top of a freshly generated RN/Expo app during test setup, overwriting its defaults — edit files here, not the generated project, for changes to persist
- **`test:ios` vs `test:setup:ios` vs `test:fast:ios`**: `test:ios` is just `test:setup:ios` followed by `test:fast:ios` — the two are meant to be split apart for local iteration.
- `test:setup:ios` (mocha `--ios --setup`) boots the simulator and provisions the test app once: copies templates, runs `pod install`, patches Info.plist/AppDelegate. It never builds or runs any test scenario.
Expand All @@ -60,6 +66,12 @@ React Native CodePush is a native module that enables over-the-air updates for R
- `npm run test:setup:ios` provisions a full test app outside the repo (under a system temp/`test-run` dir), not inside `test/` — expect to search for it rather than finding it checked into the repo tree.
- The provisioned test app's `node_modules/@bitrise/code-push-sdk` is a real copy, not a symlink — editing `ios/` (or `android/`) native source in the repo has zero effect on `test:fast:ios` runs until you re-copy those files into that `node_modules` path (or rerun `test:setup:ios`).

### Android Unit Testing & Kotlin
- New unit tests should be written in Kotlin; new non-test prod code too, going forward.
- Gotcha: only `android/app/build.gradle` is ever loaded by a downstream consumer app (RN autolinking includes it as a Gradle subproject inside the consumer's own build). The rest of `android/`'s config (root `build.gradle`, `gradle.properties`, wrapper) only governs this repo's own standalone test harness — never assume a change there affects consumers.
- Gotcha: `android/app/build.gradle` gates the classic `kotlin-android` plugin/`kotlinOptions` on `agpMajor < 9` (AGP9+ provides Kotlin built in; applying the classic plugin on top of it fails). This matches how other RN libraries adapted for AGP9 — see the patches linked from the [AGP v9 adoption RFC](https://github.com/react-native-community/discussions-and-proposals/pull/1006). This repo's own harness and `Examples/CodePushDemo` both opt out of AGP9's built-in Kotlin/new DSL via `android.builtInKotlin=false`/`android.newDsl=false` in `gradle.properties`, matching RN's own 0.87 app template (that opt-out is removed starting AGP10, per RN's 0.87 release notes) — but build.gradle's `agpMajor` check doesn't depend on that flag either way, since AGP9's built-in Kotlin compiler stays active regardless of it.
- Gotcha: `android/build.gradle`'s AGP version must track what `node_modules/@react-native/gradle-plugin` (an included composite build) itself depends on — an outdated pin here is silently overridden rather than enforced, surfacing instead as a confusing "Gradle version too old" failure. Bump `android/gradle/wrapper/gradle-wrapper.properties` in lockstep whenever bumping `react-native`.

### Build Integration
- **Android Gradle Plugin**: Automatically generates bundle hashes and processes assets
- **iOS CocoaPods**: Manages native dependencies and build configuration
Expand Down
7 changes: 7 additions & 0 deletions Examples/CodePushDemo/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,10 @@ hermesEnabled=true
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false

# Opt out of AGP 9's built-in Kotlin support and new DSL, matching what RN's own 0.87 app template
# does (this flag pair is removed in AGP10, at which point built-in Kotlin becomes mandatory —
# see the RN 0.87 release notes and the AGP v9 adoption RFC:
# https://github.com/react-native-community/discussions-and-proposals/pull/1006).
android.builtInKotlin=false
android.newDsl=false
82 changes: 77 additions & 5 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
buildscript {
// Read the Kotlin version from the consuming app's rootProject.ext first (most RN apps
// already set this for reanimated/gesture-handler/screens/etc.), falling back to a bundled
// default only when nothing else in the build graph has declared one. We deliberately use
// the classic `buildscript { classpath ... } + apply plugin` form here instead of the
// `plugins { id(...) version(...) }` DSL: the latter hard-fails the whole consumer build with
// "plugin already on the classpath with a different version" if any other subproject (e.g.
// reanimated) applies a different Kotlin version, since it enforces exact version matches per
// plugin id across the whole multi-project build. The classpath form just resolves to a
// single version (highest wins) with no such conflict.
//
// Fallback version has no ecosystem anchor to track — it only applies when nothing else in
// the host build declared a Kotlin version at all, which is already rare in the RN ecosystem.
// Kept aligned with what @react-native/gradle-plugin itself bundles (RN 0.87's `kotlin` in
// its libs.versions.toml) since that's the most common actual value in the wild.
def kotlinVersion = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.2.0"

repositories {
google()
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}

apply plugin: "com.android.library"

// AGP 9 provides Kotlin support built in; applying the classic kotlin-android plugin on top of it
// fails with "Cannot add extension with name 'kotlin', as there is an extension already
// registered with that name." Below AGP 9, the classic plugin is still required. Empirically,
// AGP 9's built-in Kotlin compilation stays active even when a consumer opts out of the new
// DSL/extension via `android.builtInKotlin=false` (RN's own 0.87 template does this — see its
// release notes; the opt-out itself is removed in AGP10), so the AGP major version alone is
// enough to decide, no need to probe for what the consumer opted into. This matches how other RN
// libraries migrated for AGP9 — see the patches linked from the AGP v9 adoption RFC
// (https://github.com/react-native-community/discussions-and-proposals/pull/1006), e.g.
// react-native-safe-area-context's.
def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
if (agpMajor < 9) {
apply plugin: "kotlin-android"
}

def isNewArchitectureEnabled() {
// To opt-in for the New Architecture, you can either:
// - Set `newArchEnabled` to true inside the `gradle.properties` file
Expand All @@ -10,15 +51,22 @@ def isNewArchitectureEnabled() {

def IS_NEW_ARCHITECTURE_ENABLED = isNewArchitectureEnabled()

def DEFAULT_COMPILE_SDK_VERSION = 26
def DEFAULT_BUILD_TOOLS_VERSION = "26.0.3"
def DEFAULT_TARGET_SDK_VERSION = 26
def DEFAULT_MIN_SDK_VERSION = 16
// These are fallbacks only — real consumer apps set compileSdkVersion/buildToolsVersion/
// targetSdkVersion/minSdkVersion on rootProject themselves (RN's own template always has),
// so bumping these defaults doesn't change behavior for any app that already does. Keep them
// aligned with RN's current app template (verified against Examples/CodePushDemo's root
// build.gradle, generated from RN 0.87's template) so a consumer relying on the fallback still
// gets a build that actually works with a current RN version.
def DEFAULT_COMPILE_SDK_VERSION = 35 // RN template: compileSdkVersion
def DEFAULT_BUILD_TOOLS_VERSION = "36.0.0" // RN template: buildToolsVersion (also AGP 9.2.1's own minimum)
def DEFAULT_TARGET_SDK_VERSION = 35 // RN template: targetSdkVersion
def DEFAULT_MIN_SDK_VERSION = 24 // RN template: minSdkVersion (RN 0.74+'s own minimum)

android {
namespace "com.microsoft.codepush.react"

compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION
// `compileSdk` (not the old `compileSdkVersion(int)` method) — AGP 9 removed that overload.
compileSdk rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION
buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION

defaultConfig {
Expand All @@ -40,9 +88,33 @@ android {
buildFeatures {
buildConfig true
}

// JVM 17 matches mise.toml's `java = "17"` pin (this repo's own toolchain) and RN 0.76+'s own
// minimum required JDK — not an arbitrary choice. Confirmed against Examples/CodePushDemo:
// its build resolves unit-test javac to JVM 17 regardless of what's set here, so this module
// must match it or Kotlin's build fails with "Inconsistent JVM Target Compatibility". Bump
// this in lockstep with mise.toml's java version, never independently.
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

// Only set when we applied the classic kotlin-android plugin ourselves above: AGP 9's built-in
// Kotlin integration doesn't expose this extension at all (`android.kotlinOptions` fails with
// "Could not find method kotlinOptions()"). Not needed there anyway — per Android's own built-in
// Kotlin migration guide, `kotlin.compilerOptions.jvmTarget` defaults to
// `android.compileOptions.targetCompatibility` (set above) when built-in Kotlin is active:
// https://developer.android.com/build/migrate-to-built-in-kotlin
if (agpMajor < 9) {
android.kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
implementation 'com.facebook.react:react-android:0.82.1'
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'

testImplementation 'junit:junit:4.13.2'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.microsoft.codepush.react

import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File

class CodePushUtilsTest {

@Test
fun appendPathComponent_joinsBaseAndChildIntoAbsolutePath() {
val basePath = "/data/data/com.example.app/files"
val result = CodePushUtils.appendPathComponent(basePath, "CodePush")

assertEquals(File(basePath, "CodePush").absolutePath, result)
}

@Test
fun appendPathComponent_handlesNestedRelativeComponent() {
val basePath = "/data/data/com.example.app/files/CodePush"
val result = CodePushUtils.appendPathComponent(basePath, "20/CodePushLoader.android.bundle")

assertEquals(File(basePath, "20/CodePushLoader.android.bundle").absolutePath, result)
}
}
7 changes: 6 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.12.0")
// Must match (or exceed) the AGP version RN's own @react-native/gradle-plugin declares: that plugin is
// included as a composite build below and its own AGP dependency wins Gradle's classpath
// conflict resolution regardless of what's pinned here, so an out-of-date pin here is
// silently overridden rather than actually enforced. Bump this in lockstep with RN's own
// gradle-plugin version whenever bumping the react-native devDependency.
classpath("com.android.tools.build:gradle:9.2.1")
classpath("com.facebook.react:react-native-gradle-plugin")

// NOTE: Do not place your application dependencies here; they belong
Expand Down
15 changes: 15 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true

# This repo's own standalone build/test harness only — real consumer apps already set this
# (RN's own template has for years), so it's never actually read by a downstream build. Needed
# here because react-android pulls in AndroidX transitively, and Gradle's unit test task
# resolves that classpath strictly.
android.useAndroidX=true

# Opt out of AGP 9's built-in Kotlin support and new DSL, matching what RN's own 0.87 app template
# does (this flag pair is removed in AGP10, at which point built-in Kotlin becomes mandatory —
# see the RN 0.87 release notes and the AGP v9 adoption RFC:
# https://github.com/react-native-community/discussions-and-proposals/pull/1006). This repo's own
# harness only; see the note above and android/app/build.gradle's comments for why downstream
# consumers may still land in either mode.
android.builtInKotlin=false
android.newDsl=false
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip