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

@@ -24,11 +24,26 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Preferred: exact VniDrop invitation MIME type. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:mimeType="application/vnd.vnidrop.transfer"/> <data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter> </intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="*/*"/>
<data android:pathPattern=".*\\.vnd"/>
<data android:pathPattern=".*\\..*\\.vnd"/>
<data android:pathPattern=".*\\..*\\..*\\.vnd"/>
<data android:pathPattern=".*\\..*\\..*\\..*\\.vnd"/>
</intent-filter>
</activity> </activity>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View File

@@ -11,8 +11,7 @@ import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.VniDropInvitationMimeType import com.vnidrop.app.feature.receive.VniDropInvitationMimeType
import java.nio.ByteBuffer import com.vnidrop.app.feature.receive.decodeInvitationBytes
import java.nio.charset.CodingErrorAction
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -53,15 +52,13 @@ class MainActivity : ComponentActivity() {
private fun readInvitation(uri: Uri, declaredType: String?): Result<String> = runCatching { private fun readInvitation(uri: Uri, declaredType: String?): Result<String> = runCatching {
val resolvedType = declaredType ?: contentResolver.getType(uri) val resolvedType = declaredType ?: contentResolver.getType(uri)
val hasExpectedName = uri.lastPathSegment?.endsWith(".$VniDropInvitationExtension", ignoreCase = true) == true val path = uri.path.orEmpty()
val lastSegment = uri.lastPathSegment.orEmpty()
val hasExpectedName = lastSegment.endsWith(".$VniDropInvitationExtension", ignoreCase = true) ||
path.endsWith(".$VniDropInvitationExtension", ignoreCase = true)
require(resolvedType == VniDropInvitationMimeType || hasExpectedName) { "This is not a VniDrop invitation" } require(resolvedType == VniDropInvitationMimeType || hasExpectedName) { "This is not a VniDrop invitation" }
val bytes = contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxVniDropInvitationBytes + 1) } val bytes = contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxVniDropInvitationBytes + 1) }
?: error("The invitation could not be opened") ?: error("The invitation could not be opened")
require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } decodeInvitationBytes(bytes)
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString()
} }
} }

View File

@@ -7,10 +7,9 @@ import com.vnidrop.app.feature.send.DesktopShareBridge
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
fun main(args: Array<String>) { fun main(args: Array<String>) {
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
@@ -45,12 +44,7 @@ private fun ExternalInvitationController.openFile(file: File) {
val result = runCatching { val result = runCatching {
require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" } require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" }
val bytes = file.inputStream().use { it.readNBytes(MaxVniDropInvitationBytes + 1) } val bytes = file.inputStream().use { it.readNBytes(MaxVniDropInvitationBytes + 1) }
require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } decodeInvitationBytes(bytes)
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString()
} }
result.fold(::openInvitation) { error -> result.fold(::openInvitation) { error ->
reportOpenFailure(error.message ?: "The invitation could not be opened") reportOpenFailure(error.message ?: "The invitation could not be opened")

View File

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

View File

@@ -40,6 +40,7 @@ import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@Composable @Composable
fun App(dependencies: AppDependencies) { fun App(dependencies: AppDependencies) {
@@ -82,7 +83,20 @@ fun App(dependencies: AppDependencies) {
dependencies.externalInvitations.invitations.collect { invitation -> dependencies.externalInvitations.invitations.collect { invitation ->
appViewModel.selectDestination(AppDestination.Receive) appViewModel.selectDestination(AppDestination.Receive)
if (invitation.isSuccess) { 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 -> receiveViewModel.state.filter { state ->
!state.isInspecting && !state.isReceiving && state.ticket.isBlank() !state.isInspecting && !state.isReceiving && state.ticket.isBlank()
}.first() }.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" } require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
raw 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) 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( private fun preferences() = FakePreferencesRepository(
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false), AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
) )

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue import kotlin.test.assertTrue
class ExternalInvitationControllerTest { class ExternalInvitationControllerTest {
@@ -30,4 +31,23 @@ class ExternalInvitationControllerTest {
assertTrue(received.all { it.isFailure }) 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.preferences.PreferencesRepository
import com.vnidrop.app.feature.send.FilePreviewRepository import com.vnidrop.app.feature.send.FilePreviewRepository
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -37,12 +38,29 @@ class FakeCoreGateway : CoreGateway {
val responses = mutableListOf<Triple<String, Boolean, String?>>() val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException()) var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var inspectionResult: Result<TicketInspectionModel> = 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 deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL) var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0 var clearReceiveHistoryCount = 0
var receiveCount = 0
var lastReceiveTicket: String? = null
var lastReceiveReceiverName: String? = null
var lastShareAccessPolicy: ShareAccessPolicy? = 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> { override suspend fun initialize(appDataDir: String): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true) mutableState.value = mutableState.value.copy(isInitialized = true)
return Result.success(Unit) return Result.success(Unit)
@@ -88,9 +106,27 @@ class FakeCoreGateway : CoreGateway {
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException()) ) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = inspectionResult override suspend fun inspectTicket(ticket: String) = inspectionResult
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) receiveCount += 1
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) 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 cancel(transferId: ULong) = Result.success(Unit)
override suspend fun delete(transferId: ULong): Result<Unit> { override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) { if (deleteResult.isSuccess) {

View File

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

View File

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

View File

@@ -321,6 +321,45 @@ class FoundationComposeTest {
onNodeWithContentDescription("Close").assertIsDisplayed() 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 @Test
fun receiveHistoryOffersPerItemDeleteAndConfirmedClearAll() = runComposeUiTest { fun receiveHistoryOffersPerItemDeleteAndConfirmedClearAll() = runComposeUiTest {
val state = mutableStateOf(ReceiveState()) val state = mutableStateOf(ReceiveState())