mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
feat(core): offer an existing share to a remembered device
Another way to deliver an invitation the user already created, alongside the QR code, rather than a second share of the same files: the ticket handed over is the stored one and the transfer id is unchanged. Only an active share can be offered. A stopped one no longer serves its content, so handing out its ticket would promise nothing.
This commit is contained in:
@@ -23,6 +23,7 @@ use crate::{
|
||||
DeliverGrant, GrantDeliveryResponse, OfferResponse, OfferService, RevokeGrant, SubmitOffer,
|
||||
},
|
||||
ticket::{encode_persisted_sender_address, parse_persisted_sender_address},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
@@ -179,17 +180,87 @@ impl CoreInner {
|
||||
metadata.access_mode = TransferAccessMode::ApprovalRequired;
|
||||
let sender_name = metadata.sender_name.clone();
|
||||
let share = self.share_files(sources, metadata).await?;
|
||||
self.offer_share(endpoint_id, grant, share, sender_name.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Offer a share that already exists, so a transfer created for an
|
||||
/// invitation can also be pushed to a remembered device.
|
||||
///
|
||||
/// The ticket is the one already stored for the transfer: this adds another
|
||||
/// way to deliver it, it does not create a second share of the same files.
|
||||
pub(super) async fn offer_transfer_to_contact(
|
||||
self: &Arc<Self>,
|
||||
transfer_id: u64,
|
||||
endpoint_id: String,
|
||||
) -> Result<ContactSendResult> {
|
||||
let grant = self
|
||||
.repository
|
||||
.contacts()
|
||||
.held_grant_for(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.ok_or_else(|| {
|
||||
VnidropError::permission(anyhow::anyhow!(
|
||||
"no live grant for this device; pair with it again"
|
||||
))
|
||||
})?;
|
||||
|
||||
let stored = self
|
||||
.repository
|
||||
.list_transfers()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.into_iter()
|
||||
.find(|transfer| transfer.transfer_id == transfer_id)
|
||||
.ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("unknown transfer {transfer_id}"))
|
||||
})?;
|
||||
|
||||
// Only a live share can be offered: a stopped one no longer serves its
|
||||
// content, so handing out its ticket would promise nothing.
|
||||
if stored.direction != TransferDirection::Send.as_str()
|
||||
|| stored.status != TransferStatus::Sharing.as_str()
|
||||
{
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"transfer {transfer_id} is not an active share"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let ticket = stored.ticket.clone().ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("transfer {transfer_id} has no invitation"))
|
||||
})?;
|
||||
|
||||
let share = ShareResult {
|
||||
transfer_id,
|
||||
ticket,
|
||||
hash: stored.content_hash.unwrap_or_default(),
|
||||
transfer_name: stored.transfer_name.unwrap_or_default(),
|
||||
file_count: stored.file_count,
|
||||
total_size: stored.total_size,
|
||||
};
|
||||
self.offer_share(endpoint_id, grant, share, None).await
|
||||
}
|
||||
|
||||
/// Deliver an offer for `share`, holding it when the device is not running.
|
||||
async fn offer_share(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: String,
|
||||
grant: HeldGrant,
|
||||
share: ShareResult,
|
||||
sender_name: Option<&str>,
|
||||
) -> Result<ContactSendResult> {
|
||||
let store = self.repository.contacts();
|
||||
|
||||
// An unreachable device is the common case on mobile, not an error: the
|
||||
// share stays here and the ticket waits for the peer to come and get it.
|
||||
let outcome = match self
|
||||
.deliver_offer(&endpoint_id, &grant, &share, sender_name.as_deref())
|
||||
.deliver_offer(&endpoint_id, &grant, &share, sender_name)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
self.hold_offer(&endpoint_id, &share, sender_name.as_deref())
|
||||
.await?;
|
||||
self.hold_offer(&endpoint_id, &share, sender_name).await?;
|
||||
tracing::debug!(%error, "offer held for later pickup");
|
||||
return Ok(ContactSendResult {
|
||||
share,
|
||||
|
||||
@@ -302,6 +302,22 @@ impl VnidropCore {
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Offer an existing share to a remembered device.
|
||||
///
|
||||
/// Another way to deliver the invitation already created for a transfer,
|
||||
/// alongside the QR code — not a second share of the same files.
|
||||
pub fn offer_transfer_to_contact(
|
||||
&self,
|
||||
transfer_id: u64,
|
||||
endpoint_id: String,
|
||||
) -> Result<ContactSendResult, VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.offer_transfer_to_contact(transfer_id, endpoint_id),
|
||||
)
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
pub fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_held_offers())
|
||||
|
||||
@@ -561,3 +561,91 @@ fn polling_a_device_that_holds_nothing_for_you_returns_nothing() {
|
||||
assert_eq!(receiver.core.poll_contacts_for_offers().unwrap(), 0);
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// A transfer created for an invitation can also be pushed to a device: the
|
||||
/// same ticket, another way to deliver it.
|
||||
#[test]
|
||||
fn an_existing_share_can_be_offered_to_a_contact() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"existing share").unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
// An ordinary share, as if the user had created it for a QR code.
|
||||
let share = sender
|
||||
.core
|
||||
.share_files(sources(&source_path), metadata(6_001))
|
||||
.expect("shared");
|
||||
|
||||
let core = sender.core.arc();
|
||||
let to = endpoint_id(&receiver);
|
||||
let handle = std::thread::spawn(move || core.offer_transfer_to_contact(share.transfer_id, to));
|
||||
|
||||
let offer = wait_for_offer(&receiver.core);
|
||||
let ticket = receiver
|
||||
.core
|
||||
.respond_to_offer(offer.offer_id, true)
|
||||
.expect("accepting yields the ticket");
|
||||
let outcome = handle.join().unwrap().expect("offer accepted");
|
||||
|
||||
assert!(outcome.delivered);
|
||||
assert_eq!(
|
||||
outcome.share.transfer_id, share.transfer_id,
|
||||
"offering reuses the existing transfer rather than creating another"
|
||||
);
|
||||
assert_eq!(ticket, share.ticket, "the invitation is the stored one");
|
||||
|
||||
receiver
|
||||
.core
|
||||
.receive(
|
||||
ticket,
|
||||
output_dir.path().to_string_lossy().to_string(),
|
||||
Some("Receiver".to_string()),
|
||||
)
|
||||
.expect("receive completes");
|
||||
assert_eq!(
|
||||
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||
b"existing share"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stopped share serves nothing, so its ticket must not be handed out.
|
||||
#[test]
|
||||
fn a_stopped_share_cannot_be_offered() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
let share = sender
|
||||
.core
|
||||
.share_files(sources(&source_path), metadata(6_002))
|
||||
.expect("shared");
|
||||
sender.core.cancel_transfer(share.transfer_id).unwrap();
|
||||
|
||||
let outcome = sender
|
||||
.core
|
||||
.offer_transfer_to_contact(share.transfer_id, endpoint_id(&receiver));
|
||||
|
||||
assert!(outcome.is_err());
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// Offering an unknown transfer is rejected rather than silently doing nothing.
|
||||
#[test]
|
||||
fn offering_an_unknown_transfer_is_rejected() {
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
assert!(sender
|
||||
.core
|
||||
.offer_transfer_to_contact(9_999, endpoint_id(&receiver))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user