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
5 changes: 5 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.gradle/
build/
**/build/
local.properties
.kotlin/
53 changes: 53 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Android (KSP) macros

The Android counterpart of the Swift macros in [`../apple`](../apple). Where Apple uses Swift
compiler macros, Android uses a [KSP](https://kotlinlang.org/docs/ksp-overview.html) (Kotlin Symbol
Processing) processor: the author annotates ordinary Kotlin, and the processor generates the Expo
module's JS surface at build time.

## Why KSP, not a port of the Swift macros

Swift macros rewrite the annotated declaration in place; KSP can only **generate new files**. So this
is a parallel implementation that lands on the same author-facing API, not a translation of the macro
code. (Pika, the IR plugin core uses for `@OptimizedRecord`, is sealed and can't be extended, so the
processor is first-party and Pika-independent.)

## What's here

- **`annotations/`** — the markers an author applies: `@ExpoModule` on a `Module` class, `@JS` on its
functions and properties.
- **`processor/`** — the KSP `SymbolProcessor`. It reads `@ExpoModule`/`@JS` and generates a
`<Module>.expoModuleDefinition()` extension that builds the module's `ModuleDefinitionData` through
the existing `ModuleDefinition { … }` DSL.

## Author API

```kotlin
@ExpoModule("MyModule")
class MyModule : Module() {
@JS fun greet(name: String): String = "hi $name"
@JS suspend fun work(id: String): Int = id.length
@JS val version: String get() = "1.0"
@JS var ready: Boolean = false

// KSP can't add this override the way a Swift macro adds members to its type,
// so the author wires the generated definition in with one line.
override fun definition() = expoModuleDefinition()
}
```

The generated extension compiles against today's `expo-modules-core` with no core changes — the only
contract is that the generated DSL is valid against core, not that this tool shares core's compiler
version. A later step can add a core convention so `definition()` doesn't need to be written by hand.

## Building and testing

```sh
./gradlew :processor:test
```

Tests use [`kotlin-compile-testing`](https://github.com/ZacSweers/kotlin-compile-testing) (the KSP
analog of the Swift suite's `assertExpansion`): they run the processor over fixture modules and assert
on the generated source. The fixtures compile against minimal stubs of the core DSL, so a green run
proves the generated code is valid Kotlin that type-checks — it does **not** prove it links against
real core. Like the `apple/` suite, integration is only proven by building a real module against core.
7 changes: 7 additions & 0 deletions android/annotations/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
plugins {
kotlin("jvm")
}

kotlin {
jvmToolchain(17)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package expo.modules.macros

/**
* Marks a class as an Expo module whose JS surface is generated at build time.
*
* The processor reads the [JS]-annotated members in the class body and generates a
* `<ClassName>.expoModuleDefinition()` extension that builds the module's `ModuleDefinitionData`
* via the existing `ModuleDefinition { … }` DSL. The author wires it in with a one-line
* `definition()` override:
*
* ```
* @ExpoModule("MyModule")
* class MyModule : Module() {
* @JS fun greet(name: String): String = "hi $name"
* @JS var ready: Boolean = false
*
* override fun definition() = expoModuleDefinition()
* }
* ```
*
* This is the Android counterpart of the Swift `@ExpoModule` macro. KSP cannot add the
* `definition()` override to the class the way a Swift macro adds members to its type, so the
* generated definition is exposed as an extension the author returns from `definition()`.
*
* @param name JS module name. Defaults to the class's simple name when blank.
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class ExpoModule(val name: String = "")
21 changes: 21 additions & 0 deletions android/annotations/src/main/kotlin/expo/modules/macros/JS.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package expo.modules.macros

/**
* Exposes a member of an [ExpoModule] class to JavaScript.
*
* Applied to functions and properties:
* - `@JS fun f(…)` becomes a synchronous `Function`.
* - `@JS suspend fun f(…)` becomes an asynchronous function returning a JS `Promise`.
* - `@JS val p` / `@JS var p` becomes a `Property`; a `var` (or a `val`/`var` with a setter)
* additionally gets a JS setter, a `val` stays read-only.
*
* `@JS("name")` overrides the JS-visible name; otherwise the Kotlin member name is used.
*
* The Android counterpart of the Swift `@JS` macro. Like its Swift sibling it applies to both
* functions and properties, not functions alone.
*
* @param name JS-visible name. Defaults to the member's Kotlin name when blank.
*/
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
annotation class JS(val name: String = "")
21 changes: 21 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The Android counterpart of the Swift macros in `apple/`. Instead of compiler macros,
// the JS surface is generated by a KSP (Kotlin Symbol Processing) processor: `:annotations`
// holds the markers an author applies (`@ExpoModule`, `@JS`), and `:processor` reads them and
// generates the module definition.
//
// Kotlin/KSP are pinned independently of `expo-modules-core` (which builds with Kotlin 2.0.21):
// this is a standalone tool whose only contract with core is that the *generated* code compiles
// against core's DSL, not that it shares a compiler version. 2.1.20 is chosen because it (and its
// matching KSP `2.1.20-2.0.1`) resolves from the local Gradle cache.

plugins {
kotlin("jvm") version "2.1.20" apply false
id("com.google.devtools.ksp") version "2.1.20-2.0.1" apply false
}

subprojects {
repositories {
mavenCentral()
google()
}
}
3 changes: 3 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.caching=true
kotlin.code.style=official
Binary file added android/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
234 changes: 234 additions & 0 deletions android/gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading