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,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.produceState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.model.KanbanTask
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
enum class KanbanArcState {
QUEUED,
@@ -54,13 +58,23 @@ object KanbanRuntimeHelper {
/** One coarse clock per board screen, rather than one coroutine per task card. */
@Composable
fun rememberKanbanNowSeconds(enabled: Boolean): Long =
produceState(
initialValue = System.currentTimeMillis() / 1000L,
fun rememberKanbanNowSeconds(
enabled: Boolean,
clock: () -> Long = { System.currentTimeMillis() / 1000L },
): Long {
val lifecycle = LocalLifecycleOwner.current.lifecycle
return produceState(
initialValue = clock(),
key1 = enabled,
key2 = lifecycle,
) {
while (enabled) {
delay(5_000L)
value = System.currentTimeMillis() / 1000L
if (enabled) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive) {
value = clock()
delay(5_000L)
}
}
}
}.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.KanbanTaskCard
import com.m57.hermescontrol.util.StreamingUriRequestBody
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import kotlin.coroutines.coroutineContext
private const val DEFAULT_COLUMN = "todo"
@@ -159,26 +162,27 @@ fun KanbanScreen(
var showOrchestrationDialog by remember { mutableStateOf(false) }
// Board Transfer (Export / Import)
var pendingExportPath by remember { mutableStateOf<String?>(null) }
var pendingExportPath by rememberPendingKanbanExportPath()
val exportLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/gzip"),
) { uri ->
if (uri != null && pendingExportPath != null) {
if (uri != null) {
coroutineScope.launch {
try {
val cachedFile = File(pendingExportPath!!)
if (cachedFile.exists()) {
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { output ->
cachedFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: error("Unable to open selected destination")
}
viewModel.showToast("Board exported successfully")
val cachedFile = File(checkNotNull(pendingExportPath) { "No pending board export" })
withContext(Dispatchers.IO) {
check(cachedFile.isFile) { "Cached board export is missing; export the board again" }
context.contentResolver.openOutputStream(uri)?.use { output ->
cachedFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: error("Unable to open selected destination")
}
viewModel.showToast("Board exported successfully")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast("Failed to save export: ${e.message}")
} finally {
@@ -230,39 +234,55 @@ fun KanbanScreen(
contentLength = contentLength,
)
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 =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart)
}
safeApiCall {
ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart)
}
when (uploadResult) {
is NetworkResult.Success -> {
val serverPath =
uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath
val importResult = viewModel.importBoard(serverPath)
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()
}
withKanbanImportCleanup(
cleanup = {
val response =
ApiClient.hermesApi.deleteManagedFile(
ManagedFileDelete(serverPath),
)
check(
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 -> {
viewModel.showToast("Import failed: ${importResult.error.message}")
is NetworkResult.Failure -> {
viewModel.showToast("Import failed: ${importResult.error.message}")
}
}
}
runCatching {
ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath))
}
}
is NetworkResult.Failure -> {
coroutineContext.ensureActive()
viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}")
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast("Import failed: ${e.message}")
}