fix(receive): harden invitation open path and cover receive loop

Tighten cold-open and in-app invitation handling so ticket acquisition is
stricter and less racey across hosts, and expand automated coverage for the
receive history and acquisition flow before merge.
This commit is contained in:
2026-07-12 04:31:19 +02:00
parent 996cc0a5ab
commit 4050a7c011
12 changed files with 301 additions and 30 deletions

View File

@@ -26,8 +26,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions {
callback(runCatching {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxInvitationBytes + 1) }
?: error("The invitation could not be opened")
require(bytes.size <= MaxInvitationBytes) { "The invitation is too large" }
bytes.decodeToString()
decodeInvitationBytes(bytes)
})
}
val nfcAdapter = remember(activity) { activity?.let(NfcAdapter::getDefaultAdapter) }
@@ -38,12 +37,15 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions {
override val nfcAvailability = if (nfcAdapter?.isEnabled == true) ReceiveMethodAvailability.Available else ReceiveMethodAvailability.Unavailable
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
// Stop NFC reader before another acquisition path so only one method is active.
cancel()
fileResult = onResult
filePicker.launch(arrayOf(InvitationMimeType, "application/octet-stream", "text/plain"))
filePicker.launch(arrayOf(InvitationMimeType, "application/octet-stream", "text/plain", "*/*"))
}
override fun scanQrCode(onResult: (Result<String>) -> Unit) {
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable")))
cancel()
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.enableAutoZoom()
@@ -54,12 +56,16 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions {
onResult(if (value.isNullOrBlank()) Result.failure(IllegalArgumentException("The QR code is empty")) else Result.success(value))
}
.addOnFailureListener { onResult(Result.failure(it)) }
.addOnCanceledListener {
onResult(Result.failure(IllegalStateException("QR scanning was cancelled")))
}
}
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) {
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
val adapter = nfcAdapter?.takeIf { it.isEnabled }
?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
cancel()
adapter.enableReaderMode(host, { tag ->
val result = runCatching {
val ndef = Ndef.get(tag) ?: error("This NFC tag does not contain an invitation")
@@ -68,7 +74,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions {
val record = ndef.ndefMessage?.records?.firstOrNull { record ->
record.tnf == android.nfc.NdefRecord.TNF_MIME_MEDIA && record.type.decodeToString() == InvitationMimeType
} ?: error("This NFC tag does not contain a VniDrop invitation")
record.payload.decodeToString()
decodeInvitationBytes(record.payload)
} finally { ndef.close() }
}
host.runOnUiThread {

View File

@@ -40,6 +40,7 @@ import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@Composable
fun App(dependencies: AppDependencies) {
@@ -82,7 +83,20 @@ fun App(dependencies: AppDependencies) {
dependencies.externalInvitations.invitations.collect { invitation ->
appViewModel.selectDestination(AppDestination.Receive)
if (invitation.isSuccess) {
receiveViewModel.coreState.filter { it.isInitialized }.first()
// Cold-open can race app startup. Wait for core before inspecting so
// the ticket is not dropped as "not initialized", but do not block
// forever if initialization failed.
val ready = withTimeoutOrNull(30_000) {
receiveViewModel.coreState.filter { it.isInitialized }.first()
}
if (ready == null) {
receiveViewModel.onInvitationResult(
ReceiveMethod.InvitationFile,
Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")),
)
return@collect
}
// Avoid clobbering an in-flight inspection or receive.
receiveViewModel.state.filter { state ->
!state.isInspecting && !state.isReceiving && state.ticket.isBlank()
}.first()

View File

@@ -34,3 +34,19 @@ internal fun validateInvitation(raw: String): Result<String> = runCatching {
require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
raw
}
/**
* Decode invitation document bytes as strict UTF-8 text.
*
* Hosts often receive invitation files as opaque binary streams. Reject payloads
* that are not valid UTF-8 so binary junk never reaches ticket inspection.
*/
fun decodeInvitationBytes(bytes: ByteArray): String {
require(bytes.isNotEmpty()) { "The invitation is empty" }
require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
val text = bytes.decodeToString()
// decodeToString() replaces malformed sequences; require a lossless round-trip.
require(text.encodeToByteArray().contentEquals(bytes)) { "The invitation is not valid text" }
require(text.isNotBlank()) { "The invitation is empty" }
return text
}

View File

@@ -308,6 +308,137 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isDeletingHistory)
}
@Test
fun receiveViewModelCompletesSuccessfulReceiveAndResetsAcquisition() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
assertEquals(1, core.receiveCount)
assertEquals("ticket-abc", core.lastReceiveTicket)
assertEquals("Receiver", core.lastReceiveReceiverName)
assertFalse(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
}
@Test
fun receiveViewModelKeepsReviewStateWhenReceiveFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
receiveResult = Result.failure(IllegalStateException("sender refused"))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.QrCode, Result.success("ticket-xyz"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
assertEquals(1, core.receiveCount)
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null)
}
@Test
fun receiveViewModelClearsTicketWhenInspectionFailsButKeepsAcquisitionOpen() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.failure(IllegalArgumentException("invalid ticket"))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("bad-ticket"))
advanceUntilIdle()
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket)
assertEquals(null, viewModel.state.value.inspection)
assertFalse(viewModel.state.value.isInspecting)
}
@Test
fun receiveViewModelIgnoresDeleteForActiveReceive() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(
isInitialized = true,
transfers = listOf(receivedTransfer(21UL, TransferStatus.Receiving)),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.requestDeleteHistoryItem(21UL)
assertEquals(null, viewModel.state.value.historyDeleteTarget)
}
@Test
fun receiveViewModelDismissResetsIdleAcquisitionButNotWhileReceiving() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
// Keep receive suspended so dismiss can be asserted mid-transfer.
receiveResult = Result.success(Unit)
receiveSuspend = true
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.openAcquisition()
viewModel.dismissAcquisition()
assertFalse(viewModel.state.value.isAcquisitionOpen)
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
viewModel.receive()
// Start receive but do not finish the suspended core call yet.
testScheduler.runCurrent()
assertTrue(viewModel.state.value.isReceiving)
viewModel.dismissAcquisition()
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("ticket", viewModel.state.value.ticket)
core.completeSuspendedReceive()
advanceUntilIdle()
assertFalse(viewModel.state.value.isAcquisitionOpen)
}
private fun preferences() = FakePreferencesRepository(
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
)

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class ExternalInvitationControllerTest {
@@ -30,4 +31,23 @@ class ExternalInvitationControllerTest {
assertTrue(received.all { it.isFailure })
}
@Test
fun decodeInvitationBytesAcceptsValidUtf8WithinLimit() {
val ticket = "vnd1:example-ticket"
assertEquals(ticket, decodeInvitationBytes(ticket.encodeToByteArray()))
}
@Test
fun decodeInvitationBytesRejectsBinaryAndOversizePayloads() {
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()))
}
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(ByteArray(MaxVniDropInvitationBytes + 1) { 'a'.code.toByte() })
}
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(byteArrayOf())
}
}
}

View File

@@ -21,6 +21,7 @@ import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.feature.send.FilePreviewRepository
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -37,12 +38,29 @@ class FakeCoreGateway : CoreGateway {
val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var inspectionResult: Result<TicketInspectionModel> = Result.failure(UnsupportedOperationException())
var receiveResult: Result<Unit> = Result.success(Unit)
var receiveSuspend: Boolean = false
private var receiveGate: CompletableDeferred<Unit>? = null
var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0
var receiveCount = 0
var lastReceiveTicket: String? = null
var lastReceiveReceiverName: String? = null
var lastShareAccessPolicy: ShareAccessPolicy? = null
fun completeSuspendedReceive() {
receiveGate?.complete(Unit)
}
private suspend fun awaitReceiveIfNeeded() {
if (!receiveSuspend) return
val gate = CompletableDeferred<Unit>()
receiveGate = gate
gate.await()
}
override suspend fun initialize(appDataDir: String): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true)
return Result.success(Unit)
@@ -88,9 +106,27 @@ class FakeCoreGateway : CoreGateway {
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = inspectionResult
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) {

View File

@@ -54,8 +54,10 @@ private class InvitationDocumentDelegate(
requireNotNull(url) { "The selected invitation URL was invalid" }
val path = url.path ?: error("The invitation path was invalid")
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
require(data.length.toLong() <= MaxInvitationBytes) { "The invitation is too large" }
data.bytes?.readBytes(data.length.toInt())?.decodeToString() ?: error("The invitation is empty")
val length = data.length.toInt()
require(length <= MaxInvitationBytes) { "The invitation is too large" }
val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty")
decodeInvitationBytes(bytes)
})
retainedInvitationDelegate = null
}

View File

@@ -18,7 +18,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
EventQueue.invokeLater {
val dialog = FileDialog(activeFrame(), "Open VniDrop invitation", FileDialog.LOAD).apply {
setFilenameFilter { _, name -> name.endsWith(".vnd", ignoreCase = true) }
setFilenameFilter { _, name -> name.endsWith(".$VniDropInvitationExtension", ignoreCase = true) }
}
try {
dialog.isVisible = true
@@ -40,8 +40,9 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb
}
private fun readInvitation(file: File): Result<String> = runCatching {
require(file.length() <= MaxInvitationBytes) { "The invitation is too large" }
file.readText()
require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" }
val bytes = file.inputStream().use { it.readNBytes(MaxInvitationBytes + 1) }
decodeInvitationBytes(bytes)
}
private fun activeFrame(): Frame? =

View File

@@ -321,6 +321,45 @@ class FoundationComposeTest {
onNodeWithContentDescription("Close").assertIsDisplayed()
}
@Test
fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest {
val state = mutableStateOf(ReceiveState())
val actions = object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Hidden
override val nfcAvailability = ReceiveMethodAvailability.Hidden
override fun pickInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun scanQrCode(onResult: (Result<String>) -> Unit) = Unit
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun cancel() = Unit
}
setContent {
VniDropTheme(isDarkTheme = false) {
ReceiveScreen(
coreState = CoreState(isInitialized = true),
state = state.value,
windowClass = WindowClass.Phone,
actions = actions,
onOpenAcquisition = { state.value = state.value.copy(isAcquisitionOpen = true) },
onDismissAcquisition = {},
onReceiverNameChanged = {},
onInvitationResult = { _, _ -> },
onWaitingForNfc = {},
onReceive = {},
onRequestDeleteHistoryItem = {},
onRequestClearHistory = {},
onDismissHistoryDelete = {},
onConfirmHistoryDelete = {},
)
}
}
onNodeWithText("Receive your first file").assertIsDisplayed()
onNodeWithText("Receive files").performClick()
onNodeWithText("How would you like to connect?").assertIsDisplayed()
onNodeWithText("Open a .vnd invitation").assertIsDisplayed()
}
@Test
fun receiveHistoryOffersPerItemDeleteAndConfirmedClearAll() = runComposeUiTest {
val state = mutableStateOf(ReceiveState())