feat(platform): adopt hardened core contracts

This commit is contained in:
2026-07-10 12:25:17 +02:00
parent e46a2522ce
commit 2271f453f1
5 changed files with 64 additions and 14 deletions

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import java.io.OutputStream import java.io.OutputStream
import java.util.UUID
@Composable @Composable
actual fun rememberFileSystemService(): FileSystemService { actual fun rememberFileSystemService(): FileSystemService {
@@ -59,10 +60,10 @@ private class AndroidFileSystemService(
if (!hasPermission) return FolderAccessStatus.PermissionRequired if (!hasPermission) return FolderAccessStatus.PermissionRequired
return runCatching { return runCatching {
val probe = AndroidTreeReceiveOutputSink(context, uri) val probe = AndroidTreeReceiveOutputSink(context, uri)
val probeName = ".vnidrop-write-test" val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
probe.startFile(probeName) probe.startFile(probeName)
probe.writeChunk(probeName, byteArrayOf()) probe.writeChunk(probeName, byteArrayOf())
probe.finishFile(probeName) probe.abortFile(probeName, "write probe complete")
FolderAccessStatus.Writable FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable) }.getOrDefault(FolderAccessStatus.Unavailable)
} }
@@ -72,26 +73,62 @@ private class AndroidTreeReceiveOutputSink(
private val context: Context, private val context: Context,
private val treeUri: Uri, private val treeUri: Uri,
) : ReceiveOutputSink { ) : ReceiveOutputSink {
private val streams = mutableMapOf<String, OutputStream>() private data class PendingDocument(
val stream: OutputStream,
val temporaryUri: Uri,
val parentUri: Uri,
val finalName: String,
)
private val pending = mutableMapOf<String, PendingDocument>()
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
streams[relativePath]?.close() check(relativePath !in pending) { "Output stream is already open for $relativePath" }
val documentUri = createDocument(relativePath) val (parent, finalName) = resolveParent(relativePath)
val stream = context.contentResolver.openOutputStream(documentUri, "w") 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") ?: 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) { override fun writeChunk(relativePath: String, bytes: ByteArray) {
val stream = streams[relativePath] ?: error("Output stream is not open for $relativePath") val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
stream.write(bytes) document.stream.write(bytes)
} }
override fun finishFile(relativePath: String) { 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<Uri, String> {
val parts = relativePath.split('/').filter { it.isNotBlank() } val parts = relativePath.split('/').filter { it.isNotBlank() }
require(parts.isNotEmpty()) { "relative path must not be empty" } require(parts.isNotEmpty()) { "relative path must not be empty" }
var parent = DocumentsContract.buildDocumentUriUsingTree( var parent = DocumentsContract.buildDocumentUriUsingTree(
@@ -103,8 +140,7 @@ private class AndroidTreeReceiveOutputSink(
?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name) ?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name)
?: error("Could not create directory $name") ?: error("Could not create directory $name")
} }
return DocumentsContract.createDocument(context.contentResolver, parent, "application/octet-stream", parts.last()) return parent to parts.last()
?: error("Could not create file ${parts.last()}")
} }
private fun findChild(parent: Uri, name: String): Uri? { private fun findChild(parent: Uri, name: String): Uri? {

View File

@@ -81,6 +81,11 @@ class VniDropAppViewModel(
private var selectedFile: PickedShareFile? = null private var selectedFile: PickedShareFile? = null
override fun onCleared() {
repository.shutdown()
super.onCleared()
}
init { init {
AppLogger.initialize(appDataDir) AppLogger.initialize(appDataDir)
AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName)) AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName))

View File

@@ -39,6 +39,12 @@ class CoreRepository(
private var core: VnidropCore? = null 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 { private val sink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) { override fun onEvent(event: CoreEvent) {
_state.update { current -> _state.update { current ->

View File

@@ -143,7 +143,7 @@ fun friendlyCoreError(raw: String?): String? {
} }
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") 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 { private fun eventLabel(event: CoreEvent): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() } val direction = event.direction?.replaceFirstChar { it.uppercase() }

View File

@@ -89,6 +89,7 @@ class AppUiModelsTest {
@Test @Test
fun transferActivityOnlyIncludesRunningStatuses() { fun transferActivityOnlyIncludesRunningStatuses() {
assertTrue(storedTransfer(status = "importing").isActiveTransfer())
assertTrue(storedTransfer(status = "sharing").isActiveTransfer()) assertTrue(storedTransfer(status = "sharing").isActiveTransfer())
assertTrue(storedTransfer(status = "receiving").isActiveTransfer()) assertTrue(storedTransfer(status = "receiving").isActiveTransfer())
assertFalse(storedTransfer(status = "done").isActiveTransfer()) assertFalse(storedTransfer(status = "done").isActiveTransfer())
@@ -98,7 +99,9 @@ class AppUiModelsTest {
private fun storedTransfer(status: String): StoredTransfer = private fun storedTransfer(status: String): StoredTransfer =
StoredTransfer( StoredTransfer(
localId = "local-1",
transferId = 1UL, transferId = 1UL,
peerId = null,
direction = "send", direction = "send",
status = status, status = status,
transferName = "Demo", transferName = "Demo",