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

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

View File

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