mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(platform): adopt hardened core contracts
This commit is contained in:
@@ -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? {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user