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
1 change: 1 addition & 0 deletions .github/VOUCHED.td
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ github:mackinleysmith
github:maria-rcks
github:MatthewFeroz
github:maxwellyoung
github:mweinbach
github:mwolson
github:myacoub91
github:naMqe-h
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,29 @@ jobs:
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait

# @expo/fingerprint swallows autolinking errors and returns no Expo module
# sources, so native changes stop moving the hash and OTAs reach binaries
# that lack them. Refuse to publish any OTA from such a fingerprint.
- name: Verify fingerprints hash Expo native modules
if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update')
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
PLATFORM: ${{ inputs.platform || 'all' }}
run: |
if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi
failed=0
for platform in $platforms; do
# eas-cli prints an environment-loaded notice to stdout before the
# JSON even with --json, so discard everything before the document.
expo_modules="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq --arg reason "expoAutolinking${platform^}" '[.sources[] | select((.reasons // []) | index($reason))] | length')"
if [ "${expo_modules:-0}" -eq 0 ]; then
failed=1
echo ":x: $platform: the native fingerprint hashes no Expo native modules — OTAs and builds skipped" >> "$GITHUB_STEP_SUMMARY"
fi
done
exit "$failed"

- name: Publish OTA update (manual)
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
working-directory: apps/mobile
Expand Down
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ dependencies {
implementation project(':expo-modules-core')
implementation 'androidx.compose.ui:ui-android:1.10.6'
implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17'
implementation 'androidx.window:window:1.5.1'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package expo.modules.t3nativecontrols

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

class T3FoldPostureModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3FoldPosture")

View(T3FoldPostureView::class) {
Events("onHingeChange")
}
}
}

/**
* Reports a vertical hinge, in this view's dp coordinates, while the device is bent like a book.
* The hinge sensor is only registered while a vertical fold is on screen.
*/
class T3FoldPostureView(
context: Context,
appContext: AppContext
) : ExpoView(context, appContext), SensorEventListener {
private val onHingeChange by EventDispatcher()
private val sensorManager = context.getSystemService(SensorManager::class.java)
private val hingeSensor =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
} else {
null
}
private var scope: CoroutineScope? = null
private var fold: FoldingFeature? = null
private var listening = false
private var bent = false
private var reported: Map<String, Any>? = null

override fun onAttachedToWindow() {
super.onAttachedToWindow()
observeFold()
}

// The activity can be missing when the view first attaches, so layout passes retry.
private fun observeFold() {
if (scope != null || !isAttachedToWindow) return
val activity = appContext.currentActivity ?: return
scope = MainScope().also { scope ->
scope.launch {
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull {
it.orientation == FoldingFeature.Orientation.VERTICAL
}
listen(fold != null)
report()
}
}
}
}

override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
listen(false)
super.onDetachedFromWindow()
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
observeFold()
if (changed) report()
}

override fun onSensorChanged(event: SensorEvent) {
// Pixel foldables report the hinge in 5° steps and rest at 175° when nearly flat. Snapping at
// 170° and releasing at 175° keeps a resting hinge from flapping the layout.
val angle = event.values[0]
bent = if (bent) angle < 175f else angle <= 170f
report()
}

override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit

private fun listen(enabled: Boolean) {
if (enabled == listening || hingeSensor == null) return
listening = enabled
if (enabled) {
sensorManager.registerListener(this, hingeSensor, SensorManager.SENSOR_DELAY_UI)
} else {
sensorManager.unregisterListener(this)
bent = false
}
}

private fun report() {
val feature = fold
// Without a hinge sensor, the platform's half-opened posture is the closest signal.
val aligned = feature != null &&
if (hingeSensor != null) bent else feature.state == FoldingFeature.State.HALF_OPENED
val hinge = if (aligned && feature != null) {
val origin = IntArray(2).also { getLocationInWindow(it) }
val density = resources.displayMetrics.density
mapOf(
"x" to (feature.bounds.left - origin[0]) / density,
"windowX" to feature.bounds.left / density,
"width" to feature.bounds.width() / density
)
} else {
emptyMap()
}
if (hinge == reported) return
reported = hinge
onHingeChange(hinge)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"modules": [
"expo.modules.t3nativecontrols.T3NativeControlsModule",
"expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule",
"expo.modules.t3nativecontrols.T3FoldPostureModule"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ public final class T3NativeControlsModule: Module {
}
return ["supportsWorkspaceColumns": false]
}
View(T3SegmentedControlView.self) {
ViewName("SegmentedControl")
Events("onSelectionChange")
Prop("labels") { (view: T3SegmentedControlView, value: [String]) in view.labels = value }
Prop("selectedIndex") { (view: T3SegmentedControlView, value: Int) in view.selectedIndex = value }
Prop("trackColor") { (view: T3SegmentedControlView, value: UIColor) in view.trackColor = value }
Prop("selectedColor") { (view: T3SegmentedControlView, value: UIColor) in view.selectedColor = value }
Prop("textColor") { (view: T3SegmentedControlView, value: UIColor) in view.textColor = value }
Prop("mutedColor") { (view: T3SegmentedControlView, value: UIColor) in view.mutedColor = value }
Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value }
}

View(T3LayoutMetricsView.self) {
ViewName("LayoutMetrics")
Events("onMetricsChange")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import ExpoModulesCore
import UIKit

final class T3SegmentedControlView: ExpoView {
let onSelectionChange = EventDispatcher()
private let control = UISegmentedControl()
var labels: [String] = [] {
didSet {
guard labels != oldValue else { return }
control.removeAllSegments()
for (index, label) in labels.enumerated() {
control.insertSegment(withTitle: label, at: index, animated: false)
}
updateSelection()
}
}
var selectedIndex = -1 { didSet { updateSelection() } }
var trackColor: UIColor = .secondarySystemBackground { didSet { control.backgroundColor = trackColor } }
var selectedColor: UIColor = .secondarySystemFill { didSet { control.selectedSegmentTintColor = selectedColor } }
var textColor: UIColor = .label { didSet { updateColors() } }
var mutedColor: UIColor = .secondaryLabel { didSet { updateColors() } }
var appearance = "light" {
didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light }
}

required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
addSubview(control)
control.addTarget(self, action: #selector(selectionChanged), for: .valueChanged)
}

override func layoutSubviews() {
super.layoutSubviews()
control.frame = bounds
}

private func updateSelection() {
control.selectedSegmentIndex = labels.indices.contains(selectedIndex) ? selectedIndex : UISegmentedControl.noSegment
}

private func updateColors() {
control.setTitleTextAttributes([.foregroundColor: mutedColor], for: .normal)
control.setTitleTextAttributes([.foregroundColor: textColor], for: .selected)
}

@objc private func selectionChanged() {
onSelectionChange(["index": control.selectedSegmentIndex])
}
}
32 changes: 16 additions & 16 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@
},
"dependencies": {
"@clerk/expo": "catalog:",
"@clerk/expo-native-components": "0.1.1",
"@clerk/expo-native-components": "0.2.0",
"@effect/atom-react": "catalog:",
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~58.0.14",
"@expo/ui": "58.0.18",
"@expo/ui": "58.0.20",
"@legendapp/list": "catalog:",
"@material/material-color-utilities": "0.3.0",
"@noble/curves": "catalog:",
Expand Down Expand Up @@ -82,46 +82,46 @@
"clsx": "^2.1.1",
"diff": "8.0.3",
"effect": "catalog:",
"expo": "58.0.11",
"expo": "58.0.14",
"expo-asset": "~58.0.16",
"expo-audio": "58.0.9",
"expo-auth-session": "~58.0.12",
"expo-blur": "58.0.3",
"expo-build-properties": "~58.0.10",
"expo-auth-session": "~58.0.13",
"expo-blur": "58.0.4",
"expo-build-properties": "~58.0.11",
"expo-camera": "~58.0.10",
"expo-clipboard": "~58.0.3",
"expo-constants": "~58.0.12",
"expo-crypto": "~58.0.6",
"expo-dev-client": "~58.0.13",
"expo-crypto": "~58.0.7",
"expo-dev-client": "~58.0.14",
"expo-device": "~58.0.5",
"expo-document-picker": "~58.0.4",
"expo-file-system": "~58.0.9",
"expo-font": "~58.0.8",
"expo-glass-effect": "58.0.3",
"expo-haptics": "~58.0.6",
"expo-glass-effect": "58.0.4",
"expo-haptics": "~58.0.7",
"expo-image": "~58.0.15",
"expo-image-manipulator": "~58.0.14",
"expo-image-picker": "~58.0.10",
"expo-keep-awake": "~58.0.3",
"expo-linking": "~58.0.13",
"expo-network": "~58.0.3",
"expo-notifications": "~58.1.4",
"expo-notifications": "~58.1.5",
"expo-paste-input": "^0.1.15",
"expo-quick-actions": "^6.0.2",
"expo-secure-store": "~58.0.2",
"expo-sensors": "~58.0.4",
"expo-sharing": "58.0.18",
"expo-splash-screen": "~58.0.9",
"expo-sharing": "58.0.19",
"expo-splash-screen": "~58.0.10",
"expo-sqlite": "~58.0.13",
"expo-symbols": "~58.0.4",
"expo-updates": "~58.0.17",
"expo-video": "~58.0.11",
"expo-web-browser": "~58.0.6",
"expo-widgets": "58.0.18",
"expo-widgets": "58.0.20",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-native": "0.88.0",
"react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.13.tgz",
"react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.15.tgz",
"react-native-gesture-handler": "~3.3.0",
"react-native-image-viewing": "^0.2.2",
"react-native-incall-manager": "4.3.0",
Expand All @@ -147,7 +147,7 @@
"@types/mdast": "^4.0.4",
"@types/react": "~19.3.0",
"@types/react-dom": "~19.2.3",
"babel-preset-expo": "~58.0.15",
"babel-preset-expo": "~58.0.17",
"jsdom": "^30.0.1",
"tailwindcss": "^4.0.0",
"typescript": "catalog:"
Expand Down
Loading