Skip to content
Merged
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ jobs:
run: |
yarn example build:android -PreactNativeArchitectures=x86_64 --build-cache

- name: Run Android unit tests
if: needs.changes.outputs.native == 'true'
working-directory: example/android
run: ./gradlew :cawrestler_react-native-duo:testDebugUnitTest -PreactNativeArchitectures=x86_64 --build-cache

build-ios:
needs: changes
# The normal macOS image has Xcode 26.x; Duo needs the 27.1 SDK.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

### Added

- `DuoGeometryView` is native on Android foldables: it reports the fold in the view's own coordinates, including for nested and scrolled views, and the system-bar insets that overlap the view.
- Kotlin unit tests for the Android hinge-status, fold-region, inset, and size-class conversions, run in CI's Android job.
- Android Maestro flow and `yarn example e2e:android`, which checks the Hinge lab flat and half-open on a foldable emulator.

### Fixed

- Android no longer logs "Could not find generated setter" for the environment view.
- Android provider `safeAreaInsets` now cover only the system bars that overlap the provider instead of the whole window's.

## 0.1.0 — 2026-10-08

First stable release, published on npm `latest`. Exports labeled stable in [API stability](./docs/API_STABILITY.md) follow semantic versioning; experimental exports may change in minor releases. See the [release notes](./RELEASE_NOTES.md).
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ The camera and navigation visibility policy tests compile the same C++ helpers u
yarn example e2e
```

On Android, boot a foldable emulator (for example the Pixel Fold AVD), install a build of the example, and run `yarn example e2e:android`. The script sets the emulator's hinge angle through `adb emu` and checks the Hinge lab flat and half-open. Kotlin unit tests for the Android conversions run with `./gradlew :cawrestler_react-native-duo:testDebugUnitTest` from `example/android` (CI runs them in the Android job).

The flows are not part of CI yet. Fold and hinge changes cannot be scripted in the simulator, and accessory scenes cannot connect there, so those paths stay on the manual checklist in [the compatibility guide](docs/COMPATIBILITY.md).

## Pull requests and protected main
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function Pane({ title, detail }: { title: string; detail: string }) {
| React Native API | Native API on iOS 27.1+ | Other platforms |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| `DuoProvider` and `useDuo()` | `UIHingeInteraction`, reserved regions, Duo camera discovery, vertical-bar traits | Android foldables: hinge posture/angle and the fold as a reserved region (`isDuo: false`); web: stable fallback object |
| `DuoGeometryView` | Reserved regions and safe area in the measured native view's coordinates | Measured React Native width/height with no Duo regions |
| `DuoGeometryView` | Reserved regions and safe area in the measured native view's coordinates | Android foldables: the fold in the view's coordinates; web: measured width/height |
| `DuoArrangementView` | `UIArrangementViewController` with split and overlay arrangements | Flexbox split or overlay; on Android foldables the split follows the fold |
| `DuoAdaptiveToolbar` | UIKit tab, navigation, and toolbar controllers with adaptive vertical-bar behavior | React Native action and tab bars |
| `DuoNavigationToolbar` | Actions attached to an existing native-stack screen; navigator retains headers, back buttons, and tabs | RN actions without a duplicate header/tab bar |
Expand Down Expand Up @@ -341,7 +341,7 @@ Use `DuoGeometryView` around the surface whose geometry you need. A region measu
</DuoGeometryView>
```

The render function receives `DuoGeometryState`, including `native`, width, height, safe-area insets, and reserved regions. You may pass ordinary children and observe `onGeometryChange` instead. Android and web report measured dimensions with zero safe-area insets and no native reserved regions.
The render function receives `DuoGeometryState`, including `native`, width, height, safe-area insets, and reserved regions. You may pass ordinary children and observe `onGeometryChange` instead. On Android foldables it reports the fold in the view's own coordinates (active while it separates content), plus the system-bar insets that overlap the view. Web reports measured dimensions with zero safe-area insets and no reserved regions.

On iOS, window regions are retained and converted into this view's coordinates, so frames can have negative origins or extend completely outside its bounds. Clip visual overlays to the measured view, or intersect each frame with those bounds before using it to avoid content. Off-bounds results preserve the fold/camera context even when the measured view sits entirely in the other pane.

Expand Down
2 changes: 2 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ dependencies {
// Foldable posture and fold bounds.
implementation "androidx.window:window:1.3.0"
implementation "androidx.window:window-java:1.3.0"

testImplementation "junit:junit:4.13.2"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package com.cawrestler.reactnativeduo
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Rect
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.view.ViewTreeObserver
import androidx.core.util.Consumer
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
Expand All @@ -26,6 +26,8 @@ import org.json.JSONObject

/**
* Reports Android foldable posture in the same environment payload the iOS view emits.
* `DuoProvider` and `DuoGeometryView` both use it; region frames and safe-area insets are
* local to this view, so a nested geometry view sees the fold in its own coordinates.
*
* Jetpack WindowManager supplies the fold's bounds, state, and occlusion; the optional
* hinge-angle sensor (API 30+) supplies the angle. iPhone Duo-specific fields stay at
Expand All @@ -50,12 +52,21 @@ class RNDuoEnvironmentView(context: Context) : ReactViewGroup(context), SensorEv
emitIfChanged()
}

// An ancestor can scroll or move this view without relaying it out, which still changes
// its local region frames. The payload comparison makes these callbacks cheap.
private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { emitIfChanged() }
private val scrollListener = ViewTreeObserver.OnScrollChangedListener { emitIfChanged() }

override fun onAttachedToWindow() {
super.onAttachedToWindow()
viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
viewTreeObserver.addOnScrollChangedListener(scrollListener)
startTracking()
}

override fun onDetachedFromWindow() {
viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
viewTreeObserver.removeOnScrollChangedListener(scrollListener)
stopTracking()
super.onDetachedFromWindow()
}
Expand Down Expand Up @@ -126,60 +137,68 @@ class RNDuoEnvironmentView(context: Context) : ReactViewGroup(context), SensorEv
foldingFeature?.let { feature ->
val active = feature.isSeparating
if (active || includeInactiveRegions) {
val bounds = Rect(feature.bounds).apply { offset(-location[0], -location[1]) }
val kind =
if (feature.occlusionType == FoldingFeature.OcclusionType.FULL) "occlusion" else "division"
val bounds = feature.bounds
val kind = RNDuoFoldGeometry.regionKind(feature.occlusionType == FoldingFeature.OcclusionType.FULL)
regions.put(
JSONObject()
.put("id", "android-fold-$kind")
.put("kind", kind)
.put("frame", rect(bounds, density))
.put("margins", insets(0f, 0f, 0f, 0f))
.put(
"frame",
frame(
RNDuoFoldGeometry.localFrame(
bounds.left, bounds.top, bounds.right, bounds.bottom, location[0], location[1], density
)
)
)
.put("margins", insets(RNDuoFoldGeometry.Insets.ZERO))
.put("isActive", active)
)
}
}

val widthDp = width / density
val heightDp = height / density
val rootView = rootView
val windowWidthDp = (rootView?.width ?: width) / density
val windowHeightDp = (rootView?.height ?: height) / density
val systemInsets =
val root = rootView ?: this
val windowWidthDp = root.width / density.toDouble()
val windowHeightDp = root.height / density.toDouble()
val windowInsets =
ViewCompat.getRootWindowInsets(this)
?.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
val safeArea =
if (systemInsets == null) insets(0f, 0f, 0f, 0f)
else insets(
systemInsets.top / density,
systemInsets.right / density,
systemInsets.bottom / density,
systemInsets.left / density,
?.let { RNDuoFoldGeometry.Insets(it.top.toDouble(), it.right.toDouble(), it.bottom.toDouble(), it.left.toDouble()) }
?: RNDuoFoldGeometry.Insets.ZERO
val rootLocation = IntArray(2).also { root.getLocationInWindow(it) }
val viewSafeArea =
RNDuoFoldGeometry.localInsets(
windowInsets, location[0], location[1], width, height, root.width, root.height, density
)
val windowSafeArea =
RNDuoFoldGeometry.localInsets(
windowInsets, rootLocation[0], rootLocation[1], root.width, root.height, root.width, root.height, density
)

val hasHinge = foldingFeature != null || hingeSensor != null
val angle = hingeAngleDegrees
val foldHalfOpened = foldingFeature?.let { it.state == FoldingFeature.State.HALF_OPENED }
return JSONObject()
.put("supportsDuoApis", false)
.put("isDuo", false)
.put("platform", "android")
.put("horizontalSizeClass", if (windowWidthDp >= 600) "regular" else "compact")
.put("verticalSizeClass", if (windowHeightDp >= 480) "regular" else "compact")
.put("horizontalSizeClass", RNDuoFoldGeometry.horizontalSizeClass(windowWidthDp))
.put("verticalSizeClass", RNDuoFoldGeometry.verticalSizeClass(windowHeightDp))
.put("supportsMultipleWindows", false)
.put(
"geometry",
JSONObject()
.put("native", true)
.put("width", widthDp.toDouble())
.put("height", heightDp.toDouble())
.put("safeAreaInsets", safeArea)
.put("width", width / density.toDouble())
.put("height", height / density.toDouble())
.put("safeAreaInsets", insets(viewSafeArea))
.put("reservedRegions", regions)
)
.put(
"hinge",
JSONObject()
.put("available", hasHinge)
.put("status", hingeStatus(foldingFeature, angle, hasHinge))
.put("status", RNDuoFoldGeometry.hingeStatus(foldHalfOpened, angle, hasHinge))
.put("angleRadians", angle?.let { Math.toRadians(it) } ?: JSONObject.NULL)
.put("angleDegrees", angle ?: JSONObject.NULL)
)
Expand All @@ -189,43 +208,26 @@ class RNDuoEnvironmentView(context: Context) : ReactViewGroup(context), SensorEv
.put(
"window",
JSONObject()
.put("width", windowWidthDp.toDouble())
.put("height", windowHeightDp.toDouble())
.put("width", windowWidthDp)
.put("height", windowHeightDp)
.put("scale", density.toDouble())
.put("safeAreaInsets", safeArea)
.put("safeAreaInsets", insets(windowSafeArea))
)
}

private fun rect(bounds: Rect, density: Float) =
private fun frame(frame: RNDuoFoldGeometry.Frame) =
JSONObject()
.put("x", (bounds.left / density).toDouble())
.put("y", (bounds.top / density).toDouble())
.put("width", (bounds.width() / density).toDouble())
.put("height", (bounds.height() / density).toDouble())
.put("x", frame.x)
.put("y", frame.y)
.put("width", frame.width)
.put("height", frame.height)

private fun insets(top: Float, right: Float, bottom: Float, left: Float) =
private fun insets(insets: RNDuoFoldGeometry.Insets) =
JSONObject()
.put("top", top.toDouble())
.put("right", right.toDouble())
.put("bottom", bottom.toDouble())
.put("left", left.toDouble())

companion object {
/** Maps WindowManager posture, or the hinge angle when no fold is reported, to DuoHingeStatus. */
fun hingeStatus(feature: FoldingFeature?, angleDegrees: Double?, hasHinge: Boolean): String {
if (feature != null) {
return if (feature.state == FoldingFeature.State.HALF_OPENED) "partiallyOpen" else "fullyOpen"
}
if (!hasHinge) return "unavailable"
// A foldable that reports no fold is usually closed and showing its outer display.
return when {
angleDegrees == null -> "unknown"
angleDegrees < 15 -> "closed"
angleDegrees < 165 -> "partiallyOpen"
else -> "fullyOpen"
}
}
}
.put("top", insets.top)
.put("right", insets.right)
.put("bottom", insets.bottom)
.put("left", insets.left)
}

private class EnvironmentChangeEvent(surfaceId: Int, viewId: Int, private val payload: String) :
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.RNDuoEnvironmentViewManagerDelegate
import com.facebook.react.viewmanagers.RNDuoEnvironmentViewManagerInterface

Expand All @@ -19,6 +20,9 @@ class RNDuoEnvironmentViewManager :

override fun createViewInstance(context: ThemedReactContext) = RNDuoEnvironmentView(context)

// The annotation registers the setter with React Native's legacy property updater too, which
// otherwise logs "Could not find generated setter"; Fabric still routes props through the delegate.
@ReactProp(name = "includeInactiveRegions")
override fun setIncludeInactiveRegions(view: RNDuoEnvironmentView, value: Boolean) {
view.includeInactiveRegions = value
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.cawrestler.reactnativeduo

/**
* Pure conversions from Android window state to the package's environment values.
* Kept free of Android framework types so they can be unit tested on the JVM.
*/
object RNDuoFoldGeometry {
data class Frame(val x: Double, val y: Double, val width: Double, val height: Double)

data class Insets(val top: Double, val right: Double, val bottom: Double, val left: Double) {
companion object {
val ZERO = Insets(0.0, 0.0, 0.0, 0.0)
}
}

/**
* Maps WindowManager posture, or the hinge angle when no fold is reported, to DuoHingeStatus.
*
* @param foldHalfOpened `null` when WindowManager reports no folding feature.
*/
fun hingeStatus(foldHalfOpened: Boolean?, angleDegrees: Double?, hasHinge: Boolean): String {
if (foldHalfOpened != null) return if (foldHalfOpened) "partiallyOpen" else "fullyOpen"
if (!hasHinge) return "unavailable"
// A foldable that reports no fold is usually closed and showing its outer display.
return when {
angleDegrees == null -> "unknown"
angleDegrees < 15 -> "closed"
angleDegrees < 165 -> "partiallyOpen"
else -> "fullyOpen"
}
}

/** A fold that hides content is an occlusion region; otherwise it divides content. */
fun regionKind(fullOcclusion: Boolean) = if (fullOcclusion) "occlusion" else "division"

/** Converts window-pixel fold bounds to points in the coordinate space of a view at `viewX`/`viewY`. */
fun localFrame(left: Int, top: Int, right: Int, bottom: Int, viewX: Int, viewY: Int, density: Float) =
Frame(
x = (left - viewX) / density.toDouble(),
y = (top - viewY) / density.toDouble(),
width = (right - left) / density.toDouble(),
height = (bottom - top) / density.toDouble(),
)

/**
* The part of the window's system insets that overlaps a view, in points. A view below the
* status bar gets no top inset; a full-window view gets the full insets.
*/
fun localInsets(
windowInsets: Insets,
viewX: Int,
viewY: Int,
viewWidth: Int,
viewHeight: Int,
windowWidth: Int,
windowHeight: Int,
density: Float,
): Insets {
fun overlap(inset: Double, distanceFromEdge: Int, viewLength: Int) =
(inset - distanceFromEdge).coerceIn(0.0, viewLength.toDouble()) / density
return Insets(
top = overlap(windowInsets.top, viewY, viewHeight),
right = overlap(windowInsets.right, windowWidth - (viewX + viewWidth), viewWidth),
bottom = overlap(windowInsets.bottom, windowHeight - (viewY + viewHeight), viewHeight),
left = overlap(windowInsets.left, viewX, viewWidth),
)
}

/** Width-based size class using the Android window size class breakpoint (600 dp). */
fun horizontalSizeClass(windowWidthDp: Double) = if (windowWidthDp >= 600) "regular" else "compact"

/** Height-based size class using the Android window size class breakpoint (480 dp). */
fun verticalSizeClass(windowHeightDp: Double) = if (windowHeightDp >= 480) "regular" else "compact"
}
Loading
Loading