diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 6cf9dd5..fff122e 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -52,6 +52,26 @@ mod tests { ); } + #[test] + fn metadata_ticket_round_trip_tolerates_wrapped_whitespace() { + let secret = SecretKey::generate(); + let addr = iroh::EndpointAddr::new(secret.public()); + let blob_ticket = BlobTicket::new(addr, Hash::new([9; 32]), BlobFormat::HashSeq); + let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) + .encode() + .unwrap(); + let wrapped = encoded + .as_bytes() + .chunks(8) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n "); + + let parsed = parse_transfer_ticket(&wrapped).unwrap(); + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); + } + #[test] fn invalid_ticket_is_rejected() { assert!(parse_transfer_ticket("not-a-ticket").is_err()); diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index a8b470e..af94e72 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -34,7 +34,8 @@ impl VnidropTicket { } fn decode(value: &str) -> Result { - let encoded = value + let normalized = normalize_ticket_input(value); + let encoded = normalized .strip_prefix(VNIDROP_TICKET_PREFIX) .context("not a VniDrop ticket")?; let bytes = BASE64URL_NOPAD @@ -51,8 +52,9 @@ pub(crate) struct ParsedTransferTicket { } pub(crate) fn parse_transfer_ticket(value: &str) -> Result { - if value.starts_with(VNIDROP_TICKET_PREFIX) { - let ticket = VnidropTicket::decode(value)?; + let normalized = normalize_ticket_input(value); + if normalized.starts_with(VNIDROP_TICKET_PREFIX) { + let ticket = VnidropTicket::decode(&normalized)?; let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) .context("invalid BlobTicket inside VniDrop ticket")?; return Ok(ParsedTransferTicket { @@ -61,9 +63,13 @@ pub(crate) fn parse_transfer_ticket(value: &str) -> Result }); } - let blob_ticket = BlobTicket::from_str(value).context("invalid BlobTicket")?; + let blob_ticket = BlobTicket::from_str(&normalized).context("invalid BlobTicket")?; Ok(ParsedTransferTicket { blob_ticket, metadata: None, }) } + +fn normalize_ticket_input(value: &str) -> String { + value.chars().filter(|char| !char.is_whitespace()).collect() +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 46a2215..4db8334 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -2,12 +2,14 @@ package com.vnidrop.app import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -18,6 +20,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreRepository @@ -33,6 +37,7 @@ fun App() { val repository = remember { CoreRepository() } val state by repository.state.collectAsState() val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } var sourcePath by remember { mutableStateOf("") } var selectedFile by remember { mutableStateOf(null) } @@ -129,7 +134,19 @@ fun App() { } state.lastShare?.let { share -> Text("Ticket") - Text(share.ticket, style = MaterialTheme.typography.bodySmall) + SelectionContainer { + Text(share.ticket, style = MaterialTheme.typography.bodySmall) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + clipboardManager.setText(AnnotatedString(share.ticket)) + }) { + Text("Copy ticket") + } + Button(onClick = { ticket = share.ticket }) { + Text("Use locally") + } + } } } }