From 2271f453f134c9b2f40fe4dd4609458bb4412ee2 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 10 Jul 2026 12:25:17 +0200 Subject: [PATCH] feat(platform): adopt hardened core contracts --- .../app/core/FileSystemService.android.kt | 62 +++++++++++++++---- .../com/vnidrop/app/VniDropAppViewModel.kt | 5 ++ .../com/vnidrop/app/core/CoreRepository.kt | 6 ++ .../com/vnidrop/app/ui/state/AppUiModels.kt | 2 +- .../vnidrop/app/ui/state/AppUiModelsTest.kt | 3 + 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 3da4407..026b8ae 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import uniffi.vnidrop.ReceiveOutputSink import java.io.OutputStream +import java.util.UUID @Composable actual fun rememberFileSystemService(): FileSystemService { @@ -59,10 +60,10 @@ private class AndroidFileSystemService( if (!hasPermission) return FolderAccessStatus.PermissionRequired return runCatching { val probe = AndroidTreeReceiveOutputSink(context, uri) - val probeName = ".vnidrop-write-test" + val probeName = ".vnidrop-write-test-${UUID.randomUUID()}" probe.startFile(probeName) probe.writeChunk(probeName, byteArrayOf()) - probe.finishFile(probeName) + probe.abortFile(probeName, "write probe complete") FolderAccessStatus.Writable }.getOrDefault(FolderAccessStatus.Unavailable) } @@ -72,26 +73,62 @@ private class AndroidTreeReceiveOutputSink( private val context: Context, private val treeUri: Uri, ) : ReceiveOutputSink { - private val streams = mutableMapOf() + private data class PendingDocument( + val stream: OutputStream, + val temporaryUri: Uri, + val parentUri: Uri, + val finalName: String, + ) + + private val pending = mutableMapOf() override fun startFile(relativePath: String) { - streams[relativePath]?.close() - val documentUri = createDocument(relativePath) - val stream = context.contentResolver.openOutputStream(documentUri, "w") + check(relativePath !in pending) { "Output stream is already open for $relativePath" } + val (parent, finalName) = resolveParent(relativePath) + check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } + val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" + val temporaryUri = DocumentsContract.createDocument( + context.contentResolver, + parent, + "application/octet-stream", + temporaryName, + ) ?: error("Could not create temporary file for $relativePath") + val stream = context.contentResolver.openOutputStream(temporaryUri, "w") ?: error("Could not open output stream for $relativePath") - streams[relativePath] = stream + pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) } override fun writeChunk(relativePath: String, bytes: ByteArray) { - val stream = streams[relativePath] ?: error("Output stream is not open for $relativePath") - stream.write(bytes) + val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") + document.stream.write(bytes) } override fun finishFile(relativePath: String) { - streams.remove(relativePath)?.close() + val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") + try { + document.stream.close() + check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" } + checkNotNull( + DocumentsContract.renameDocument( + context.contentResolver, + document.temporaryUri, + document.finalName, + ), + ) { "Could not commit received file $relativePath" } + } catch (error: Throwable) { + runCatching { document.stream.close() } + DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + throw error + } } - private fun createDocument(relativePath: String): Uri { + override fun abortFile(relativePath: String, reason: String) { + val document = pending.remove(relativePath) ?: return + runCatching { document.stream.close() } + DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + } + + private fun resolveParent(relativePath: String): Pair { val parts = relativePath.split('/').filter { it.isNotBlank() } require(parts.isNotEmpty()) { "relative path must not be empty" } var parent = DocumentsContract.buildDocumentUriUsingTree( @@ -103,8 +140,7 @@ private class AndroidTreeReceiveOutputSink( ?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name) ?: error("Could not create directory $name") } - return DocumentsContract.createDocument(context.contentResolver, parent, "application/octet-stream", parts.last()) - ?: error("Could not create file ${parts.last()}") + return parent to parts.last() } private fun findChild(parent: Uri, name: String): Uri? { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt index 2990c7e..19d229d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt @@ -81,6 +81,11 @@ class VniDropAppViewModel( private var selectedFile: PickedShareFile? = null + override fun onCleared() { + repository.shutdown() + super.onCleared() + } + init { AppLogger.initialize(appDataDir) AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName)) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 95355ab..ac4c48d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -39,6 +39,12 @@ class CoreRepository( private var core: VnidropCore? = null + fun shutdown() { + core?.shutdown() + core = null + _state.update { it.copy(isInitialized = false, status = "Not initialized") } + } + private val sink = object : CoreEventSink { override fun onEvent(event: CoreEvent) { _state.update { current -> diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index 223d21e..ad46df2 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -143,7 +143,7 @@ fun friendlyCoreError(raw: String?): String? { } private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") -private val activeTransferStatuses = setOf("sharing", "receiving") +private val activeTransferStatuses = setOf("importing", "sharing", "receiving") private fun eventLabel(event: CoreEvent): String { val direction = event.direction?.replaceFirstChar { it.uppercase() } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index e88097d..72376bc 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -89,6 +89,7 @@ class AppUiModelsTest { @Test fun transferActivityOnlyIncludesRunningStatuses() { + assertTrue(storedTransfer(status = "importing").isActiveTransfer()) assertTrue(storedTransfer(status = "sharing").isActiveTransfer()) assertTrue(storedTransfer(status = "receiving").isActiveTransfer()) assertFalse(storedTransfer(status = "done").isActiveTransfer()) @@ -98,7 +99,9 @@ class AppUiModelsTest { private fun storedTransfer(status: String): StoredTransfer = StoredTransfer( + localId = "local-1", transferId = 1UL, + peerId = null, direction = "send", status = status, transferName = "Demo",