From 60afc81ed9731e5715aa4f740f0b3727a27cb592 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Fri, 15 Mar 2024 19:58:12 +0900 Subject: [PATCH 01/11] feat: support reading resources as flows --- .../resources/ResourceReader.android.kt | 38 +++++++++- .../compose/resources/ResourceReader.kt | 24 +++++++ .../compose/resources/ComposeResourceTest.kt | 17 +++++ .../resources/ResourceReader.desktop.kt | 39 ++++++++++- .../compose/resources/ResourceReader.ios.kt | 46 ++++++++++++ .../compose/resources/ResourceReader.js.kt | 50 +++++++++++++ .../compose/resources/ResourceReader.macos.kt | 47 +++++++++++++ .../resources/ResourceReader.wasmJs.kt | 70 ++++++++++++++++++- 8 files changed, 324 insertions(+), 7 deletions(-) diff --git a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt b/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt index b04857b4247..f88f906e5ec 100644 --- a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt +++ b/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt @@ -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 { + 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() } \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt index c8d7f9ac1a8..c8f48777874 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt @@ -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. * @@ -14,6 +21,23 @@ 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 = 8192): Flow + internal interface ResourceReader { suspend fun read(path: String): ByteArray } diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index 61949ee8425..4d948163690 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt @@ -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.collect import kotlinx.coroutines.test.runTest import kotlin.test.* @@ -149,6 +150,9 @@ class ComposeResourceTest { assertFailsWith { readResourceBytes("missing.png") } + assertFailsWith { + getResourceAsFlow("missing.png").collect() + } val error = assertFailsWith { getString(TestStringResource("unknown_id")) } @@ -176,4 +180,17 @@ class ComposeResourceTest { bytes.decodeToString() ) } + + @Test + fun testGetFileResourceAsSource() = runTest { + val bytes = readResourceBytes("strings.xml") + val source = mutableListOf() + getResourceAsFlow("strings.xml").collect { chunk -> + println("p") + source.addAll(chunk.asList()) + println("q") + } + println("r") + assertContentEquals(bytes, source.toByteArray()) + } } diff --git a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt index 9d393b859a6..0e7ace9aa52 100644 --- a/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt +++ b/components/resources/library/src/desktopMain/kotlin/org/jetbrains/compose/resources/ResourceReader.desktop.kt @@ -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 { + 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) } \ No newline at end of file diff --git a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt index 0a81769a562..1a2b4286377 100644 --- a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt +++ b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.ios.kt @@ -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) @@ -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 { + 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) } \ No newline at end of file diff --git a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt index 8cfb63b243a..aaef2b91344 100644 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt +++ b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt @@ -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() @@ -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 { + 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() + 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() + 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 +} + +private external interface ReadableStreamBYOBReaderReadResult { + val value: ArrayBufferView? + val done: Boolean } \ No newline at end of file diff --git a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt index 906df6ae6cc..b08a4ad6be4 100644 --- a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt +++ b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/ResourceReader.macos.kt @@ -1,8 +1,16 @@ 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.NSFileManager +import platform.Foundation.NSInputStream +import platform.Foundation.inputStreamWithFileAtPath import platform.posix.memcpy @OptIn(ExperimentalResourceApi::class) @@ -21,4 +29,43 @@ 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 { + check(byteCount > 0) { "byteCount: $byteCount" } + return flow { + val currentDirectoryPath = NSFileManager.defaultManager().currentDirectoryPath + val stream = NSFileManager.defaultManager().run { + //todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain) + inputStreamAsPath("$currentDirectoryPath/src/macosMain/composeResources/$path") + ?: inputStreamAsPath("$currentDirectoryPath/src/macosTest/composeResources/$path") + ?: inputStreamAsPath("$currentDirectoryPath/src/commonMain/composeResources/$path") + ?: inputStreamAsPath("$currentDirectoryPath/src/commonTest/composeResources/$path") + } ?: 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 -> break + numBytesRead > 0 -> emit(buffer.sliceArray(0 until numBytesRead)) + else -> throw ResourceIOException(stream.streamError?.description) + } + } + } finally { + stream.close() + } + }.flowOn(Dispatchers.IO) +} + +private fun NSFileManager.inputStreamAsPath(path: String): NSInputStream? { + if (!isReadableFileAtPath(path)) { + return null + } + return NSInputStream.inputStreamWithFileAtPath(path) } \ No newline at end of file diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt index efb80613b36..19e0aecf75f 100644 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt @@ -2,9 +2,13 @@ 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 org.w3c.fetch.Response +import kotlin.js.Promise import kotlin.wasm.unsafe.UnsafeWasmMemoryApi import kotlin.wasm.unsafe.withScopedMemoryAllocator @@ -25,11 +29,46 @@ actual suspend fun readResourceBytes(path: String): ByteArray { return response.arrayBuffer().await().toByteArray() } -private fun ArrayBuffer.toByteArray(): ByteArray { +private fun ArrayBuffer.toByteArray(): ByteArray { val source = Int8Array(this, 0, byteLength) return jsInt8ArrayToKotlinByteArray(source) } +@OptIn(ExperimentalResourceApi::class) +@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE") +@InternalResourceApi +actual fun getResourceAsFlow(path: String, byteCount: Int): Flow { + 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 as ReadableStream).getBYOBReader() + 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 = jsInt8ArrayToKotlinByteArray(value as Int8Array) + if (array.isNotEmpty()) { + emit(array) + } + buffer = value.buffer + } + if (readResult.done) { + break + } + } + } +} + @JsFun( """ (src, size, dstAddr) => { const mem8 = new Int8Array(wasmExports.memory.buffer, dstAddr, size); @@ -42,11 +81,36 @@ internal external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray { val size = x.length - @OptIn(UnsafeWasmMemoryApi::class) - return withScopedMemoryAllocator { allocator -> + @OptIn(UnsafeWasmMemoryApi::class) return withScopedMemoryAllocator { allocator -> val memBuffer = allocator.allocate(size) val dstAddress = memBuffer.address.toInt() jsExportInt8ArrayToWasm(x, size, dstAddress) ByteArray(size) { i -> (memBuffer + i).loadByte() } } +} + +/** + * Exposes the JavaScript [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) to Kotlin + */ +private external interface ReadableStream : JsAny { + fun getReader(options: JsAny): T +} + +private fun byobReaderOption(): JsAny = js("""({ mode: "byob" })""") + +private fun ReadableStream.getBYOBReader(): ReadableStreamBYOBReader { + return getReader(byobReaderOption()) +} + +/** + * Exposes the JavaScript [ReadableStreamBYOBReader](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamBYOBReader) to Kotlin + */ +private external interface ReadableStreamBYOBReader : JsAny { + fun read(view: ArrayBufferView): Promise + fun releaseLock() +} + +private external interface ReadableStreamBYOBReaderReadResult : JsAny { + val value: ArrayBufferView? + val done: Boolean } \ No newline at end of file From 4be96281b16328ec7918662f69e0191ce8e28e2a Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Fri, 15 Mar 2024 20:34:14 +0900 Subject: [PATCH 02/11] feat: generate Res.getAsFlow --- .../compose/resources/ResourcesSpec.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt index 100291d8663..48033d45f47 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt @@ -1,6 +1,7 @@ package org.jetbrains.compose.resources import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.plusParameter import org.jetbrains.compose.internal.utils.uppercaseFirstChar import java.nio.file.Path import java.util.* @@ -151,6 +152,39 @@ internal fun getResFileSpecs( .addStatement("""return %M("$moduleDir" + path)""", readResourceBytes) .build() ) + + val getResourceAsFlow = MemberName("org.jetbrains.compose.resources", "getResourceAsFlow") + resObject.addFunction( + FunSpec.builder("getAsFlow") + .addKdoc( + """ + 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]. + + Example: `val bytes = Res.getAsFlow("files/key.bin").collectToList().flatten()` + + @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. + """.trimIndent() + ) + .addParameter("path", String::class) + .addParameter("byteCount", Int::class) + .returns( + ClassName("kotlinx.coroutines.flow", "Flow") + .plusParameter(ByteArray::class.asTypeName()) + ) + .addStatement("""return %M("$moduleDir" + path, byteCount)""", getResourceAsFlow) + .build() + ) + ResourceType.values().forEach { type -> resObject.addType(TypeSpec.objectBuilder(type.typeName).build()) } From 78d1f504b630ce67dfca71d5a678b32ae41616c3 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 00:59:33 +0900 Subject: [PATCH 03/11] chore: fix typos in generated KDoc of getAsFlow --- .../org/jetbrains/compose/resources/ResourcesSpec.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt index 48033d45f47..e4d35becf92 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt @@ -160,15 +160,15 @@ internal fun getResFileSpecs( """ 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]. + [MissingResourceException][org.jetbrains.compose.resources.MissingResourceException] when the + resource file is missing or [ResourceIOException][org.jetbrains.compose.resources.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]. Example: `val bytes = Res.getAsFlow("files/key.bin").collectToList().flatten()` @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. + @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. From 32ce0c6c0efff6da3edba82f5b438ed60d56ee2d Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 00:59:56 +0900 Subject: [PATCH 04/11] chore: add a default value to Res.getAsFlow's byteCount --- .../kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt index e4d35becf92..75e85165b3a 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt @@ -176,7 +176,11 @@ internal fun getResFileSpecs( """.trimIndent() ) .addParameter("path", String::class) - .addParameter("byteCount", Int::class) + .addParameter( + ParameterSpec.builder("byteCount", Int::class) + .defaultValue("%L", 8192) + .build() + ) .returns( ClassName("kotlinx.coroutines.flow", "Flow") .plusParameter(ByteArray::class.asTypeName()) From 5d85765e89e0a7458464761ede7b71d6fdf4d594 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 01:09:57 +0900 Subject: [PATCH 05/11] Add demo code using Res.getAsFlow() --- components/gradle/libs.versions.toml | 2 + .../resources/demo/shared/build.gradle.kts | 1 + .../compose/resources/demo/shared/FileRes.kt | 50 ++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/components/gradle/libs.versions.toml b/components/gradle/libs.versions.toml index 0a6d16ca83d..607c2dd1c5f 100644 --- a/components/gradle/libs.versions.toml +++ b/components/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/components/resources/demo/shared/build.gradle.kts b/components/resources/demo/shared/build.gradle.kts index fd6510bd452..74f055ffc59 100644 --- a/components/resources/demo/shared/build.gradle.kts +++ b/components/resources/demo/shared/build.gradle.kts @@ -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 diff --git a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt index efe4b687374..73ed97f4cbe 100644 --- a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt +++ b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt @@ -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), @@ -50,7 +51,7 @@ fun FileRes(paddingValues: PaddingValues) { ) Text( modifier = Modifier.padding(16.dp), - text = "File: 'files/platform-text.txt'", + text = "File: 'files/icon.xml'", style = MaterialTheme.typography.titleLarge ) OutlinedCard( @@ -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() + ) } } \ No newline at end of file From a7e86a79f0ef11af27ee0d4715ba7b7e17cf7202 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:07:21 +0900 Subject: [PATCH 06/11] refactor: prevent hard-coding of byteCount of resource flow --- .../org/jetbrains/compose/resources/ResourceReader.kt | 10 ++++++++-- .../org/jetbrains/compose/resources/ResourcesSpec.kt | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt index c8f48777874..960fb9f642c 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt @@ -33,10 +33,16 @@ expect suspend fun readResourceBytes(path: String): ByteArray * * @return A flow that emits the content of the file as byte sub-arrays. * - * @throws IllegalArgumentException - when [byteCount] is not positive. + * @throws IllegalArgumentException When [byteCount] is not positive. */ @InternalResourceApi -expect fun getResourceAsFlow(path: String, byteCount: Int = 8192): Flow +expect fun getResourceAsFlow(path: String, byteCount: Int = DEFAULT_RESOURCE_CHUNK_SIZE): Flow + +/** + * 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 diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt index 75e85165b3a..744aa2e5a8f 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt @@ -153,6 +153,9 @@ internal fun getResFileSpecs( .build() ) + val defaultResourceChunkSize = MemberName( + "org.jetbrains.compose.resources", "DEFAULT_RESOURCE_CHUNK_SIZE" + ) val getResourceAsFlow = MemberName("org.jetbrains.compose.resources", "getResourceAsFlow") resObject.addFunction( FunSpec.builder("getAsFlow") @@ -172,13 +175,13 @@ internal fun getResFileSpecs( @return A flow that emits the content of the file as byte sub-arrays. - @throws IllegalArgumentException - when [byteCount] is not positive. + @throws IllegalArgumentException When [byteCount] is not positive. """.trimIndent() ) .addParameter("path", String::class) .addParameter( ParameterSpec.builder("byteCount", Int::class) - .defaultValue("%L", 8192) + .defaultValue("%M", defaultResourceChunkSize) .build() ) .returns( From 2d16da11df52f231de0c8337082a69d221654034 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:12:28 +0900 Subject: [PATCH 07/11] chore: remove `println`s from tests --- .../org/jetbrains/compose/resources/ComposeResourceTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index 4d948163690..35ff8248dd7 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt @@ -186,11 +186,8 @@ class ComposeResourceTest { val bytes = readResourceBytes("strings.xml") val source = mutableListOf() getResourceAsFlow("strings.xml").collect { chunk -> - println("p") source.addAll(chunk.asList()) - println("q") } - println("r") assertContentEquals(bytes, source.toByteArray()) } } From d98463d88d2fdf0d9c0db73045d0933e4c820a03 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:13:49 +0900 Subject: [PATCH 08/11] chore: remove unused functions --- .../org/jetbrains/compose/resources/ResourceReader.wasmJs.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt index 19e0aecf75f..3ad4522b7d2 100644 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt @@ -107,7 +107,6 @@ private fun ReadableStream.getBYOBReader(): ReadableStreamBYOBReader { */ private external interface ReadableStreamBYOBReader : JsAny { fun read(view: ArrayBufferView): Promise - fun releaseLock() } private external interface ReadableStreamBYOBReaderReadResult : JsAny { From f225ad799b85b92ea8ff15ae6d3ec50fdb4044a7 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:17:51 +0900 Subject: [PATCH 09/11] chore: fix example code in generated Res.getAsFlow --- .../org/jetbrains/compose/resources/ComposeResourceTest.kt | 7 ++----- .../org/jetbrains/compose/resources/ResourcesSpec.kt | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index 35ff8248dd7..211e406f316 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt @@ -5,7 +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.collect +import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import kotlin.test.* @@ -184,10 +184,7 @@ class ComposeResourceTest { @Test fun testGetFileResourceAsSource() = runTest { val bytes = readResourceBytes("strings.xml") - val source = mutableListOf() - getResourceAsFlow("strings.xml").collect { chunk -> - source.addAll(chunk.asList()) - } + val source = getResourceAsFlow("strings.xml").toList().flatMap { it.asList() } assertContentEquals(bytes, source.toByteArray()) } } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt index 744aa2e5a8f..1161d7fc07d 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ResourcesSpec.kt @@ -168,7 +168,7 @@ internal fun getResFileSpecs( 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]. - Example: `val bytes = Res.getAsFlow("files/key.bin").collectToList().flatten()` + Example: `val bytes = Res.getAsFlow("files/key.bin").toList().flatMap { it.asList() }` @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. From f0be61b59b3159e2dfc6477453bd36a2425bfaa4 Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:24:51 +0900 Subject: [PATCH 10/11] chore: fix section title in the demo app --- .../org/jetbrains/compose/resources/demo/shared/FileRes.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt index 73ed97f4cbe..b246cad41d4 100644 --- a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt +++ b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/FileRes.kt @@ -51,7 +51,7 @@ fun FileRes(paddingValues: PaddingValues) { ) Text( modifier = Modifier.padding(16.dp), - text = "File: 'files/icon.xml'", + text = "File: 'files/platform-text.txt'", style = MaterialTheme.typography.titleLarge ) OutlinedCard( From 97f6cf8796de99f8cae196b9043ec2c9104f7bdd Mon Sep 17 00:00:00 2001 From: Chanjung Kim Date: Mon, 18 Mar 2024 02:26:03 +0900 Subject: [PATCH 11/11] chore: revert code style changes --- .../org/jetbrains/compose/resources/ResourceReader.wasmJs.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt index 3ad4522b7d2..02ecedd54b2 100644 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt @@ -81,7 +81,8 @@ internal external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray { val size = x.length - @OptIn(UnsafeWasmMemoryApi::class) return withScopedMemoryAllocator { allocator -> + @OptIn(UnsafeWasmMemoryApi::class) + return withScopedMemoryAllocator { allocator -> val memBuffer = allocator.allocate(size) val dstAddress = memBuffer.address.toInt() jsExportInt8ArrayToWasm(x, size, dstAddress)