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())