mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -24,11 +24,26 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Preferred: exact VniDrop invitation MIME type. -->
|
||||
<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:mimeType="application/vnd.vnidrop.transfer"/>
|
||||
</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>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -11,8 +11,7 @@ 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.VniDropInvitationMimeType
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -53,15 +52,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun readInvitation(uri: Uri, declaredType: String?): Result<String> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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? =
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user