fix(kanban): preserve transfer cleanup and screen lifecycle (#1137)

This commit is contained in:
M57 committed 2026-09-17 00:33:57 +03:00
1 parent 748f7854d2
commit 6f076a79d3
6 files changed
+295 -36

No files matched your search

@@ -0,0 +1,81 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KanbanLifecycleTest {
@get:Rule
val compose = createComposeRule()
@Test
fun pendingExportSurvivesSavedStateRestoration() {
val restoration = StateRestorationTester(compose)
lateinit var path: MutableState<String?>
restoration.setContent {
path = rememberPendingKanbanExportPath()
}
compose.runOnIdle { path.value = "/cache/board.tar.gz" }
restoration.emulateSavedInstanceStateRestore()
compose.runOnIdle { assertEquals("/cache/board.tar.gz", path.value) }
}
@Test
fun tickerStopsInBackgroundRefreshesOnResumeAndStopsOnDisposal() {
lateinit var registry: LifecycleRegistry
val owner =
object : LifecycleOwner {
override val lifecycle: Lifecycle get() = registry
}
compose.runOnUiThread {
registry = LifecycleRegistry(owner)
registry.currentState = Lifecycle.State.STARTED
}
var now = 100L
val visible = mutableStateOf(true)
var displayed = 0L
compose.mainClock.autoAdvance = false
compose.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides owner) {
if (visible.value) {
displayed = rememberKanbanNowSeconds(true) { now }
Text("$displayed")
}
}
}
compose.mainClock.advanceTimeBy(32)
compose.onNodeWithText("100").assertTextEquals("100")
compose.runOnUiThread { now = 105L }
compose.mainClock.advanceTimeBy(5_100)
compose.onNodeWithText("105").assertTextEquals("105")
compose.runOnUiThread {
registry.currentState = Lifecycle.State.CREATED
now = 200L
}
compose.mainClock.advanceTimeBy(10_100)
compose.onNodeWithText("105").assertTextEquals("105")
compose.runOnUiThread { registry.currentState = Lifecycle.State.STARTED }
compose.mainClock.advanceTimeBy(64)
compose.onNodeWithText("200").assertTextEquals("200")
compose.runOnUiThread { visible.value = false }
compose.mainClock.advanceTimeBy(64)
compose.runOnUiThread { now = 300L }
compose.mainClock.advanceTimeBy(10_100)
compose.runOnIdle { assertEquals(200L, displayed) }
}
}
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
/** Keep the cache path while the external document picker recreates the Activity (#1137). */
@Composable
internal fun rememberPendingKanbanExportPath(): MutableState<String?> = rememberSaveable { mutableStateOf(null) }
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.kanban
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
/** Own only confirmed uploads; cancellation must not strand their temporary archive (#1137). */
internal suspend fun <T> withKanbanImportCleanup(
cleanup: suspend () -> Unit,
onCleanupFailure: (Exception) -> Unit,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
operation: suspend () -> T,
): T =
try {
operation()
} finally {
withContext(NonCancellable + dispatcher) {
try {
withTimeout(10_000L) { cleanup() }
} catch (e: Exception) {
onCleanupFailure(e)
}
}
}
@@ -2,8 +2,12 @@ package com.m57.hermescontrol.ui.kanban
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.KanbanTask
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
enum class KanbanArcState { enum class KanbanArcState {
QUEUED, QUEUED,
@@ -54,13 +58,23 @@ object KanbanRuntimeHelper {
/** One coarse clock per board screen, rather than one coroutine per task card. */ /** One coarse clock per board screen, rather than one coroutine per task card. */
@Composable @Composable
fun rememberKanbanNowSeconds(enabled: Boolean): Long = fun rememberKanbanNowSeconds(
produceState( enabled: Boolean,
initialValue = System.currentTimeMillis() / 1000L, clock: () -> Long = { System.currentTimeMillis() / 1000L },
): Long {
val lifecycle = LocalLifecycleOwner.current.lifecycle
return produceState(
initialValue = clock(),
key1 = enabled, key1 = enabled,
key2 = lifecycle,
) { ) {
while (enabled) { if (enabled) {
delay(5_000L) lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
value = System.currentTimeMillis() / 1000L while (isActive) {
value = clock()
delay(5_000L)
}
}
} }
}.value }.value
}
@@ -90,13 +90,16 @@ import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
import com.m57.hermescontrol.util.StreamingUriRequestBody import com.m57.hermescontrol.util.StreamingUriRequestBody
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File import java.io.File
import kotlin.coroutines.coroutineContext
private const val DEFAULT_COLUMN = "todo" private const val DEFAULT_COLUMN = "todo"
@@ -159,26 +162,27 @@ fun KanbanScreen(
var showOrchestrationDialog by remember { mutableStateOf(false) } var showOrchestrationDialog by remember { mutableStateOf(false) }
// Board Transfer (Export / Import) // Board Transfer (Export / Import)
var pendingExportPath by remember { mutableStateOf<String?>(null) } var pendingExportPath by rememberPendingKanbanExportPath()
val exportLauncher = val exportLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/gzip"), contract = ActivityResultContracts.CreateDocument("application/gzip"),
) { uri -> ) { uri ->
if (uri != null && pendingExportPath != null) { if (uri != null) {
coroutineScope.launch { coroutineScope.launch {
try { try {
val cachedFile = File(pendingExportPath!!) val cachedFile = File(checkNotNull(pendingExportPath) { "No pending board export" })
if (cachedFile.exists()) { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { check(cachedFile.isFile) { "Cached board export is missing; export the board again" }
context.contentResolver.openOutputStream(uri)?.use { output -> context.contentResolver.openOutputStream(uri)?.use { output ->
cachedFile.inputStream().use { input -> cachedFile.inputStream().use { input ->
input.copyTo(output) input.copyTo(output)
} }
} ?: error("Unable to open selected destination") } ?: error("Unable to open selected destination")
}
viewModel.showToast("Board exported successfully")
} }
viewModel.showToast("Board exported successfully")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
viewModel.showToast("Failed to save export: ${e.message}") viewModel.showToast("Failed to save export: ${e.message}")
} finally { } finally {
@@ -230,39 +234,55 @@ fun KanbanScreen(
contentLength = contentLength, contentLength = contentLength,
) )
val filePart = MultipartBody.Part.createFormData("file", fileName, requestBody) val filePart = MultipartBody.Part.createFormData("file", fileName, requestBody)
// Retrofit streams the body on OkHttp's worker; avoid losing a confirmed
// response to withContext's prompt cancellation on the return hop (#1137).
val uploadResult = val uploadResult =
withContext(Dispatchers.IO) { safeApiCall {
safeApiCall { ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart)
ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart)
}
} }
when (uploadResult) { when (uploadResult) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val serverPath = val serverPath =
uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath
val importResult = viewModel.importBoard(serverPath) withKanbanImportCleanup(
when (importResult) { cleanup = {
is NetworkResult.Success -> { val response =
val res = importResult.data ApiClient.hermesApi.deleteManagedFile(
var msg = "Board '${res.name}' imported" ManagedFileDelete(serverPath),
if (res.renamed) msg += " (as ${res.board})" )
viewModel.showToast(msg) check(
viewModel.loadBoards() response.isSuccessful,
} ) { "Archive cleanup failed (HTTP ${response.code()})" }
},
onCleanupFailure = {
viewModel.showToast("Temporary import archive cleanup failed: ${it.message}")
},
) {
val importResult = viewModel.importBoard(serverPath)
coroutineContext.ensureActive()
when (importResult) {
is NetworkResult.Success -> {
val res = importResult.data
var msg = "Board '${res.name}' imported"
if (res.renamed) msg += " (as ${res.board})"
viewModel.showToast(msg)
viewModel.loadBoards()
}
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
viewModel.showToast("Import failed: ${importResult.error.message}") viewModel.showToast("Import failed: ${importResult.error.message}")
}
} }
} }
runCatching {
ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath))
}
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
coroutineContext.ensureActive()
viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}") viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}")
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
viewModel.showToast("Import failed: ${e.message}") viewModel.showToast("Import failed: ${e.message}")
} }
@@ -0,0 +1,108 @@
package com.m57.hermescontrol.ui.kanban
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanImportCleanupTest {
@Test
fun cleansAfterSuccess() =
runTest {
var cleaned = false
val result =
withKanbanImportCleanup(
cleanup = { cleaned = true },
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { "imported" }
assertEquals("imported", result)
assertTrue(cleaned)
}
@Test
fun cleansAfterImportFailureWithoutMaskingIt() =
runTest {
val failure = IllegalStateException("import failed")
var cleaned = false
try {
withKanbanImportCleanup(
cleanup = { cleaned = true },
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { throw failure }
} catch (e: IllegalStateException) {
assertSame(failure, e)
}
assertTrue(cleaned)
}
@Test
fun cancellationWaitsForSuspendingCleanupAndRemainsCancelled() =
runTest {
var cleaned = false
var cancellationPropagated = false
val job =
launch {
try {
withKanbanImportCleanup(
cleanup = {
delay(100)
cleaned = true
},
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { awaitCancellation() }
} catch (e: CancellationException) {
cancellationPropagated = true
throw e
}
}
runCurrent()
job.cancel()
advanceUntilIdle()
assertTrue(cleaned)
assertTrue(cancellationPropagated)
assertTrue(job.isCancelled)
}
@Test
fun cleanupFailureIsReportedWithoutMaskingImportResult() =
runTest {
val failure = IllegalStateException("HTTP 500")
var reported: Exception? = null
val result =
withKanbanImportCleanup(
cleanup = { throw failure },
onCleanupFailure = { reported = it },
dispatcher = StandardTestDispatcher(testScheduler),
) { "imported" }
assertEquals("imported", result)
// Coroutine stack-trace recovery can copy exceptions across dispatcher boundaries.
assertEquals(failure.message, reported?.message)
assertTrue(reported is IllegalStateException)
}
@Test
fun cleanupIsBounded() =
runTest {
var reported = false
withKanbanImportCleanup(
cleanup = { awaitCancellation() },
onCleanupFailure = { reported = true },
dispatcher = StandardTestDispatcher(testScheduler),
) { Unit }
assertTrue(reported)
assertEquals(10_000L, testScheduler.currentTime)
}
}