diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt new file mode 100644 index 000000000..3fb36181a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt @@ -0,0 +1,165 @@ +package com.devil.phoenixproject.util + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * A cancelled content read propagates out of [readUriContent] and [readUriContentUpTo]. + * [CancellationException] extends [Exception], so the Android readers rethrow it + * and map every other failure to null / [BoundedUriContent.Unreadable]. Cancelling the + * caller mid-read interrupts the reading thread, so an interruptible provider read stops + * instead of running to completion. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class UriContentReaderCancellationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + startKoin { + modules(module { single { context } }) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun readUriContent_rethrowsCancellation() = runTest { + val cancellation = CancellationException("picker dismissed") + register("content://files/routine.csv", ThrowingStream(cancellation)) + + val thrown = assertFailsWith { + readUriContent("content://files/routine.csv") + } + + assertEquals(cancellation.message, thrown.message) + } + + @Test + fun readUriContentUpTo_rethrowsCancellation() = runTest { + val cancellation = CancellationException("picker dismissed") + register("content://files/routine.csv", ThrowingStream(cancellation)) + + val thrown = assertFailsWith { + readUriContentUpTo("content://files/routine.csv", maxBytes = 100) + } + + assertEquals(cancellation.message, thrown.message) + } + + @Test + fun cancellingReadUriContentInterruptsTheInFlightRead() = runBlocking { + val stream = BlockingStream() + register("content://files/slow.csv", stream) + + val read = async(Dispatchers.Default) { readUriContent("content://files/slow.csv") } + assertTrue(stream.started.await(LATCH_SECONDS, TimeUnit.SECONDS), "provider read never started") + read.cancel() + + assertTrue(stream.interrupted.await(LATCH_SECONDS, TimeUnit.SECONDS), "blocking read was not interrupted") + assertFailsWith { read.await() } + } + + @Test + fun cancellingReadUriContentUpToInterruptsTheInFlightRead() = runBlocking { + val stream = BlockingStream() + register("content://files/slow.csv", stream) + + val read = async(Dispatchers.Default) { readUriContentUpTo("content://files/slow.csv", maxBytes = 100) } + assertTrue(stream.started.await(LATCH_SECONDS, TimeUnit.SECONDS), "provider read never started") + read.cancel() + + assertTrue(stream.interrupted.await(LATCH_SECONDS, TimeUnit.SECONDS), "blocking read was not interrupted") + assertFailsWith { read.await() } + } + + @Test + fun otherReadFailuresStayUnreadable() = runTest { + register("content://files/missing.csv", ThrowingStream(IOException("denied"))) + register("content://files/bounded.csv", ThrowingStream(IOException("denied"))) + + assertNull(readUriContent("content://files/missing.csv")) + assertEquals( + BoundedUriContent.Unreadable, + readUriContentUpTo("content://files/bounded.csv", maxBytes = 100), + ) + } + + @Test + fun contentWithinTheLimitIsRead() = runTest { + register("content://files/routine.csv", "a,b\n".byteInputStream()) + register("content://files/bounded.csv", "a,b\n".byteInputStream()) + + assertEquals("a,b\n", readUriContent("content://files/routine.csv")) + assertEquals( + BoundedUriContent.Read("a,b\n"), + readUriContentUpTo("content://files/bounded.csv", maxBytes = 100), + ) + } + + private fun register(uri: String, stream: InputStream) { + shadowOf(context.contentResolver).registerInputStream(Uri.parse(uri), stream) + } + + private class ThrowingStream(private val error: Exception) : InputStream() { + override fun read(): Int = throw error + + override fun read(b: ByteArray, off: Int, len: Int): Int = throw error + } + + /** Blocks in [read] until the reading thread is interrupted, then rethrows the interrupt. */ + private class BlockingStream : InputStream() { + val started = CountDownLatch(1) + val interrupted = CountDownLatch(1) + + override fun read(): Int = block() + + override fun read(b: ByteArray, off: Int, len: Int): Int = block() + + private fun block(): Int { + started.countDown() + try { + Thread.sleep(BLOCK_MILLIS) + } catch (e: InterruptedException) { + interrupted.countDown() + throw e + } + return -1 + } + } + + private companion object { + const val LATCH_SECONDS = 5L + + /** Longer than [LATCH_SECONDS], so an uninterrupted read fails the test instead of passing. */ + const val BLOCK_MILLIS = 15_000L + } +} diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt index a569e7480..f4427987d 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt @@ -3,7 +3,9 @@ package com.devil.phoenixproject.util import android.content.Context import androidx.core.net.toUri import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent.getKoin import java.io.ByteArrayOutputStream @@ -14,12 +16,16 @@ private val log = Logger.withTag("UriContentReader") /** * Android implementation: reads content from a `content://` URI or a plain file path. * Uses the Application [Context] retrieved from Koin (registered by the Android platform module). + * Cancelling the caller interrupts the reading thread, so a stream that honours interrupts + * stops early, and the cancellation is rethrown rather than mapped to null / Unreadable. */ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Dispatchers.IO) { try { openUriOrPath(uriOrPath)?.use { stream -> - stream.bufferedReader().readText() + runInterruptible { stream.bufferedReader().readText() } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.e(e) { "Failed to read URI content: $uriOrPath" } null @@ -28,7 +34,9 @@ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Disp actual suspend fun readUriContentUpTo(uriOrPath: String, maxBytes: Int): BoundedUriContent = withContext(Dispatchers.IO) { try { - openUriOrPath(uriOrPath)?.use { stream -> readUpTo(stream, maxBytes) } ?: BoundedUriContent.Unreadable + openUriOrPath(uriOrPath)?.use { stream -> runInterruptible { readUpTo(stream, maxBytes) } } ?: BoundedUriContent.Unreadable + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.e(e) { "Failed to read URI content: $uriOrPath" } BoundedUriContent.Unreadable