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/fixtures/native-navigation/NativeCapabilitiesTest.ktmodified+97-0
Changes to packages/android/fixtures/native-navigation/NativeCapabilitiesTest.kt
@@ -1,6 +1,9 @@
11package dev.craft.navigationtest
22
33import android.content.Intent
4import android.media.AudioManager
5import android.media.MediaPlayer
6import androidx.test.core.app.ActivityScenario
47import androidx.test.ext.junit.runners.AndroidJUnit4
58import androidx.test.platform.app.InstrumentationRegistry
69import java.net.ServerSocket
@@ -43,6 +46,100 @@ class NativeCapabilitiesTest {
4346
4447 private val instrumentation = InstrumentationRegistry.getInstrumentation()
4548
49 // A real local PCM file keeps these playback regressions independent of
50 // networking, codecs downloaded by the device, and the user's media.
51 private fun silentWav(): java.io.File {
52 val samples = 8000 * 2
53 val bytes = java.nio.ByteBuffer.allocate(44 + samples * 2).order(java.nio.ByteOrder.LITTLE_ENDIAN)
54 bytes.put("RIFF".toByteArray()).putInt(36 + samples * 2).put("WAVEfmt ".toByteArray())
55 bytes.putInt(16).putShort(1.toShort()).putShort(1.toShort()).putInt(8000).putInt(16000)
56 bytes.putShort(2.toShort()).putShort(16.toShort()).put("data".toByteArray()).putInt(samples * 2)
57 return java.io.File(instrumentation.targetContext.cacheDir, "sound-${UUID.randomUUID()}.wav").apply { writeBytes(bytes.array()) }
58 }
59
60 private fun sound(service: CraftNativeSounds): Any =
61 (CraftNativeSounds::class.java.getDeclaredField("sounds").apply { isAccessible = true }.get(service) as Map<*, *>)["tone"]!!
62
63 private fun field(sound: Any, name: String): Any? =
64 sound.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(sound)
65
66 private fun focus(service: CraftNativeSounds, change: Int) {
67 CraftNativeSounds::class.java.getDeclaredMethod("onAudioFocusChange", Int::class.javaPrimitiveType)
68 .apply { isAccessible = true }.invoke(service, change)
69 }
70
71 @Test
72 fun replacingAPreparingSoundSettlesItAndItsOldCancellationCannotStopTheReplacement() {
73 val file = silentWav()
74 try {
75 ActivityScenario.launch(MainActivity::class.java).use { scenario ->
76 lateinit var service: CraftNativeSounds
77 val ready = CountDownLatch(1)
78 var replacement: CraftNativeCapabilityResult? = null
79 var cancelled = 0
80 try {
81 scenario.onActivity { activity ->
82 service = CraftNativeSounds(activity)
83 service.play("tone", file.toURI().toString(), JSONObject()) {
84 assertEquals("CANCELLED", it.code)
85 cancelled++
86 }
87 val oldCancel = service.cancellation("tone")
88 service.play("tone", file.toURI().toString(), JSONObject().put("loop", true)) {
89 replacement = it
90 ready.countDown()
91 }
92 assertEquals(1, cancelled)
93 oldCancel()
94 }
95 assertTrue("replacement did not prepare", ready.await(10, TimeUnit.SECONDS))
96 assertNull(replacement?.message, replacement?.code)
97 scenario.onActivity {
98 assertTrue((field(sound(service), "player") as MediaPlayer).isPlaying)
99 assertEquals(1, cancelled)
100 }
101 } finally { scenario.onActivity { service.close() } }
102 }
103 } finally { file.delete() }
104 }
105
106 @Test
107 fun explicitPauseSurvivesFocusGainAndVolumeChangesStayCurrent() {
108 val file = silentWav()
109 try {
110 ActivityScenario.launch(MainActivity::class.java).use { scenario ->
111 lateinit var service: CraftNativeSounds
112 val ready = CountDownLatch(1)
113 var result: CraftNativeCapabilityResult? = null
114 try {
115 scenario.onActivity { activity ->
116 service = CraftNativeSounds(activity)
117 service.play("tone", file.toURI().toString(), JSONObject().put("loop", true).put("volume", 0.8).put("fadeIn", 1000)) {
118 result = it
119 ready.countDown()
120 }
121 assertEquals(0f, field(sound(service), "volume"))
122 }
123 assertTrue("sound did not prepare", ready.await(10, TimeUnit.SECONDS))
124 assertNull(result?.message, result?.code)
125 scenario.onActivity {
126 assertTrue(service.setVolume("tone", 0.25, 0.0))
127 assertEquals(0.25f, field(sound(service), "volume"))
128 focus(service, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)
129 focus(service, AudioManager.AUDIOFOCUS_GAIN)
130 assertEquals(0.25f, field(sound(service), "volume"))
131 focus(service, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
132 assertTrue(service.pause("tone"))
133 focus(service, AudioManager.AUDIOFOCUS_GAIN)
134 assertFalse((field(sound(service), "player") as MediaPlayer).isPlaying)
135 assertTrue(service.resume("tone"))
136 assertTrue((field(sound(service), "player") as MediaPlayer).isPlaying)
137 }
138 } finally { scenario.onActivity { service.close() } }
139 }
140 } finally { file.delete() }
141 }
142
46143 @Test
47144 fun rejectedSnapshotReplacementPreservesDiskCacheAndTheOtherIsolates() {
48145 val base = instrumentation.targetContext
packages/android/templates/CraftNativeSounds.kt.templatemodified+34-16
Changes to packages/android/templates/CraftNativeSounds.kt.template
@@ -26,7 +26,9 @@ internal class CraftNativeSounds(private val context: Context) {
2626 var volume: Float,
2727 var paused: Boolean = false,
2828 var interrupted: Boolean = false,
29 var prepared: Boolean = false,
2930 var fade: Runnable? = null,
31 var pending: ((CraftNativeCapabilityResult) -> Unit)? = null,
3032 )
3133
3234 private val main = Handler(Looper.getMainLooper())
@@ -58,24 +60,23 @@ internal class CraftNativeSounds(private val context: Context) {
5860 val fadeIn = options.optDouble("fadeIn", 0.0)
5961 if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
6062 val player = MediaPlayer()
61 val sound = Sound(player, volume)
63 val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
6264 sounds[id] = sound
63 var settled = false
6465 fun settle(result: CraftNativeCapabilityResult) {
65 if (settled) return
66 settled = true
67 done(result)
66 val answer = sound.pending ?: return
67 sound.pending = null
68 answer(result)
6869 }
6970 fun failed(message: String) {
7071 if (sounds[id] !== sound) return
71 remove(id)
7272 settle(failure("AUDIO_ERROR", message))
73 remove(id)
7374 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message))
7475 }
7576
7677 if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
77 remove(id)
7878 settle(failure("AUDIO_FOCUS_DENIED", "Audio focus was denied by the operating system"))
79 remove(id)
7980 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was denied by the operating system"))
8081 return
8182 }
@@ -89,9 +90,13 @@ internal class CraftNativeSounds(private val context: Context) {
8990 player.setVolume(if (fadeIn > 0) 0f else volume, if (fadeIn > 0) 0f else volume)
9091 player.setOnPreparedListener {
9192 if (sounds[id] !== sound) return@setOnPreparedListener
93 sound.prepared = true
94 if (!sound.paused && !sound.interrupted) player.start()
9295 settle(CraftNativeCapabilityResult(data = true))
93 player.start()
94 if (fadeIn > 0) ramp(sound, volume, fadeIn / 1000.0)
96 if (fadeIn > 0) {
97 if (!sound.paused) ramp(sound, volume, fadeIn / 1000.0)
98 else { sound.volume = volume; player.setVolume(volume, volume) }
99 }
95100 }
96101 player.setOnCompletionListener {
97102 if (sounds[id] !== sound) return@setOnCompletionListener
@@ -106,25 +111,32 @@ internal class CraftNativeSounds(private val context: Context) {
106111 player.prepareAsync()
107112 }.onFailure { error ->
108113 if (sounds[id] === sound) {
109 remove(id)
110114 settle(failure("AUDIO_ERROR", error.message ?: "The sound could not be loaded"))
115 remove(id)
111116 emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", error.message ?: "The sound could not be loaded"))
112117 }
113118 }
114119 }
115120
121 /** Cancellation belongs to this playback, not a later reuse of its id. */
122 fun cancellation(id: String): () -> Unit {
123 val sound = sounds[id]
124 return { if (sound != null && sounds[id] === sound) remove(id) }
125 }
126
116127 fun pause(id: String): Boolean {
117128 val sound = sounds[id] ?: return false
118129 sound.fade?.let(main::removeCallbacks)
119130 sound.fade = null
120131 runCatching { if (sound.player.isPlaying) sound.player.pause() }
121132 sound.paused = true
133 sound.interrupted = false
122134 return true
123135 }
124136
125137 fun resume(id: String): Boolean {
126138 val sound = sounds[id] ?: return false
127 runCatching { sound.player.start() }.onFailure { return false }
139 if (sound.prepared) runCatching { sound.player.start() }.onFailure { return false }
128140 sound.paused = false
129141 sound.interrupted = false
130142 return true
@@ -137,7 +149,10 @@ internal class CraftNativeSounds(private val context: Context) {
137149 sound.fade?.let(main::removeCallbacks)
138150 sound.fade = null
139151 if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
140 else sound.player.setVolume(target, target)
152 else {
153 sound.volume = target
154 sound.player.setVolume(target, target)
155 }
141156 return true
142157 }
143158
@@ -181,6 +196,9 @@ internal class CraftNativeSounds(private val context: Context) {
181196 }
182197 sound.player.release()
183198 if (sounds.isEmpty()) audioManager.abandonAudioFocusRequest(focusRequest)
199 val answer = sound.pending
200 sound.pending = null
201 answer?.invoke(failure("CANCELLED", "Audio playback was stopped or replaced"))
184202 }
185203
186204 private fun onAudioFocusChange(change: Int) {
@@ -194,18 +212,18 @@ internal class CraftNativeSounds(private val context: Context) {
194212 }
195213 }
196214 AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> sounds.values.toList().forEach { sound ->
197 if (!sound.paused && runCatching { sound.player.isPlaying }.getOrDefault(false)) {
215 if (!sound.paused) {
198216 sound.interrupted = true
199 runCatching { sound.player.pause() }
217 if (sound.prepared) runCatching { sound.player.pause() }
200218 }
201219 }
202220 AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> sounds.values.toList().forEach { sound ->
203221 runCatching { sound.player.setVolume(sound.volume * 0.2f, sound.volume * 0.2f) }
204222 }
205223 AudioManager.AUDIOFOCUS_GAIN -> sounds.values.toList().forEach { sound ->
206 if (sound.interrupted) {
224 if (sound.interrupted && !sound.paused) {
207225 sound.interrupted = false
208 runCatching { sound.player.start() }
226 if (sound.prepared) runCatching { sound.player.start() }
209227 } else {
210228 runCatching { sound.player.setVolume(sound.volume, sound.volume) }
211229 }
packages/android/templates/MainActivityNative.kt.templatemodified+3-3
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -7663,10 +7663,10 @@ class MainActivity : AppCompatActivity() {
76637663 } else if (module == "Audio") {
76647664 { completion ->
76657665 val id = args.optString(0)
7666 if (method == "play" && id.isNotBlank()) {
7667 screen.soundRequestCancellers[token] = { screen.sounds.stop(id, 0.0) }
7668 }
76697666 performAudio(screen, method, args, completion)
7667 if (method == "play" && id.isNotBlank() && screen.pendingCapabilities.contains(token)) {
7668 screen.soundRequestCancellers[token] = screen.sounds.cancellation(id)
7669 }
76707670 }
76717671 } else if (services.handles(module)) {
76727672 { completion -> services.perform(module, method, args, completion) }