peachsim 0.1.673 → 0.1.675

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 (83) 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 +257 -236
  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 +78 -2
  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/native-firebase-metadata.cjs +1 -1
  48. package/dist-lib/native-firebase-metadata.mjs +1 -1
  49. package/dist-lib/plist.cjs +1 -1
  50. package/dist-lib/plist.mjs +1 -1
  51. package/dist-lib/profiles.cjs +1 -1
  52. package/dist-lib/public-brand.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.mjs +1 -1
  55. package/dist-lib/render-mode.cjs +1 -1
  56. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  57. package/dist-lib/sdk.cjs +1 -1
  58. package/dist-lib/sdk.mjs +1 -1
  59. package/dist-lib/skills.cjs +434 -406
  60. package/dist-lib/swift-compile.cjs +21 -7
  61. package/dist-lib/swift-compile.mjs +21 -7
  62. package/dist-lib/swift-package.cjs +1 -1
  63. package/dist-lib/swift-package.mjs +1 -1
  64. package/dist-lib/swift-submit.cjs +1 -1
  65. package/dist-lib/swift-submit.mjs +1 -1
  66. package/dist-lib/swift.cjs +1 -1
  67. package/dist-lib/vite-swift.cjs +109 -88
  68. package/dist-lib/vite.cjs +1 -1
  69. package/kotlin/AppSources/android/content/Context.kt +4 -0
  70. package/kotlin/AppSources/android/content/res/Configuration.kt +4 -1
  71. package/kotlin/AppSources/android/content/res/Resources.kt +45 -1
  72. package/kotlin/AppSources/androidx/compose/ui/platform/AndroidCompositionLocals.kt +19 -0
  73. package/kotlin/AppSources/androidx/compose/ui/res/StringResources.kt +53 -0
  74. package/kotlin/AppSources/dev/peach/app/JavaFormat.kt +264 -0
  75. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +23 -7
  76. package/kotlin/fixtures/kotlin-app-variants/Strings.kt +49 -0
  77. package/package.json +1 -1
  78. package/src/android-strings.ts +163 -0
  79. package/src/android-xml.ts +3 -3
  80. package/src/kotlin-app.ts +4 -0
  81. package/src/kotlin-host.ts +98 -0
  82. package/src/swift-compile.ts +31 -10
  83. package/src/swift-host.ts +2 -4
@@ -0,0 +1,53 @@
1
+ package androidx.compose.ui.res
2
+
3
+ import android.content.res.Resources
4
+ import androidx.compose.runtime.Composable
5
+ import androidx.compose.runtime.ReadOnlyComposable
6
+ import androidx.compose.ui.platform.LocalConfiguration
7
+ import androidx.compose.ui.platform.LocalContext
8
+
9
+ // StringResources.android.kt and Resources.android.kt: a string, plural or
10
+ // string array resource read through the composition's context, recomposed
11
+ // when its configuration changes
12
+
13
+ @Composable
14
+ @ReadOnlyComposable
15
+ fun stringResource(id: Int): String {
16
+ val resources = resources()
17
+ return resources.getString(id)
18
+ }
19
+
20
+ @Composable
21
+ @ReadOnlyComposable
22
+ fun stringResource(id: Int, vararg formatArgs: Any): String {
23
+ val resources = resources()
24
+ return resources.getString(id, *formatArgs)
25
+ }
26
+
27
+ @Composable
28
+ @ReadOnlyComposable
29
+ fun pluralStringResource(id: Int, count: Int): String {
30
+ val resources = resources()
31
+ return resources.getQuantityString(id, count)
32
+ }
33
+
34
+ @Composable
35
+ @ReadOnlyComposable
36
+ fun pluralStringResource(id: Int, count: Int, vararg formatArgs: Any): String {
37
+ val resources = resources()
38
+ return resources.getQuantityString(id, count, *formatArgs)
39
+ }
40
+
41
+ @Composable
42
+ @ReadOnlyComposable
43
+ fun stringArrayResource(id: Int): Array<String> {
44
+ val resources = resources()
45
+ return resources.getStringArray(id)
46
+ }
47
+
48
+ @Composable
49
+ @ReadOnlyComposable
50
+ internal fun resources(): Resources {
51
+ LocalConfiguration.current
52
+ return LocalContext.current.resources
53
+ }
@@ -0,0 +1,264 @@
1
+ package dev.peach.app
2
+
3
+ // java.util.Formatter as String.format(locale, format, args) runs it on the
4
+ // device, which Resources.getString(id, args) calls: the general, character,
5
+ // integral and decimal float conversions with their flags, width, precision
6
+ // and argument indexes, digits and separators in the locale's symbols. a
7
+ // conversion outside those (scientific, hex float, date) is rejected by name.
8
+
9
+ private val SPECIFIER = Regex("%(\\d+\\$)?([-#+ 0,(<]*)(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])")
10
+
11
+ private class Symbols(val zero: Char, val decimal: String, val group: String)
12
+
13
+ // one part of the locale's number format, as ICU's decimal format symbols
14
+ // give it (Intl reads the same CLDR data)
15
+ private fun numberPart(locale: String, value: Double, type: String): String =
16
+ js("(new Intl.NumberFormat(locale || undefined, { useGrouping: true }).formatToParts(value).find((part) => part.type === type) || { value: '' }).value")
17
+
18
+ private fun symbols(locale: String) = Symbols(
19
+ zero = numberPart(locale, 0.0, "integer").single(),
20
+ decimal = numberPart(locale, 1.5, "decimal"),
21
+ group = numberPart(locale, 1234567.0, "group"),
22
+ )
23
+
24
+ private class Spec(val flags: String, val width: Int, val precision: Int, val text: String) {
25
+ fun has(flag: Char) = flag in flags
26
+ }
27
+
28
+ internal fun javaFormat(locale: String, format: String, args: Array<out Any?>): String {
29
+ val out = StringBuilder()
30
+ var ordinary = 0
31
+ var last = -1
32
+ var index = 0
33
+ while (index < format.length) {
34
+ val percent = format.indexOf('%', index)
35
+ if (percent == -1) {
36
+ out.append(format, index, format.length)
37
+ break
38
+ }
39
+ out.append(format, index, percent)
40
+ val match = SPECIFIER.matchAt(format, percent)
41
+ ?: throw IllegalArgumentException(
42
+ "Conversion = '${format.getOrNull(percent + 1) ?: '%'}'",
43
+ )
44
+ index = match.range.last + 1
45
+ val (position, flags, width, precision, time, conversion) = match.destructured
46
+ if (time.isNotEmpty())
47
+ throw UnsupportedOperationException("peach kotlin: date conversion ${match.value} is not supported")
48
+ val spec = Spec(
49
+ flags,
50
+ width.toIntOrNull() ?: -1,
51
+ precision.drop(1).toIntOrNull() ?: -1,
52
+ match.value,
53
+ )
54
+ val c = conversion.single()
55
+ if (c == '%') {
56
+ out.append(justify(spec, "%"))
57
+ continue
58
+ }
59
+ if (c == 'n') {
60
+ out.append('\n')
61
+ continue
62
+ }
63
+ val argument = when {
64
+ spec.has('<') -> last
65
+ position.isNotEmpty() -> position.dropLast(1).toInt() - 1
66
+ else -> ordinary++
67
+ }
68
+ if (argument !in args.indices) throw IllegalArgumentException("Format specifier '${match.value}'")
69
+ last = argument
70
+ out.append(convert(c, args[argument], spec, locale))
71
+ }
72
+ return out.toString()
73
+ }
74
+
75
+ private fun justify(spec: Spec, value: String): String {
76
+ if (spec.width <= value.length) return value
77
+ val pad = " ".repeat(spec.width - value.length)
78
+ return if (spec.has('-')) value + pad else pad + value
79
+ }
80
+
81
+ private fun typeName(value: Any?): String = when (value) {
82
+ is String -> "java.lang.String"
83
+ is Int -> "java.lang.Integer"
84
+ is Long -> "java.lang.Long"
85
+ is Short -> "java.lang.Short"
86
+ is Byte -> "java.lang.Byte"
87
+ is Double -> "java.lang.Double"
88
+ is Float -> "java.lang.Float"
89
+ is Boolean -> "java.lang.Boolean"
90
+ is Char -> "java.lang.Character"
91
+ else -> value?.let { it::class.simpleName } ?: "null"
92
+ }
93
+
94
+ private fun mismatch(c: Char, value: Any?): Nothing =
95
+ throw IllegalArgumentException("$c != ${typeName(value)}")
96
+
97
+ private fun convert(c: Char, value: Any?, spec: Spec, locale: String): String = when (c) {
98
+ 'b', 'B' -> {
99
+ val text = when (value) {
100
+ null -> "false"
101
+ is Boolean -> value.toString()
102
+ else -> "true"
103
+ }
104
+ justify(spec, precise(spec, if (c == 'B') text.uppercase() else text))
105
+ }
106
+ 's', 'S' -> {
107
+ val text = precise(spec, value.toString())
108
+ justify(spec, if (c == 'S') text.uppercase() else text)
109
+ }
110
+ 'c', 'C' -> {
111
+ val text = when (value) {
112
+ null -> "null"
113
+ is Char -> value.toString()
114
+ is Int -> codePoint(value)
115
+ is Byte -> codePoint(value.toInt())
116
+ is Short -> codePoint(value.toInt())
117
+ else -> mismatch(c, value)
118
+ }
119
+ justify(spec, if (c == 'C') text.uppercase() else text)
120
+ }
121
+ 'd' -> when (value) {
122
+ null -> justify(spec, "null")
123
+ is Int -> decimal(spec, value.toString(), symbols(locale))
124
+ is Long -> decimal(spec, value.toString(), symbols(locale))
125
+ is Short -> decimal(spec, value.toString(), symbols(locale))
126
+ is Byte -> decimal(spec, value.toString(), symbols(locale))
127
+ else -> mismatch(c, value)
128
+ }
129
+ 'x', 'X', 'o' -> {
130
+ val radix = if (c == 'o') 8 else 16
131
+ val digits = when (value) {
132
+ null -> null
133
+ is Int -> value.toUInt().toString(radix)
134
+ is Long -> value.toULong().toString(radix)
135
+ is Short -> (value.toInt() and 0xffff).toString(radix)
136
+ is Byte -> (value.toInt() and 0xff).toString(radix)
137
+ else -> mismatch(c, value)
138
+ }
139
+ if (digits == null) justify(spec, "null")
140
+ else {
141
+ val prefix = if (spec.has('#')) (if (radix == 16) "0x" else "0") else ""
142
+ var body = digits
143
+ if (spec.has('0')) body = body.padStart(spec.width - prefix.length, '0')
144
+ val text = prefix + body
145
+ justify(spec, if (c == 'X') text.uppercase() else text)
146
+ }
147
+ }
148
+ 'f' -> when (value) {
149
+ null -> justify(spec, "null")
150
+ is Double -> fixed(spec, value, symbols(locale))
151
+ is Float -> fixed(spec, value.toDouble(), symbols(locale))
152
+ else -> mismatch(c, value)
153
+ }
154
+ else -> throw UnsupportedOperationException("peach kotlin: conversion ${spec.text} is not supported")
155
+ }
156
+
157
+ private fun codePoint(value: Int): String =
158
+ if (value < 0x10000) value.toChar().toString()
159
+ else {
160
+ val offset = value - 0x10000
161
+ charArrayOf((0xd800 + (offset shr 10)).toChar(), (0xdc00 + (offset and 0x3ff)).toChar()).concatToString()
162
+ }
163
+
164
+ private fun precise(spec: Spec, text: String) =
165
+ if (spec.precision >= 0 && spec.precision < text.length) text.take(spec.precision) else text
166
+
167
+ // Formatter.print(long): the sign, then the localized magnitude zero padded
168
+ // to the width, then the closing parenthesis, then the justification
169
+ private fun decimal(spec: Spec, value: String, symbols: Symbols): String {
170
+ val negative = value.startsWith('-')
171
+ return signed(spec, negative, value.removePrefix("-"), symbols)
172
+ }
173
+
174
+ private fun signed(spec: Spec, negative: Boolean, magnitude: String, symbols: Symbols): String {
175
+ val out = StringBuilder()
176
+ if (negative) out.append(if (spec.has('(')) '(' else '-')
177
+ else if (spec.has('+')) out.append('+')
178
+ else if (spec.has(' ')) out.append(' ')
179
+ val width = if (spec.width != -1 && negative && spec.has('(')) spec.width - 1 else spec.width
180
+ localized(out, magnitude, spec, width, symbols)
181
+ if (negative && spec.has('(')) out.append(')')
182
+ return justify(spec, out.toString())
183
+ }
184
+
185
+ // Formatter.localizedMagnitude: each digit in the locale's digits, grouped
186
+ // by three with the ',' flag, the decimal separator in place of the point,
187
+ // and zero padding inserted after the sign
188
+ private fun localized(out: StringBuilder, value: String, spec: Spec, width: Int, symbols: Symbols) {
189
+ val begin = out.length
190
+ val dot = value.indexOf('.').let { if (it == -1) value.length else it }
191
+ var group = spec.has(',')
192
+ for (index in value.indices) {
193
+ if (index == dot) {
194
+ out.append(symbols.decimal)
195
+ group = false
196
+ continue
197
+ }
198
+ out.append(symbols.zero + (value[index] - '0'))
199
+ if (group && index != dot - 1 && (dot - index) % 3 == 1) out.append(symbols.group)
200
+ }
201
+ if (width != -1 && spec.has('0')) while (out.length < width) out.insert(begin, symbols.zero)
202
+ }
203
+
204
+ // Formatter.print(double) for %f: the shortest decimal digits of the value,
205
+ // rounded half up to the precision, as FormattedFloatingDecimal does
206
+ private fun fixed(spec: Spec, value: Double, symbols: Symbols): String {
207
+ if (value.isNaN()) return justify(spec, "NaN")
208
+ val negative = value < 0.0 || (value == 0.0 && 1.0 / value < 0.0)
209
+ if (value.isInfinite()) {
210
+ val text = if (negative) (if (spec.has('(')) "(Infinity)" else "-Infinity")
211
+ else if (spec.has('+')) "+Infinity" else if (spec.has(' ')) " Infinity" else "Infinity"
212
+ return justify(spec, text)
213
+ }
214
+ val precision = if (spec.precision == -1) 6 else spec.precision
215
+ var magnitude = roundHalfUp(shortestDigits(kotlin.math.abs(value)), precision)
216
+ if (precision == 0 && spec.has('#')) magnitude += "."
217
+ return signed(spec, negative, magnitude, symbols)
218
+ }
219
+
220
+ // the shortest round trip decimal of a finite non negative double as plain
221
+ // digits and a point, the digits Double.toString prints
222
+ private fun shortestDigits(value: Double): String {
223
+ val text = value.toString()
224
+ val exponentAt = text.indexOfFirst { it == 'e' || it == 'E' }
225
+ if (exponentAt == -1) return text
226
+ val mantissa = text.substring(0, exponentAt)
227
+ val exponent = text.substring(exponentAt + 1).toInt()
228
+ val point = mantissa.indexOf('.').let { if (it == -1) mantissa.length else it }
229
+ val digits = mantissa.replace(".", "")
230
+ val shifted = point + exponent
231
+ return when {
232
+ shifted <= 0 -> "0." + "0".repeat(-shifted) + digits
233
+ shifted >= digits.length -> digits + "0".repeat(shifted - digits.length)
234
+ else -> digits.substring(0, shifted) + "." + digits.substring(shifted)
235
+ }
236
+ }
237
+
238
+ private fun roundHalfUp(decimal: String, precision: Int): String {
239
+ val point = decimal.indexOf('.')
240
+ val whole = if (point == -1) decimal else decimal.substring(0, point)
241
+ val fraction = if (point == -1) "" else decimal.substring(point + 1)
242
+ if (fraction.length <= precision) {
243
+ val padded = fraction.padEnd(precision, '0')
244
+ return if (precision == 0) whole else "$whole.$padded"
245
+ }
246
+ val kept = (whole + fraction.substring(0, precision)).toCharArray()
247
+ if (fraction[precision] >= '5') {
248
+ var carry = true
249
+ var index = kept.size - 1
250
+ while (carry && index >= 0) {
251
+ if (kept[index] == '9') kept[index] = '0'
252
+ else {
253
+ kept[index] = kept[index] + 1
254
+ carry = false
255
+ }
256
+ index--
257
+ }
258
+ val digits = (if (carry) "1" else "") + kept.concatToString()
259
+ val wholeLength = digits.length - precision
260
+ return if (precision == 0) digits else digits.substring(0, wholeLength) + "." + digits.substring(wholeLength)
261
+ }
262
+ val digits = kept.concatToString()
263
+ return if (precision == 0) digits else digits.substring(0, whole.length) + "." + digits.substring(whole.length)
264
+ }
@@ -20,6 +20,8 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
20
20
  import androidx.compose.runtime.Composable
21
21
  import androidx.compose.runtime.CompositionLocalProvider
22
22
  import androidx.compose.ui.platform.ComposeView
23
+ import androidx.compose.ui.platform.LocalConfiguration
24
+ import androidx.compose.ui.platform.LocalContext
23
25
  import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner
24
26
  import androidx.compose.ui.platform.PlatformInsets
25
27
  import androidx.compose.ui.platform.PlatformWindowInsets
@@ -27,6 +29,7 @@ import androidx.compose.runtime.getValue
27
29
  import androidx.compose.runtime.key
28
30
  import androidx.compose.runtime.mutableIntStateOf
29
31
  import androidx.compose.runtime.mutableStateOf
32
+ import androidx.compose.runtime.neverEqualPolicy
30
33
  import androidx.compose.runtime.setValue
31
34
  import androidx.compose.ui.text.font.FontFamily
32
35
  import androidx.lifecycle.Lifecycle
@@ -130,13 +133,17 @@ private class AppProcess(
130
133
  var reportedNight = false
131
134
  // the software keyboard's height above the screen's bottom edge, in pixels
132
135
  var ime by mutableIntStateOf(0)
136
+ // the configuration the content view composes with: a copy the view's
137
+ // configuration change replaces, as AndroidComposeView's observer does
138
+ val configuration = mutableStateOf(Configuration(), neverEqualPolicy())
133
139
  }
134
140
 
135
141
  private var process: AppProcess? = null
136
142
 
137
- private fun nightConfiguration(night: Boolean) = Configuration().apply {
143
+ private fun deviceConfiguration(night: Boolean, locale: String) = Configuration().apply {
138
144
  uiMode = Configuration.UI_MODE_TYPE_NORMAL or
139
145
  if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
146
+ localeTag = locale
140
147
  }
141
148
 
142
149
  // a cold start in ActivityThread's order: the Application and its onCreate,
@@ -153,7 +160,7 @@ internal fun peachStartApp(
153
160
  try {
154
161
  require(backend == "graphite" || backend == "raster")
155
162
  preparePeachDevice(locale)
156
- processResources = Resources(nightConfiguration(night))
163
+ processResources = Resources(deviceConfiguration(night, locale))
157
164
  val application = spec.createApplication()
158
165
  application.onCreate()
159
166
  val app = AppProcess(
@@ -235,12 +242,20 @@ private fun launchActivity(app: AppProcess, lastNonConfigurationInstance: Any?):
235
242
  insets = DeviceInsets(window, app.insets) { app.ime },
236
243
  clockRunning = { componentActivity == null || componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
237
244
  )
245
+ app.configuration.value = Configuration().apply { setTo(activity.resources.configuration) }
238
246
  mount.setContent {
239
247
  val content = @Composable { held?.let { view -> key(place) { view.Content() } }; Unit }
240
- // the content view's tree resolves the activity as its back dispatcher owner
241
- if (componentActivity != null)
242
- CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides componentActivity, content = content)
243
- else content()
248
+ // the content view's context is the activity, its configuration the
249
+ // copy above, and its tree resolves the activity as its back
250
+ // dispatcher owner
251
+ CompositionLocalProvider(
252
+ LocalContext provides activity,
253
+ LocalConfiguration provides app.configuration.value,
254
+ ) {
255
+ if (componentActivity != null)
256
+ CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides componentActivity, content = content)
257
+ else content()
258
+ }
244
259
  }
245
260
  if (app.mountId == 0) app.mountId = addMount(mount)
246
261
  else {
@@ -319,6 +334,7 @@ private fun reportConfiguration(app: AppProcess): Boolean {
319
334
  }
320
335
  app.reportedNight = isNight(configuration)
321
336
  app.activity.performConfigurationChanged(Configuration().apply { setTo(configuration) })
337
+ app.configuration.value = Configuration().apply { setTo(configuration) }
322
338
  app.mount.night = app.reportedNight
323
339
  return false
324
340
  }
@@ -341,7 +357,7 @@ internal fun peachAppNight(night: Boolean) {
341
357
  val app = runningApp()
342
358
  val configuration = processResources.configuration
343
359
  if (isNight(configuration) == night) return
344
- configuration.setTo(nightConfiguration(night))
360
+ configuration.setTo(deviceConfiguration(night, app.locale))
345
361
  app.application.onConfigurationChanged(Configuration().apply { setTo(configuration) })
346
362
  if (!app.stopped) reportConfiguration(app)
347
363
  }
@@ -0,0 +1,49 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.foundation.layout.padding
8
+ import androidx.compose.foundation.layout.safeDrawingPadding
9
+ import androidx.compose.material3.Text
10
+ import androidx.compose.runtime.Composable
11
+ import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.graphics.Color
13
+ import androidx.compose.ui.platform.LocalContext
14
+ import androidx.compose.ui.res.pluralStringResource
15
+ import androidx.compose.ui.res.stringArrayResource
16
+ import androidx.compose.ui.res.stringResource
17
+ import androidx.compose.ui.unit.dp
18
+
19
+ // string, plural and array resources read through the compose helpers and
20
+ // the context, each line showing what aapt2 compiled, the plural rule chose
21
+ // and String.format made of one rule
22
+ @Composable
23
+ fun StringsScreen() {
24
+ val context = LocalContext.current
25
+ Column(
26
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
27
+ verticalArrangement = Arrangement.spacedBy(8.dp),
28
+ ) {
29
+ Text(stringResource(R.string.title))
30
+ Text(stringResource(R.string.greeting, "Ada", 3))
31
+ Text("[" + stringResource(R.string.spaced) + "]")
32
+ Text("[" + stringResource(R.string.quoted) + "]")
33
+ Text(context.getString(R.string.escaped))
34
+ Text(stringResource(R.string.styled))
35
+ Text(stringResource(R.string.entity))
36
+ Text(stringResource(R.string.percent))
37
+ Text(stringResource(R.string.numbers, 1234567, 1.005, 42, 255, "ab"))
38
+ Text(context.resources.getString(R.string.numbers, -7, 2.5, -3, -1, "x"))
39
+ Text(stringResource(R.string.signs, 5, -5, 1234.5678, 0.125f))
40
+ Text(
41
+ pluralStringResource(R.plurals.messages, 0, 0) + " / " +
42
+ pluralStringResource(R.plurals.messages, 1, 1) + " / " +
43
+ pluralStringResource(R.plurals.messages, 2, 2),
44
+ )
45
+ Text(pluralStringResource(R.plurals.only_other, 1) + " / " + context.resources.getQuantityString(R.plurals.messages, 1, 1))
46
+ Text(stringArrayResource(R.array.planets).joinToString("|"))
47
+ Text(context.resources.getStringArray(R.array.seasons).joinToString("|"))
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.673",
3
+ "version": "0.1.675",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for peach.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,163 @@
1
+ // an Android module's string resources as aapt2 compiles them: each
2
+ // <string>, <plurals> and <string-array> under res/values and its locale
3
+ // qualified directories, the text with its style tags removed (getString
4
+ // returns the unstyled text) and aapt2's StringBuilder rules applied. a
5
+ // qualifier Peach does not resolve yet, or a value that is a reference, is
6
+ // rejected by name, never read as the default or as text.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { decodeXmlText } from './android-xml'
10
+
11
+ // one resource's value per locale: '' for values, then a language or a
12
+ // language and region as a BCP 47 tag (values-en-rGB is en-GB). the device
13
+ // resolves a whole plurals or array resource to one locale, never per item
14
+ export interface AndroidResource<Value> {
15
+ name: string
16
+ values: Record<string, Value>
17
+ }
18
+
19
+ export type PluralQuantity = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
20
+
21
+ export interface AndroidStringResources {
22
+ strings: AndroidResource<string>[]
23
+ plurals: AndroidResource<Partial<Record<PluralQuantity, string>>>[]
24
+ arrays: AndroidResource<string[]>[]
25
+ }
26
+
27
+ const VALUES_DIR = /^values(?:-([a-z]{2,3})(?:-r([A-Z]{2}))?)?$/
28
+ const ELEMENT =
29
+ /<(string|plurals|string-array)(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/g
30
+ const ITEM = /<item(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/item\s*>)/g
31
+ const NAME = /\bname\s*=\s*"([^"]*)"/
32
+ const QUANTITY = /\bquantity\s*=\s*"([^"]*)"/
33
+ const QUANTITIES: readonly PluralQuantity[] = [
34
+ 'zero',
35
+ 'one',
36
+ 'two',
37
+ 'few',
38
+ 'many',
39
+ 'other',
40
+ ]
41
+
42
+ const isQuantity = (value: string): value is PluralQuantity =>
43
+ QUANTITIES.some((quantity) => quantity === value)
44
+
45
+ export function readAndroidStrings(moduleDir: string): AndroidStringResources {
46
+ const strings = new Map<string, Record<string, string>>()
47
+ const plurals = new Map<
48
+ string,
49
+ Record<string, Partial<Record<PluralQuantity, string>>>
50
+ >()
51
+ const arrays = new Map<string, Record<string, string[]>>()
52
+ const res = path.join(moduleDir, 'src/main/res')
53
+ const dirs = fs.existsSync(res) ? fs.readdirSync(res).sort() : []
54
+ for (const dir of dirs) {
55
+ if (!dir.startsWith('values')) continue
56
+ const qualifier = VALUES_DIR.exec(dir)
57
+ for (const entry of fs.readdirSync(path.join(res, dir)).sort()) {
58
+ if (!entry.endsWith('.xml')) continue
59
+ const file = path.join(res, dir, entry)
60
+ const text = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '')
61
+ for (const match of text.matchAll(ELEMENT)) {
62
+ const kind = match[1] ?? ''
63
+ const name = NAME.exec(match[2] ?? '')?.[1]
64
+ if (!name) throw new Error(`${file}: a <${kind}> without a name`)
65
+ if (!qualifier)
66
+ throw new Error(
67
+ `${file}: ${kind} ${name} under ${dir} is not resolved by peach kotlin yet (only values, values-<language> and values-<language>-r<REGION>)`,
68
+ )
69
+ const locale = qualifier[2]
70
+ ? `${qualifier[1]}-${qualifier[2]}`
71
+ : (qualifier[1] ?? '')
72
+ const body = match[3] ?? ''
73
+ const where = `${file}: ${kind} ${name}`
74
+ if (kind === 'string')
75
+ add(strings, name, locale, compileString(body, where), where)
76
+ else if (kind === 'plurals') {
77
+ const items: Partial<Record<PluralQuantity, string>> = {}
78
+ for (const item of body.matchAll(ITEM)) {
79
+ const quantity = QUANTITY.exec(item[1] ?? '')?.[1] ?? ''
80
+ if (!isQuantity(quantity))
81
+ throw new Error(`${where} has an item with quantity "${quantity}"`)
82
+ if (quantity in items) throw new Error(`${where} defines ${quantity} twice`)
83
+ items[quantity] = compileString(item[2] ?? '', `${where} ${quantity}`)
84
+ }
85
+ add(plurals, name, locale, items, where)
86
+ } else
87
+ add(
88
+ arrays,
89
+ name,
90
+ locale,
91
+ [...body.matchAll(ITEM)].map((item, index) =>
92
+ compileString(item[2] ?? '', `${where} item ${index}`),
93
+ ),
94
+ where,
95
+ )
96
+ }
97
+ }
98
+ }
99
+ return { strings: sorted(strings), plurals: sorted(plurals), arrays: sorted(arrays) }
100
+ }
101
+
102
+ function add<Value>(
103
+ byName: Map<string, Record<string, Value>>,
104
+ name: string,
105
+ locale: string,
106
+ value: Value,
107
+ where: string,
108
+ ) {
109
+ const values = byName.get(name) ?? {}
110
+ if (locale in values) throw new Error(`${where} is defined twice for one locale`)
111
+ values[locale] = value
112
+ byName.set(name, values)
113
+ }
114
+
115
+ // the R class code compiles against lists only the resources the default
116
+ // values define (AGP's ParseLibraryResourcesTask reads the unqualified
117
+ // directories), so a resource only a qualified directory defines has no field
118
+ const sorted = <Value>(
119
+ byName: Map<string, Record<string, Value>>,
120
+ ): AndroidResource<Value>[] =>
121
+ [...byName.entries()]
122
+ .filter(([, values]) => '' in values)
123
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
124
+ .map(([name, values]) => ({ name, values }))
125
+
126
+ // aapt2's StringBuilder over the decoded text: outside double quotes a run of
127
+ // whitespace is one space and the ends are trimmed, an unescaped apostrophe is
128
+ // an error; a double quote toggles quoting and is dropped; a backslash escapes
129
+ // the next character (\n, \t and \uXXXX, any other character as itself). a
130
+ // value beginning with @ or ? is a reference aapt2 resolves, which Peach does
131
+ // not yet
132
+ function compileString(raw: string, where: string): string {
133
+ const text = decodeXmlText(raw.replace(/<[^>]*>/g, ''), where)
134
+ if (/^\s*[@?]/.test(text))
135
+ throw new Error(`${where} is a resource reference, not resolved by peach kotlin yet`)
136
+ const out: { char: string; soft: boolean }[] = []
137
+ let quoted = false
138
+ for (let index = 0; index < text.length; index++) {
139
+ const char = text[index] ?? ''
140
+ if (char === '\\') {
141
+ const next = text[index + 1]
142
+ if (next === undefined) break
143
+ index++
144
+ if (next === 'n') out.push({ char: '\n', soft: false })
145
+ else if (next === 't') out.push({ char: '\t', soft: false })
146
+ else if (next === 'u') {
147
+ const hex = text.slice(index + 1, index + 5)
148
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
149
+ throw new Error(`${where} has an invalid unicode escape \\u${hex}`)
150
+ out.push({ char: String.fromCharCode(Number.parseInt(hex, 16)), soft: false })
151
+ index += 4
152
+ } else out.push({ char: next, soft: false })
153
+ } else if (char === '"') quoted = !quoted
154
+ else if (char === "'" && !quoted)
155
+ throw new Error(`${where} has an unescaped apostrophe`)
156
+ else if (!quoted && /\s/.test(char)) {
157
+ if (out.at(-1)?.soft !== true) out.push({ char: ' ', soft: true })
158
+ } else out.push({ char, soft: false })
159
+ }
160
+ while (out[0]?.soft) out.shift()
161
+ while (out.at(-1)?.soft) out.pop()
162
+ return out.map((part) => part.char).join('')
163
+ }
@@ -21,7 +21,7 @@ const ENTITIES: Record<string, string> = {
21
21
  const TAG = /<(\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/y
22
22
  const ATTRIBUTE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
23
23
 
24
- function decode(value: string, file: string): string {
24
+ export function decodeXmlText(value: string, file: string): string {
25
25
  return value.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (_match, entity: string) => {
26
26
  if (entity.startsWith('#x'))
27
27
  return String.fromCodePoint(Number.parseInt(entity.slice(2), 16))
@@ -42,7 +42,7 @@ export function parseXml(text: string, file: string): XmlElement {
42
42
  const open = body.indexOf('<', index)
43
43
  const content = body.slice(index, open === -1 ? body.length : open)
44
44
  const parent = stack.at(-1)
45
- if (parent && content.trim()) parent.text += decode(content, file)
45
+ if (parent && content.trim()) parent.text += decodeXmlText(content, file)
46
46
  if (open === -1) break
47
47
  TAG.lastIndex = open
48
48
  const match = TAG.exec(body)
@@ -59,7 +59,7 @@ export function parseXml(text: string, file: string): XmlElement {
59
59
  }
60
60
  const attributes: Record<string, string> = {}
61
61
  for (const [, key, double, single] of (rawAttributes ?? '').matchAll(ATTRIBUTE))
62
- if (key) attributes[key] = decode(double ?? single ?? '', file)
62
+ if (key) attributes[key] = decodeXmlText(double ?? single ?? '', file)
63
63
  const element: XmlElement = { name, attributes, children: [], text: '' }
64
64
  stack.at(-1)?.children.push(element)
65
65
  if (!selfClosing) stack.push(element)
package/src/kotlin-app.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
+ import { readAndroidStrings, type AndroidStringResources } from './android-strings'
8
9
  import {
9
10
  frameworkColor,
10
11
  readResourceValues,
@@ -47,6 +48,8 @@ export interface KotlinAppProject {
47
48
  // how the activity's window meets the keyboard: pan over a covered focused
48
49
  // view, resize above the keyboard, or nothing
49
50
  softInputAdjust: 'pan' | 'resize' | 'nothing'
51
+ // the module's string resources, which its R class names
52
+ strings: AndroidStringResources
50
53
  }
51
54
 
52
55
  export interface AndroidPins {
@@ -501,5 +504,6 @@ export function readKotlinApp(
501
504
  predictiveBack,
502
505
  handlesUiMode,
503
506
  softInputAdjust,
507
+ strings: readAndroidStrings(moduleDir),
504
508
  }
505
509
  }