From 4050a7c0115ae59dcb410f4f7ea16964c12d4bd5 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 04:31:19 +0200 Subject: [PATCH] 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. --- androidApp/src/main/AndroidManifest.xml | 15 ++ .../kotlin/com/vnidrop/app/MainActivity.kt | 15 +- .../src/main/kotlin/com/vnidrop/app/main.kt | 10 +- .../ReceiveInvitationActions.android.kt | 14 +- .../commonMain/kotlin/com/vnidrop/app/App.kt | 16 ++- .../receive/ExternalInvitationController.kt | 16 +++ .../com/vnidrop/app/feature/ViewModelsTest.kt | 131 ++++++++++++++++++ .../ExternalInvitationControllerTest.kt | 20 +++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 42 +++++- .../receive/ReceiveInvitationActions.ios.kt | 6 +- .../receive/ReceiveInvitationActions.jvm.kt | 7 +- .../vnidrop/app/ui/FoundationComposeTest.kt | 39 ++++++ 12 files changed, 301 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 4f57d20..c7faec7 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -24,11 +24,26 @@ + + + + + + + + + + + + + + + = runCatching { 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" } val bytes = contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxVniDropInvitationBytes + 1) } ?: error("The invitation could not be opened") - require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() + decodeInvitationBytes(bytes) } } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 7c07526..0921de6 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -7,10 +7,9 @@ import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.VniDropInvitationExtension +import com.vnidrop.app.feature.receive.decodeInvitationBytes import java.awt.Desktop import java.io.File -import java.nio.ByteBuffer -import java.nio.charset.CodingErrorAction fun main(args: Array) { val externalInvitations = ExternalInvitationController() @@ -45,12 +44,7 @@ private fun ExternalInvitationController.openFile(file: File) { val result = runCatching { require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" } val bytes = file.inputStream().use { it.readNBytes(MaxVniDropInvitationBytes + 1) } - require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" } - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() + decodeInvitationBytes(bytes) } result.fold(::openInvitation) { error -> reportOpenFailure(error.message ?: "The invitation could not be opened") diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt index 4665492..4c13dd3 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.android.kt @@ -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) -> 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) -> 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) -> 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 { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 8cd9f91..2db6bf5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt index 8aa4dcb..5a8844a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationController.kt @@ -34,3 +34,19 @@ internal fun validateInvitation(raw: String): Result = 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 +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 455ed66..e926198 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -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), ) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt index c467b09..41354d8 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/receive/ExternalInvitationControllerTest.kt @@ -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 { + decodeInvitationBytes(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())) + } + assertFailsWith { + decodeInvitationBytes(ByteArray(MaxVniDropInvitationBytes + 1) { 'a'.code.toByte() }) + } + assertFailsWith { + decodeInvitationBytes(byteArrayOf()) + } + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index fa0576d..50bd2c0 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -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>() var shareResult: Result = Result.failure(UnsupportedOperationException()) var inspectionResult: Result = Result.failure(UnsupportedOperationException()) + var receiveResult: Result = Result.success(Unit) + var receiveSuspend: Boolean = false + private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() 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() + receiveGate = gate + gate.await() + } + override suspend fun initialize(appDataDir: String): Result { mutableState.value = mutableState.value.copy(isInitialized = true) return Result.success(Unit) @@ -88,9 +106,27 @@ class FakeCoreGateway : CoreGateway { accessPolicy: ShareAccessPolicy, ) = Result.failure(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 { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } + override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result { + receiveCount += 1 + lastReceiveTicket = ticket + lastReceiveReceiverName = receiverName + awaitReceiveIfNeeded() + return receiveResult + } + override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result { + 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 { if (deleteResult.isSuccess) { diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt index 90fa685..31fcf75 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -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 } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt index 6b67fc0..8af4f4a 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.jvm.kt @@ -18,7 +18,7 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb override fun pickInvitation(onResult: (Result) -> 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 = 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? = diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 933799c..0377e9f 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -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) -> Unit) = Unit + override fun scanQrCode(onResult: (Result) -> Unit) = Unit + override fun readNfcInvitation(onResult: (Result) -> 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())