ReviewOS

also looking at this

craft-native/craft

fix(native): interaction, audio, document and checkpoint gaps stranded after #401

#415
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
19 files +987 -50

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/src/index.test.tsmodified+17-0
Changes to packages/android/src/index.test.ts
@@ -782,6 +782,23 @@ describe('Craft Android builder', () => {
782782 expect(native).toContain('control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }')
783783 })
784784
785 it('gives native screens screen-owned audio playback with cancellation and teardown', async () => {
786 const output = createTempDir('craft-android-native-audio-')
787 await init({ name: 'NativeAudio', packageName: 'org.example.nativeaudio', output, runtimeDir: null, config: { renderer: 'native' } })
788 const root = join(output, 'app/src/main/java/org/example/nativeaudio')
789 const sounds = readFileSync(join(root, 'CraftNativeSounds.kt'), 'utf8')
790 const native = readFileSync(join(root, 'MainActivity.kt'), 'utf8')
791 expect(sounds).toContain('setOnPreparedListener')
792 expect(sounds).toContain('setOnCompletionListener')
793 expect(sounds).toContain('setOnErrorListener')
794 expect(sounds).toContain('sound.player.release()')
795 expect(sounds).toContain('CraftNativeMedia.local(context, text)')
796 expect(native).toContain('put("audio")')
797 expect(native).toContain('screen.soundRequestCancellers[token]')
798 expect(native).toContain('sounds.close()')
799 expect(native).toContain('"SOUND_EVENT"')
800 })
801
785802 it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => {
786803 const output = createTempDir('craft-android-native-wallet-')
787804 await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } })
packages/android/src/index.tsmodified+1-0
Changes to packages/android/src/index.ts
@@ -884,6 +884,7 @@ import com.google.android.gms.pay.PayClient` : '')
884884 done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured"))
885885 }`
886886 : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))'))
887 write('CraftNativeSounds.kt.template', 'CraftNativeSounds.kt')
887888 if (config.enablePushNotifications) write('CraftNativeMessagingService.kt.template', 'CraftNativeMessagingService.kt')
888889 else rmSync(join(javaDir, 'CraftNativeMessagingService.kt'), { force: true })
889890 // Files a screen shares (craft.files' copies, in the cache) go to other apps through this provider.
packages/android/templates/CraftNativeSounds.kt.templateadded+189-0
Changes to packages/android/templates/CraftNativeSounds.kt.template
@@ -0,0 +1,189 @@
1package {⁠{PACKAGE_NAME}}
2
3import android.content.Context
4import android.media.AudioAttributes
5import android.media.MediaPlayer
6import android.net.Uri
7import android.os.Handler
8import android.os.Looper
9import org.json.JSONObject
10import java.io.File
11import kotlin.math.max
12import kotlin.math.min
13
14/**
15 * Screen-owned `craft.audio` playback for native Android routes.
16 *
17 * Each id owns one MediaPlayer, so effects can overlap and a replacement
18 * invalidates the previous load. The player is released on every terminal
19 * path, including cancellation, decode failure and screen teardown.
20 */
21internal class CraftNativeSounds(private val context: Context) {
22 private data class Sound(
23 val player: MediaPlayer,
24 var volume: Float,
25 var paused: Boolean = false,
26 var fade: Runnable? = null,
27 )
28
29 private val main = Handler(Looper.getMainLooper())
30 private val sounds = mutableMapOf<String, Sound>()
31
32 /** `{ id, event: "ended" | "error", message? }` for the screen bridge. */
33 var emit: ((JSONObject) -> Unit)? = null
34
35 fun play(id: String, address: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
36 if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
37 val uri = source(address)
38 ?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
39 stop(id, 0.0)
40
41 val requestedVolume = options.optDouble("volume", 1.0)
42 val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
43 ?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
44 val fadeIn = options.optDouble("fadeIn", 0.0)
45 if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
46 val player = MediaPlayer()
47 val sound = Sound(player, volume)
48 sounds[id] = sound
49 var settled = false
50 fun settle(result: CraftNativeCapabilityResult) {
51 if (settled) return
52 settled = true
53 done(result)
54 }
55 fun failed(message: String) {
56 if (sounds[id] !== sound) return
57 remove(id)
58 settle(failure("AUDIO_ERROR", message))
59 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message))
60 }
61
62 runCatching {
63 player.setAudioAttributes(AudioAttributes.Builder()
64 .setUsage(AudioAttributes.USAGE_GAME)
65 .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
66 .build())
67 player.isLooping = options.optBoolean("loop", false)
68 player.setVolume(if (fadeIn > 0) 0f else volume, if (fadeIn > 0) 0f else volume)
69 player.setOnPreparedListener {
70 if (sounds[id] !== sound) return@setOnPreparedListener
71 settle(CraftNativeCapabilityResult(data = true))
72 player.start()
73 if (fadeIn > 0) ramp(sound, volume, fadeIn / 1000.0)
74 }
75 player.setOnCompletionListener {
76 if (sounds[id] !== sound) return@setOnCompletionListener
77 remove(id)
78 emit?.invoke(JSONObject().put("id", id).put("event", "ended"))
79 }
80 player.setOnErrorListener { _, what, extra ->
81 failed("The sound could not be played ($what, $extra)")
82 true
83 }
84 player.setDataSource(context, uri)
85 player.prepareAsync()
86 }.onFailure { error ->
87 if (sounds[id] === sound) {
88 remove(id)
89 settle(failure("AUDIO_ERROR", error.message ?: "The sound could not be loaded"))
90 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", error.message ?: "The sound could not be loaded"))
91 }
92 }
93 }
94
95 fun pause(id: String): Boolean {
96 val sound = sounds[id] ?: return false
97 sound.fade?.let(main::removeCallbacks)
98 sound.fade = null
99 runCatching { if (sound.player.isPlaying) sound.player.pause() }
100 sound.paused = true
101 return true
102 }
103
104 fun resume(id: String): Boolean {
105 val sound = sounds[id] ?: return false
106 runCatching { sound.player.start() }.onFailure { return false }
107 sound.paused = false
108 return true
109 }
110
111 fun setVolume(id: String, volume: Double, fade: Double): Boolean {
112 val sound = sounds[id] ?: return false
113 if (!volume.isFinite() || !fade.isFinite() || fade < 0) return false
114 val target = volume.coerceIn(0.0, 1.0).toFloat()
115 sound.fade?.let(main::removeCallbacks)
116 sound.fade = null
117 if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
118 else sound.player.setVolume(target, target)
119 return true
120 }
121
122 fun stop(id: String, fadeOut: Double): Boolean {
123 val sound = sounds[id] ?: return false
124 if (!fadeOut.isFinite() || fadeOut < 0) return false
125 if (fadeOut > 0 && !sound.paused) {
126 ramp(sound, 0f, fadeOut / 1000.0) { if (sounds[id] === sound) remove(id) }
127 } else remove(id)
128 return true
129 }
130
131 fun stopAll(fadeOut: Double): Boolean {
132 if (!fadeOut.isFinite() || fadeOut < 0) return false
133 sounds.keys.toList().forEach { stop(it, fadeOut) }
134 return true
135 }
136
137 fun close() {
138 sounds.keys.toList().forEach(::remove)
139 emit = null
140 }
141
142 private fun source(address: String): Uri? {
143 val text = address.trim()
144 if (text.isBlank()) return null
145 CraftNativeMedia.local(context, text)?.let { file: File -> return Uri.fromFile(file) }
146 val uri = runCatching { Uri.parse(text) }.getOrNull() ?: return null
147 return uri.takeIf { it.scheme?.lowercase() in setOf("http", "https", "file", "content") && !it.toString().contains(" ") }
148 }
149
150 private fun remove(id: String) {
151 val sound = sounds.remove(id) ?: return
152 sound.fade?.let(main::removeCallbacks)
153 sound.fade = null
154 runCatching {
155 sound.player.setOnPreparedListener(null)
156 sound.player.setOnCompletionListener(null)
157 sound.player.setOnErrorListener(null)
158 sound.player.stop()
159 }
160 sound.player.release()
161 }
162
163 private fun ramp(sound: Sound, target: Float, seconds: Double, done: (() -> Unit)? = null) {
164 sound.fade?.let(main::removeCallbacks)
165 val start = sound.volume
166 val began = android.os.SystemClock.uptimeMillis()
167 val duration = max(1L, (seconds * 1000).toLong())
168 val tick = object : Runnable {
169 override fun run() {
170 if (!sounds.containsValue(sound)) return
171 val progress = min(1.0, (android.os.SystemClock.uptimeMillis() - began).toDouble() / duration).toFloat()
172 val value = start + (target - start) * progress
173 sound.player.setVolume(value, value)
174 sound.volume = value
175 if (progress >= 1f) {
176 sound.fade = null
177 done?.invoke()
178 } else {
179 sound.fade = this
180 main.postDelayed(this, 33L)
181 }
182 }
183 }
184 sound.fade = tick
185 main.post(tick)
186 }
187
188 private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
189}
packages/android/templates/MainActivityNative.kt.templatemodified+41-0
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -4003,6 +4003,8 @@ class MainActivity : AppCompatActivity() {
40034003 val pendingCapabilities = mutableSetOf<String>()
40044004 val capabilityTimeouts = mutableMapOf<String, Runnable>()
40054005 val audio = CraftNativeAudio()
4006 val sounds = CraftNativeSounds(view.context)
4007 val soundRequestCancellers = mutableMapOf<String, () -> Unit>()
40064008 var active = true
40074009 var closing = false
40084010 private set
@@ -4018,6 +4020,9 @@ class MainActivity : AppCompatActivity() {
40184020 active = false
40194021 closing = true
40204022 audio.close()
4023 soundRequestCancellers.values.toList().forEach { it() }
4024 soundRequestCancellers.clear()
4025 sounds.close()
40214026 // Evaluation completion does not mean its outbound messages have
40224027 // reached the host. Close only after the ordered channel barrier,
40234028 // so onDestroy's final writes run before navigation continues.
@@ -4549,6 +4554,9 @@ class MainActivity : AppCompatActivity() {
45494554 activeScreen.view.onLayoutChanged = {
45504555 if (activeScreen.active) emitLayoutEvents(activeScreen)
45514556 }
4557 activeScreen.sounds.emit = { payload ->
4558 if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
4559 }
45524560 screen = activeScreen
45534561 CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
45544562 if (activeScreen.active) send(activeScreen, type, payload)
@@ -4926,6 +4934,7 @@ class MainActivity : AppCompatActivity() {
49264934 /** The bridge's capability names: Craft's core set and this host's services. */
49274935 private fun capabilityNames(): JSONArray = capabilities.names().apply {
49284936 services.names().forEach { name -> put(name) }
4937 put("audio")
49294938 put("viewFocus")
49304939 put("viewMeasure")
49314940 put("viewScroll")
@@ -5190,6 +5199,7 @@ class MainActivity : AppCompatActivity() {
51905199 val token = "${screen.capabilityScope}/$id"
51915200 val pending = screen.pendingCapabilities.remove(token)
51925201 screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
5202 screen.soundRequestCancellers.remove(token)?.invoke()
51935203 if (pending) capabilities.cancel(token)
51945204 }
51955205 }
@@ -7354,6 +7364,7 @@ class MainActivity : AppCompatActivity() {
73547364 val timeout = Runnable {
73557365 if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
73567366 screen.capabilityTimeouts.remove(token)
7367 screen.soundRequestCancellers.remove(token)?.invoke()
73577368 capabilities.cancel(token)
73587369 send(screen, "API_ERROR", JSONObject()
73597370 .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
@@ -7371,6 +7382,14 @@ class MainActivity : AppCompatActivity() {
73717382 val method = payload.optString("method")
73727383 val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
73737384 { completion -> performView(screen, method, args, completion) }
7385 } else if (module == "Audio") {
7386 { completion ->
7387 val id = args.optString(0)
7388 if (method == "play" && id.isNotBlank()) {
7389 screen.soundRequestCancellers[token] = { screen.sounds.stop(id, 0.0) }
7390 }
7391 performAudio(screen, method, args, completion)
7392 }
73747393 } else if (services.handles(module)) {
73757394 { completion -> services.perform(module, method, args, completion) }
73767395 } else {
@@ -7388,6 +7407,7 @@ class MainActivity : AppCompatActivity() {
73887407 perform { result ->
73897408 if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
73907409 screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
7410 screen.soundRequestCancellers.remove(token)
73917411 val response = JSONObject()
73927412 .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
73937413 .put("requestId", id)
@@ -7397,6 +7417,27 @@ class MainActivity : AppCompatActivity() {
73977417 }
73987418 }
73997419
7420 private fun performAudio(
7421 screen: Screen,
7422 method: String,
7423 args: JSONArray,
7424 completion: (CraftNativeCapabilityResult) -> Unit,
7425 ) {
7426 val id = args.optString(0)
7427 when (method) {
7428 "play" -> {
7429 val address = args.optString(1)
7430 screen.sounds.play(id, address, args.optJSONObject(2) ?: JSONObject(), completion)
7431 }
7432 "pause" -> completion(CraftNativeCapabilityResult(data = screen.sounds.pause(id)))
7433 "resume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.resume(id)))
7434 "setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id, args.optDouble(1, Double.NaN), args.optJSONObject(2)?.optDouble("fade", 0.0) ?: 0.0)))
7435 "stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, args.optJSONObject(1)?.optDouble("fadeOut", 0.0) ?: 0.0)))
7436 "stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(args.optJSONObject(0)?.optDouble("fadeOut", 0.0) ?: 0.0)))
7437 else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Audio method"))
7438 }
7439 }
7440
74007441 /** View operations are screen-owned: node ids only exist while a route is mounted. */
74017442 private fun performView(
74027443 screen: Screen,