From 3049398cee9f479147bb79b8154bfe5254f73536 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 08:32:00 +0000 Subject: [PATCH] fix(import): rethrow cancellation in Android CSV import CancellationException extends Exception, so the Android CSV importer treated a cancelled import as a failed row and kept writing. Rethrow it from both catches and from the analytics import caller. Co-authored-by: Devil <9thLevelSoftware@users.noreply.github.com> --- .../AndroidCsvImporterCancellationTest.kt | 125 ++++++++++++++++++ .../util/CsvImporter.android.kt | 4 + .../presentation/screen/AnalyticsScreen.kt | 2 + 3 files changed, 131 insertions(+) create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/AndroidCsvImporterCancellationTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/AndroidCsvImporterCancellationTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/AndroidCsvImporterCancellationTest.kt new file mode 100644 index 000000000..562c063bb --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/AndroidCsvImporterCancellationTest.kt @@ -0,0 +1,125 @@ +package com.devil.phoenixproject.util + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import java.io.IOException +import java.io.InputStream +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * [CancellationException] extends [Exception], so [AndroidCsvImporter] must rethrow it + * and stop writing later rows. A real read or save failure still comes back as + * [CsvImportResult]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class AndroidCsvImporterCancellationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun importFromCsv_rethrowsCancellationAndDoesNotWriteLaterRows() = runTest { + val cancellation = CancellationException("import scope cancelled") + val repository = FakeWorkoutRepository().apply { + beforeSaveSession = { session -> + if (session.exerciseName == "Squat") throw cancellation + } + } + register(HISTORY_URI, historyCsv()) + + val thrown = assertFailsWith { + importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID) + } + + assertEquals(cancellation.message, thrown.message) + assertEquals(listOf("Bench Press"), savedNames(repository)) + assertEquals(listOf("Bench Press", "Squat"), attemptedNames(repository)) + assertEquals(PROFILE_ID, repository.allSessions().single().profileId) + } + + @Test + fun importFromCsv_rethrowsCancellationFromTheFileRead() = runTest { + val cancellation = CancellationException("left screen") + val repository = FakeWorkoutRepository() + register(HISTORY_URI, ThrowingStream(cancellation)) + + val thrown = assertFailsWith { + importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID) + } + + assertEquals(cancellation.message, thrown.message) + assertTrue(repository.saveSessionAttempts.isEmpty()) + } + + @Test + fun importFromCsv_reportsSaveFailureAndContinues() = runTest { + val repository = FakeWorkoutRepository().apply { + beforeSaveSession = { session -> + if (session.exerciseName == "Squat") throw IOException("disk full") + } + } + register(HISTORY_URI, historyCsv()) + + val result = importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID) + + assertEquals(2, result.imported) + assertEquals(0, result.skipped) + assertEquals(1, result.failed) + assertEquals(listOf("Failed to save session (Squat): disk full"), result.errors) + assertEquals(listOf("Bench Press", "Row"), savedNames(repository)) + } + + @Test + fun importFromCsv_reportsUnreadableFile() = runTest { + val repository = FakeWorkoutRepository() + register(HISTORY_URI, ThrowingStream(IOException("denied"))) + + val result = importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID) + + assertEquals( + CsvImportResult(imported = 0, skipped = 0, failed = 0, errors = listOf("Import failed: denied")), + result, + ) + assertTrue(repository.saveSessionAttempts.isEmpty()) + } + + private fun importer(repository: FakeWorkoutRepository) = AndroidCsvImporter(context, repository) + + private fun register(uri: String, stream: InputStream) { + shadowOf(context.contentResolver).registerInputStream(Uri.parse(uri), stream) + } + + private fun savedNames(repository: FakeWorkoutRepository): List = + repository.allSessions().map { it.exerciseName.orEmpty() } + + private fun attemptedNames(repository: FakeWorkoutRepository): List = + repository.saveSessionAttempts.map { it.exerciseName.orEmpty() } + + private fun historyCsv(): InputStream = """ + Date,Time,Exercise,Mode,Target Reps,Warmup Reps,Working Reps,Total Reps,Weight,Progression,Duration (s),Just Lift,Eccentric Load + 2026-03-10,09:00:00,Bench Press,OldSchool,10,0,10,10,80.0,0,45,No,100 + 2026-03-11,09:00:00,Squat,OldSchool,8,0,8,8,100.0,0,60,No,100 + 2026-03-12,09:00:00,Row,OldSchool,8,0,8,8,40.0,0,30,No,100 + """.trimIndent().byteInputStream() + + 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 + } + + private companion object { + const val HISTORY_URI = "content://files/history.csv" + const val PROFILE_ID = "profile-a" + } +} diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvImporter.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvImporter.android.kt index 06c2ce816..26b3227e2 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvImporter.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvImporter.android.kt @@ -68,6 +68,9 @@ class AndroidCsvImporter(private val context: Context, private val workoutReposi existingKeys.add(key) // Track intra-file duplicates imported++ } catch (e: Exception) { + // CancellationException extends Exception. Rethrow it so a cancelled + // import stops instead of recording a failed row and writing the rest. + e.rethrowIfCancellation() Logger.w("CsvImporter") { "Failed to save row: ${e.message}" } if (e.message?.contains("UNIQUE", ignoreCase = true) == true) { skipped++ @@ -89,6 +92,7 @@ class AndroidCsvImporter(private val context: Context, private val workoutReposi errors = importErrors, ) } catch (e: Exception) { + e.rethrowIfCancellation() Logger.e("CsvImporter", e) { "Import failed" } CsvImportResult( imported = 0, diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt index 0db8a6c8c..839d77fd3 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt @@ -37,6 +37,7 @@ import com.devil.phoenixproject.util.CsvImportResult import com.devil.phoenixproject.util.CsvImporter import com.devil.phoenixproject.util.KmpUtils import com.devil.phoenixproject.util.rememberFilePicker +import com.devil.phoenixproject.util.rethrowIfCancellation import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -812,6 +813,7 @@ fun AnalyticsScreen( importResult = result showImportResultDialog = true } catch (e: Exception) { + e.rethrowIfCancellation() importResult = CsvImportResult( imported = 0, skipped = 0,