peachsim 0.1.648 → 0.1.649

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist-lib/agent-daemon-client.cjs +1 -1
  2. package/dist-lib/agent-events.cjs +1 -1
  3. package/dist-lib/agent-identity.cjs +1 -1
  4. package/dist-lib/agent-sessions.cjs +1 -1
  5. package/dist-lib/attached-projects.cjs +1 -1
  6. package/dist-lib/auth/shared-session.cjs +1 -1
  7. package/dist-lib/backend-origin.cjs +1 -1
  8. package/dist-lib/beta.cjs +1 -1
  9. package/dist-lib/beta.mjs +1 -1
  10. package/dist-lib/bridge-constants.cjs +1 -1
  11. package/dist-lib/bridge-contract-input.cjs +1 -1
  12. package/dist-lib/bridge-contract-input.mjs +1 -1
  13. package/dist-lib/bridge-contract.cjs +1 -1
  14. package/dist-lib/bridge-contract.mjs +1 -1
  15. package/dist-lib/capture-contract.cjs +1 -1
  16. package/dist-lib/capture-contract.mjs +1 -1
  17. package/dist-lib/cli-constants.cjs +1 -1
  18. package/dist-lib/cloud-contract.cjs +1 -1
  19. package/dist-lib/cloud-contract.mjs +1 -1
  20. package/dist-lib/cloud-test-args.cjs +1 -1
  21. package/dist-lib/cloud-test-args.mjs +1 -1
  22. package/dist-lib/cloud.cjs +1 -1
  23. package/dist-lib/cloud.mjs +1 -1
  24. package/dist-lib/config.cjs +1 -1
  25. package/dist-lib/detox/index.cjs +1 -1
  26. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  27. package/dist-lib/home-paths.cjs +1 -1
  28. package/dist-lib/host/bridge-host.cjs +1 -1
  29. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  30. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  31. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  32. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  33. package/dist-lib/host/websocket-proxy.cjs +1 -1
  34. package/dist-lib/index.cjs +1 -1
  35. package/dist-lib/jump-to-source-babel.cjs +1 -1
  36. package/dist-lib/jump-to-source-native.cjs +1 -1
  37. package/dist-lib/kotlin-host.cjs +21 -5
  38. package/dist-lib/menu.cjs +1 -1
  39. package/dist-lib/menu.mjs +1 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/native-app-identity.cjs +1 -1
  46. package/dist-lib/native-app-identity.mjs +1 -1
  47. package/dist-lib/profiles.cjs +1 -1
  48. package/dist-lib/public-brand.cjs +1 -1
  49. package/dist-lib/react-native-host-modules.cjs +1 -1
  50. package/dist-lib/react-native-host-modules.mjs +1 -1
  51. package/dist-lib/render-mode.cjs +1 -1
  52. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  53. package/dist-lib/sdk.cjs +1 -1
  54. package/dist-lib/sdk.mjs +1 -1
  55. package/dist-lib/skills.cjs +14 -14
  56. package/dist-lib/swift-compile.cjs +1 -1
  57. package/dist-lib/swift-compile.mjs +1 -1
  58. package/dist-lib/swift-package.cjs +1 -1
  59. package/dist-lib/swift-package.mjs +1 -1
  60. package/dist-lib/swift-submit.cjs +1 -1
  61. package/dist-lib/swift-submit.mjs +1 -1
  62. package/dist-lib/swift.cjs +1 -1
  63. package/dist-lib/vite-swift.cjs +1 -1
  64. package/dist-lib/vite.cjs +1 -1
  65. package/kotlin/ComposeSources/PeachComposeHost.kt +295 -0
  66. package/kotlin/Sources/dev/onejs/one/OneNativeSourceViewDispatch.kt +13 -0
  67. package/kotlin/Sources/org/json/JSON.kt +6 -0
  68. package/kotlin/fixtures/counter/Counter.d.kt.ts +16 -0
  69. package/kotlin/fixtures/counter/Counter.kt +18 -0
  70. package/kotlin/fixtures/counter/CounterApp.native.tsx +34 -0
  71. package/kotlin/fixtures/counter/CounterApp.tsx +3 -0
  72. package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +7 -0
  73. package/kotlin/fixtures/counter/CounterScreen.kt +18 -3
  74. package/kotlin/fixtures/counter/app/_layout.tsx +4 -0
  75. package/kotlin/fixtures/counter/app/index+spa.tsx +1 -0
  76. package/kotlin/fixtures/counter/app/routes.d.ts +18 -0
  77. package/kotlin/fixtures/counter/package.json +7 -0
  78. package/kotlin/fixtures/counter/tsconfig.json +19 -0
  79. package/kotlin/fixtures/counter/vite.config.ts +10 -0
  80. package/kotlin/fixtures/headless-counter/Headless.kt +5 -0
  81. package/kotlin/fixtures/kotlin-app/app/build.gradle.kts +30 -0
  82. package/kotlin/fixtures/kotlin-app/app/src/main/AndroidManifest.xml +13 -0
  83. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/MainActivity.kt +60 -0
  84. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/ProbeApplication.kt +18 -0
  85. package/kotlin/fixtures/kotlin-app/build.gradle.kts +5 -0
  86. package/kotlin/fixtures/kotlin-app/gradle.properties +4 -0
  87. package/kotlin/fixtures/kotlin-app/settings.gradle.kts +4 -0
  88. package/kotlin/fork/NOTICE +7 -5
  89. package/kotlin/fork/README.md +41 -49
  90. package/kotlin/fork/build.sh +3 -2
  91. package/kotlin/fork/compose-worker-globals.patch +22 -11
  92. package/kotlin/fork/fork.json +2 -2
  93. package/kotlin/fork/publish.ts +30 -15
  94. package/kotlin/fork/skiko-worker-clock.patch +38 -0
  95. package/kotlin/toolchain.json +9 -9
  96. package/package.json +1 -1
  97. package/src/kotlin-host.ts +20 -4
@@ -0,0 +1,295 @@
1
+ @file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.ui.InternalComposeUiApi::class, androidx.compose.ui.text.ExperimentalTextApi::class, kotlin.js.ExperimentalJsExport::class, kotlin.js.ExperimentalWasmJsInterop::class)
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.CompositionLocalProvider
5
+ import androidx.compose.runtime.getValue
6
+ import androidx.compose.runtime.mutableStateOf
7
+ import androidx.compose.runtime.remember
8
+ import androidx.compose.runtime.setValue
9
+ import androidx.compose.runtime.snapshots.Snapshot
10
+ import androidx.compose.ui.geometry.Offset
11
+ import androidx.compose.ui.graphics.asComposeCanvas
12
+ import androidx.compose.ui.input.InputMode
13
+ import androidx.compose.ui.input.InputModeManager
14
+ import androidx.compose.ui.input.pointer.PointerEventType
15
+ import androidx.compose.ui.input.pointer.PointerType
16
+ import androidx.compose.ui.platform.FrameRecomposer
17
+ import androidx.compose.ui.platform.LocalFontFamilyResolver
18
+ import androidx.compose.ui.platform.PlatformContext
19
+ import androidx.compose.ui.platform.WindowInfo
20
+ import androidx.compose.ui.platform.registerSkikoComposeImplementation
21
+ import androidx.compose.ui.scene.CanvasLayersComposeScene
22
+ import androidx.compose.ui.scene.hasInvalidations
23
+ import androidx.compose.ui.semantics.SemanticsNode
24
+ import androidx.compose.ui.semantics.SemanticsOwner
25
+ import androidx.compose.ui.semantics.SemanticsProperties
26
+ import androidx.compose.ui.semantics.getOrNull
27
+ import androidx.compose.ui.text.font.FontFamily
28
+ import androidx.compose.ui.text.font.FontStyle
29
+ import androidx.compose.ui.text.font.FontSynthesis
30
+ import androidx.compose.ui.text.font.FontWeight
31
+ import androidx.compose.ui.text.font.PlatformTypefacesLoader
32
+ import androidx.compose.ui.text.font.createPlatformFontFamilyResolver
33
+ import androidx.compose.ui.text.font.platformTypefacesLoader
34
+ import androidx.compose.ui.text.intl.LocaleList
35
+ import androidx.compose.ui.text.intl.setPlatformLocaleProvider
36
+ import androidx.compose.ui.text.platform.Font
37
+ import androidx.compose.ui.unit.Constraints
38
+ import androidx.compose.ui.unit.Density
39
+ import androidx.compose.ui.unit.DpSize
40
+ import androidx.compose.ui.unit.IntSize
41
+ import androidx.compose.ui.unit.LayoutDirection
42
+ import dev.onejs.one.OneNativeSourceViewDispatch
43
+ import kotlin.io.encoding.Base64
44
+ import kotlinx.coroutines.Dispatchers
45
+ import org.jetbrains.skia.BackendRenderTarget
46
+ import org.jetbrains.skia.Bitmap
47
+ import org.jetbrains.skia.Color
48
+ import org.jetbrains.skia.ColorAlphaType
49
+ import org.jetbrains.skia.ColorSpace
50
+ import org.jetbrains.skia.ColorType
51
+ import org.jetbrains.skia.DirectContext
52
+ import org.jetbrains.skia.ImageInfo
53
+ import org.jetbrains.skia.Surface
54
+ import org.jetbrains.skia.SurfaceColorFormat
55
+ import org.jetbrains.skia.SurfaceOrigin
56
+ import org.jetbrains.skia.SurfaceProps
57
+ import org.jetbrains.skiko.OS
58
+ import org.jetbrains.skiko.setHostOsProvider
59
+ import org.json.JSONArray
60
+ import org.json.JSONObject
61
+
62
+ private class PeachComposePlatform(
63
+ val density: Density,
64
+ override val localeList: LocaleList,
65
+ var size: IntSize,
66
+ ) : PlatformContext by PlatformContext.Empty(), PlatformContext.SemanticsOwnerListener {
67
+ val owners = mutableSetOf<SemanticsOwner>()
68
+ override val windowInfo: WindowInfo = object : WindowInfo {
69
+ override val isWindowFocused: Boolean get() = true
70
+ override val containerSize: IntSize get() = size
71
+ override val containerDpSize: DpSize get() = with(density) { DpSize(size.width.toDp(), size.height.toDp()) }
72
+ }
73
+ override val inputModeManager: InputModeManager = object : InputModeManager {
74
+ override var inputMode: InputMode by mutableStateOf(InputMode.Touch)
75
+ override fun requestInputMode(inputMode: InputMode): Boolean {
76
+ this.inputMode = inputMode
77
+ return true
78
+ }
79
+ }
80
+ override val semanticsOwnerListener: PlatformContext.SemanticsOwnerListener get() = this
81
+ override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { owners.add(semanticsOwner) }
82
+ override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { owners.remove(semanticsOwner) }
83
+ override fun onSemanticsChange(semanticsOwner: SemanticsOwner) = Unit
84
+ override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) = Unit
85
+ }
86
+
87
+ // android's default and sans-serif families use the same device font bytes as
88
+ // peach's react native renderer. compose owns shaping and all other families.
89
+ @Composable
90
+ private fun PeachAndroidFonts(roboto: FontFamily, content: @Composable () -> Unit) {
91
+ val parent = LocalFontFamilyResolver.current
92
+ val resolver = remember(parent, roboto) {
93
+ val backend = checkNotNull(parent.platformTypefacesLoader())
94
+ createPlatformFontFamilyResolver(object : PlatformTypefacesLoader by backend {
95
+ override val cacheKey: Any get() = this
96
+ override fun loadPlatformTypes(fontFamily: FontFamily, fontWeight: FontWeight, fontStyle: FontStyle): Any =
97
+ if (fontFamily == FontFamily.Default || fontFamily == FontFamily.SansSerif)
98
+ parent.resolve(roboto, fontWeight, fontStyle, FontSynthesis.All).value
99
+ else backend.loadPlatformTypes(fontFamily, fontWeight, fontStyle)
100
+ })
101
+ }
102
+ CompositionLocalProvider(LocalFontFamilyResolver provides resolver, content = content)
103
+ }
104
+
105
+ private class PeachComposeMount(
106
+ dispatch: OneNativeSourceViewDispatch,
107
+ view: String,
108
+ propsJson: String,
109
+ density: Float,
110
+ fontScale: Float,
111
+ locale: String,
112
+ private val gpu: Boolean,
113
+ private val stencilBits: Int,
114
+ fonts: FontFamily,
115
+ val invalidate: (Boolean) -> Unit,
116
+ ) : AutoCloseable {
117
+ var props by mutableStateOf(propsJson)
118
+ var constraints = Constraints()
119
+ set(value) { if (field != value) { field = value; needsMeasure = true } }
120
+ private val events = mutableListOf<JSONObject>()
121
+ private val platform = PeachComposePlatform(Density(density, fontScale), LocaleList(locale), IntSize.Zero)
122
+ private val recomposer = FrameRecomposer(Dispatchers.Main) { invalidate(false) }
123
+ private var needsMeasure = true
124
+ private var measuredSize = IntSize.Zero
125
+ private val scene = CanvasLayersComposeScene(
126
+ recomposer, platform.density, LayoutDirection.Ltr, null, platform,
127
+ invalidateLayout = { needsMeasure = true; invalidate(true) }, invalidateDraw = { invalidate(false) },
128
+ )
129
+ private var surface: Surface? = null
130
+ private var context: DirectContext? = null
131
+ private var target: BackendRenderTarget? = null
132
+ private var pixels: Bitmap? = null
133
+ private var size = IntSize.Zero
134
+
135
+ init {
136
+ try {
137
+ scene.setContent {
138
+ PeachAndroidFonts(fonts) {
139
+ dispatch.Content(view, props) { name, args ->
140
+ events.add(JSONObject().put("name", name).put("args", args))
141
+ invalidate(false)
142
+ }
143
+ }
144
+ }
145
+ } catch (failure: Throwable) {
146
+ close()
147
+ throw failure
148
+ }
149
+ }
150
+
151
+ fun measure(): IntSize {
152
+ val measured = scene.measureContent(constraints)
153
+ scene.size = measured
154
+ platform.size = measured
155
+ measuredSize = measured
156
+ needsMeasure = false
157
+ return measured
158
+ }
159
+
160
+ fun frame(nanos: Long): String {
161
+ Snapshot.sendApplyNotifications()
162
+ recomposer.performFrame(nanos)
163
+ val measured = if (needsMeasure) measure() else measuredSize
164
+ // a zero-size composition still runs its effects and reports its size;
165
+ // its backing surface uses one pixel until layout gives it real bounds.
166
+ val backing = IntSize(measured.width.coerceAtLeast(1), measured.height.coerceAtLeast(1))
167
+ if (backing != size) {
168
+ releaseSurface()
169
+ size = backing
170
+ platform.size = measured
171
+ scene.size = measured
172
+ if (gpu) {
173
+ context = DirectContext.makeGL()
174
+ target = BackendRenderTarget.makeGL(size.width, size.height, 1, stencilBits, 0, 0x8058)
175
+ surface = checkNotNull(Surface.makeFromBackendRenderTarget(
176
+ checkNotNull(context), checkNotNull(target), SurfaceOrigin.BOTTOM_LEFT,
177
+ SurfaceColorFormat.RGBA_8888, ColorSpace.sRGB, SurfaceProps(),
178
+ )) { "Skiko GPU surface creation failed" }
179
+ } else {
180
+ surface = Surface.makeRasterN32Premul(size.width, size.height)
181
+ pixels = Bitmap().also { check(it.allocPixels(ImageInfo(size.width, size.height, ColorType.RGBA_8888, ColorAlphaType.PREMUL, ColorSpace.sRGB))) }
182
+ }
183
+ }
184
+ scene.measureAndLayout()
185
+ val canvas = checkNotNull(surface).canvas
186
+ canvas.clear(Color.TRANSPARENT)
187
+ scene.draw(canvas.asComposeCanvas())
188
+ val reply = JSONObject().put("width", measured.width).put("height", measured.height)
189
+ .put("pending", recomposer.hasPendingWork() || scene.hasInvalidations())
190
+ .put("events", JSONArray(events))
191
+ events.clear()
192
+ if (gpu) checkNotNull(surface).flushAndSubmit()
193
+ else {
194
+ check(checkNotNull(surface).readPixels(checkNotNull(pixels), 0, 0))
195
+ val map = checkNotNull(checkNotNull(pixels).peekPixels())
196
+ try { reply.put("pointer", map.addr).put("length", size.width * size.height * 4) }
197
+ finally { map.close() }
198
+ }
199
+ return reply.toString()
200
+ }
201
+
202
+ fun pointer(phase: String, x: Float, y: Float, millis: Long) {
203
+ if (phase == "cancel") scene.cancelPointerInput()
204
+ else scene.sendPointerEvent(
205
+ when (phase) {
206
+ "press" -> PointerEventType.Press
207
+ "release" -> PointerEventType.Release
208
+ "move" -> PointerEventType.Move
209
+ else -> error("unknown pointer phase $phase")
210
+ }, Offset(x, y), timeMillis = millis, type = PointerType.Touch,
211
+ )
212
+ invalidate(false)
213
+ }
214
+
215
+ fun semantics(): String {
216
+ val nodes = JSONArray()
217
+ fun collect(node: SemanticsNode) {
218
+ val bounds = node.boundsInRoot
219
+ nodes.put(JSONObject().put("text", node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text })
220
+ .put("tag", node.config.getOrNull(SemanticsProperties.TestTag))
221
+ .put("left", bounds.left).put("top", bounds.top).put("right", bounds.right).put("bottom", bounds.bottom))
222
+ node.children.forEach { collect(it) }
223
+ }
224
+ platform.owners.forEach { collect(it.unmergedRootSemanticsNode) }
225
+ return nodes.toString()
226
+ }
227
+
228
+ private fun releaseSurface() {
229
+ pixels?.close(); pixels = null
230
+ surface?.close(); surface = null
231
+ target?.close(); target = null
232
+ context?.close(); context = null
233
+ }
234
+
235
+ override fun close() {
236
+ try { scene.close() }
237
+ finally {
238
+ try { recomposer.close() }
239
+ finally { releaseSurface() }
240
+ }
241
+ }
242
+ }
243
+
244
+ private val mounts = mutableMapOf<Int, PeachComposeMount>()
245
+ private var nextMount = 1
246
+ private var deviceLocale: String? = null
247
+
248
+ @JsExport
249
+ fun peachViewMount(source: String, view: String, hash: String, props: String,
250
+ density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,
251
+ regular: String, medium: String, bold: String, invalidate: (Boolean) -> Unit): String {
252
+ try {
253
+ registerSkikoComposeImplementation()
254
+ val dispatch = peachViewDispatcher(source)
255
+ if (dispatch.contractHash != hash) return JSONObject().put("ok", false).put("code", "E_NATIVE_SOURCE_REBUILD")
256
+ .put("error", "composable source $source changed; rebuild the app").toString()
257
+ require(backend == "graphite" || backend == "raster")
258
+ if (deviceLocale == null) {
259
+ setHostOsProvider { OS.Android }
260
+ val locales = LocaleList(locale)
261
+ setPlatformLocaleProvider { locales }
262
+ deviceLocale = locale
263
+ }
264
+ require(deviceLocale == locale) { "device locale changes require a new Kotlin process" }
265
+ val fonts = FontFamily(
266
+ Font("Roboto-400", Base64.decode(regular), FontWeight.Normal),
267
+ Font("Roboto-500", Base64.decode(medium), FontWeight.Medium),
268
+ Font("Roboto-700", Base64.decode(bold), FontWeight.Bold),
269
+ )
270
+ val id = nextMount++
271
+ mounts[id] = PeachComposeMount(dispatch, view, props, density, fontScale, locale, backend == "graphite", stencilBits, fonts, invalidate)
272
+ return JSONObject().put("ok", true).put("id", id).toString()
273
+ } catch (error: Throwable) {
274
+ return JSONObject().put("ok", false).put("code", "E_NATIVE_SOURCE").put("error", "$source.$view: ${error.message}").toString()
275
+ }
276
+ }
277
+
278
+ @JsExport fun peachViewUpdate(id: Int, props: String) { checkNotNull(mounts[id]).props = props }
279
+ @JsExport fun peachViewConstraints(id: Int, minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int) {
280
+ checkNotNull(mounts[id]).also {
281
+ it.constraints = Constraints(minWidth, maxWidth, minHeight, maxHeight)
282
+ it.invalidate(false)
283
+ }
284
+ }
285
+ @JsExport fun peachViewFrame(id: Int, millis: Double): String = checkNotNull(mounts[id]).frame((millis * 1_000_000).toLong())
286
+ @JsExport fun peachViewPointer(id: Int, phase: String, x: Float, y: Float, millis: Double) = checkNotNull(mounts[id]).pointer(phase, x, y, millis.toLong())
287
+ @JsExport fun peachViewSemantics(id: Int): String = checkNotNull(mounts[id]).semantics()
288
+ @JsExport fun peachViewDispose(id: Int) { mounts.remove(id)?.close() }
289
+
290
+ @JsExport fun peachViewMeasure(id: Int, minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int): String {
291
+ val mount = checkNotNull(mounts[id])
292
+ mount.constraints = Constraints(minWidth, maxWidth, minHeight, maxHeight)
293
+ val size = mount.measure()
294
+ return JSONObject().put("width", size.width).put("height", size.height).toString()
295
+ }
@@ -0,0 +1,13 @@
1
+ package dev.onejs.one
2
+
3
+ import androidx.compose.runtime.Composable
4
+
5
+ // generated per .kt source that declares public @Composable functions: renders
6
+ // one of them from the host's json props. a callback reports its arguments
7
+ // through emit as a json array.
8
+ interface OneNativeSourceViewDispatch {
9
+ val contractHash: String
10
+
11
+ @Composable
12
+ fun Content(view: String, propsJson: String, emit: (String, String) -> Unit)
13
+ }
@@ -207,6 +207,12 @@ class JSONObject() {
207
207
  return this
208
208
  }
209
209
 
210
+ fun optBoolean(name: String): Boolean = when (val value = opt(name)) {
211
+ is Boolean -> value
212
+ is String -> value.equals("true", ignoreCase = true)
213
+ else -> false
214
+ }
215
+
210
216
  fun keys(): Iterator<String> = fields.keys.iterator()
211
217
 
212
218
  internal fun writeTo(out: StringBuilder) {
@@ -0,0 +1,16 @@
1
+ // generated by one typegen; edits will be replaced
2
+ export declare const Counter: {
3
+ read(): Promise<number>
4
+ increment(): Promise<number>
5
+ reset(): Promise<number>
6
+ delayedRead(ms: number): Promise<number>
7
+ }
8
+
9
+ export declare const CounterEffects: {
10
+ recordLocale(current: string, composition: string): Promise<void>
11
+ locales(): Promise<string[]>
12
+ enter(): Promise<void>
13
+ dispose(): Promise<void>
14
+ cancel(): Promise<void>
15
+ read(): Promise<number[]>
16
+ }
@@ -24,3 +24,21 @@ object Counter : OneModule {
24
24
  return count
25
25
  }
26
26
  }
27
+
28
+ // lifecycle counters let the mount probe observe effect cleanup after removal.
29
+ object CounterEffects : OneModule {
30
+ private var entered = 0
31
+ private var disposed = 0
32
+ private var cancelled = 0
33
+ private var compositionLocale = ""
34
+ private var currentLocale = ""
35
+ internal fun recordLocale(current: String, composition: String) {
36
+ currentLocale = current
37
+ compositionLocale = composition
38
+ }
39
+ fun locales(): List<String> = listOf(currentLocale, compositionLocale)
40
+ fun enter() { entered += 1 }
41
+ fun dispose() { disposed += 1 }
42
+ fun cancel() { cancelled += 1 }
43
+ fun read(): List<Int> = listOf(entered, disposed, cancelled)
44
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Pressable, Text, View } from 'react-native'
3
+ import { CounterEffects } from './Counter.kt'
4
+ import { CounterScreen } from './CounterScreen.kt'
5
+
6
+ export default function Index() {
7
+ const [title, setTitle] = useState('Kotlin counter')
8
+ const [mounted, setMounted] = useState(true)
9
+ const [callback, setCallback] = useState(0)
10
+ const [effects, setEffects] = useState<number[]>([])
11
+ const [locales, setLocales] = useState<string[]>([])
12
+ useEffect(() => {
13
+ if (callback > 0) void CounterEffects.locales().then(setLocales)
14
+ }, [callback])
15
+ useEffect(() => {
16
+ if (!mounted) void CounterEffects.read().then(setEffects)
17
+ }, [mounted])
18
+ return (
19
+ <View style={{ flex: 1 }}>
20
+ {mounted && <CounterScreen title={title} onTap={setCallback} />}
21
+ <View style={{ position: 'absolute', bottom: 40, left: 24 }}>
22
+ <Text testID={`callback-${callback}`}>Callback: {callback}</Text>
23
+ <Text testID={`effects-${effects.join('-')}`}>Effects: {effects.join(',')}</Text>
24
+ <Text testID={`locales-${locales.join('-')}`}>Locales: {locales.join(',')}</Text>
25
+ <Pressable testID="edit-title" onPress={() => setTitle('Edited title')}>
26
+ <Text>Edit title</Text>
27
+ </Pressable>
28
+ <Pressable testID="unmount" onPress={() => setMounted(false)}>
29
+ <Text>Unmount</Text>
30
+ </Pressable>
31
+ </View>
32
+ </View>
33
+ )
34
+ }
@@ -0,0 +1,3 @@
1
+ export default function CounterApp() {
2
+ throw new Error('the imported Kotlin counter fixture requires an Android device')
3
+ }
@@ -0,0 +1,7 @@
1
+ // generated by one typegen; edits will be replaced
2
+ import type { ReactElement } from 'react'
3
+
4
+ export declare function CounterScreen(props: {
5
+ title?: string | null
6
+ onTap?: ((value0: number) => void) | null
7
+ }): ReactElement
@@ -8,6 +8,12 @@ import androidx.compose.material3.Button
8
8
  import androidx.compose.material3.MaterialTheme
9
9
  import androidx.compose.material3.Text
10
10
  import androidx.compose.runtime.Composable
11
+ import androidx.compose.runtime.DisposableEffect
12
+ import androidx.compose.runtime.LaunchedEffect
13
+ import androidx.compose.runtime.SideEffect
14
+ import androidx.compose.ui.platform.LocalLocaleList
15
+ import androidx.compose.ui.text.intl.Locale
16
+ import kotlinx.coroutines.awaitCancellation
11
17
  import androidx.compose.runtime.getValue
12
18
  import androidx.compose.runtime.mutableStateOf
13
19
  import androidx.compose.runtime.remember
@@ -20,7 +26,16 @@ import androidx.compose.material3.Surface
20
26
  import androidx.compose.material3.lightColorScheme
21
27
 
22
28
  @Composable
23
- fun CounterScreen() {
29
+ fun CounterScreen(title: String? = null, onTap: ((Int) -> Unit)? = null) {
30
+ val locales = LocalLocaleList.current
31
+ SideEffect { CounterEffects.recordLocale(Locale.current.toLanguageTag(), locales.joinToString(",") { it.toLanguageTag() }) }
32
+ DisposableEffect(Unit) {
33
+ CounterEffects.enter()
34
+ onDispose { CounterEffects.dispose() }
35
+ }
36
+ LaunchedEffect(Unit) {
37
+ try { awaitCancellation() } finally { CounterEffects.cancel() }
38
+ }
24
39
  var count by remember { mutableStateOf(Counter.read()) }
25
40
  MaterialTheme(colorScheme = lightColorScheme(primary = Color(0xFF6750A4))) {
26
41
  Surface(modifier = Modifier.fillMaxSize()) {
@@ -28,10 +43,10 @@ fun CounterScreen() {
28
43
  modifier = Modifier.fillMaxSize().padding(24.dp),
29
44
  verticalArrangement = Arrangement.spacedBy(16.dp),
30
45
  ) {
31
- Text("Kotlin counter", style = MaterialTheme.typography.headlineSmall)
46
+ Text(title ?: "Kotlin counter", style = MaterialTheme.typography.headlineSmall)
32
47
  Text("Count: $count", modifier = Modifier.testTag("counter-value"))
33
48
  Button(
34
- onClick = { count = Counter.increment() },
49
+ onClick = { count = Counter.increment(); onTap?.invoke(count) },
35
50
  modifier = Modifier.testTag("counter-increment"),
36
51
  ) { Text("Increment") }
37
52
  }
@@ -0,0 +1,4 @@
1
+ import { Slot } from 'one'
2
+ export default function Layout() {
3
+ return <Slot />
4
+ }
@@ -0,0 +1 @@
1
+ export { default } from '../CounterApp'
@@ -0,0 +1,18 @@
1
+ // deno-lint-ignore-file
2
+ /* eslint-disable */
3
+ // biome-ignore: needed import
4
+ import type { OneRouter } from 'one'
5
+
6
+ declare module 'one' {
7
+ export namespace OneRouter {
8
+ export interface __routes<T extends string = string> extends Record<string, unknown> {
9
+ StaticRoutes:
10
+ | `/`
11
+ | `/_sitemap`
12
+ DynamicRoutes: never
13
+ DynamicRouteTemplate: never
14
+ IsTyped: true
15
+
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "peach-kotlin-counter-fixture",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": { "one": "catalog:", "react": "catalog:", "react-native": "catalog:" },
6
+ "devDependencies": { "peachsim": "workspace:*", "vite": "catalog:" }
7
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "~/*": ["./*"]
5
+ },
6
+ "strict": true,
7
+ "rootDir": ".",
8
+ "module": "Preserve",
9
+ // allows react-native style imports without path extensions, for compat with platform-specific files
10
+ "moduleResolution": "Bundler",
11
+ "preserveSymlinks": true,
12
+ "skipLibCheck": true,
13
+ "jsx": "react-jsx",
14
+ "noImplicitAny": false,
15
+ "types": ["node", "react", "vite/client"],
16
+ "lib": ["dom", "esnext"]
17
+ },
18
+ "exclude": ["node_modules", "**/test", "**/dist", "**/types", "**/__tests__"]
19
+ }
@@ -0,0 +1,10 @@
1
+ import { one } from 'one/vite'
2
+ import { rnxPlugin } from 'peachsim/vite'
3
+ export default {
4
+ root: import.meta.dirname,
5
+ plugins: [
6
+ one({ web: { defaultRenderMode: 'spa' }, native: { bundler: 'vite' } }),
7
+ rnxPlugin(),
8
+ ],
9
+ server: { host: '0.0.0.0' },
10
+ }
@@ -33,6 +33,9 @@ import androidx.compose.ui.text.font.PlatformTypefacesLoader
33
33
  import androidx.compose.ui.text.font.createPlatformFontFamilyResolver
34
34
  import androidx.compose.ui.text.font.platformTypefacesLoader
35
35
  import androidx.compose.ui.text.intl.LocaleList
36
+ import androidx.compose.ui.text.intl.setPlatformLocaleProvider
37
+ import org.jetbrains.skiko.OS
38
+ import org.jetbrains.skiko.setHostOsProvider
36
39
  import androidx.compose.ui.text.platform.Font
37
40
  import androidx.compose.ui.unit.Density
38
41
  import androidx.compose.ui.unit.DpSize
@@ -85,7 +88,9 @@ private class DeviceScene(locale: String, content: @Composable () -> Unit) : Aut
85
88
  private val size = IntSize(1080, 2400)
86
89
  private val density = Density(420f / 160f)
87
90
  init {
91
+ setHostOsProvider { OS.Android }
88
92
  registerSkikoComposeImplementation()
93
+ setPlatformLocaleProvider { LocaleList(locale) }
89
94
  }
90
95
  val context = PeachPlatformContext(size, density, LocaleList(locale))
91
96
  private val recomposer = FrameRecomposer(Dispatchers.Unconfined)
@@ -0,0 +1,30 @@
1
+ plugins {
2
+ id("com.android.application")
3
+ id("org.jetbrains.kotlin.android")
4
+ id("org.jetbrains.kotlin.plugin.compose")
5
+ }
6
+
7
+ android {
8
+ namespace = "dev.contrast.kotlinapp"
9
+ compileSdk = 37
10
+ defaultConfig {
11
+ applicationId = "dev.contrast.kotlinapp"
12
+ minSdk = 24
13
+ targetSdk = 37
14
+ versionCode = 1
15
+ versionName = "1"
16
+ }
17
+ buildFeatures { compose = true }
18
+ compileOptions {
19
+ sourceCompatibility = JavaVersion.VERSION_17
20
+ targetCompatibility = JavaVersion.VERSION_17
21
+ }
22
+ kotlinOptions { jvmTarget = "17" }
23
+ }
24
+
25
+ dependencies {
26
+ implementation("androidx.activity:activity-compose:1.9.3")
27
+ implementation("androidx.compose.ui:ui:1.13.0-alpha02")
28
+ implementation("androidx.compose.foundation:foundation:1.13.0-alpha02")
29
+ implementation("androidx.compose.material3:material3:1.5.0-alpha27")
30
+ }
@@ -0,0 +1,13 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <application
3
+ android:name=".ProbeApplication"
4
+ android:label="Kotlin app"
5
+ android:theme="@android:style/Theme.Material.Light.NoActionBar">
6
+ <activity android:name=".MainActivity" android:exported="true">
7
+ <intent-filter>
8
+ <action android:name="android.intent.action.MAIN" />
9
+ <category android:name="android.intent.category.LAUNCHER" />
10
+ </intent-filter>
11
+ </activity>
12
+ </application>
13
+ </manifest>
@@ -0,0 +1,60 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import android.os.Bundle
4
+ import androidx.activity.ComponentActivity
5
+ import androidx.activity.compose.setContent
6
+ import androidx.compose.foundation.background
7
+ import androidx.compose.foundation.layout.Arrangement
8
+ import androidx.compose.foundation.layout.Column
9
+ import androidx.compose.foundation.layout.fillMaxSize
10
+ import androidx.compose.foundation.layout.padding
11
+ import androidx.compose.foundation.layout.safeDrawingPadding
12
+ import androidx.compose.material3.Button
13
+ import androidx.compose.material3.Text
14
+ import androidx.compose.runtime.Composable
15
+ import androidx.compose.runtime.LaunchedEffect
16
+ import androidx.compose.runtime.SideEffect
17
+ import androidx.compose.runtime.getValue
18
+ import androidx.compose.runtime.mutableIntStateOf
19
+ import androidx.compose.runtime.remember
20
+ import androidx.compose.runtime.setValue
21
+ import androidx.compose.ui.Modifier
22
+ import androidx.compose.ui.graphics.Color
23
+ import androidx.compose.ui.platform.testTag
24
+ import androidx.compose.ui.unit.dp
25
+
26
+ class MainActivity : ComponentActivity() {
27
+ override fun onCreate(savedInstanceState: Bundle?) {
28
+ super.onCreate(savedInstanceState)
29
+ Probe.log += "activity.onCreate ${intent.action}"
30
+ setContent { ProbeScreen(content = "first", replace = { replaceContent() }) }
31
+ }
32
+
33
+ // a second setContent on the same activity: android reuses its ComposeView
34
+ private fun replaceContent() {
35
+ Probe.log += "activity.setContent"
36
+ setContent { ProbeScreen(content = "second", replace = { replaceContent() }) }
37
+ }
38
+
39
+ override fun onStart() { super.onStart(); Probe.log += "activity.onStart" }
40
+ override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState); Probe.log += "activity.onPostCreate" }
41
+ override fun onResume() { super.onResume(); Probe.log += "activity.onResume" }
42
+ override fun onPostResume() { super.onPostResume(); Probe.log += "activity.onPostResume" }
43
+ }
44
+
45
+ @Composable
46
+ fun ProbeScreen(content: String, replace: () -> Unit) {
47
+ var count by remember { mutableIntStateOf(0) }
48
+ SideEffect { if ("compose" !in Probe.log) Probe.log += "compose" }
49
+ LaunchedEffect(Unit) { Probe.log += "effect" }
50
+ Column(
51
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
52
+ verticalArrangement = Arrangement.spacedBy(12.dp),
53
+ ) {
54
+ Text("Content: $content", Modifier.testTag("content"))
55
+ Text("Greeting: ${Probe.greeting}", Modifier.testTag("greeting"))
56
+ Button(onClick = { count += 1 }, Modifier.testTag("count")) { Text("Count: $count") }
57
+ Button(onClick = replace, Modifier.testTag("replace")) { Text("Replace content") }
58
+ Text(Probe.log.joinToString("\n"), Modifier.testTag("log"))
59
+ }
60
+ }