package dev.darkpyonix.composerust.ui.platform
import dev.darkpyonix.composerust.protocol.HostEvent
import dev.darkpyonix.composerust.runtime.ComposeRustHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
/**
* The process-wide Host.
*
* Android recreates an Activity for a configuration change but not the process, and the
* VirtualDom is kept for the life of the process. Holding the interpreted tree here, and
* not in the composition, is what makes a rotation cost nothing: the new Activity composes
* the same node table again, with no boundary call and no state lost.
*
* Everything here runs on the UI thread, which is where the VirtualDom lives.
*/
object ComposeRustRuntime {
/**
* The application's cdylib, once it has been named.
*
* It is the Rust side of the app: it links this crate, declares its root component
* through `compose_rust::android_main!`, and carries the generated JNI shims and
* `JNI_OnLoad` with it. Its file name is the application's to choose, which is why it
* is not a constant here: an application built by the Dioxus CLI gets one name, a
* Gradle project put together by hand another.
*/
private var library: String? = null
private val connection = AndroidHostConnection()
private var host: ComposeRustHost? = null
/**
* Loads the application's cdylib. The generated Activity calls this before anything
* else, with the name the build gave it.
*
* Loading more than once is the ordinary case rather than a mistake: an Activity is
* recreated for a configuration change and the process is not. The second call is the
* runtime's own no-op.
*/
fun load(name: String) {
try {
System.loadLibrary(name)
} catch (missing: UnsatisfiedLinkError) {
// The default message names the library and the directories it looked in,
// which leaves the reader with a file name and no way to get the file. So the
// message says what produces it.
throw UnsatisfiedLinkError(
"lib$name.so is not in this APK, so there is no Host to draw with. It is " +
"the Rust side of this application, built for an Android target and " +
"packaged under lib/<abi>/ in the APK. " +
"(${missing.message})",
)
}
library = name
}
/** The Host, started on first use and kept until the process ends. */
fun host(): ComposeRustHost {
host?.let { return it }
checkNotNull(library) {
"no cdylib has been loaded, so the boundary functions this is about to call " +
"are not in the process yet. An Activity calls ComposeRustRuntime.load() with " +
"the name of the application's own library before asking for the Host."
}
installAndroidBack()
val created = ComposeRustHost(connection)
created.start()
host = created
return created
}
/** The UI came back on screen. Timers and animations resume. */
fun start() {
host?.dispatch(HostEvent.LifecycleStart(NO_NODE, NO_HANDLER))
}
/** The UI left the screen. The Host suppresses timers and animations. */
fun stop() {
host?.dispatch(HostEvent.LifecycleStop(NO_NODE, NO_HANDLER))
}
/**
* Asks for the whole tree again, for a Renderer that lost its node table.
*
* Nothing calls it on the ordinary path, and that is the point: the two sides share a
* process, so the table can only be lost if the Renderer throws it away, and this
* object does not. It is here for a Renderer that chooses to, and it costs component
* state, because the Host keeps no shadow of the tree it already sent.
*/
fun resync() {
host?.resync()
}
private val scope = MainScope()
private var unattended: Job? = null
/**
* An Activity is drawing the Host's tree again, and its frame loop serves the Host's
* frame requests.
*/
fun attached() {
unattended?.cancel()
unattended = null
}
/**
* No Activity is drawing, and none may be for a while: the user went back out of the
* application and the process stayed.
*
* The Host is still running and a worker can still finish. Its frame request has no
* frame loop to reach, so it is served here, on the UI thread, straight away. The UI is
* stopped, so the Host renders nothing for it but what has to go out while nobody is
* looking, which is a notification.
*/
fun detached() {
val host = host ?: return
if (unattended != null) return
unattended = scope.launch {
serveFrameRequests(
FrameRequests.global.counter,
awaitFrame = { awaitCancellation() },
serve = host::renderFrame,
stoppedAfterMillis = 0,
)
}
}
/** True once the Host exists, so a recreated Activity can tell the two cases apart. */
val isStarted: Boolean get() = host != null
/** A lifecycle event addresses the Host itself, so it carries no node and no handler. */
private const val NO_NODE = 0
private const val NO_HANDLER = 0L
}