From 69c844a73a2ed07f8c2330b37b1b74862cb9399a Mon Sep 17 00:00:00 2001 From: Jakub Grzywacz Date: Thu, 18 Jun 2026 12:09:41 +0200 Subject: [PATCH 1/9] [widgets][Android] Add Hermes runtime (#46684) # Why Android needs own JavaScript runtime (Hermes) equivalent to the existing iOS JSC runtime so widgets can render registered layouts with props and environment values. # How - Added a native `WidgetsHermesRuntime` backed by Hermes # Test Plan - Verified on the stack. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-widgets/CHANGELOG.md | 1 + packages/expo-widgets/android/CMakeLists.txt | 38 +++++ packages/expo-widgets/android/build.gradle | 48 ++++-- .../src/main/cpp/WidgetsHermesRuntime.cpp | 149 ++++++++++++++++++ .../expo/modules/widgets/WidgetsJSRuntime.kt | 38 +++++ .../widgets/jni/WidgetsHermesRuntime.kt | 36 +++++ 6 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 packages/expo-widgets/android/CMakeLists.txt create mode 100644 packages/expo-widgets/android/src/main/cpp/WidgetsHermesRuntime.cpp create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/WidgetsJSRuntime.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/jni/WidgetsHermesRuntime.kt diff --git a/packages/expo-widgets/CHANGELOG.md b/packages/expo-widgets/CHANGELOG.md index 133eb13a059577..ea9c7c93696ac3 100644 --- a/packages/expo-widgets/CHANGELOG.md +++ b/packages/expo-widgets/CHANGELOG.md @@ -12,6 +12,7 @@ - Expose shared directory for images. ([#46339](https://github.com/expo/expo/pull/46339) by [@jakex7](https://github.com/jakex7)) - Add a initial layout registry for widgets. ([#46501](https://github.com/expo/expo/pull/46501) by [@jakex7](https://github.com/jakex7)) - Add `initialProps` to widgets layout registry. ([#46527](https://github.com/expo/expo/pull/46527) by [@jakex7](https://github.com/jakex7)) +- [Android] Add Hermes runtime. ([#46684](https://github.com/expo/expo/pull/46684) by [@jakex7](https://github.com/jakex7)) ### 🐛 Bug fixes diff --git a/packages/expo-widgets/android/CMakeLists.txt b/packages/expo-widgets/android/CMakeLists.txt new file mode 100644 index 00000000000000..01905dc989c0ca --- /dev/null +++ b/packages/expo-widgets/android/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) + +project(expo-widgets) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include("${REACT_NATIVE_DIR}/ReactAndroid/cmake-utils/folly-flags.cmake") + +find_package(ReactAndroid REQUIRED CONFIG) +find_package(fbjni REQUIRED CONFIG) +find_package(hermes-engine REQUIRED CONFIG) + +add_library( + expo-widgets + SHARED + src/main/cpp/WidgetsHermesRuntime.cpp +) + +target_compile_options( + expo-widgets + PRIVATE + ${folly_FLAGS} + -frtti + -fexceptions + --std=c++20 + -O2 + -Wall + -fstack-protector-all +) + +target_link_libraries( + expo-widgets + ReactAndroid::jsi + ReactAndroid::reactnative + fbjni::fbjni + hermes-engine::hermesvm +) diff --git a/packages/expo-widgets/android/build.gradle b/packages/expo-widgets/android/build.gradle index 1ee60822b6f40e..e6fa5483dda585 100644 --- a/packages/expo-widgets/android/build.gradle +++ b/packages/expo-widgets/android/build.gradle @@ -1,19 +1,11 @@ import org.apache.tools.ant.taskdefs.condition.Os -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}") - } +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' + id 'org.jetbrains.kotlin.plugin.compose' version "$kotlinVersion" } -apply plugin: 'com.android.library' -apply plugin: 'expo-module-gradle-plugin' -apply plugin: 'org.jetbrains.kotlin.plugin.compose' - group = 'expo.modules.widgets' version = '56.0.15' @@ -34,17 +26,47 @@ expoModule { canBePublished false } +def reactNativeArchitectures() { + def value = project.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + android { namespace "expo.modules.widgets" defaultConfig { versionCode 1 versionName '56.0.15' + + externalNativeBuild { + cmake { + def cppArguments = [ + "-DANDROID_STL=c++_shared", + "-DREACT_NATIVE_DIR=${expoModule.reactNativeDir}", + ] + + abiFilters(*reactNativeArchitectures()) + arguments(*cppArguments) + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } } buildFeatures { prefab true compose true } + + packagingOptions { + excludes += [ + "**/libc++_shared.so", + "**/libhermesvm.so", + ] + } } def generateExpoWidgetsBundle = tasks.register("generateExpoWidgetsBundle", Exec) { @@ -81,6 +103,8 @@ androidComponents { } dependencies { + implementation 'com.facebook.react:react-android' + implementation 'com.facebook.react:hermes-android' api 'androidx.glance:glance:1.2.0-rc01' api 'androidx.glance:glance-appwidget:1.2.0-rc01' } diff --git a/packages/expo-widgets/android/src/main/cpp/WidgetsHermesRuntime.cpp b/packages/expo-widgets/android/src/main/cpp/WidgetsHermesRuntime.cpp new file mode 100644 index 00000000000000..91ab604644fe54 --- /dev/null +++ b/packages/expo-widgets/android/src/main/cpp/WidgetsHermesRuntime.cpp @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace jsi = facebook::jsi; +namespace jni = facebook::jni; +namespace react = facebook::react; + +namespace expo::widgets { + std::optional jstringToOptionalString(const jni::alias_ref &value) { + if (!value) { + return std::nullopt; + } + return value->toStdString(); + } + + void throwRuntimeException(const std::string &message) { + jni::throwNewJavaException("java/lang/RuntimeException", message.c_str()); + } + + jsi::Value readableMapToValue( + jsi::Runtime &rt, + const jni::alias_ref &map + ) { + if (!map) { + return jsi::Value::undefined(); + } + + return jsi::valueFromDynamic(rt, map->cthis()->consume()); + } + + jsi::Value evaluateScript(jsi::Runtime &rt, const std::string &script, const std::string &sourceUrl) { + return rt.evaluateJavaScript(std::make_shared(script), sourceUrl); + } + + class WidgetsHermesRuntime : public jni::HybridClass { + public: + static constexpr auto kJavaDescriptor = "Lexpo/modules/widgets/jni/WidgetsHermesRuntime;"; + + WidgetsHermesRuntime() { + auto config = ::hermes::vm::RuntimeConfig::Builder() + .withEnableSampleProfiling(false) + .build(); + runtime = facebook::hermes::makeHermesRuntime(config); + } + + static jni::local_ref initHybrid(jni::alias_ref clazz) { + return makeCxxInstance(); + } + + static void registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", WidgetsHermesRuntime::initHybrid), + makeNativeMethod("nativeEvaluateBundle", WidgetsHermesRuntime::nativeEvaluateBundle), + makeNativeMethod("nativeRender", WidgetsHermesRuntime::nativeRender), + }); + } + + void nativeEvaluateBundle( + const jni::alias_ref &script, + const jni::alias_ref &sourceUrl + ) { + try { + std::scoped_lock lock(mutex); + evaluateScript(*runtime, script->toStdString(), sourceUrl->toStdString()); + } catch (const jsi::JSError &error) { + throwRuntimeException(error.getMessage()); + } catch (const std::exception &error) { + throwRuntimeException(error.what()); + } + } + + jni::local_ref nativeRender( + const jni::alias_ref &layout, + const jni::alias_ref &props, + const jni::alias_ref &environment + ) { + try { + std::scoped_lock lock(mutex); + return renderWithFunction( + layout->toStdString(), + props, + environment + ); + } catch (const jsi::JSError &error) { + throwRuntimeException(error.getMessage()); + } catch (const std::exception &error) { + throwRuntimeException(error.what()); + } + return nullptr; + } + + private: + std::unique_ptr runtime; + std::mutex mutex; + std::unordered_map> layoutCache; + + void ensureLayoutFunction(const std::string &layout) { + if (layoutCache.contains(layout)) { + return; + } + + auto layoutValue = evaluateScript(*runtime, "(" + layout + ")", "expo-widget-layout.js"); + if (!layoutValue.isObject() || !layoutValue.asObject(*runtime).isFunction(*runtime)) { + throw std::runtime_error("Widget layout string did not evaluate to a function"); + } + + layoutCache.emplace(layout, std::make_unique(*runtime, layoutValue)); + } + + jni::local_ref renderWithFunction( + const std::string &layout, + const jni::alias_ref &propsMap, + const jni::alias_ref &environmentMap + ) { + auto &rt = *runtime; + ensureLayoutFunction(layout); + + rt.global().setProperty(rt, "__expoWidgetLayout", jsi::Value(rt, *layoutCache.at(layout))); + + auto props = readableMapToValue(rt, propsMap); + auto environment = readableMapToValue(rt, environmentMap); + jsi::Value args[] = {std::move(props), std::move(environment)}; + const jsi::Value *argsPtr = args; + auto render = rt.global().getPropertyAsFunction(rt, "__expoWidgetRender"); + auto result = render.call(rt, argsPtr, static_cast(2)); + if (!result.isObject()) { + throw std::runtime_error("Widget render function must return an object"); + } + + auto dynamic = jsi::dynamicFromValue(rt, result); + return react::ReadableNativeMap::createWithContents(std::move(dynamic)); + } + }; +} // namespace expo::widgets + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { + return jni::initialize(vm, [] { + expo::widgets::WidgetsHermesRuntime::registerNatives(); + }); +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/WidgetsJSRuntime.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/WidgetsJSRuntime.kt new file mode 100644 index 00000000000000..23d7768491fb2a --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/WidgetsJSRuntime.kt @@ -0,0 +1,38 @@ +package expo.modules.widgets + +import android.content.Context +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableMap +import expo.modules.widgets.jni.WidgetsHermesRuntime + +internal object WidgetsJSRuntime { + private var runtime: WidgetsHermesRuntime? = null + + @Synchronized + fun render( + context: Context, + layout: String, + props: Map?, + environment: Map + ): ReadableMap { + return getRuntime(context).render( + layout, + Arguments.makeNativeMap(props), + Arguments.makeNativeMap(environment) + ) + } + + private fun getRuntime(context: Context): WidgetsHermesRuntime { + return runtime ?: WidgetsHermesRuntime().also { + it.evaluateBundle(readBundle(context)) + runtime = it + } + } + + private fun readBundle(context: Context): String { + return context.applicationContext.resources + .openRawResource(R.raw.expo_widgets_bundle) + .bufferedReader() + .use { it.readText() } + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/jni/WidgetsHermesRuntime.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/jni/WidgetsHermesRuntime.kt new file mode 100644 index 00000000000000..b2aa3115444697 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/jni/WidgetsHermesRuntime.kt @@ -0,0 +1,36 @@ +package expo.modules.widgets.jni + +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStripAny +import com.facebook.react.bridge.ReadableNativeMap +import java.io.Closeable + +@DoNotStripAny +internal class WidgetsHermesRuntime : Closeable { + @Suppress("NoHungarianNotation") + private val mHybridData: HybridData = initHybrid() + + fun evaluateBundle(script: String) { + nativeEvaluateBundle(script, "ExpoWidgets.bundle") + } + + fun render(layout: String, props: ReadableNativeMap?, environment: ReadableNativeMap): ReadableNativeMap { + return nativeRender(layout, props, environment) + } + + override fun close() { + mHybridData.resetNative() + } + + private external fun nativeEvaluateBundle(script: String, sourceUrl: String) + private external fun nativeRender(layout: String, props: ReadableNativeMap?, environment: ReadableNativeMap): ReadableNativeMap + + companion object { + init { + System.loadLibrary("expo-widgets") + } + + @JvmStatic + private external fun initHybrid(): HybridData + } +} From 50b6c1353de5f922379be2185814743709dde19a Mon Sep 17 00:00:00 2001 From: Jakub Grzywacz Date: Thu, 18 Jun 2026 12:20:26 +0200 Subject: [PATCH 2/9] [widgets][Android] Basic Android components (#46951) # Why This PR adds the Android components layer for Expo Widgets, it uses the `@expo/ui/jetpack-compose` js layer with custom native components from Jetpack Glance. # How Adds a basic rendering layer that maps widget layout output to Android Glance components, reusing shared Expo UI props and modifier data where possible. # Test Plan - Build an Android app using `expo-widgets`. - Verify basic widget layouts render correctly on Android. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- .../java/expo/modules/ui/ModifierRegistry.kt | 26 +-- packages/expo-widgets/CHANGELOG.md | 1 + packages/expo-widgets/android/build.gradle | 3 + .../java/expo/modules/widgets/DynamicView.kt | 102 ++++++++ .../java/expo/modules/widgets/ui/BoxView.kt | 34 +++ .../expo/modules/widgets/ui/ButtonView.kt | 220 ++++++++++++++++++ .../expo/modules/widgets/ui/CheckboxView.kt | 33 +++ .../expo/modules/widgets/ui/ColumnView.kt | 76 ++++++ .../expo/modules/widgets/ui/ProgressView.kt | 57 +++++ .../modules/widgets/ui/RadioButtonView.kt | 15 ++ .../java/expo/modules/widgets/ui/RowView.kt | 76 ++++++ .../expo/modules/widgets/ui/SpacerView.kt | 10 + .../expo/modules/widgets/ui/SwitchView.kt | 38 +++ .../java/expo/modules/widgets/ui/TextView.kt | 142 +++++++++++ .../modules/widgets/ui/WidgetColorProvider.kt | 11 + .../modules/widgets/ui/WidgetModifiers.kt | 164 +++++++++++++ .../bundle/__tests__/decorator.test.ts | 19 ++ packages/expo-widgets/bundle/decorator.ts | 10 +- 18 files changed, 1023 insertions(+), 14 deletions(-) create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/DynamicView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/BoxView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ButtonView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/CheckboxView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ColumnView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ProgressView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RadioButtonView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RowView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SpacerView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SwitchView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/TextView.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetColorProvider.kt create mode 100644 packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetModifiers.kt diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt index d442c5e1d253ff..627dbedf484995 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt @@ -88,12 +88,12 @@ typealias ModifierFactory = @Composable (ModifierType, ComposableScope?, AppCont // region Modifier Params @OptimizedRecord -internal data class PaddingAllParams( +data class PaddingAllParams( @Field val all: Int = 0 ) : Record @OptimizedRecord -internal data class PaddingParams( +data class PaddingParams( @Field val start: Int = 0, @Field val top: Int = 0, @Field val end: Int = 0, @@ -101,48 +101,48 @@ internal data class PaddingParams( ) : Record @OptimizedRecord -internal data class SizeParams( +data class SizeParams( @Field val width: Int = 0, @Field val height: Int = 0 ) : Record @OptimizedRecord -internal data class FillMaxSizeParams( +data class FillMaxSizeParams( @Field val fraction: Float = 1.0f ) : Record @OptimizedRecord -internal data class FillMaxWidthParams( +data class FillMaxWidthParams( @Field val fraction: Float = 1.0f ) : Record @OptimizedRecord -internal data class FillMaxHeightParams( +data class FillMaxHeightParams( @Field val fraction: Float = 1.0f ) : Record @OptimizedRecord -internal data class WidthParams( +data class WidthParams( @Field val width: Int = 0 ) : Record @OptimizedRecord -internal data class HeightParams( +data class HeightParams( @Field val height: Int = 0 ) : Record @OptimizedRecord -internal data class WrapContentWidthParams( +data class WrapContentWidthParams( @Field val alignment: AlignmentType? = null ) : Record @OptimizedRecord -internal data class WrapContentHeightParams( +data class WrapContentHeightParams( @Field val alignment: AlignmentType? = null ) : Record @OptimizedRecord -internal data class DefaultMinSizeParams( +data class DefaultMinSizeParams( @Field val minWidth: Float? = null, @Field val minHeight: Float? = null ) : Record @@ -154,7 +154,7 @@ internal data class OffsetParams( ) : Record @OptimizedRecord -internal data class BackgroundParams( +data class BackgroundParams( @Field val color: Color? = null ) : Record @@ -235,7 +235,7 @@ internal data class AlignParams( ) : Record @OptimizedRecord -internal data class TestIDParams( +data class TestIDParams( @Field val testID: String? = null ) : Record diff --git a/packages/expo-widgets/CHANGELOG.md b/packages/expo-widgets/CHANGELOG.md index ea9c7c93696ac3..2b3e41c325cfa8 100644 --- a/packages/expo-widgets/CHANGELOG.md +++ b/packages/expo-widgets/CHANGELOG.md @@ -13,6 +13,7 @@ - Add a initial layout registry for widgets. ([#46501](https://github.com/expo/expo/pull/46501) by [@jakex7](https://github.com/jakex7)) - Add `initialProps` to widgets layout registry. ([#46527](https://github.com/expo/expo/pull/46527) by [@jakex7](https://github.com/jakex7)) - [Android] Add Hermes runtime. ([#46684](https://github.com/expo/expo/pull/46684) by [@jakex7](https://github.com/jakex7)) +- [Android] Basic Android components. ([#46951](https://github.com/expo/expo/pull/46951) by [@jakex7](https://github.com/jakex7)) ### 🐛 Bug fixes diff --git a/packages/expo-widgets/android/build.gradle b/packages/expo-widgets/android/build.gradle index e6fa5483dda585..5ccacbcb726258 100644 --- a/packages/expo-widgets/android/build.gradle +++ b/packages/expo-widgets/android/build.gradle @@ -105,6 +105,9 @@ androidComponents { dependencies { implementation 'com.facebook.react:react-android' implementation 'com.facebook.react:hermes-android' + implementation expoModule.getExpoDependency("expo-ui") + implementation 'androidx.compose.foundation:foundation-android:1.10.6' + implementation 'androidx.compose.ui:ui-android:1.10.6' api 'androidx.glance:glance:1.2.0-rc01' api 'androidx.glance:glance-appwidget:1.2.0-rc01' } diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/DynamicView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/DynamicView.kt new file mode 100644 index 00000000000000..24d8322dd5b468 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/DynamicView.kt @@ -0,0 +1,102 @@ +package expo.modules.widgets + +import androidx.compose.runtime.Composable +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import expo.modules.kotlin.views.ComposeProps +import expo.modules.kotlin.views.createComposeProps +import expo.modules.ui.CheckboxProps +import expo.modules.ui.CircularProgressIndicatorProps +import expo.modules.ui.LayoutProps +import expo.modules.ui.LinearProgressIndicatorProps +import expo.modules.ui.LoadingIndicatorProps +import expo.modules.ui.RadioButtonProps +import expo.modules.ui.SpacerProps +import expo.modules.ui.SwitchProps +import expo.modules.ui.TextProps +import expo.modules.ui.button.ButtonProps +import expo.modules.widgets.ui.ButtonView +import expo.modules.widgets.ui.ElevatedButtonView +import expo.modules.widgets.ui.FilledTonalButtonView +import expo.modules.widgets.ui.OutlinedButtonView +import expo.modules.widgets.ui.BoxView +import expo.modules.widgets.ui.CheckboxView +import expo.modules.widgets.ui.CircularProgressIndicatorView +import expo.modules.widgets.ui.ColumnView +import expo.modules.widgets.ui.LinearProgressIndicatorView +import expo.modules.widgets.ui.LoadingIndicatorView +import expo.modules.widgets.ui.RadioButtonView +import expo.modules.widgets.ui.RowView +import expo.modules.widgets.ui.SpacerView +import expo.modules.widgets.ui.SwitchView +import expo.modules.widgets.ui.TextButtonView +import expo.modules.widgets.ui.TextView + +@Composable +internal fun DynamicView(node: ReadableMap) { + val type = if (node.hasKey("type")) node.getString("type") else null + when (type) { + "Button" -> ButtonView(node.props(), node.children()) + "FilledTonalButton" -> FilledTonalButtonView(node.props(), node.children()) + "OutlinedButton" -> OutlinedButtonView(node.props(), node.children()) + "ElevatedButton" -> ElevatedButtonView(node.props(), node.children()) + "TextButton" -> TextButtonView(node.props(), node.children()) + "BoxView" -> BoxView(node.props()) { + node.children().forEach { DynamicView(it) } + } + "CheckboxView" -> CheckboxView(node.props()) + "CircularProgressIndicatorView" -> CircularProgressIndicatorView(node.props()) + "ColumnView" -> ColumnView(node.props()) { + node.children().forEach { DynamicView(it) } + } + "LinearProgressIndicatorView" -> LinearProgressIndicatorView(node.props()) + "LoadingIndicatorView" -> LoadingIndicatorView(node.props()) + "RadioButtonView" -> RadioButtonView(node.props()) + "react.fragment" -> node.children().forEach { DynamicView(it) } + "RowView" -> RowView(node.props()) { + node.children().forEach { DynamicView(it) } + } + "SpacerView" -> SpacerView(node.props()) + "SwitchView" -> SwitchView(node.props()) + "TextView" -> TextView(node.props()) + else -> TextView(TextProps("View not found")) + } +} + +private inline fun ReadableMap.props(): Props { + return createComposeProps( + propsMap() + ) +} + +private fun ReadableMap.propsMap(): ReadableMap? { + return if (hasKey("props") && !isNull("props")) { + getMap("props") + } else { + null + } +} + +private fun ReadableMap.children(): List { + val props = propsMap() ?: return emptyList() + if (!props.hasKey("children") || props.isNull("children")) { + return emptyList() + } + + return when (props.getType("children")) { + ReadableType.Map -> listOfNotNull(props.getMap("children")) + ReadableType.Array -> props.getArray("children")?.children() ?: emptyList() + else -> emptyList() + } +} + +private fun ReadableArray.children(): List { + return buildList { + for (index in 0 until size()) { + if (getType(index) == ReadableType.Map) { + getMap(index)?.let(::add) + } + } + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/BoxView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/BoxView.kt new file mode 100644 index 00000000000000..7c438183bfe1b8 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/BoxView.kt @@ -0,0 +1,34 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import expo.modules.ui.LayoutProps +import expo.modules.ui.convertibles.ContentAlignment + +@Composable +fun BoxView( + props: LayoutProps, + content: @Composable () -> Unit = {} +) { + Box( + modifier = props.modifiers.toGlanceModifier(), + contentAlignment = props.contentAlignment?.toGlanceAlignment() ?: Alignment.TopStart, + ) { + content() + } +} + +private fun ContentAlignment.toGlanceAlignment(): Alignment { + return when (this) { + ContentAlignment.TOP_START -> Alignment.TopStart + ContentAlignment.TOP_CENTER -> Alignment.TopCenter + ContentAlignment.TOP_END -> Alignment.TopEnd + ContentAlignment.CENTER_START -> Alignment.CenterStart + ContentAlignment.CENTER -> Alignment.Center + ContentAlignment.CENTER_END -> Alignment.CenterEnd + ContentAlignment.BOTTOM_START -> Alignment.BottomStart + ContentAlignment.BOTTOM_CENTER -> Alignment.BottomCenter + ContentAlignment.BOTTOM_END -> Alignment.BottomEnd + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ButtonView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ButtonView.kt new file mode 100644 index 00000000000000..eb5ab67599aeca --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ButtonView.kt @@ -0,0 +1,220 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.glance.Button +import androidx.glance.ButtonDefaults +import androidx.glance.GlanceTheme +import androidx.glance.unit.ColorProvider +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import expo.modules.ui.button.ButtonProps +import expo.modules.ui.colorToComposeColorOrNull +import androidx.glance.ButtonColors +import androidx.glance.appwidget.components.FilledButton +import androidx.glance.appwidget.components.OutlineButton + +@Composable +internal fun ButtonView( + props: ButtonProps, + children: List = emptyList() +) { + FilledButton( + text = props.textContent(children), + onClick = {}, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.enabled, + colors = props.toGlanceButtonColors( + defaultBackgroundColor = GlanceTheme.colors.primary, + defaultContentColor = GlanceTheme.colors.onPrimary + ) + ) +} + +@Composable +internal fun FilledTonalButtonView( + props: ButtonProps, + children: List = emptyList() +) { + FilledButton( + text = props.textContent(children), + onClick = {}, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.enabled, + colors = props.toGlanceButtonColors( + defaultBackgroundColor = GlanceTheme.colors.secondaryContainer, + defaultContentColor = GlanceTheme.colors.onSecondaryContainer + ) + ) +} + +@Composable +internal fun OutlinedButtonView( + props: ButtonProps, + children: List = emptyList() +) { + OutlineButton( + text = props.textContent(children), + contentColor = props.toGlanceContentColor(GlanceTheme.colors.primary), + onClick = {}, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.enabled + ) +} + +@Composable +internal fun ElevatedButtonView( + props: ButtonProps, + children: List = emptyList() +) { + FilledButton( + text = props.textContent(children), + onClick = {}, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.enabled, + colors = props.toGlanceButtonColors( + defaultBackgroundColor = GlanceTheme.colors.surface, + defaultContentColor = GlanceTheme.colors.primary + ) + ) +} + +@Composable +internal fun TextButtonView( + props: ButtonProps, + children: List = emptyList() +) { + Button( + text = props.textContent(children), + onClick = {}, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.enabled, + colors = props.toGlanceButtonColors( + defaultBackgroundColor = Color.Transparent.toGlanceColorProvider(), + defaultContentColor = GlanceTheme.colors.primary + ) + ) +} + +// TODO(@jakex7): Find a better way to get text +private fun ButtonProps.textContent(children: List): String { + return children.textContent() ?: "" +} + +private fun List.textContent(): String? { + return mapNotNull { it.textFromTextNode() } + .joinToString(separator = "") + .takeIf { it.isNotEmpty() } +} + +@Composable +private fun ButtonProps.toGlanceButtonColors( + defaultBackgroundColor: ColorProvider, + defaultContentColor: ColorProvider +): ButtonColors { + return ButtonDefaults.buttonColors( + backgroundColor = toGlanceContainerColor(defaultBackgroundColor), + contentColor = toGlanceContentColor(defaultContentColor) + ) +} + +private fun ButtonProps.toGlanceContainerColor(defaultColor: ColorProvider): ColorProvider { + return colorToComposeColorOrNull( + if (enabled) { + colors.containerColor + } else { + colors.disabledContainerColor ?: colors.containerColor + } + )?.toGlanceColorProvider() ?: defaultColor +} + +private fun ButtonProps.toGlanceContentColor(defaultColor: ColorProvider): ColorProvider { + return colorToComposeColorOrNull( + if (enabled) { + colors.contentColor + } else { + colors.disabledContentColor ?: colors.contentColor + } + )?.toGlanceColorProvider() ?: defaultColor +} + +private fun ReadableMap.textFromTextNode(): String? { + val type = if (hasKey("type")) getString("type") else null + return when (type) { + "TextView" -> propsMap()?.textContent() + "react.fragment" -> children().textContent() + else -> null + } +} + +private fun ReadableMap.textContent(): String? { + return spansText() + ?: stringValue("text")?.takeIf { it.isNotEmpty() } +} + +private fun ReadableMap.spansText(): String? { + if (!hasKey("spans") || getType("spans") != ReadableType.Array) { + return null + } + + return getArray("spans") + ?.spansText() + ?.takeIf { it.isNotEmpty() } +} + +private fun ReadableArray.spansText(): String { + return buildString { + for (index in 0 until size()) { + if (getType(index) == ReadableType.Map) { + getMap(index)?.let { span -> + append(span.spansText() ?: span.stringValue("text").orEmpty()) + } + } + } + } +} + +private fun ReadableMap.stringValue(key: String): String? { + if (!hasKey(key) || isNull(key)) { + return null + } + + return when (getType(key)) { + ReadableType.Boolean -> getBoolean(key).toString() + ReadableType.Number -> getDouble(key).toString() + ReadableType.String -> getString(key) + else -> null + } +} + +private fun ReadableMap.children(): List { + val props = propsMap() ?: return emptyList() + if (!props.hasKey("children")) { + return emptyList() + } + + return when (props.getType("children")) { + ReadableType.Map -> listOfNotNull(props.getMap("children")) + ReadableType.Array -> props.getArray("children")?.children() ?: emptyList() + else -> emptyList() + } +} + +private fun ReadableArray.children(): List { + return buildList { + for (index in 0 until size()) { + if (getType(index) == ReadableType.Map) { + getMap(index)?.let(::add) + } + } + } +} + +private fun ReadableMap.propsMap(): ReadableMap? { + return if (hasKey("props") && !isNull("props")) { + getMap("props") + } else { + null + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/CheckboxView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/CheckboxView.kt new file mode 100644 index 00000000000000..48d95afdd9f1f2 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/CheckboxView.kt @@ -0,0 +1,33 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.appwidget.CheckBox +import androidx.glance.appwidget.CheckboxDefaults +import androidx.glance.appwidget.CheckBoxColors +import expo.modules.ui.CheckboxProps +import expo.modules.ui.colorToComposeColorOrNull + +@Composable +fun CheckboxView(props: CheckboxProps) { + CheckBox( + checked = props.value, + onCheckedChange = null, + modifier = props.modifiers.toGlanceModifier(), + colors = props.toGlanceCheckboxColors() + ) +} + +@Composable +private fun CheckboxProps.toGlanceCheckboxColors(): CheckBoxColors { + val checkedColor = colorToComposeColorOrNull(colors.checkedColor) + val uncheckedColor = colorToComposeColorOrNull(colors.uncheckedColor) + + return if (checkedColor != null && uncheckedColor != null) { + CheckboxDefaults.colors( + checkedColor = checkedColor.toGlanceColorProvider(), + uncheckedColor = uncheckedColor.toGlanceColorProvider() + ) + } else { + CheckboxDefaults.colors() + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ColumnView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ColumnView.kt new file mode 100644 index 00000000000000..4ae559b26921d3 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ColumnView.kt @@ -0,0 +1,76 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import expo.modules.ui.LayoutProps +import expo.modules.ui.convertibles.HorizontalAlignment +import expo.modules.ui.convertibles.HorizontalArrangementDefault +import expo.modules.ui.convertibles.VerticalAlignment +import expo.modules.ui.convertibles.VerticalArrangementDefault + +@Composable +fun ColumnView( + props: LayoutProps, + content: @Composable () -> Unit = {} +) { + Column( + modifier = props.modifiers.toGlanceModifier(), + verticalAlignment = props.toGlanceVerticalAlignment(), + horizontalAlignment = props.toGlanceHorizontalAlignment() + ) { + content() + } +} + +private fun LayoutProps.toGlanceHorizontalAlignment(): Alignment.Horizontal { + horizontalAlignment?.let { + return it.toGlanceHorizontalAlignment() + } + + val arrangement = horizontalArrangement + return if (arrangement?.`is`(HorizontalArrangementDefault::class) == true) { + when (arrangement.first()) { + HorizontalArrangementDefault.START -> Alignment.Start + HorizontalArrangementDefault.CENTER -> Alignment.CenterHorizontally + HorizontalArrangementDefault.END -> Alignment.End + else -> Alignment.Start + } + } else { + Alignment.Start + } +} + +private fun LayoutProps.toGlanceVerticalAlignment(): Alignment.Vertical { + verticalAlignment?.let { + return it.toGlanceVerticalAlignment() + } + + val arrangement = verticalArrangement + return if (arrangement?.`is`(VerticalArrangementDefault::class) == true) { + when (arrangement.first()) { + VerticalArrangementDefault.TOP -> Alignment.Top + VerticalArrangementDefault.CENTER -> Alignment.CenterVertically + VerticalArrangementDefault.BOTTOM -> Alignment.Bottom + else -> Alignment.Top + } + } else { + Alignment.Top + } +} + +private fun HorizontalAlignment.toGlanceHorizontalAlignment(): Alignment.Horizontal { + return when (this) { + HorizontalAlignment.START -> Alignment.Start + HorizontalAlignment.CENTER -> Alignment.CenterHorizontally + HorizontalAlignment.END -> Alignment.End + } +} + +private fun VerticalAlignment.toGlanceVerticalAlignment(): Alignment.Vertical { + return when (this) { + VerticalAlignment.TOP -> Alignment.Top + VerticalAlignment.CENTER -> Alignment.CenterVertically + VerticalAlignment.BOTTOM -> Alignment.Bottom + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ProgressView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ProgressView.kt new file mode 100644 index 00000000000000..918c4f8f3ad54f --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/ProgressView.kt @@ -0,0 +1,57 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.LinearProgressIndicator +import androidx.glance.appwidget.ProgressIndicatorDefaults +import expo.modules.ui.CircularProgressIndicatorProps +import expo.modules.ui.LinearProgressIndicatorProps +import expo.modules.ui.LoadingIndicatorProps +import expo.modules.ui.colorToComposeColorOrNull + +@Composable +fun LinearProgressIndicatorView(props: LinearProgressIndicatorProps) { + val modifier = props.modifiers.toGlanceModifier() + val color = colorToComposeColorOrNull(props.color) + ?.toGlanceColorProvider() + ?: ProgressIndicatorDefaults.IndicatorColorProvider + val trackColor = colorToComposeColorOrNull(props.trackColor) + ?.toGlanceColorProvider() + ?: ProgressIndicatorDefaults.BackgroundColorProvider + + val progress = props.progress + if (progress != null) { + LinearProgressIndicator( + progress = progress, + modifier = modifier, + color = color, + backgroundColor = trackColor + ) + } else { + LinearProgressIndicator( + modifier = modifier, + color = color, + backgroundColor = trackColor + ) + } +} + +@Composable +fun CircularProgressIndicatorView(props: CircularProgressIndicatorProps) { + CircularProgressIndicator( + modifier = props.modifiers.toGlanceModifier(), + color = colorToComposeColorOrNull(props.color) + ?.toGlanceColorProvider() + ?: ProgressIndicatorDefaults.IndicatorColorProvider + ) +} + +@Composable +fun LoadingIndicatorView(props: LoadingIndicatorProps) { + CircularProgressIndicator( + modifier = props.modifiers.toGlanceModifier(), + color = colorToComposeColorOrNull(props.color) + ?.toGlanceColorProvider() + ?: ProgressIndicatorDefaults.IndicatorColorProvider + ) +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RadioButtonView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RadioButtonView.kt new file mode 100644 index 00000000000000..4a84d967b983ce --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RadioButtonView.kt @@ -0,0 +1,15 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.appwidget.RadioButton +import expo.modules.ui.RadioButtonProps + +@Composable +fun RadioButtonView(props: RadioButtonProps) { + RadioButton( + checked = props.selected, + onClick = null, + modifier = props.modifiers.toGlanceModifier(), + enabled = props.clickable + ) +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RowView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RowView.kt new file mode 100644 index 00000000000000..c239003f2c9eb8 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/RowView.kt @@ -0,0 +1,76 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import expo.modules.ui.LayoutProps +import expo.modules.ui.convertibles.HorizontalAlignment +import expo.modules.ui.convertibles.HorizontalArrangementDefault +import expo.modules.ui.convertibles.VerticalAlignment +import expo.modules.ui.convertibles.VerticalArrangementDefault + +@Composable +fun RowView( + props: LayoutProps, + content: @Composable () -> Unit = {} +) { + Row( + modifier = props.modifiers.toGlanceModifier(), + horizontalAlignment = props.toGlanceHorizontalAlignment(), + verticalAlignment = props.toGlanceVerticalAlignment() + ) { + content() + } +} + +private fun LayoutProps.toGlanceHorizontalAlignment(): Alignment.Horizontal { + horizontalAlignment?.let { + return it.toGlanceHorizontalAlignment() + } + + val arrangement = horizontalArrangement + return if (arrangement?.`is`(HorizontalArrangementDefault::class) == true) { + when (arrangement.first()) { + HorizontalArrangementDefault.START -> Alignment.Start + HorizontalArrangementDefault.CENTER -> Alignment.CenterHorizontally + HorizontalArrangementDefault.END -> Alignment.End + else -> Alignment.Start + } + } else { + Alignment.Start + } +} + +private fun LayoutProps.toGlanceVerticalAlignment(): Alignment.Vertical { + verticalAlignment?.let { + return it.toGlanceVerticalAlignment() + } + + val arrangement = verticalArrangement + return if (arrangement?.`is`(VerticalArrangementDefault::class) == true) { + when (arrangement.first()) { + VerticalArrangementDefault.TOP -> Alignment.Top + VerticalArrangementDefault.CENTER -> Alignment.CenterVertically + VerticalArrangementDefault.BOTTOM -> Alignment.Bottom + else -> Alignment.Top + } + } else { + Alignment.Top + } +} + +private fun HorizontalAlignment.toGlanceHorizontalAlignment(): Alignment.Horizontal { + return when (this) { + HorizontalAlignment.START -> Alignment.Start + HorizontalAlignment.CENTER -> Alignment.CenterHorizontally + HorizontalAlignment.END -> Alignment.End + } +} + +private fun VerticalAlignment.toGlanceVerticalAlignment(): Alignment.Vertical { + return when (this) { + VerticalAlignment.TOP -> Alignment.Top + VerticalAlignment.CENTER -> Alignment.CenterVertically + VerticalAlignment.BOTTOM -> Alignment.Bottom + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SpacerView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SpacerView.kt new file mode 100644 index 00000000000000..5c8dc326f1e3e1 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SpacerView.kt @@ -0,0 +1,10 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.layout.Spacer +import expo.modules.ui.SpacerProps + +@Composable +fun SpacerView(props: SpacerProps) { + Spacer(modifier = props.modifiers.toGlanceModifier()) +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SwitchView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SwitchView.kt new file mode 100644 index 00000000000000..358f7e9a260f98 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/SwitchView.kt @@ -0,0 +1,38 @@ +package expo.modules.widgets.ui + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.Switch +import androidx.glance.appwidget.SwitchColors +import androidx.glance.appwidget.SwitchDefaults +import expo.modules.ui.SwitchProps +import expo.modules.ui.colorToComposeColorOrNull + +@Composable +fun SwitchView(props: SwitchProps) { + Switch( + checked = props.value, + onCheckedChange = null, + modifier = props.modifiers.toGlanceModifier(), + colors = props.toGlanceSwitchColors() + ) +} + +@Composable +private fun SwitchProps.toGlanceSwitchColors(): SwitchColors { + val checkedThumbColor = + colorToComposeColorOrNull(colors.checkedThumbColor)?.toGlanceColorProvider() + val uncheckedThumbColor = + colorToComposeColorOrNull(colors.uncheckedThumbColor)?.toGlanceColorProvider() + val checkedTrackColor = + colorToComposeColorOrNull(colors.checkedTrackColor)?.toGlanceColorProvider() + val uncheckedTrackColor = + colorToComposeColorOrNull(colors.uncheckedTrackColor)?.toGlanceColorProvider() + + return SwitchDefaults.colors( + checkedThumbColor = checkedThumbColor ?: GlanceTheme.colors.onPrimary, + uncheckedThumbColor = uncheckedThumbColor ?: GlanceTheme.colors.outline, + checkedTrackColor = checkedTrackColor ?: GlanceTheme.colors.primary, + uncheckedTrackColor = uncheckedTrackColor ?: GlanceTheme.colors.surfaceVariant + ) +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/TextView.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/TextView.kt new file mode 100644 index 00000000000000..871ab0fd736451 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/TextView.kt @@ -0,0 +1,142 @@ +package expo.modules.widgets.ui + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.background +import androidx.glance.text.FontFamily +import androidx.glance.text.FontStyle +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextDecoration +import androidx.glance.text.TextDefaults +import androidx.glance.text.TextStyle +import expo.modules.ui.TextAlignType +import expo.modules.ui.TextDecorationType +import expo.modules.ui.TextFontStyle +import expo.modules.ui.TextFontWeight +import expo.modules.ui.TextProps +import expo.modules.ui.TextSpanRecord +import expo.modules.ui.TypographyStyle +import expo.modules.ui.colorToComposeColorOrNull + +@Composable +fun TextView(props: TextProps) { + Text( + text = props.textContent(), + modifier = props.glanceModifier(), + style = props.glanceTextStyle(), + maxLines = props.maxLines ?: Int.MAX_VALUE + ) +} + +private fun TextProps.textContent(): String { + return spans?.joinToString(separator = "") { it.textContent() } ?: text +} + +private fun TextSpanRecord.textContent(): String { + return children?.joinToString(separator = "") { it.textContent() } ?: text +} + +private fun TextProps.glanceModifier(): GlanceModifier { + val backgroundColor = colorToComposeColorOrNull(background) + val modifier = modifiers.toGlanceModifier() + return if (backgroundColor != null) { + modifier.background(backgroundColor) + } else { + modifier + } +} + +@SuppressLint("RestrictedApi") +private fun TextProps.glanceTextStyle(): TextStyle { + val typography = typography?.glanceTypographyStyle() + + return TextStyle( + color = colorToComposeColorOrNull(color)?.toGlanceColorProvider() + ?: typography?.color + ?: TextDefaults.defaultTextColor, + fontSize = fontSize?.sp ?: typography?.fontSize, + fontWeight = fontWeight?.toGlanceFontWeight() ?: typography?.fontWeight, + fontStyle = fontStyle?.toGlanceFontStyle() ?: typography?.fontStyle, + textAlign = textAlign?.toGlanceTextAlign() ?: typography?.textAlign, + textDecoration = textDecoration?.toGlanceTextDecoration() ?: typography?.textDecoration, + fontFamily = fontFamily.toGlanceFontFamily() ?: typography?.fontFamily + ) +} + +private fun TypographyStyle.glanceTypographyStyle(): TextStyle { + return when (this) { + TypographyStyle.DISPLAY_LARGE -> TextStyle(fontSize = 57.sp) + TypographyStyle.DISPLAY_MEDIUM -> TextStyle(fontSize = 45.sp) + TypographyStyle.DISPLAY_SMALL -> TextStyle(fontSize = 36.sp) + TypographyStyle.HEADLINE_LARGE -> TextStyle(fontSize = 32.sp) + TypographyStyle.HEADLINE_MEDIUM -> TextStyle(fontSize = 28.sp) + TypographyStyle.HEADLINE_SMALL -> TextStyle(fontSize = 24.sp) + TypographyStyle.TITLE_LARGE -> TextStyle(fontSize = 22.sp) + TypographyStyle.TITLE_MEDIUM -> TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Medium) + TypographyStyle.TITLE_SMALL -> TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium) + TypographyStyle.BODY_LARGE -> TextStyle(fontSize = 16.sp) + TypographyStyle.BODY_MEDIUM -> TextStyle(fontSize = 14.sp) + TypographyStyle.BODY_SMALL -> TextStyle(fontSize = 12.sp) + TypographyStyle.LABEL_LARGE -> TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium) + TypographyStyle.LABEL_MEDIUM -> TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium) + TypographyStyle.LABEL_SMALL -> TextStyle(fontSize = 11.sp, fontWeight = FontWeight.Medium) + } +} + +private fun TextFontWeight.toGlanceFontWeight(): FontWeight { + return when (this) { + TextFontWeight.NORMAL, + TextFontWeight.W100, + TextFontWeight.W200, + TextFontWeight.W300, + TextFontWeight.W400 -> FontWeight.Normal + TextFontWeight.W500, + TextFontWeight.W600 -> FontWeight.Medium + TextFontWeight.BOLD, + TextFontWeight.W700, + TextFontWeight.W800, + TextFontWeight.W900 -> FontWeight.Bold + } +} + +private fun TextFontStyle.toGlanceFontStyle(): FontStyle { + return when (this) { + TextFontStyle.NORMAL -> FontStyle.Normal + TextFontStyle.ITALIC -> FontStyle.Italic + } +} + +private fun TextAlignType.toGlanceTextAlign(): TextAlign { + return when (this) { + TextAlignType.LEFT -> TextAlign.Left + TextAlignType.RIGHT -> TextAlign.Right + TextAlignType.CENTER -> TextAlign.Center + TextAlignType.JUSTIFY, + TextAlignType.START -> TextAlign.Start + TextAlignType.END -> TextAlign.End + } +} + +private fun TextDecorationType.toGlanceTextDecoration(): TextDecoration { + return when (this) { + TextDecorationType.NONE -> TextDecoration.None + TextDecorationType.UNDERLINE -> TextDecoration.Underline + TextDecorationType.LINE_THROUGH -> TextDecoration.LineThrough + } +} + +private fun String?.toGlanceFontFamily(): FontFamily? { + return when (this) { + null, + "default" -> null + "sansSerif" -> FontFamily.SansSerif + "serif" -> FontFamily.Serif + "monospace" -> FontFamily.Monospace + "cursive" -> FontFamily.Cursive + else -> FontFamily(this) + } +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetColorProvider.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetColorProvider.kt new file mode 100644 index 00000000000000..f5d7c274a13113 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetColorProvider.kt @@ -0,0 +1,11 @@ +package expo.modules.widgets.ui + +import android.annotation.SuppressLint +import androidx.compose.ui.graphics.Color +import androidx.glance.unit.ColorProvider + +// TODO(@jakex7): Inspect restricted API use +@SuppressLint("RestrictedApi") +internal fun Color.toGlanceColorProvider(): ColorProvider { + return ColorProvider(this) +} diff --git a/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetModifiers.kt b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetModifiers.kt new file mode 100644 index 00000000000000..7fd6d339463855 --- /dev/null +++ b/packages/expo-widgets/android/src/main/java/expo/modules/widgets/ui/WidgetModifiers.kt @@ -0,0 +1,164 @@ +package expo.modules.widgets.ui + +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.background +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.layout.wrapContentHeight +import androidx.glance.layout.wrapContentWidth +import androidx.glance.semantics.semantics +import androidx.glance.semantics.testTag +import expo.modules.kotlin.records.recordFromMap +import expo.modules.ui.BackgroundParams +import expo.modules.ui.DefaultMinSizeParams +import expo.modules.ui.FillMaxHeightParams +import expo.modules.ui.FillMaxSizeParams +import expo.modules.ui.FillMaxWidthParams +import expo.modules.ui.HeightParams +import expo.modules.ui.ModifierList +import expo.modules.ui.ModifierType +import expo.modules.ui.PaddingAllParams +import expo.modules.ui.PaddingParams +import expo.modules.ui.SizeParams +import expo.modules.ui.TestIDParams +import expo.modules.ui.WidthParams +import expo.modules.ui.WrapContentHeightParams +import expo.modules.ui.WrapContentWidthParams +import expo.modules.ui.colorToComposeColorOrNull + +internal typealias WidgetModifierFactory = (ModifierType) -> GlanceModifier + +internal fun ModifierList.toGlanceModifier(): GlanceModifier { + return WidgetModifierRegistry.applyModifiers(this) +} + +internal object WidgetModifierRegistry { + private val modifierFactories: MutableMap = mutableMapOf() + + init { + registerBuiltInModifiers() + } + + fun register( + type: String, + factory: WidgetModifierFactory + ) { + modifierFactories[type] = factory + } + + fun unregister(type: String) { + modifierFactories.remove(type) + } + + fun applyModifiers(modifiers: ModifierList?): GlanceModifier { + if (modifiers.isNullOrEmpty()) { + return GlanceModifier + } + + var result: GlanceModifier = GlanceModifier + for (config in modifiers) { + val type = config["\$type"] as? String ?: continue + val modifier = modifierFactories[type]?.invoke(config) ?: GlanceModifier + result = result.then(modifier) + } + return result + } + + fun hasModifier(type: String): Boolean { + return modifierFactories[type] != null + } + + fun registeredTypes(): List { + return modifierFactories.keys.toList() + } + + private fun registerBuiltInModifiers() { + register("paddingAll") { map -> + val params = recordFromMap(map) + GlanceModifier.padding(params.all.dp) + } + + register("padding") { map -> + val params = recordFromMap(map) + GlanceModifier.padding( + start = params.start.dp, + top = params.top.dp, + end = params.end.dp, + bottom = params.bottom.dp + ) + } + + register("size") { map -> + val params = recordFromMap(map) + GlanceModifier.size(params.width.dp, params.height.dp) + } + + register("width") { map -> + val params = recordFromMap(map) + GlanceModifier.width(params.width.dp) + } + + register("height") { map -> + val params = recordFromMap(map) + GlanceModifier.height(params.height.dp) + } + + register("defaultMinSize") { map -> + val params = recordFromMap(map) + val minWidth = params.minWidth + val minHeight = params.minHeight + when { + minWidth != null && minHeight != null -> GlanceModifier.size(minWidth.dp, minHeight.dp) + minWidth != null -> GlanceModifier.width(minWidth.dp) + minHeight != null -> GlanceModifier.height(minHeight.dp) + else -> GlanceModifier + } + } + + register("wrapContentWidth") { map -> + recordFromMap(map) + GlanceModifier.wrapContentWidth() + } + + register("wrapContentHeight") { map -> + recordFromMap(map) + GlanceModifier.wrapContentHeight() + } + + register("fillMaxSize") { map -> + recordFromMap(map) + GlanceModifier.fillMaxSize() + } + + register("fillMaxWidth") { map -> + recordFromMap(map) + GlanceModifier.fillMaxWidth() + } + + register("fillMaxHeight") { map -> + recordFromMap(map) + GlanceModifier.fillMaxHeight() + } + + register("background") { map -> + val params = recordFromMap(map) + val color = colorToComposeColorOrNull(params.color) ?: return@register GlanceModifier + GlanceModifier.background(color) + } + + register("testID") { map -> + val params = recordFromMap(map) + params.testID?.let { testID -> + GlanceModifier.semantics { + testTag = testID + } + } ?: GlanceModifier + } + } +} diff --git a/packages/expo-widgets/bundle/__tests__/decorator.test.ts b/packages/expo-widgets/bundle/__tests__/decorator.test.ts index 6291f8b816a7fa..562f34f9600570 100644 --- a/packages/expo-widgets/bundle/__tests__/decorator.test.ts +++ b/packages/expo-widgets/bundle/__tests__/decorator.test.ts @@ -24,6 +24,25 @@ describe('jsx-runtime-stub', () => { expect(tree.props.children[1].props.target).toBe('__expo_widgets_target_1'); }); + it('adds targets to material button variants during render', () => { + globalThis.__expoWidgetLayout = () => + jsxs('View', { + children: [ + jsx('FilledTonalButton', { label: 'First', onButtonPress: () => ({ id: 'first' }) }), + jsx('OutlinedButton', { label: 'Second', onButtonPress: () => ({ id: 'second' }) }), + jsx('ElevatedButton', { label: 'Third', onButtonPress: () => ({ id: 'third' }) }), + jsx('TextButton', { label: 'Fourth', onButtonPress: () => ({ id: 'fourth' }) }), + ], + }); + + const tree = globalThis.__expoWidgetRender({}, { timestamp: 1 }) as any; + + expect(tree.props.children[0].props.target).toBe('__expo_widgets_target_0'); + expect(tree.props.children[1].props.target).toBe('__expo_widgets_target_1'); + expect(tree.props.children[2].props.target).toBe('__expo_widgets_target_2'); + expect(tree.props.children[3].props.target).toBe('__expo_widgets_target_3'); + }); + it('uses the nearest keyed parent when generating button targets', () => { globalThis.__expoWidgetLayout = () => jsx( diff --git a/packages/expo-widgets/bundle/decorator.ts b/packages/expo-widgets/bundle/decorator.ts index db02d5e39a1cca..d06d8296aa2561 100644 --- a/packages/expo-widgets/bundle/decorator.ts +++ b/packages/expo-widgets/bundle/decorator.ts @@ -1,12 +1,20 @@ import { ReactElementNode } from './jsx-runtime-stub'; +const interactiveTargetTypes = [ + 'Button', + 'FilledTonalButton', + 'OutlinedButton', + 'ElevatedButton', + 'TextButton', +]; + export function decorateInteractiveTargets(node: unknown) { return decorateNode(node, { nearestParentKey: null, nextTargetIndex: { current: 0, }, - typesToDecorate: ['Button'], + typesToDecorate: interactiveTargetTypes, }); } From 5fc1bd5f6de55a31cf90759c150fd31b7ac4b0c3 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Thu, 18 Jun 2026 16:05:20 +0530 Subject: [PATCH 3/9] [docs] Update section titles in Downloading updates (#47011) --- docs/pages/eas-update/download-updates.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/pages/eas-update/download-updates.mdx b/docs/pages/eas-update/download-updates.mdx index 590590e147e35c..734c7dd634c2a5 100644 --- a/docs/pages/eas-update/download-updates.mdx +++ b/docs/pages/eas-update/download-updates.mdx @@ -27,7 +27,7 @@ You can disable the default behavior by setting the [`checkAutomatically`](/vers -## Checking for updates while the app is running +## Checking for updates while the app is running (foreground) You can use `Updates.checkForUpdateAsync()` to check for updates while the app is running. This will return a promise that resolves to a [`UpdateCheckResult` object](/versions/latest/sdk/updates/#updatecheckresult), with `isAvailable` set to `true` if an update is available, and information about the update in the [`manifest`](/versions/latest/sdk/manifests/#expoupdatesmanifest) property. @@ -40,9 +40,9 @@ If an update is available, you can use the `Updates.fetchUpdateAsync()` method t -## Checking for updates while the app is backgrounded +## Checking for background updates (while the app is backgrounded) -You can use [`expo-background-task`](/versions/latest/sdk/background-task/) to check for updates while the app is backgrounded. To do this, use the same `Updates.checkForUpdateAsync()` and `Updates.fetchUpdateAsync()` methods as you would in the foreground, but execute them inside of a background task. This is a great way to ensure that the user always has the latest version of the app, even if they have not opened the app in a while. +You can download **background updates** by checking for and fetching updates while the app is backgrounded, also known as _background fetch_. You can use [`expo-background-task`](/versions/latest/sdk/background-task/) to run the same `Updates.checkForUpdateAsync()` and `Updates.fetchUpdateAsync()` methods you would in the foreground, but inside a background task. This is a great way to ensure that the user always has the latest version of the app, even if they have not opened the app in a while. It's worth considering whether you want to reload the app after an update is downloaded in the background, or wait for the user to close and reopen it. If you choose to only download it in the background and not apply it, this should still be useful to ensure that the next boot will immediately have the latest version, and it will lead to a faster adoption rate for updates compared to the default behavior. From 97b82145c619a974c938c66dc89443038f23e813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 18 Jun 2026 16:45:38 +0530 Subject: [PATCH 4/9] [dom-webview][ios] Fix DOM component props containing double quotes (#47019) Fixes #47016 # Why? Here's a minimal repro to test it in browser console. ``` https://github.com/expo/expo/blob/9be13fd21712607e1d6b6031c4df7b2dbc258952/packages/%40expo/dom-webview/src/DomWebView.tsx#L44 a = JSON.stringify({ font: '"Inter"' }); // https://github.com/expo/expo/blob/9be13fd21712607e1d6b6031c4df7b2dbc258952/packages/%40expo/dom-webview/ios/DomWebView.swift#L228 b = new Function('return `' + source + '`;'); // https://github.com/expo/expo/blob/9be13fd21712607e1d6b6031c4df7b2dbc258952/packages/%40expo/cli/src/start/server/middleware/DomComponentsMiddleware.ts#L122 JSON.parse(b()) // throws error ``` ## How? Add `JSON.stringify` to function returned object so it stays compatible with `JSON.parse`. ## Test Plan Tested the original user repro. --- packages/@expo/dom-webview/ios/DomWebView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@expo/dom-webview/ios/DomWebView.swift b/packages/@expo/dom-webview/ios/DomWebView.swift index ea7b144c772324..d18e5cbc44dc35 100644 --- a/packages/@expo/dom-webview/ios/DomWebView.swift +++ b/packages/@expo/dom-webview/ios/DomWebView.swift @@ -225,7 +225,7 @@ internal final class DomWebView: ExpoView, UIScrollViewDelegate, WKUIDelegate, W let script = """ window.ReactNativeWebView = window.ReactNativeWebView || {}; window.ReactNativeWebView.injectedObjectJson = function () { - return `\(source)`; + return JSON.stringify(\(source)); } true; """ From 20926d58d3c84d0d353616ea651ebb38f1913c4c Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:55:36 +0200 Subject: [PATCH 5/9] [observe-tester] test app crash during lunch (#47002) # Why To test crashes which happen before JS had time to load. # How Add config plugin which produces native crashes during init # Test Plan # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- apps/observe-tester/app.json | 1 + .../app/(tabs)/(sessions)/_layout.tsx | 1 + .../app/(tabs)/(sessions)/sessions/index.tsx | 97 ++++++++++++++++++- .../(sessions)/sessions/orphaned/[index].tsx | 88 +++++++++++++++++ .../plugins/withCrashOnLaunch.js | 58 +++++++++++ 5 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx create mode 100644 apps/observe-tester/plugins/withCrashOnLaunch.js diff --git a/apps/observe-tester/app.json b/apps/observe-tester/app.json index 7950813025d830..c6f28ef08ff988 100644 --- a/apps/observe-tester/app.json +++ b/apps/observe-tester/app.json @@ -45,6 +45,7 @@ } } ], + ["./plugins/withCrashOnLaunch", { "android": false, "ios": false }], [ "expo-build-properties", { diff --git a/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx index 57c1a4c4ab88b9..7ed4d3b2f082cd 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/_layout.tsx @@ -14,6 +14,7 @@ export default function SessionsLayout() { + ); } diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx index 5abc481f785348..61a36d12a9d21e 100644 --- a/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/index.tsx @@ -1,4 +1,9 @@ -import AppMetrics, { type DebugSession, type Session, type SessionType } from 'expo-app-metrics'; +import AppMetrics, { + type CrashReport, + type DebugSession, + type Session, + type SessionType, +} from 'expo-app-metrics'; import { useObserve } from 'expo-observe'; import { type Href, router, Stack, useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; @@ -18,6 +23,7 @@ import { useTheme } from '@/utils/theme'; // A row's worth of session data, normalized from a `Session` record — the live // main session or an inactive one. type SessionRowData = { + kind: 'session'; id: string; type: SessionType; startDate: string; @@ -30,7 +36,20 @@ type SessionRowData = { href: Href; }; -type Section = { title: string; data: SessionRowData[] }; +// A startup crash not attributed to any session (from `getOrphanedCrashReports`). +// Keyed by position since crash reports have no id; the detail screen re-fetches +// the same ordered list and looks the report up by index. +type OrphanRowData = { + kind: 'orphan'; + index: number; + summary: string; + timestamp: string; + href: Href; +}; + +type RowData = SessionRowData | OrphanRowData; + +type Section = { title: string; data: RowData[] }; export default function SessionsList() { const theme = useTheme(); @@ -59,9 +78,14 @@ export default function SessionsList() { .map(inactiveSessionToRow) .sort((a, b) => (a.startDate < b.startDate ? 1 : -1)); + // Startup crashes that predate any session — Android only, hence the optional call. + const orphanReports = (await AppMetrics.getOrphanedCrashReports?.()) ?? []; + const orphans: OrphanRowData[] = orphanReports.map(orphanCrashToRow); + setSections([ ...(active.length ? [{ title: 'Active', data: active }] : []), ...(inactive.length ? [{ title: 'Inactive', data: inactive }] : []), + ...(orphans.length ? [{ title: 'Startup crashes', data: orphans }] : []), ]); setLoaded(true); }, []); @@ -101,7 +125,7 @@ export default function SessionsList() { style={[styles.container, { backgroundColor: theme.background.screen }]} contentContainerStyle={styles.contentContainer} sections={sections} - keyExtractor={(session) => session.id} + keyExtractor={(item) => (item.kind === 'orphan' ? `orphan-${item.index}` : item.id)} stickySectionHeadersEnabled={false} refreshControl={ Platform.OS === 'ios' ? ( @@ -137,7 +161,9 @@ export default function SessionsList() { {section.title} )} - renderItem={({ item }) => } + renderItem={({ item }) => + item.kind === 'orphan' ? : + } /> ); @@ -145,6 +171,7 @@ export default function SessionsList() { async function liveSessionToRow(session: Session): Promise { return { + kind: 'session', id: session.id, type: session.type, startDate: session.startDate, @@ -158,6 +185,7 @@ async function liveSessionToRow(session: Session): Promise { function inactiveSessionToRow(session: DebugSession): SessionRowData { return { + kind: 'session', id: session.id, type: session.type, startDate: session.startDate, @@ -169,6 +197,29 @@ function inactiveSessionToRow(session: DebugSession): SessionRowData { }; } +function orphanCrashToRow(report: CrashReport, index: number): OrphanRowData { + return { + kind: 'orphan', + index, + summary: crashSummary(report), + timestamp: report.timestampBegin, + href: `/sessions/orphaned/${index}`, + }; +} + +// A short one-line description of a crash. Android JVM crashes carry the throwable +// message as a plain `exceptionReason` string; native crashes fall back to the signal. +function crashSummary(report: CrashReport): string { + const reason = report.exceptionReason; + if (typeof reason === 'string' && reason.trim()) { + return reason.split('\n')[0]; + } + if (reason && typeof reason === 'object') { + return reason.composedMessage || reason.exceptionName; + } + return report.terminationReason ?? 'Native crash'; +} + function SessionRow({ session }: { session: SessionRowData }) { const theme = useTheme(); const { metricCount, isActive } = session; @@ -224,6 +275,44 @@ function SessionRow({ session }: { session: SessionRowData }) { ); } +function OrphanRow({ row }: { row: OrphanRowData }) { + const theme = useTheme(); + return ( + router.push(row.href)} + style={({ pressed }) => [ + styles.row, + { + backgroundColor: theme.background.element, + borderColor: theme.border.default, + borderLeftWidth: 3, + borderLeftColor: theme.icon.danger, + }, + pressed && styles.rowPressed, + ]}> + + + {formatDate(new Date(row.timestamp))} + + + + Crashed + + + + + {row.summary} + + + ); +} + const dateFormatter = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: '2-digit', diff --git a/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx b/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx new file mode 100644 index 00000000000000..2bb920050dd5bb --- /dev/null +++ b/apps/observe-tester/app/(tabs)/(sessions)/sessions/orphaned/[index].tsx @@ -0,0 +1,88 @@ +import AppMetrics, { type CrashReport } from 'expo-app-metrics'; +import { useObserve } from 'expo-observe'; +import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import { Platform, ScrollView, StyleSheet, Text } from 'react-native'; + +import { CallStackTreeView } from '@/components/CallStackTreeView'; +import { CrashReportPanel } from '@/components/CrashReportPanel'; +import { Divider } from '@/components/Divider'; +import { useTheme } from '@/utils/theme'; + +// Detail for a startup crash that isn't attributed to any session. The list passes +// the report's position in `getOrphanedCrashReports`; we re-fetch that ordered list +// and look it up by index, since crash reports have no stable id. The index is only +// stable within a launch, but orphaned crashes are only produced at startup — never +// while the app is foregrounded — so the list can't shift under an open detail screen. +export default function OrphanedCrashScreen() { + const theme = useTheme(); + const { index } = useLocalSearchParams<{ index: string }>(); + const [report, setReport] = useState(null); + const [loaded, setLoaded] = useState(false); + + useFocusEffect( + useCallback(() => { + let cancelled = false; + AppMetrics.getOrphanedCrashReports?.().then((reports) => { + if (cancelled) return; + setReport(reports[Number(index)] ?? null); + setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [index]) + ); + + const { markInteractive } = useObserve(); + useEffect(() => { + setTimeout(() => { + markInteractive(); + }, 100); + }, []); + + return ( + + + {!loaded ? null : report ? ( + <> + Crash report + + {report.callStackTree ? ( + <> + + Call stacks + + + ) : null} + + ) : ( + Crash report not found + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + contentContainer: { + paddingBottom: Platform.select({ ios: 30, android: 150 }), + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 12, + }, + divider: { + marginVertical: 16, + }, + notFound: { + fontSize: 16, + fontWeight: 'bold', + textAlign: 'center', + }, +}); diff --git a/apps/observe-tester/plugins/withCrashOnLaunch.js b/apps/observe-tester/plugins/withCrashOnLaunch.js new file mode 100644 index 00000000000000..8a671f0f201871 --- /dev/null +++ b/apps/observe-tester/plugins/withCrashOnLaunch.js @@ -0,0 +1,58 @@ +// Test-only: randomly crashes (native / runtime / not at all) at app startup to +// exercise crash reporting. Off by default — enable per platform via app.json props: +// ["./plugins/withCrashOnLaunch", { "android": true, "ios": true }] +const { withMainApplication, withAppDelegate, CodeGenerator } = require('expo/config-plugins'); + +// SIGSEGV isn't stored as a report; the JVM throw is. Throwing here is safe — onCreate +// isn't wrapped by expo-modules-core's exception decorator. +const ANDROID_CRASH_BLOCK = ` when ((0..5).random()) { + 0 -> android.system.Os.kill(android.os.Process.myPid(), android.system.OsConstants.SIGSEGV) + 1 -> throw RuntimeException("Intentional crash-on-launch test (JVM)") + else -> { /* no crash */ } + }`; + +// MetricKit captures both outcomes and delivers them on the next launch. +const IOS_CRASH_BLOCK = ` switch Int.random(in: 0...5) { + case 0: + // EXC_BAD_ACCESS — bogus pointer deref. + _ = UnsafePointer(bitPattern: 0x1)!.pointee + case 1: + // Uncaught NSException — sets exceptionReason. + NSException(name: .genericException, reason: "Intentional crash-on-launch test (iOS)", userInfo: nil).raise() + default: + break // no crash + }`; + +const withCrashOnLaunch = (config, { android = false, ios = false } = {}) => { + if (android) { + config = withMainApplication(config, (config) => { + config.modResults.contents = CodeGenerator.mergeContents({ + src: config.modResults.contents, + newSrc: ANDROID_CRASH_BLOCK, + tag: 'crash-on-launch', + anchor: /ApplicationLifecycleDispatcher\.onApplicationCreate\(this\)/, + offset: 1, + comment: ' //', + }).contents; + return config; + }); + } + + if (ios) { + config = withAppDelegate(config, (config) => { + config.modResults.contents = CodeGenerator.mergeContents({ + src: config.modResults.contents, + newSrc: IOS_CRASH_BLOCK, + tag: 'crash-on-launch', + anchor: /\)\s*->\s*Bool\s*\{/, + offset: 1, + comment: ' //', + }).contents; + return config; + }); + } + + return config; +}; + +module.exports = withCrashOnLaunch; From da51211a383149ddfcc023b852907ddd5cedcca9 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Thu, 18 Jun 2026 15:07:40 +0200 Subject: [PATCH 6/9] [app-metrics] Record unhandled JS errors as OpenTelemetry exception events (#46923) --- .../components/CrashReportsSection.tsx | 18 ++- apps/test-suite/tests/AppMetrics.ts | 86 +++++++++++++++ packages/expo-app-metrics/CHANGELOG.md | 1 + .../expo-app-metrics/android/build.gradle | 2 + .../modules/appmetrics/AppMetricsModule.kt | 34 ++++++ .../appmetrics/jserrors/ErrorReport.kt | 103 ++++++++++++++++++ .../appmetrics/jserrors/PendingErrorStore.kt | 90 +++++++++++++++ .../jserrors/PendingErrorStoreTest.kt | 76 +++++++++++++ .../expo-app-metrics/ios/AppMetrics.swift | 22 ++++ .../ios/AppMetricsModule.swift | 17 +++ .../ios/CrashReporting/CrashReport.swift | 4 + .../ios/LogEvents/ErrorReport.swift | 89 +++++++++++++++ .../ios/LogEvents/PendingErrorStore.swift | 103 ++++++++++++++++++ .../ios/Sessions/MainSession.swift | 3 + .../ios/Tests/PendingErrorStoreTests.swift | 83 ++++++++++++++ packages/expo-app-metrics/src/index.ts | 6 + .../src/installErrorHandler.ts | 44 ++++++++ packages/expo-app-metrics/src/module.web.ts | 1 + packages/expo-app-metrics/src/types.ts | 15 +++ pnpm-lock.yaml | 2 + 20 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/jserrors/ErrorReport.kt create mode 100644 packages/expo-app-metrics/android/src/main/java/expo/modules/appmetrics/jserrors/PendingErrorStore.kt create mode 100644 packages/expo-app-metrics/android/src/test/java/expo/modules/appmetrics/jserrors/PendingErrorStoreTest.kt create mode 100644 packages/expo-app-metrics/ios/LogEvents/ErrorReport.swift create mode 100644 packages/expo-app-metrics/ios/LogEvents/PendingErrorStore.swift create mode 100644 packages/expo-app-metrics/ios/Tests/PendingErrorStoreTests.swift create mode 100644 packages/expo-app-metrics/src/installErrorHandler.ts diff --git a/apps/observe-tester/components/CrashReportsSection.tsx b/apps/observe-tester/components/CrashReportsSection.tsx index 70f5dcdd7bfb15..4e51ace71f58d1 100644 --- a/apps/observe-tester/components/CrashReportsSection.tsx +++ b/apps/observe-tester/components/CrashReportsSection.tsx @@ -14,6 +14,15 @@ const CRASH_TRIGGERS: { kind: CrashKind; title: string; description: string }[] { kind: 'stackOverflow', title: 'Stack overflow', description: 'Unbounded recursion' }, ]; +// Throws from a timer callback so the error is genuinely uncaught: it unwinds to React Native's +// global error handler (where expo-app-metrics' handler is chained) instead of being swallowed by +// the press handler or a React error boundary. +function triggerUncaughtError() { + setTimeout(() => { + throw new Error('Intentional uncaught JS error from observe-tester'); + }, 0); +} + export function CrashReportsSection() { const theme = useTheme(); @@ -25,8 +34,15 @@ export function CrashReportsSection() { <> Crash reports - Trigger real crashes to produce crash diagnostics. + Trigger real crashes to produce crash diagnostics, or throw an uncaught JS error to exercise + the JavaScript error handler. +