diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6eaf67..383a91c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kapacity = "0.9.9-beta08" +kapacity = "0.9.9-beta09" agp = "8.11.2" android-compileSdk = "36" android-minSdk = "24" diff --git a/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/InputStreamKapacityTest.kt b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/InputStreamKapacityTest.kt new file mode 100644 index 0000000..8f478c5 --- /dev/null +++ b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/InputStreamKapacityTest.kt @@ -0,0 +1,115 @@ +package io.github.developrofthings.kapacity.io + +import io.github.developrofthings.kapacity.Kapacity +import io.github.developrofthings.kapacity.byte +import java.io.ByteArrayInputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + + +class InputStreamKapacityTest { + + @Test + fun testReadFillsDestinationCorrectlyWithinKapacity() { + val data = byteArrayOf(1, 2, 3, 4, 5) + val stream = ByteArrayInputStream(data) + val destination = ByteArray(5) + + val bytesRead = stream.read(destination, kapacity = 3.byte) + + assertEquals(3, bytesRead) + assertEquals(1, destination[0]) + assertEquals(3, destination[2]) + assertEquals(0, destination[3]) // Remaining bytes should be untouched + } + + @Test + fun testReadReturnsMinusOneAtEndOfStream() { + val stream = ByteArrayInputStream(byteArrayOf()) + val destination = ByteArray(10) + + val bytesRead = stream.read(destination, kapacity = 5.byte) + + assertEquals(-1, bytesRead) + } + + @Test + fun testReadCoercesLengthToAvailableSpaceInDestination() { + val data = byteArrayOf(10, 20, 30, 40, 50) + val stream = ByteArrayInputStream(data) + val destination = ByteArray(3) // Smaller than the data and the requested kapacity + + // We request 5 bytes, but the buffer only has 3 slots. + val bytesRead = stream.read(destination, kapacity = 5.byte) + + assertEquals(3, bytesRead, "Should have capped the read at the destination size") + assertEquals(10, destination[0]) + assertEquals(30, destination[2]) + } + + @Test + fun testReadRespectsDestinationOffsetAndCapsRemainingSpace() { + val data = byteArrayOf(9, 9, 9, 9, 9) + val stream = ByteArrayInputStream(data) + val destination = ByteArray(10) + + // Start at index 8. Only 2 slots (8, 9) are left in the destination. + // We ask for 5 bytes, but should only safely get 2. + val bytesRead = stream.read(destination, destinationOffset = 8, kapacity = 5.byte) + + assertEquals(2, bytesRead) + assertEquals(9, destination[8]) + assertEquals(9, destination[9]) + } + + @Test + fun testReadReturnsZeroForNegativeKapacity() { + val stream = ByteArrayInputStream(byteArrayOf(1)) + val destination = ByteArray(5) + + val readBytes = stream.read(destination, kapacity = (-1).byte) + assertEquals(0, readBytes) + } + + @Test + fun testReadThrowsExceptionForOutOfBoundsOffset() { + val stream = ByteArrayInputStream(byteArrayOf(1)) + val destination = ByteArray(5) + + assertFailsWith { + // Offset is larger than the array size + stream.read(destination, destinationOffset = 6, kapacity = 1.byte) + } + + assertFailsWith { + // Negative offset + stream.read(destination, destinationOffset = -1, kapacity = 1.byte) + } + } + + // --- Tests for readKapacity() --- + + @Test + fun testReadKapacityReturnsValidKapacityOnSuccess() { + val data = byteArrayOf(100, 101, 102) + val stream = ByteArrayInputStream(data) + val destination = ByteArray(5) + + val kapacityRead = stream.readKapacity(destination, kapacity = 2.byte) + + assertEquals(2.byte, kapacityRead) + assertEquals(100, destination[0]) + assertEquals(101, destination[1]) + } + + @Test + fun testReadKapacityReturnsInvalidAtEndOfStream() { + val stream = ByteArrayInputStream(byteArrayOf()) + val destination = ByteArray(5) + + val kapacityRead = stream.readKapacity(destination, kapacity = 5.byte) + + assertEquals(Kapacity.INVALID, kapacityRead) + } +} \ No newline at end of file diff --git a/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/OutputStreamKapacityTest.kt b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/OutputStreamKapacityTest.kt new file mode 100644 index 0000000..4369a0a --- /dev/null +++ b/kapacity-io/src/androidDeviceTest/kotlin/io/github/developrofthings/kapacity/io/OutputStreamKapacityTest.kt @@ -0,0 +1,68 @@ +package io.github.developrofthings.kapacity.io + +import io.github.developrofthings.kapacity.byte +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class OutputStreamKapacityTest { + @Test + fun testOutputStreamWrite_exactFit() { + val stream = ByteArrayOutputStream() + val source = byteArrayOf(1, 2, 3, 4, 5) + + stream.write(source = source, kapacity = 5.byte) + + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), stream.toByteArray()) + } + + @Test + fun testOutputStreamWrite_limitedByKapacity() { + val stream = ByteArrayOutputStream() + val source = byteArrayOf(1, 2, 3, 4, 5) + + // Source has 5 bytes, but we strictly limit the write to 3 bytes + stream.write(source = source, kapacity = 3.byte) + + assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray()) + } + + @Test + fun testOutputStreamWrite_limitedBySourceSpace() { + val stream = ByteArrayOutputStream() + val source = byteArrayOf(1, 2, 3) + + // We ask to write 5 bytes, but the source array only has 3 + stream.write(source = source, kapacity = 5.byte) + + // Should safely clamp and write only the 3 available bytes without crashing + assertContentEquals(byteArrayOf(1, 2, 3), stream.toByteArray()) + } + + @Test + fun testOutputStreamWrite_withSourceOffset() { + val stream = ByteArrayOutputStream() + val source = byteArrayOf(9, 8, 7, 6, 5) + + // Skip the first 2 bytes. We ask for 10 bytes, but only 3 remain in the array. + stream.write( + source = source, + sourceOffset = 2, + kapacity = 10.byte + ) + + // Should clamp to the remaining bytes after the offset + assertContentEquals(byteArrayOf(7, 6, 5), stream.toByteArray()) + } + + @Test + fun testOutputStreamWrite_zeroKapacityEarlyExit() { + val stream = ByteArrayOutputStream() + val source = byteArrayOf(1, 2, 3) + + stream.write(source = source, kapacity = 0.byte) + + assertEquals(0, stream.toByteArray().size, "Stream should remain completely empty") + } +} \ No newline at end of file diff --git a/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt b/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt index 7572246..7d4b083 100644 --- a/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt +++ b/kapacity-io/src/androidMain/kotlin/io/github/developrofthings/kapacity/io/Kapacity.android.kt @@ -3,10 +3,14 @@ package io.github.developrofthings.kapacity.io +import io.github.developrofthings.kapacity.ExperimentalKapacityApi import io.github.developrofthings.kapacity.InternalKapacityApi import io.github.developrofthings.kapacity.Kapacity import io.github.developrofthings.kapacity.byte import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.io.OutputStreamWriter import java.nio.ByteBuffer import java.nio.file.Path import kotlin.io.path.fileSize @@ -123,4 +127,119 @@ fun ByteBuffer.put( /* offset = */ sourceOffset, /* length = */ safeLength, ) +} + + +/** + * Returns an estimate of the number of bytes that can be read (or skipped over) from this + * [InputStream] without blocking, safely wrapped as a [Kapacity] instance. + * + * **Important Note on Java IO:** This property directly delegates to [InputStream.available]. + * It represents the number of bytes currently buffered locally or immediately accessible. + * It does **not** represent the total remaining size of the stream or file. You should never + * use this property to allocate a buffer intended to hold the entire contents of a network stream. + * + * @return The estimated number of non-blocking bytes currently available, represented as [Kapacity]. + * @throws java.io.IOException If an I/O error occurs while checking the underlying stream. + */ +val InputStream.available: Kapacity get() = available().byte + +/** + * Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array. + * + * This function safely guards against buffer overflows. It automatically calculates the available + * space in the [destination] array starting from the [destinationOffset] and ensures that the number + * of bytes read does not exceed this available space, even if the requested [kapacity] is larger. + * + * @param destination The byte array to which data is written. + * @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0. + * @param kapacity The maximum number of bytes to read from the stream. + * @return The total number of bytes read into the buffer, or `-1` if there is no more data because the end of the stream has been reached. + * @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array, + * or if [kapacity] represents a negative value. + */ +fun InputStream.read( + destination: ByteArray, + destinationOffset: Int = 0, + kapacity: Kapacity, +): Int { + require(destinationOffset in 0..destination.size) { + "destinationOffset ($destinationOffset) must be between 0 and ${destination.size}" + } + require(kapacity.rawBytes >= 0L) { + "Cannot read a negative kapacity: $kapacity" + } + + val readLength = kapacity.rawBytesCoercedToIntRange + val availableSpace = (destination.size - destinationOffset) + val safeLength = minOf( + a = readLength, + b = availableSpace, + ) + return this.read( + /* b = */ destination, + /* off = */ destinationOffset, + /* len = */ safeLength + ) +} + +/** + * Reads up to the specified [kapacity] of bytes from this input stream into the given [destination] array, + * returning the result as a [Kapacity] instance. + * + * Like its primitive counterpart, this function safely limits the read length to the available space + * in the [destination] array to prevent buffer overflows. + * + * @param destination The byte array to which data is written. + * @param destinationOffset The starting offset in the [destination] array where the data will be written. Defaults to 0. + * @param kapacity The maximum number of bytes to read from the stream. + * @return The total number of bytes read wrapped in a [Kapacity] instance, or [Kapacity.INVALID] if + * the end of the stream has been reached. + * @throws IllegalArgumentException If [destinationOffset] is outside the bounds of the [destination] array, + * or if [kapacity] represents a negative value. + */ +@ExperimentalKapacityApi +fun InputStream.readKapacity( + destination: ByteArray, + destinationOffset: Int = 0, + kapacity: Kapacity, +): Kapacity = this.read( + destination = destination, + destinationOffset = destinationOffset, + kapacity = kapacity +).takeIf { it >= 0 }?.byte ?: Kapacity.INVALID + +/** + * Writes up to the specified [kapacity] of bytes from the [source] array to this output stream. + * + * This function blocks until the bytes are written or an exception is thrown. + * * **Safe Bounds:** The actual number of bytes written is safely clamped to prevent + * `IndexOutOfBoundsException`. The length will be the minimum of: the requested [kapacity] or + * the available data in the [source] array accounting for the [sourceOffset]. + * + * @param source The data to write. + * @param sourceOffset The start offset in the [source] array from which to begin reading. Defaults to 0. + * @param kapacity The maximum number of bytes to write to the stream. + * @throws java.io.IOException If an I/O error occurs. + */ +fun OutputStream.write( + source: ByteArray, + sourceOffset: Int = 0, + kapacity: Kapacity, +) { + val writeLength = kapacity.rawBytesCoercedToIntRange + val availableSpace = (source.size - sourceOffset) + val safeLength = minOf( + a = writeLength, + b = availableSpace, + ) + + // Exit early if capacity is 0 or offsets are out of bounds + if (safeLength <= 0) return + + this.write( + /* b = */ source, + /* off = */ sourceOffset, + /* len = */ safeLength + ) } \ No newline at end of file diff --git a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/ExperimentalKapacityIoApi.kt b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/ExperimentalKapacityIoApi.kt new file mode 100644 index 0000000..ce11098 --- /dev/null +++ b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/ExperimentalKapacityIoApi.kt @@ -0,0 +1,9 @@ +package io.github.developrofthings.kapacity + +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, // Or ERROR to be stricter + message = "This Kapacity API is experimental and subject to change." +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +annotation class ExperimentalKapacityApi \ No newline at end of file diff --git a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt index 1c73957..05af330 100644 --- a/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt +++ b/kapacity/src/commonMain/kotlin/io/github/developrofthings/kapacity/Kapacity.kt @@ -225,6 +225,19 @@ value class Kapacity private constructor(val rawBytes: Long) : Comparable