mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 18:39:55 +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 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<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) {
|
||||
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<Uri, String> {
|
||||
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? {
|
||||
|
||||
Reference in New Issue
Block a user