diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..2e252e3
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,13 @@
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.{kt,kts}]
+indent_size = 4
+max_line_length = 120
+ktlint_standard_function-naming = disabled
+ktlint_standard_no-wildcard-imports = disabled
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8f182f..055cc89 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,3 +88,25 @@ jobs:
- name: Check Swift formatting
run: npm run format:swift:check
+
+ # TODO: Add ktlint back at some point (it takes over 2 minutes to install)
+ # ktlint:
+ # name: ktlint Check
+ # runs-on: ubuntu-latest
+ # steps:
+ # - name: Checkout code
+ # uses: actions/checkout@v4
+
+ # - name: Set up Node.js
+ # uses: actions/setup-node@v4
+ # with:
+ # node-version: '22'
+ # cache: 'npm'
+
+ # - name: Install ktlint
+ # run: |
+ # eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
+ # brew install ktlint
+
+ # - name: Check Kotlin formatting
+ # run: npm run format:kotlin:check
diff --git a/.gitignore b/.gitignore
index 230a8ba..4aa4664 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,20 @@ xcuserdata/
## Playground app
/example/ios
+# Android/IJ
+#
+.classpath
+.cxx
+.gradle
+.idea
+.project
+.settings
+local.properties
+android.iml
+android/app/libs
+android/keystores/debug.keystore
+build/
+
## Node.js
node_modules/
npm-debug.log
diff --git a/.npmignore b/.npmignore
index e779527..6a9a35e 100644
--- a/.npmignore
+++ b/.npmignore
@@ -17,6 +17,7 @@ __tests__
/.prettierignore
/.prettierrc
/.swiftformat
+/.editorconfig
# Android build artifacts
/android/src/androidTest/
diff --git a/README.md b/README.md
index 359429d..2ba905c 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,24 @@

-### Build Live Activities with JSX in React Native
+### Build Live Activities and Widgets with JSX in React Native
[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome]
-Voltra turns React Native JSX into SwiftUI so you can ship custom Live Activities, Dynamic Island layouts without touching Xcode. Author everything in React, keep hot reload, and let the config plugin handle the extension targets.
+Voltra turns React Native JSX into SwiftUI and Jetpack Compose Glance so you can ship custom Live Activities, Dynamic Island layouts, and Android Widgets without touching native code. Author everything in React, keep hot reload, and let the config plugin handle the native extension targets.
## Features
-- **Ship Native iOS Surfaces**: Create Live Activities, Dynamic Island variants, and static widgets directly from React components - no Swift or Xcode required.
+- **Ship Native Surfaces**: Create iOS Live Activities, Dynamic Island variants, and Android Home Screen widgets directly from React components - no Swift, Kotlin, or Xcode/Android Studio UI work required.
-- **Fast Development Workflow**: Hooks respect Fast Refresh and both JS and native layers enforce ActivityKit payload budgets.
+- **Fast Development Workflow**: Hooks respect Fast Refresh and both JS and native layers enforce platform-specific payload budgets.
-- **Production-Ready Push Notifications**: Collect ActivityKit push tokens and push-to-start tokens, stream lifecycle updates, and build server-driven refreshes.
+- **Production-Ready Push Notifications**: Support for ActivityKit push tokens (iOS) and FCM (Android) to stream lifecycle updates and build server-driven refreshes.
-- **Familiar Styling**: Use React Native style props and ordered SwiftUI modifiers in one place.
+- **Familiar Styling**: Use React Native style props and platform-native modifiers (SwiftUI/Glance) in one place.
- **Type-Safe & Developer-Friendly**: The Voltra schema, hooks, and examples ship with TypeScript definitions, tests, and docs so AI coding agents stay productive.
-- **Works With Your Setup**: Compatible with Expo Dev Client and bare React Native projects. The config plugin automatically wires iOS extension targets for you.
+- **Works With Your Setup**: Compatible with Expo Dev Client and bare React Native projects. The config plugin automatically wires native extension targets for you.
## Documentation
@@ -50,7 +50,7 @@ Add the config plugin to your `app.json`:
}
```
-Then run `npx expo prebuild --clean` to generate the iOS extension target.
+Then run `npx expo prebuild --clean` to generate the native extension targets.
See the [documentation](https://use-voltra.dev/getting-started/quick-start) for detailed setup instructions.
@@ -83,7 +83,10 @@ export function OrderTracker({ orderId }: { orderId: string }) {
## Platform compatibility
-**Note:** This module is intended for use on **iOS devices only**.
+Voltra is a cross-platform library that supports:
+
+- **iOS**: Live Activities and Dynamic Island (SwiftUI).
+- **Android**: Home Screen Widgets (Jetpack Compose Glance).
## Authors
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..87f1ee7
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,84 @@
+buildscript {
+ // 1. ADD THIS BLOCK TO RESOLVE THE PLUGIN
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ // Ensure this version matches your project's Kotlin version (e.g., 2.0.0, 2.0.20)
+ classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.0"
+ classpath "org.jetbrains.kotlin:kotlin-serialization:2.0.21"
+ }
+
+ // Existing helper
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
+apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
+
+group = 'voltra'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+ useDefaultAndroidSdkVersions()
+} else {
+ buildscript {
+ // Simple helper that allows the root project to override versions declared by this library.
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
+ }
+ project.android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 31)
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
+ }
+ }
+}
+
+android {
+ namespace "voltra"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+ buildFeatures {
+ compose true
+ }
+}
+
+dependencies {
+ // Jetpack Glance - use 'api' instead of 'implementation' to make these available to consuming apps
+ api "androidx.glance:glance:1.2.0-rc01"
+ api "androidx.glance:glance-appwidget:1.2.0-rc01"
+
+ // Compose runtime (required for Glance)
+ api "androidx.compose.runtime:runtime:1.6.8"
+
+ // JSON parsing
+ implementation "com.google.code.gson:gson:2.10.1"
+
+ // Coroutines
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
+
+ // Kotlinx Serialization
+ implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..97f6aec
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/src/main/java/voltra/VoltraModule.kt b/android/src/main/java/voltra/VoltraModule.kt
new file mode 100644
index 0000000..68f0d88
--- /dev/null
+++ b/android/src/main/java/voltra/VoltraModule.kt
@@ -0,0 +1,296 @@
+package voltra
+
+import android.util.Log
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.runBlocking
+import voltra.events.VoltraEventBus
+import voltra.images.VoltraImageManager
+import voltra.widget.VoltraGlanceWidget
+import voltra.widget.VoltraWidgetManager
+
+class VoltraModule : Module() {
+ companion object {
+ private const val TAG = "VoltraModule"
+ }
+
+ private val notificationManager by lazy {
+ VoltraNotificationManager(appContext.reactContext!!)
+ }
+
+ private val widgetManager by lazy {
+ VoltraWidgetManager(appContext.reactContext!!)
+ }
+
+ private val imageManager by lazy {
+ VoltraImageManager(appContext.reactContext!!)
+ }
+
+ private val eventBus by lazy {
+ VoltraEventBus.getInstance(appContext.reactContext!!)
+ }
+
+ private var eventBusUnsubscribe: (() -> Unit)? = null
+
+ override fun definition() =
+ ModuleDefinition {
+ Name("VoltraModule")
+
+ OnStartObserving {
+ Log.d(TAG, "OnStartObserving: Starting event bus subscription")
+
+ // Replay any persisted events from SharedPreferences (cold start)
+ val persistedEvents = eventBus.popAll()
+ Log.d(TAG, "Replaying ${persistedEvents.size} persisted events")
+
+ persistedEvents.forEach { event ->
+ sendEvent(event.type, event.toMap())
+ }
+
+ // Subscribe to hot event delivery (broadcast receiver)
+ eventBusUnsubscribe =
+ eventBus.addListener { event ->
+ Log.d(TAG, "Received hot event: ${event.type}")
+ sendEvent(event.type, event.toMap())
+ }
+ }
+
+ OnStopObserving {
+ Log.d(TAG, "OnStopObserving: Unsubscribing from event bus")
+ eventBusUnsubscribe?.invoke()
+ eventBusUnsubscribe = null
+ }
+
+ // Android Live Update APIs
+
+ AsyncFunction("startAndroidLiveUpdate") {
+ payload: String,
+ options: Map,
+ ->
+
+ Log.d(TAG, "startAndroidLiveUpdate called")
+
+ val updateName = options["updateName"] as? String
+ val channelId = options["channelId"] as? String ?: "voltra_live_updates"
+
+ Log.d(TAG, "updateName=$updateName, channelId=$channelId")
+
+ val result =
+ runBlocking {
+ notificationManager.startLiveUpdate(payload, updateName, channelId)
+ }
+
+ Log.d(TAG, "startAndroidLiveUpdate returning: $result")
+ result
+ }
+
+ AsyncFunction("updateAndroidLiveUpdate") {
+ notificationId: String,
+ payload: String,
+ ->
+
+ Log.d(TAG, "updateAndroidLiveUpdate called with notificationId=$notificationId")
+
+ runBlocking {
+ notificationManager.updateLiveUpdate(notificationId, payload)
+ }
+
+ Log.d(TAG, "updateAndroidLiveUpdate completed")
+ }
+
+ AsyncFunction("stopAndroidLiveUpdate") { notificationId: String ->
+ Log.d(TAG, "stopAndroidLiveUpdate called with notificationId=$notificationId")
+ notificationManager.stopLiveUpdate(notificationId)
+ }
+
+ Function("isAndroidLiveUpdateActive") { updateName: String ->
+ notificationManager.isLiveUpdateActive(updateName)
+ }
+
+ AsyncFunction("endAllAndroidLiveUpdates") {
+ notificationManager.endAllLiveUpdates()
+ }
+
+ // Android Widget APIs
+
+ AsyncFunction("updateAndroidWidget") {
+ widgetId: String,
+ jsonString: String,
+ options: Map,
+ ->
+
+ Log.d(TAG, "updateAndroidWidget called with widgetId=$widgetId")
+
+ val deepLinkUrl = options["deepLinkUrl"] as? String
+
+ widgetManager.writeWidgetData(widgetId, jsonString, deepLinkUrl)
+
+ runBlocking {
+ widgetManager.updateWidget(widgetId)
+ }
+
+ Log.d(TAG, "updateAndroidWidget completed")
+ }
+
+ AsyncFunction("reloadAndroidWidgets") { widgetIds: ArrayList? ->
+ Log.d(TAG, "reloadAndroidWidgets called with widgetIds=$widgetIds")
+
+ runBlocking {
+ widgetManager.reloadWidgets(widgetIds)
+ }
+
+ Log.d(TAG, "reloadAndroidWidgets completed")
+ }
+
+ AsyncFunction("clearAndroidWidget") { widgetId: String ->
+ Log.d(TAG, "clearAndroidWidget called with widgetId=$widgetId")
+
+ widgetManager.clearWidgetData(widgetId)
+
+ runBlocking {
+ widgetManager.updateWidget(widgetId)
+ }
+
+ Log.d(TAG, "clearAndroidWidget completed")
+ }
+
+ AsyncFunction("clearAllAndroidWidgets") {
+ Log.d(TAG, "clearAllAndroidWidgets called")
+
+ widgetManager.clearAllWidgetData()
+
+ runBlocking {
+ widgetManager.reloadAllWidgets()
+ }
+
+ Log.d(TAG, "clearAllAndroidWidgets completed")
+ }
+
+ AsyncFunction("requestPinGlanceAppWidget") {
+ widgetId: String,
+ options: Map?,
+ ->
+
+ Log.d(TAG, "requestPinGlanceAppWidget called with widgetId=$widgetId")
+
+ val context = appContext.reactContext!!
+
+ // Construct the receiver class name following the convention
+ val receiverClassName = "${context.packageName}.widget.VoltraWidget_${widgetId}Receiver"
+
+ Log.d(TAG, "Looking for receiver: $receiverClassName")
+
+ // Get the receiver class using reflection
+ val receiverClass =
+ try {
+ Class.forName(receiverClassName) as Class
+ } catch (e: ClassNotFoundException) {
+ Log.e(TAG, "Widget receiver class not found: $receiverClassName", e)
+ throw IllegalArgumentException("Widget receiver not found for id: $widgetId")
+ }
+
+ // Get GlanceAppWidgetManager and request pin
+ val glanceManager = GlanceAppWidgetManager(context)
+
+ // Parse preview size from options (optional)
+ // See: https://developer.android.com/develop/ui/compose/glance/pin-in-app
+ val previewSize =
+ if (options != null) {
+ val width = (options["previewWidth"] as? Number)?.toFloat()
+ val height = (options["previewHeight"] as? Number)?.toFloat()
+ if (width != null && height != null) {
+ DpSize(width.dp, height.dp)
+ } else {
+ null
+ }
+ } else {
+ null
+ }
+
+ val result =
+ runBlocking {
+ // requestPinGlanceAppWidget is a suspend function
+ // See: https://developer.android.com/develop/ui/compose/glance/pin-in-app
+ if (previewSize != null) {
+ // Create preview widget with preview dimensions
+ val previewWidget = VoltraGlanceWidget(widgetId)
+ glanceManager.requestPinGlanceAppWidget(
+ receiver = receiverClass,
+ preview = previewWidget,
+ previewState = previewSize,
+ )
+ } else {
+ // Basic pin request without preview
+ glanceManager.requestPinGlanceAppWidget(receiverClass)
+ }
+ }
+
+ Log.d(TAG, "requestPinGlanceAppWidget completed with result=$result")
+ result
+ }
+
+ AsyncFunction("preloadImages") { images: List