also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
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.
| @@ -782,6 +782,23 @@ describe('Craft Android builder', () => { | ||
| 782 | 782 | expect(native).toContain('control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }') |
| 783 | 783 | }) |
| 784 | 784 | |
| 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 | ||
| 785 | 802 | it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => { |
| 786 | 803 | const output = createTempDir('craft-android-native-wallet-') |
| 787 | 804 | await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } }) |
| @@ -884,6 +884,7 @@ import com.google.android.gms.pay.PayClient` : '') | ||
| 884 | 884 | done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured")) |
| 885 | 885 | }` |
| 886 | 886 | : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))')) |
| 887 | write('CraftNativeSounds.kt.template', 'CraftNativeSounds.kt') | |
| 887 | 888 | if (config.enablePushNotifications) write('CraftNativeMessagingService.kt.template', 'CraftNativeMessagingService.kt') |
| 888 | 889 | else rmSync(join(javaDir, 'CraftNativeMessagingService.kt'), { force: true }) |
| 889 | 890 | // Files a screen shares (craft.files' copies, in the cache) go to other apps through this provider. |
| @@ -0,0 +1,189 @@ | ||
| 1 | package {{PACKAGE_NAME}} | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.media.AudioAttributes | |
| 5 | import android.media.MediaPlayer | |
| 6 | import android.net.Uri | |
| 7 | import android.os.Handler | |
| 8 | import android.os.Looper | |
| 9 | import org.json.JSONObject | |
| 10 | import java.io.File | |
| 11 | import kotlin.math.max | |
| 12 | import 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 | */ | |
| 21 | internal 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 | } | |
| @@ -4003,6 +4003,8 @@ class MainActivity : AppCompatActivity() { | ||
| 4003 | 4003 | val pendingCapabilities = mutableSetOf<String>() |
| 4004 | 4004 | val capabilityTimeouts = mutableMapOf<String, Runnable>() |
| 4005 | 4005 | val audio = CraftNativeAudio() |
| 4006 | val sounds = CraftNativeSounds(view.context) | |
| 4007 | val soundRequestCancellers = mutableMapOf<String, () -> Unit>() | |
| 4006 | 4008 | var active = true |
| 4007 | 4009 | var closing = false |
| 4008 | 4010 | private set |
| @@ -4018,6 +4020,9 @@ class MainActivity : AppCompatActivity() { | ||
| 4018 | 4020 | active = false |
| 4019 | 4021 | closing = true |
| 4020 | 4022 | audio.close() |
| 4023 | soundRequestCancellers.values.toList().forEach { it() } | |
| 4024 | soundRequestCancellers.clear() | |
| 4025 | sounds.close() | |
| 4021 | 4026 | // Evaluation completion does not mean its outbound messages have |
| 4022 | 4027 | // reached the host. Close only after the ordered channel barrier, |
| 4023 | 4028 | // so onDestroy's final writes run before navigation continues. |
| @@ -4549,6 +4554,9 @@ class MainActivity : AppCompatActivity() { | ||
| 4549 | 4554 | activeScreen.view.onLayoutChanged = { |
| 4550 | 4555 | if (activeScreen.active) emitLayoutEvents(activeScreen) |
| 4551 | 4556 | } |
| 4557 | activeScreen.sounds.emit = { payload -> | |
| 4558 | if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload) | |
| 4559 | } | |
| 4552 | 4560 | screen = activeScreen |
| 4553 | 4561 | CraftNativeHub.register(activeScreen.capabilityScope) { type, payload -> |
| 4554 | 4562 | if (activeScreen.active) send(activeScreen, type, payload) |
| @@ -4926,6 +4934,7 @@ class MainActivity : AppCompatActivity() { | ||
| 4926 | 4934 | /** The bridge's capability names: Craft's core set and this host's services. */ |
| 4927 | 4935 | private fun capabilityNames(): JSONArray = capabilities.names().apply { |
| 4928 | 4936 | services.names().forEach { name -> put(name) } |
| 4937 | put("audio") | |
| 4929 | 4938 | put("viewFocus") |
| 4930 | 4939 | put("viewMeasure") |
| 4931 | 4940 | put("viewScroll") |
| @@ -5190,6 +5199,7 @@ class MainActivity : AppCompatActivity() { | ||
| 5190 | 5199 | val token = "${screen.capabilityScope}/$id" |
| 5191 | 5200 | val pending = screen.pendingCapabilities.remove(token) |
| 5192 | 5201 | screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks) |
| 5202 | screen.soundRequestCancellers.remove(token)?.invoke() | |
| 5193 | 5203 | if (pending) capabilities.cancel(token) |
| 5194 | 5204 | } |
| 5195 | 5205 | } |
| @@ -7354,6 +7364,7 @@ class MainActivity : AppCompatActivity() { | ||
| 7354 | 7364 | val timeout = Runnable { |
| 7355 | 7365 | if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable |
| 7356 | 7366 | screen.capabilityTimeouts.remove(token) |
| 7367 | screen.soundRequestCancellers.remove(token)?.invoke() | |
| 7357 | 7368 | capabilities.cancel(token) |
| 7358 | 7369 | send(screen, "API_ERROR", JSONObject() |
| 7359 | 7370 | .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) |
| @@ -7371,6 +7382,14 @@ class MainActivity : AppCompatActivity() { | ||
| 7371 | 7382 | val method = payload.optString("method") |
| 7372 | 7383 | val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") { |
| 7373 | 7384 | { 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 | } | |
| 7374 | 7393 | } else if (services.handles(module)) { |
| 7375 | 7394 | { completion -> services.perform(module, method, args, completion) } |
| 7376 | 7395 | } else { |
| @@ -7388,6 +7407,7 @@ class MainActivity : AppCompatActivity() { | ||
| 7388 | 7407 | perform { result -> |
| 7389 | 7408 | if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform |
| 7390 | 7409 | screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks) |
| 7410 | screen.soundRequestCancellers.remove(token) | |
| 7391 | 7411 | val response = JSONObject() |
| 7392 | 7412 | .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) |
| 7393 | 7413 | .put("requestId", id) |
| @@ -7397,6 +7417,27 @@ class MainActivity : AppCompatActivity() { | ||
| 7397 | 7417 | } |
| 7398 | 7418 | } |
| 7399 | 7419 | |
| 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 | ||
| 7400 | 7441 | /** View operations are screen-owned: node ids only exist while a route is mounted. */ |
| 7401 | 7442 | private fun performView( |
| 7402 | 7443 | screen: Screen, |