Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support retrieving resources as flows #4500

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions components/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[versions]
kotlinx-coroutines = "1.7.3"
kotlinx-io = "0.3.1"
androidx-appcompat = "1.6.1"
androidx-activity-compose = "1.8.2"
androidx-test = "1.5.0"
Expand All @@ -8,6 +9,7 @@ androidx-compose = "1.6.0"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
Expand Down
1 change: 1 addition & 0 deletions components/resources/demo/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ kotlin {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.material3)
implementation(libs.kotlinx.io.core)
implementation(project(":resources:library"))
}
val desktopMain by getting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.shared.generated.resources.Res
import kotlinx.io.*

@Composable
fun FileRes(paddingValues: PaddingValues) {
Column(
modifier = Modifier.padding(paddingValues)
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())
) {
Text(
modifier = Modifier.padding(16.dp),
Expand Down Expand Up @@ -80,5 +81,50 @@ fun FileRes(paddingValues: PaddingValues) {
Text(bytes.decodeToString())
""".trimIndent()
)
Text(
modifier = Modifier.padding(16.dp),
text = "File: 'drawable/compose.png'",
style = MaterialTheme.typography.titleLarge
)
OutlinedCard(
modifier = Modifier.padding(horizontal = 16.dp),
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
var content by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
@OptIn(ExperimentalStdlibApi::class)
Buffer().use { buffer ->
Res.getAsFlow("drawable/compose.png").collect { chunk ->
buffer.write(chunk)
}
content = buffer.readByteArray().asList().toString()
}
}
Text(
modifier = Modifier.padding(8.dp).height(200.dp).verticalScroll(rememberScrollState()),
text = content,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Text(
modifier = Modifier.padding(16.dp),
text = """
import kotlinx.io.*

var content by remember {
mutableStateOf("")
}
LaunchedEffect(Unit) {
Buffer().use { buffer ->
Res.getAsFlow("drawable/compose.png").collect { chunk ->
buffer.write(chunk)
}
content = buffer.readByteArray().asList().toString()
}
}
Text(content)
""".trimIndent()
)
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
package org.jetbrains.compose.resources

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.File
import java.io.IOException
import java.io.InputStream

private object AndroidResourceReader

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
try {
return getResourceAsStream(path).readBytes()
} catch (e: IOException) {
throw ResourceIOException(e)
}
}

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual fun getResourceAsFlow(path: String, byteCount: Int): Flow<ByteArray> {
check(byteCount > 0) { "byteCount: $byteCount" }
return flow {
try {
val resource = getResourceAsStream(path)
val buffer = ByteArray(byteCount)
resource.use {
var numBytesRead: Int
while (resource.read(buffer).also { numBytesRead = it } != -1) {
emit(buffer.sliceArray(0 until numBytesRead))
}
}
} catch (e: IOException) {
throw ResourceIOException(e)
}
}.flowOn(Dispatchers.IO)
}

@OptIn(ExperimentalResourceApi::class)
private fun getResourceAsStream(path: String): InputStream {
val classLoader = Thread.currentThread().contextClassLoader ?: AndroidResourceReader.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: run {
return classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).parentFile?.name.orEmpty() == "font") {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource.readBytes()
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package org.jetbrains.compose.resources

import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.Flow

@ExperimentalResourceApi
class MissingResourceException(path: String) : Exception("Missing resource with path: $path")

@ExperimentalResourceApi
class ResourceIOException : Exception {
constructor(message: String?) : super(message)
constructor(cause: Throwable?) : super(cause)
}

/**
* Reads the content of the resource file at the specified path and returns it as a byte array.
*
Expand All @@ -14,6 +21,29 @@ class MissingResourceException(path: String) : Exception("Missing resource with
@InternalResourceApi
expect suspend fun readResourceBytes(path: String): ByteArray

/**
* Returns a flow which emits the content of the resource file as byte array chunks. The length of each chunk is not
* empty and has the length of [byteCount] or smaller. The flow will throw [MissingResourceException] when the resource
* file is missing or [ResourceIOException] if any IO error occurs. You can catch those with the
* [catch][kotlinx.coroutines.flow.catch] operator. This function is useful when the resource is too big to be contained
* in a single [ByteArray].
*
* @param path The path of the file to read in the resource's directory.
* @param byteCount The maximum length of the emitted byte arrays. The flow can emit an array smaller than this length.
*
* @return A flow that emits the content of the file as byte sub-arrays.
*
* @throws IllegalArgumentException When [byteCount] is not positive.
*/
@InternalResourceApi
expect fun getResourceAsFlow(path: String, byteCount: Int = DEFAULT_RESOURCE_CHUNK_SIZE): Flow<ByteArray>

/**
* The default size of byte array chunks emitted by flows built with [getResourceAsFlow].
*/
@InternalResourceApi
const val DEFAULT_RESOURCE_CHUNK_SIZE: Int = 8192

internal interface ResourceReader {
suspend fun read(path: String): ByteArray
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import kotlin.test.*

Expand Down Expand Up @@ -149,6 +150,9 @@ class ComposeResourceTest {
assertFailsWith<MissingResourceException> {
readResourceBytes("missing.png")
}
assertFailsWith<MissingResourceException> {
getResourceAsFlow("missing.png").collect()
}
val error = assertFailsWith<IllegalStateException> {
getString(TestStringResource("unknown_id"))
}
Expand Down Expand Up @@ -176,4 +180,11 @@ class ComposeResourceTest {
bytes.decodeToString()
)
}

@Test
fun testGetFileResourceAsSource() = runTest {
val bytes = readResourceBytes("strings.xml")
val source = getResourceAsFlow("strings.xml").toList().flatMap { it.asList() }
assertContentEquals(bytes, source.toByteArray())
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
package org.jetbrains.compose.resources

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.IOException
import java.io.InputStream

private object JvmResourceReader

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
try {
return getResourceAsStream(path).readBytes()
} catch (e: IOException) {
throw ResourceIOException(e)
}
}

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual fun getResourceAsFlow(path: String, byteCount: Int): Flow<ByteArray> {
check(byteCount > 0) { "byteCount: $byteCount" }
return flow {
try {
val resource = getResourceAsStream(path)
val buffer = ByteArray(byteCount)
resource.use {
var numBytesRead: Int
while (resource.read(buffer).also { numBytesRead = it } != -1) {
emit(buffer.sliceArray(0 until numBytesRead))
}
}
} catch (e: IOException) {
throw ResourceIOException(e)
}
}.flowOn(Dispatchers.IO)
}

@OptIn(ExperimentalResourceApi::class)
private fun getResourceAsStream(path: String): InputStream {
val classLoader = Thread.currentThread().contextClassLoader ?: JvmResourceReader.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
return resource.readBytes()
return classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package org.jetbrains.compose.resources

import kotlinx.cinterop.addressOf
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import platform.Foundation.NSBundle
import platform.Foundation.NSFileManager
import platform.Foundation.NSInputStream
import platform.Foundation.inputStreamWithFileAtPath
import platform.posix.memcpy

@OptIn(ExperimentalResourceApi::class)
Expand All @@ -18,4 +26,42 @@ actual suspend fun readResourceBytes(path: String): ByteArray {
memcpy(it.addressOf(0), contentsAtPath.bytes, contentsAtPath.length)
}
}
}

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual fun getResourceAsFlow(path: String, byteCount: Int): Flow<ByteArray> {
check(byteCount > 0) { "byteCount: $byteCount" }
return flow {
val fileManager = NSFileManager.defaultManager()
// todo: support fallback path at bundle root?
val composeResourcesPath = NSBundle.mainBundle.resourcePath + "/compose-resources/" + path
val stream = fileManager.inputStreamAsPath(composeResourcesPath) ?: throw MissingResourceException(path)
try {
stream.open()
val buffer = ByteArray(byteCount)
while (true) {
val numBytesRead = buffer.usePinned { pinned ->
stream.read(pinned.addressOf(0).reinterpret(), byteCount.toULong())
}.toInt()
when {
numBytesRead < 0 -> throw ResourceIOException(
stream.streamError?.localizedDescription ?: "Unknown error"
)

numBytesRead == 0 -> break
numBytesRead > 0 -> emit(buffer.sliceArray(0 until numBytesRead))
}
}
} finally {
stream.close()
}
}.flowOn(Dispatchers.IO)
}

private fun NSFileManager.inputStreamAsPath(path: String): NSInputStream? {
if (!isReadableFileAtPath(path)) {
return null
}
return NSInputStream.inputStreamWithFileAtPath(path)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package org.jetbrains.compose.resources

import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.ArrayBufferView
import org.khronos.webgl.Int8Array
import kotlin.js.Promise

private fun ArrayBuffer.toByteArray(): ByteArray =
Int8Array(this, 0, byteLength).unsafeCast<ByteArray>()
Expand All @@ -17,4 +21,50 @@ actual suspend fun readResourceBytes(path: String): ByteArray {
throw MissingResourceException(resPath)
}
return response.arrayBuffer().await().toByteArray()
}

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual fun getResourceAsFlow(path: String, byteCount: Int): Flow<ByteArray> {
check(byteCount > 0) { "byteCount: $byteCount" }
return flow {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await()
if (!response.ok) {
throw MissingResourceException(resPath)
}
val body = response.body ?: throw MissingResourceException(resPath)
val bodyReader = body.getReader(js("""({ mode: "byob" })""")).unsafeCast<ReadableStreamBYOBReader>()
var buffer = ArrayBuffer(byteCount)
while (true) {
val readResult = try {
bodyReader.read(Int8Array(buffer)).await()
} catch (e: Throwable) {
throw ResourceIOException(e)
}
val value = readResult.value
if (value != null) {
val array = value.unsafeCast<ByteArray>()
if (array.isNotEmpty()) {
emit(array)
}
buffer = value.buffer
}
if (readResult.done) {
break
}
}
}
}

/**
* Exposes the JavaScript [ReadableStreamBYOBReader](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamBYOBReader) to Kotlin
*/
private external interface ReadableStreamBYOBReader {
fun read(view: ArrayBufferView): Promise<ReadableStreamBYOBReaderReadResult>
}

private external interface ReadableStreamBYOBReaderReadResult {
val value: ArrayBufferView?
val done: Boolean
}
Loading