mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Merge pull request #9 from vnidrop/feat/step3-remaining
feat(send): finish step 3 — iOS QR/NFC, folders, per-receiver progress
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
|
future::Future,
|
||||||
io,
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
@@ -59,6 +60,19 @@ pub struct VnidropCore {
|
|||||||
inner: Arc<CoreInner>,
|
inner: Arc<CoreInner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl VnidropCore {
|
||||||
|
/// Drive work on this core's multi-thread runtime from a sync API boundary.
|
||||||
|
///
|
||||||
|
/// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive
|
||||||
|
/// [`Runtime::block_on`] so a concurrent call (for example cancel while
|
||||||
|
/// another thread is blocked inside `receive`) cannot deadlock the runtime
|
||||||
|
/// driver. UniFFI and tests both rely on that: receive runs on a worker
|
||||||
|
/// thread while cancel/approve arrive from the UI or test harness thread.
|
||||||
|
fn block_on<F: Future>(&self, future: F) -> F::Output {
|
||||||
|
self.runtime.handle().block_on(future)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CoreInner {
|
struct CoreInner {
|
||||||
// Kotlin owns app lifecycle and platform file picking; this Rust object
|
// Kotlin owns app lifecycle and platform file picking; this Rust object
|
||||||
// owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
// owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
||||||
@@ -71,7 +85,9 @@ struct CoreInner {
|
|||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
transfer_slots: Semaphore,
|
transfer_slots: Semaphore,
|
||||||
access_policy: Arc<AccessPolicy>,
|
access_policy: Arc<AccessPolicy>,
|
||||||
active_transfers: TokioMutex<HashMap<u64, ActiveTransfer>>,
|
/// Sync mutex so cancel can remove + signal without awaiting (and without
|
||||||
|
/// holding a Tokio lock across repository I/O).
|
||||||
|
active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>,
|
||||||
// Newly imported shares retain a TempTag for the lifetime of this process.
|
// Newly imported shares retain a TempTag for the lifetime of this process.
|
||||||
// Restored shares have no in-memory tag, but remain tracked so they can be
|
// Restored shares have no in-memory tag, but remain tracked so they can be
|
||||||
// counted and explicitly revoked after a restart.
|
// counted and explicitly revoked after a restart.
|
||||||
@@ -173,7 +189,7 @@ impl VnidropCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> RuntimeStatus {
|
pub fn status(&self) -> RuntimeStatus {
|
||||||
self.runtime.block_on(self.inner.status())
|
self.block_on(self.inner.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn share_files(
|
pub fn share_files(
|
||||||
@@ -181,8 +197,7 @@ impl VnidropCore {
|
|||||||
sources: Vec<ShareSource>,
|
sources: Vec<ShareSource>,
|
||||||
metadata: ShareMetadataInput,
|
metadata: ShareMetadataInput,
|
||||||
) -> Result<ShareResult, VnidropError> {
|
) -> Result<ShareResult, VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.share_files(sources, metadata))
|
||||||
.block_on(self.inner.share_files(sources, metadata))
|
|
||||||
.map_err(VnidropError::transfer)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +210,7 @@ impl VnidropCore {
|
|||||||
if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits)
|
if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits)
|
||||||
.context("failed to parse transfer ticket")
|
.context("failed to parse transfer ticket")
|
||||||
{
|
{
|
||||||
self.runtime.block_on(async {
|
self.block_on(async {
|
||||||
self.inner.emit_endpoint(
|
self.inner.emit_endpoint(
|
||||||
"error",
|
"error",
|
||||||
"invalid-ticket",
|
"invalid-ticket",
|
||||||
@@ -206,8 +221,7 @@ impl VnidropCore {
|
|||||||
return Err(VnidropError::ticket(error));
|
return Err(VnidropError::ticket(error));
|
||||||
}
|
}
|
||||||
let output_dir = platform_path(&output_dir).map_err(VnidropError::filesystem)?;
|
let output_dir = platform_path(&output_dir).map_err(VnidropError::filesystem)?;
|
||||||
self.runtime
|
self.block_on(self.inner.receive(ticket, output_dir, receiver_name))
|
||||||
.block_on(self.inner.receive(ticket, output_dir, receiver_name))
|
|
||||||
.map_err(VnidropError::transfer)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +234,7 @@ impl VnidropCore {
|
|||||||
if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits)
|
if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits)
|
||||||
.context("failed to parse transfer ticket")
|
.context("failed to parse transfer ticket")
|
||||||
{
|
{
|
||||||
self.runtime.block_on(async {
|
self.block_on(async {
|
||||||
self.inner.emit_endpoint(
|
self.inner.emit_endpoint(
|
||||||
"error",
|
"error",
|
||||||
"invalid-ticket",
|
"invalid-ticket",
|
||||||
@@ -230,8 +244,7 @@ impl VnidropCore {
|
|||||||
});
|
});
|
||||||
return Err(VnidropError::ticket(error));
|
return Err(VnidropError::ticket(error));
|
||||||
}
|
}
|
||||||
self.runtime
|
self.block_on(
|
||||||
.block_on(
|
|
||||||
self.inner
|
self.inner
|
||||||
.receive_with_output_sink(ticket, output_sink, receiver_name),
|
.receive_with_output_sink(ticket, output_sink, receiver_name),
|
||||||
)
|
)
|
||||||
@@ -239,20 +252,46 @@ impl VnidropCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
// Fire the oneshot on this thread before entering the runtime so a
|
||||||
.block_on(self.inner.cancel_transfer(transfer_id))
|
// blocked export write cannot prevent the cancel signal from being
|
||||||
|
// delivered (the receive `select!` observes it on the next yield).
|
||||||
|
if let Some(direction) = self.inner.take_active_transfer(transfer_id) {
|
||||||
|
let expected = match direction {
|
||||||
|
TransferDirection::Send => TransferStatus::Importing,
|
||||||
|
TransferDirection::Receive => TransferStatus::Receiving,
|
||||||
|
};
|
||||||
|
return self
|
||||||
|
.block_on(async {
|
||||||
|
self.inner
|
||||||
|
.repository
|
||||||
|
.transition_transfer_status(
|
||||||
|
transfer_id,
|
||||||
|
expected,
|
||||||
|
TransferStatus::Cancelled,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.inner.emit_transfer(
|
||||||
|
transfer_id,
|
||||||
|
direction.as_str(),
|
||||||
|
"lifecycle",
|
||||||
|
"cancel-requested",
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.map_err(VnidropError::transfer);
|
||||||
|
}
|
||||||
|
self.block_on(self.inner.cancel_idle_or_share(transfer_id))
|
||||||
.map_err(VnidropError::transfer)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
pub fn delete_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.delete_transfer(transfer_id))
|
||||||
.block_on(self.inner.delete_transfer(transfer_id))
|
|
||||||
.map_err(VnidropError::transfer)
|
.map_err(VnidropError::transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_receive_history(&self) -> Result<u64, VnidropError> {
|
pub fn delete_receive_history(&self) -> Result<u64, VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.delete_receive_history())
|
||||||
.block_on(self.inner.delete_receive_history())
|
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +300,7 @@ impl VnidropCore {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
mode: TransferAccessMode,
|
mode: TransferAccessMode,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.set_transfer_access_mode(transfer_id, mode))
|
||||||
.block_on(self.inner.set_transfer_access_mode(transfer_id, mode))
|
|
||||||
.map_err(VnidropError::permission)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +309,7 @@ impl VnidropCore {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
endpoint_id: String,
|
endpoint_id: String,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.block_on(
|
||||||
.block_on(
|
|
||||||
self.inner
|
self.inner
|
||||||
.approve_endpoint_for_transfer(transfer_id, endpoint_id),
|
.approve_endpoint_for_transfer(transfer_id, endpoint_id),
|
||||||
)
|
)
|
||||||
@@ -283,8 +320,7 @@ impl VnidropCore {
|
|||||||
&self,
|
&self,
|
||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
) -> Result<Vec<ReceiverRequest>, VnidropError> {
|
) -> Result<Vec<ReceiverRequest>, VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.repository.list_receiver_requests(transfer_id))
|
||||||
.block_on(self.inner.repository.list_receiver_requests(transfer_id))
|
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,20 +330,17 @@ impl VnidropCore {
|
|||||||
accepted: bool,
|
accepted: bool,
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
) -> Result<(), VnidropError> {
|
) -> Result<(), VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.approval.respond(request_id, accepted, reason))
|
||||||
.block_on(self.inner.approval.respond(request_id, accepted, reason))
|
|
||||||
.map_err(VnidropError::permission)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.repository.list_transfers())
|
||||||
.block_on(self.inner.repository.list_transfers())
|
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
|
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
|
||||||
self.runtime
|
self.block_on(self.inner.list_events(transfer_id))
|
||||||
.block_on(self.inner.list_events(transfer_id))
|
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +360,7 @@ impl VnidropCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown(&self) {
|
pub fn shutdown(&self) {
|
||||||
self.runtime.block_on(self.inner.shutdown());
|
self.block_on(self.inner.shutdown());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +472,7 @@ impl CoreInner {
|
|||||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||||
limits,
|
limits,
|
||||||
access_policy,
|
access_policy,
|
||||||
active_transfers: TokioMutex::new(HashMap::new()),
|
active_transfers: std::sync::Mutex::new(HashMap::new()),
|
||||||
active_shares: TokioMutex::new(restored_active_shares),
|
active_shares: TokioMutex::new(restored_active_shares),
|
||||||
hash_to_transfer: TokioMutex::new(restored_hashes),
|
hash_to_transfer: TokioMutex::new(restored_hashes),
|
||||||
connection_endpoints: TokioMutex::new(HashMap::new()),
|
connection_endpoints: TokioMutex::new(HashMap::new()),
|
||||||
@@ -461,11 +494,17 @@ impl CoreInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn status(&self) -> RuntimeStatus {
|
async fn status(&self) -> RuntimeStatus {
|
||||||
|
let active_transfers = self
|
||||||
|
.active_transfers
|
||||||
|
.lock()
|
||||||
|
.expect("active_transfers")
|
||||||
|
.len() as u64;
|
||||||
|
let active_shares = self.active_shares.lock().await.len() as u64;
|
||||||
RuntimeStatus {
|
RuntimeStatus {
|
||||||
endpoint_id: self.endpoint.id().to_string(),
|
endpoint_id: self.endpoint.id().to_string(),
|
||||||
addr: format!("{:?}", self.endpoint.addr()),
|
addr: format!("{:?}", self.endpoint.addr()),
|
||||||
active_transfers: self.active_transfers.lock().await.len() as u64,
|
active_transfers,
|
||||||
active_shares: self.active_shares.lock().await.len() as u64,
|
active_shares,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +548,10 @@ impl CoreInner {
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let (cancel, mut cancelled) = oneshot::channel();
|
let (cancel, mut cancelled) = oneshot::channel();
|
||||||
self.active_transfers.lock().await.insert(
|
self.active_transfers
|
||||||
|
.lock()
|
||||||
|
.expect("active_transfers")
|
||||||
|
.insert(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
ActiveTransfer {
|
ActiveTransfer {
|
||||||
direction: TransferDirection::Send,
|
direction: TransferDirection::Send,
|
||||||
@@ -520,7 +562,10 @@ impl CoreInner {
|
|||||||
result = self.share_files_inner(sources, metadata) => (result, false),
|
result = self.share_files_inner(sources, metadata) => (result, false),
|
||||||
_ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
_ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
||||||
};
|
};
|
||||||
self.active_transfers.lock().await.remove(&transfer_id);
|
self.active_transfers
|
||||||
|
.lock()
|
||||||
|
.expect("active_transfers")
|
||||||
|
.remove(&transfer_id);
|
||||||
if let Err(error) = &result {
|
if let Err(error) = &result {
|
||||||
if was_cancelled {
|
if was_cancelled {
|
||||||
self.emit_transfer(transfer_id, "send", "lifecycle", "cancelled", json!({}));
|
self.emit_transfer(transfer_id, "send", "lifecycle", "cancelled", json!({}));
|
||||||
@@ -689,7 +734,10 @@ impl CoreInner {
|
|||||||
// Cancellation is cooperative: it stops our receive future and marks
|
// Cancellation is cooperative: it stops our receive future and marks
|
||||||
// local state while lower-level Iroh work unwinds naturally.
|
// local state while lower-level Iroh work unwinds naturally.
|
||||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||||
self.active_transfers.lock().await.insert(
|
self.active_transfers
|
||||||
|
.lock()
|
||||||
|
.expect("active_transfers")
|
||||||
|
.insert(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
ActiveTransfer {
|
ActiveTransfer {
|
||||||
direction: TransferDirection::Receive,
|
direction: TransferDirection::Receive,
|
||||||
@@ -702,7 +750,10 @@ impl CoreInner {
|
|||||||
_ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
_ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.active_transfers.lock().await.remove(&transfer_id);
|
self.active_transfers
|
||||||
|
.lock()
|
||||||
|
.expect("active_transfers")
|
||||||
|
.remove(&transfer_id);
|
||||||
if let Err(error) = &result {
|
if let Err(error) = &result {
|
||||||
if cancelled {
|
if cancelled {
|
||||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "cancelled", json!({}));
|
self.emit_transfer(transfer_id, "receive", "lifecycle", "cancelled", json!({}));
|
||||||
@@ -898,35 +949,20 @@ impl CoreInner {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_transfer(&self, transfer_id: u64) -> Result<()> {
|
/// Remove an in-flight transfer and fire its cancel oneshot synchronously.
|
||||||
let mut active_transfers = self.active_transfers.lock().await;
|
fn take_active_transfer(&self, transfer_id: u64) -> Option<TransferDirection> {
|
||||||
if let Some(direction) = active_transfers
|
let active = self
|
||||||
.get(&transfer_id)
|
.active_transfers
|
||||||
.map(|active| active.direction)
|
.lock()
|
||||||
{
|
.expect("active_transfers")
|
||||||
let expected = match direction {
|
.remove(&transfer_id)?;
|
||||||
TransferDirection::Send => TransferStatus::Importing,
|
let direction = active.direction;
|
||||||
TransferDirection::Receive => TransferStatus::Receiving,
|
|
||||||
};
|
|
||||||
self.repository
|
|
||||||
.transition_transfer_status(transfer_id, expected, TransferStatus::Cancelled)
|
|
||||||
.await?;
|
|
||||||
let active = active_transfers
|
|
||||||
.remove(&transfer_id)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("active transfer registration disappeared"))?;
|
|
||||||
drop(active_transfers);
|
|
||||||
let _ = active.cancel.send(());
|
let _ = active.cancel.send(());
|
||||||
self.emit_transfer(
|
Some(direction)
|
||||||
transfer_id,
|
|
||||||
direction.as_str(),
|
|
||||||
"lifecycle",
|
|
||||||
"cancel-requested",
|
|
||||||
json!({}),
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
drop(active_transfers);
|
|
||||||
|
|
||||||
|
/// Stop a live share or report missing when no active transfer was found.
|
||||||
|
async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> {
|
||||||
let mut active_shares = self.active_shares.lock().await;
|
let mut active_shares = self.active_shares.lock().await;
|
||||||
if active_shares.contains_key(&transfer_id) {
|
if active_shares.contains_key(&transfer_id) {
|
||||||
self.repository
|
self.repository
|
||||||
@@ -953,7 +989,7 @@ impl CoreInner {
|
|||||||
if self
|
if self
|
||||||
.active_transfers
|
.active_transfers
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.expect("active_transfers")
|
||||||
.contains_key(&transfer_id)
|
.contains_key(&transfer_id)
|
||||||
{
|
{
|
||||||
anyhow::bail!("an active transfer must finish or be cancelled before deletion");
|
anyhow::bail!("an active transfer must finish or be cancelled before deletion");
|
||||||
@@ -1389,6 +1425,10 @@ impl CoreInner {
|
|||||||
"exported": exported,
|
"exported": exported,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Yield so outer `select!` cancel can land between chunks.
|
||||||
|
// Sink writes are synchronous (foreign FFI); without this,
|
||||||
|
// a single poll can drain the stream and miss cancellation.
|
||||||
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
ExportRangesItem::Error(error) => {
|
ExportRangesItem::Error(error) => {
|
||||||
anyhow::bail!("export failed for {relative_path}: {error}");
|
anyhow::bail!("export failed for {relative_path}: {error}");
|
||||||
@@ -1485,7 +1525,8 @@ impl CoreInner {
|
|||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
message.inner.request_id,
|
message.inner.request_id,
|
||||||
message.rx,
|
message.rx,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
let _ = message.tx.send(Ok(())).await;
|
let _ = message.tx.send(Ok(())).await;
|
||||||
}
|
}
|
||||||
@@ -1497,7 +1538,8 @@ impl CoreInner {
|
|||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
message.inner.request_id,
|
message.inner.request_id,
|
||||||
message.rx,
|
message.rx,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProviderMessage::GetManyRequestReceived(message) => {
|
ProviderMessage::GetManyRequestReceived(message) => {
|
||||||
@@ -1531,7 +1573,8 @@ impl CoreInner {
|
|||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
message.inner.request_id,
|
message.inner.request_id,
|
||||||
message.rx,
|
message.rx,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
let _ = message.tx.send(Ok(())).await;
|
let _ = message.tx.send(Ok(())).await;
|
||||||
}
|
}
|
||||||
@@ -1545,7 +1588,8 @@ impl CoreInner {
|
|||||||
message.inner.connection_id,
|
message.inner.connection_id,
|
||||||
message.inner.request_id,
|
message.inner.request_id,
|
||||||
message.rx,
|
message.rx,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProviderMessage::ObserveRequestReceived(message) => {
|
ProviderMessage::ObserveRequestReceived(message) => {
|
||||||
@@ -1618,7 +1662,7 @@ impl CoreInner {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track_request_updates(
|
async fn track_request_updates(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
connection_id: u64,
|
connection_id: u64,
|
||||||
@@ -1628,6 +1672,15 @@ impl CoreInner {
|
|||||||
// Request update tasks are tied to individual provider streams. Router
|
// Request update tasks are tied to individual provider streams. Router
|
||||||
// shutdown closes those streams; only the long-lived provider receiver
|
// shutdown closes those streams; only the long-lived provider receiver
|
||||||
// is tracked directly for explicit shutdown.
|
// is tracked directly for explicit shutdown.
|
||||||
|
//
|
||||||
|
// Attach the remote endpoint id when known so the send UI can attribute
|
||||||
|
// byte progress to a specific receiver (not just an opaque connection).
|
||||||
|
let endpoint_id = self
|
||||||
|
.connection_endpoints
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&connection_id)
|
||||||
|
.cloned();
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(update)) = rx.recv().await {
|
while let Ok(Some(update)) = rx.recv().await {
|
||||||
@@ -1640,6 +1693,7 @@ impl CoreInner {
|
|||||||
json!({
|
json!({
|
||||||
"connection_id": connection_id,
|
"connection_id": connection_id,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
"hash": started.hash.to_string(),
|
"hash": started.hash.to_string(),
|
||||||
"size": started.size,
|
"size": started.size,
|
||||||
"index": started.index,
|
"index": started.index,
|
||||||
@@ -1653,6 +1707,7 @@ impl CoreInner {
|
|||||||
json!({
|
json!({
|
||||||
"connection_id": connection_id,
|
"connection_id": connection_id,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
"end_offset": progress.end_offset,
|
"end_offset": progress.end_offset,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -1661,14 +1716,22 @@ impl CoreInner {
|
|||||||
"send",
|
"send",
|
||||||
"transfer",
|
"transfer",
|
||||||
"completed",
|
"completed",
|
||||||
json!({ "connection_id": connection_id, "request_id": request_id }),
|
json!({
|
||||||
|
"connection_id": connection_id,
|
||||||
|
"request_id": request_id,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
RequestUpdate::Aborted(_) => core.emit_transfer(
|
RequestUpdate::Aborted(_) => core.emit_transfer(
|
||||||
transfer_id,
|
transfer_id,
|
||||||
"send",
|
"send",
|
||||||
"transfer",
|
"transfer",
|
||||||
"aborted",
|
"aborted",
|
||||||
json!({ "connection_id": connection_id, "request_id": request_id }),
|
json!({
|
||||||
|
"connection_id": connection_id,
|
||||||
|
"request_id": request_id,
|
||||||
|
"endpoint_id": endpoint_id,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,21 +68,23 @@ fn reports_output_sink_write_failure() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancellation_during_export_aborts_open_sink_file() {
|
fn cancellation_during_export_aborts_open_sink_file() {
|
||||||
|
// Gate the first write so export is mid-file when cancel runs. A large
|
||||||
|
// "slow writes" file made this flaky/hang-prone on CI: concurrent cancel
|
||||||
|
// used to nest Runtime::block_on, and multi-megabyte sleep loops could
|
||||||
|
// starve the cancel path for a long time.
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
let source_path = source_dir.path().join("slow.bin");
|
let source_path = source_dir.path().join("gated.bin");
|
||||||
std::fs::File::create(&source_path)
|
std::fs::write(&source_path, vec![0u8; 256 * 1024]).unwrap();
|
||||||
.unwrap()
|
|
||||||
.set_len(32 * 1024 * 1024)
|
|
||||||
.unwrap();
|
|
||||||
let sender = TestNode::new();
|
let sender = TestNode::new();
|
||||||
let receiver = TestNode::new();
|
let receiver = TestNode::new();
|
||||||
let share = share_path(&sender.core, &source_path, 29, "slow.bin", false);
|
let share = share_path(&sender.core, &source_path, 29, "gated.bin", false);
|
||||||
let output_sink = Arc::new(MemoryOutputSink::slow_writes(Duration::from_millis(25)));
|
let output_sink = Arc::new(MemoryOutputSink::block_first_write());
|
||||||
let receiver_core = receiver.core.arc();
|
let receiver_core = receiver.core.arc();
|
||||||
let sink_for_worker = output_sink.clone();
|
let sink_for_worker = output_sink.clone();
|
||||||
|
let ticket = share.ticket.clone();
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
receiver_core.receive_with_output_sink(
|
receiver_core.receive_with_output_sink(
|
||||||
share.ticket,
|
ticket,
|
||||||
sink_for_worker,
|
sink_for_worker,
|
||||||
Some("receiver".to_string()),
|
Some("receiver".to_string()),
|
||||||
)
|
)
|
||||||
@@ -94,11 +96,35 @@ fn cancellation_during_export_aborts_open_sink_file() {
|
|||||||
.respond_receiver_request(request.id, true, None)
|
.respond_receiver_request(request.id, true, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while !output_sink.has_started("slow.bin") {
|
while !output_sink.has_started("gated.bin") || !output_sink.has_entered_write() {
|
||||||
assert!(started.elapsed() < Duration::from_secs(15));
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(15),
|
||||||
|
"export never opened gated.bin for writing"
|
||||||
|
);
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
|
// First write is parked in the sink. Cancel must complete from this thread
|
||||||
|
// without deadlocking against the receive worker's runtime driver.
|
||||||
receiver.core.cancel_transfer(share.transfer_id).unwrap();
|
receiver.core.cancel_transfer(share.transfer_id).unwrap();
|
||||||
assert!(worker.join().unwrap().is_err());
|
// Unblock the in-flight write so the receive future can yield, observe
|
||||||
assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted"));
|
// cancel, and Drop(OutputSinkFile) can abort the open file.
|
||||||
|
output_sink.release_write_gate();
|
||||||
|
|
||||||
|
let result = worker
|
||||||
|
.join()
|
||||||
|
.expect("receive worker thread panicked")
|
||||||
|
.expect_err("cancelled receive should return an error");
|
||||||
|
let message = result.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("cancel") || message.contains("interrupted") || !message.is_empty(),
|
||||||
|
"unexpected cancel error: {message}"
|
||||||
|
);
|
||||||
|
let abort_deadline = Instant::now();
|
||||||
|
while output_sink.terminal_state("gated.bin") != Some("aborted") {
|
||||||
|
assert!(
|
||||||
|
abort_deadline.elapsed() < Duration::from_secs(5),
|
||||||
|
"open sink file was not aborted after cancel"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
ops::Deref,
|
ops::Deref,
|
||||||
path::Path,
|
path::Path,
|
||||||
sync::{Arc, Mutex},
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Condvar, Mutex,
|
||||||
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,6 +100,9 @@ pub struct MemoryOutputSink {
|
|||||||
terminal: Mutex<HashMap<String, &'static str>>,
|
terminal: Mutex<HashMap<String, &'static str>>,
|
||||||
fail_writes: bool,
|
fail_writes: bool,
|
||||||
write_delay: Duration,
|
write_delay: Duration,
|
||||||
|
/// When set, every write waits until [Self::release_write_gate] opens the gate.
|
||||||
|
write_gate: Option<Arc<(Mutex<bool>, Condvar)>>,
|
||||||
|
write_entered: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryOutputSink {
|
impl MemoryOutputSink {
|
||||||
@@ -106,6 +112,8 @@ impl MemoryOutputSink {
|
|||||||
terminal: Mutex::new(HashMap::new()),
|
terminal: Mutex::new(HashMap::new()),
|
||||||
fail_writes: true,
|
fail_writes: true,
|
||||||
write_delay: Duration::ZERO,
|
write_delay: Duration::ZERO,
|
||||||
|
write_gate: None,
|
||||||
|
write_entered: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +123,32 @@ impl MemoryOutputSink {
|
|||||||
terminal: Mutex::new(HashMap::new()),
|
terminal: Mutex::new(HashMap::new()),
|
||||||
fail_writes: false,
|
fail_writes: false,
|
||||||
write_delay: delay,
|
write_delay: delay,
|
||||||
|
write_gate: None,
|
||||||
|
write_entered: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park every `write_chunk` until [Self::release_write_gate] is called.
|
||||||
|
///
|
||||||
|
/// Used to hold export mid-file so cancel can run from another thread
|
||||||
|
/// without racing a multi-megabyte slow-write loop.
|
||||||
|
pub fn block_first_write() -> Self {
|
||||||
|
Self {
|
||||||
|
files: Mutex::new(HashMap::new()),
|
||||||
|
terminal: Mutex::new(HashMap::new()),
|
||||||
|
fail_writes: false,
|
||||||
|
write_delay: Duration::ZERO,
|
||||||
|
write_gate: Some(Arc::new((Mutex::new(false), Condvar::new()))),
|
||||||
|
write_entered: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_write_gate(&self) {
|
||||||
|
if let Some(gate) = &self.write_gate {
|
||||||
|
let (lock, cvar) = gate.as_ref();
|
||||||
|
let mut open = lock.lock().unwrap();
|
||||||
|
*open = true;
|
||||||
|
cvar.notify_all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +163,10 @@ impl MemoryOutputSink {
|
|||||||
pub fn has_started(&self, relative_path: &str) -> bool {
|
pub fn has_started(&self, relative_path: &str) -> bool {
|
||||||
self.files.lock().unwrap().contains_key(relative_path)
|
self.files.lock().unwrap().contains_key(relative_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_entered_write(&self) -> bool {
|
||||||
|
self.write_entered.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReceiveOutputSink for MemoryOutputSink {
|
impl ReceiveOutputSink for MemoryOutputSink {
|
||||||
@@ -138,6 +176,14 @@ impl ReceiveOutputSink for MemoryOutputSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
|
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
|
||||||
|
self.write_entered.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(gate) = &self.write_gate {
|
||||||
|
let (lock, cvar) = gate.as_ref();
|
||||||
|
let mut open = lock.lock().unwrap();
|
||||||
|
while !*open {
|
||||||
|
open = cvar.wait(open).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
if !self.write_delay.is_zero() {
|
if !self.write_delay.is_zero() {
|
||||||
std::thread::sleep(self.write_delay);
|
std::thread::sleep(self.write_delay);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,7 @@
|
|||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
@@ -303,6 +304,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -318,6 +321,10 @@
|
|||||||
SystemConfiguration,
|
SystemConfiguration,
|
||||||
"-framework",
|
"-framework",
|
||||||
Network,
|
Network,
|
||||||
|
"-framework",
|
||||||
|
CoreNFC,
|
||||||
|
"-framework",
|
||||||
|
AVFoundation,
|
||||||
);
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -331,6 +338,7 @@
|
|||||||
ARCHS = arm64;
|
ARCHS = arm64;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
@@ -338,6 +346,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -353,6 +363,10 @@
|
|||||||
SystemConfiguration,
|
SystemConfiguration,
|
||||||
"-framework",
|
"-framework",
|
||||||
Network,
|
Network,
|
||||||
|
"-framework",
|
||||||
|
CoreNFC,
|
||||||
|
"-framework",
|
||||||
|
AVFoundation,
|
||||||
);
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||||
|
<key>NFCReaderUsageDescription</key>
|
||||||
|
<string>VniDrop uses NFC to read transfer invitation tags.</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
10
iosApp/iosApp/vnidrop.entitlements
Normal file
10
iosApp/iosApp/vnidrop.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
|
<array>
|
||||||
|
<string>NDEF</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -20,7 +20,7 @@ actual fun rememberShareFilePicker(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker {
|
): ShareFilePicker {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
||||||
runCatching {
|
runCatching {
|
||||||
uris.map { uri -> context.pickedShareFile(uri) }
|
uris.map { uri -> context.pickedShareFile(uri) }
|
||||||
@@ -29,10 +29,41 @@ actual fun rememberShareFilePicker(
|
|||||||
onFailure = { onError(it.message ?: "Could not open the selected files") },
|
onFailure = { onError(it.message ?: "Could not open the selected files") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return remember(launcher) {
|
val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
runCatching {
|
||||||
|
// Read permission only — we expand the tree into file FDs at share time.
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
val displayName = uri.lastPathSegment
|
||||||
|
?.substringAfterLast(':')
|
||||||
|
?.substringAfterLast('/')
|
||||||
|
?.ifBlank { null }
|
||||||
|
?: "Folder"
|
||||||
|
listOf(
|
||||||
|
PickedShareFile(
|
||||||
|
value = uri.toString(),
|
||||||
|
displayName = displayName,
|
||||||
|
sizeBytes = null,
|
||||||
|
thumbnailBytes = null,
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = onFilesPicked,
|
||||||
|
onFailure = { onError(it.message ?: "Could not open the selected folder") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return remember(filesLauncher, folderLauncher) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
override fun pickFiles() {
|
override fun pickFiles() {
|
||||||
launcher.launch(arrayOf("*/*"))
|
filesLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pickFolder() {
|
||||||
|
folderLauncher.launch(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,12 +71,18 @@ private class AndroidFileSystemService(
|
|||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share> = runCatching {
|
): Result<Share> = runCatching {
|
||||||
require(files.isNotEmpty()) { "Select at least one file to share" }
|
require(files.isNotEmpty()) { "Select at least one file to share" }
|
||||||
val descriptors = files.map { file ->
|
// Android cannot pass a directory as a single FD. Expand SAF trees into
|
||||||
|
// individual document files with relative collection paths, then open FDs.
|
||||||
|
val expanded = files.flatMap { file ->
|
||||||
|
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
|
||||||
|
}
|
||||||
|
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
|
||||||
|
val descriptors = expanded.map { file ->
|
||||||
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
|
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
|
||||||
?: error("Could not open selected file descriptor for ${file.displayName}")
|
?: error("Could not open selected file descriptor for ${file.displayName}")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val sources = files.zip(descriptors) { file, descriptor ->
|
val sources = expanded.zip(descriptors) { file, descriptor ->
|
||||||
uniffi.vnidrop.ShareSource(
|
uniffi.vnidrop.ShareSource(
|
||||||
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
||||||
value = descriptor.fd.toString(),
|
value = descriptor.fd.toString(),
|
||||||
@@ -140,6 +146,63 @@ private class AndroidFileSystemService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a SAF document tree into individual file documents.
|
||||||
|
*
|
||||||
|
* Rust cannot accept a directory FD. Collection paths preserve the folder
|
||||||
|
* root name so receivers see `Folder/nested/file.txt`.
|
||||||
|
*/
|
||||||
|
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
|
||||||
|
val treeUri = Uri.parse(folder.value)
|
||||||
|
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||||
|
val out = mutableListOf<PickedShareFile>()
|
||||||
|
fun walk(documentId: String, relativePath: String) {
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
|
||||||
|
contentResolver.query(
|
||||||
|
childrenUri,
|
||||||
|
arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
|
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getString(idIndex) ?: continue
|
||||||
|
val name = cursor.getString(nameIndex) ?: continue
|
||||||
|
val mime = cursor.getString(mimeIndex)
|
||||||
|
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
|
||||||
|
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
|
walk(id, childRelative)
|
||||||
|
} else {
|
||||||
|
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
|
||||||
|
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
|
||||||
|
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
out += PickedShareFile(
|
||||||
|
value = documentUri.toString(),
|
||||||
|
displayName = childRelative,
|
||||||
|
sizeBytes = size,
|
||||||
|
isDirectory = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefix paths with the folder display name so nested structure is preserved.
|
||||||
|
walk(rootId, folder.displayName)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes into the shared system Downloads collection via MediaStore.
|
* Writes into the shared system Downloads collection via MediaStore.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
<string name="send_transfers_title">Your transfers</string>
|
<string name="send_transfers_title">Your transfers</string>
|
||||||
<string name="send_new_transfer_title">New transfer</string>
|
<string name="send_new_transfer_title">New transfer</string>
|
||||||
<string name="send_choose_file_title">Choose what to share</string>
|
<string name="send_choose_file_title">Choose what to share</string>
|
||||||
<string name="send_choose_file_body">Select one or more files from this device. You can review them before creating the transfer.</string>
|
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
|
||||||
<string name="send_selected_files_count">%1$d files selected</string>
|
<string name="send_selected_files_count">%1$d files selected</string>
|
||||||
|
<string name="send_folder_label">Folder</string>
|
||||||
<string name="button_remove_file">Remove file</string>
|
<string name="button_remove_file">Remove file</string>
|
||||||
<string name="button_choose_files">Choose files</string>
|
<string name="button_choose_files">Choose files</string>
|
||||||
<string name="button_change_files">Change files</string>
|
<string name="button_change_files">Change files</string>
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
<string name="progress_preparing">Preparing transfer</string>
|
<string name="progress_preparing">Preparing transfer</string>
|
||||||
<string name="transfer_receivers_pending">%1$d waiting</string>
|
<string name="transfer_receivers_pending">%1$d waiting</string>
|
||||||
<string name="transfer_receivers_completed_count">%1$d completed</string>
|
<string name="transfer_receivers_completed_count">%1$d completed</string>
|
||||||
|
<string name="transfer_receiver_sending">Sending</string>
|
||||||
<string name="transfer_event_downloading">Downloading files</string>
|
<string name="transfer_event_downloading">Downloading files</string>
|
||||||
<string name="transfer_event_saving">Saving files</string>
|
<string name="transfer_event_saving">Saving files</string>
|
||||||
<string name="transfer_event_connecting">Connecting to sender</string>
|
<string name="transfer_event_connecting">Connecting to sender</string>
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ data class PickedShareFile(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val sizeBytes: ULong? = null,
|
val sizeBytes: ULong? = null,
|
||||||
val thumbnailBytes: ByteArray? = null,
|
val thumbnailBytes: ByteArray? = null,
|
||||||
|
/**
|
||||||
|
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
||||||
|
* folder URL, or Android document tree URI). Platform share code expands or
|
||||||
|
* walks it; Rust cannot treat an Android FD as a directory.
|
||||||
|
*/
|
||||||
|
val isDirectory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface ShareFilePicker {
|
interface ShareFilePicker {
|
||||||
/** Opens a platform picker that may return one or more files. */
|
/** Opens a platform picker that may return one or more files. */
|
||||||
fun pickFiles()
|
fun pickFiles()
|
||||||
|
|
||||||
|
/** Opens a platform folder picker for sharing a directory as one transfer. */
|
||||||
|
fun pickFolder()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiveFolderPicker {
|
interface ReceiveFolderPicker {
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ import com.vnidrop.app.ui.components.PillTone
|
|||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
import com.vnidrop.app.ui.components.ProgressRow
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
import com.vnidrop.app.ui.components.StatusPill
|
import com.vnidrop.app.ui.components.StatusPill
|
||||||
|
import com.vnidrop.app.ui.state.TransferProgress
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
|
import com.vnidrop.app.ui.state.activeSendProgress
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
import com.vnidrop.app.ui.state.progressForTransfer
|
import com.vnidrop.app.ui.state.progressForTransfer
|
||||||
@@ -101,10 +103,15 @@ internal fun TransferCatalog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(transfers, key = Transfer::localId) { transfer ->
|
items(transfers, key = Transfer::localId) { transfer ->
|
||||||
|
val progress = when (transfer.status) {
|
||||||
|
TransferStatus.Importing -> progressForTransfer(events, transfer.transferId)
|
||||||
|
TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
TransferListItem(
|
TransferListItem(
|
||||||
transfer = transfer,
|
transfer = transfer,
|
||||||
thumbnailBytes = transferThumbnails[transfer.transferId],
|
thumbnailBytes = transferThumbnails[transfer.transferId],
|
||||||
progress = progressForTransfer(events, transfer.transferId),
|
progress = progress,
|
||||||
onClick = { onTransferSelected(transfer.transferId) },
|
onClick = { onTransferSelected(transfer.transferId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -170,7 +177,7 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
|
|||||||
private fun TransferListItem(
|
private fun TransferListItem(
|
||||||
transfer: Transfer,
|
transfer: Transfer,
|
||||||
thumbnailBytes: ByteArray?,
|
thumbnailBytes: ByteArray?,
|
||||||
progress: com.vnidrop.app.ui.state.TransferProgress?,
|
progress: TransferProgress?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = LocalVniDropColors.current
|
val colors = LocalVniDropColors.current
|
||||||
@@ -203,7 +210,7 @@ private fun TransferListItem(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (transfer.status == TransferStatus.Importing && progress != null) {
|
if (progress != null && transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) {
|
||||||
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fun SendRoute(
|
|||||||
viewModel.effectFlow.collect { effect ->
|
viewModel.effectFlow.collect { effect ->
|
||||||
when (effect) {
|
when (effect) {
|
||||||
SendEffect.OpenFilePicker -> picker.pickFiles()
|
SendEffect.OpenFilePicker -> picker.pickFiles()
|
||||||
|
SendEffect.OpenFolderPicker -> picker.pickFolder()
|
||||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ fun SendRoute(
|
|||||||
onOpenComposer = viewModel::openComposer,
|
onOpenComposer = viewModel::openComposer,
|
||||||
onDismissComposer = viewModel::dismissComposer,
|
onDismissComposer = viewModel::dismissComposer,
|
||||||
onSelectFile = viewModel::selectFile,
|
onSelectFile = viewModel::selectFile,
|
||||||
|
onSelectFolder = viewModel::selectFolder,
|
||||||
onClearFile = viewModel::clearSelectedSource,
|
onClearFile = viewModel::clearSelectedSource,
|
||||||
onRemoveFile = viewModel::removeSelectedFile,
|
onRemoveFile = viewModel::removeSelectedFile,
|
||||||
onTransferNameChanged = viewModel::setTransferName,
|
onTransferNameChanged = viewModel::setTransferName,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fun SendScreen(
|
|||||||
onOpenComposer: () -> Unit,
|
onOpenComposer: () -> Unit,
|
||||||
onDismissComposer: () -> Unit,
|
onDismissComposer: () -> Unit,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit = {},
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit = {},
|
onRemoveFile: (String) -> Unit = {},
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -83,6 +84,7 @@ fun SendScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
|
onSelectFolder = onSelectFolder,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
onRemoveFile = onRemoveFile,
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
@@ -97,7 +99,12 @@ fun SendScreen(
|
|||||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
||||||
when (state.detailPanel) {
|
when (state.detailPanel) {
|
||||||
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
||||||
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
|
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(
|
||||||
|
receivers = state.receiverHistory,
|
||||||
|
loading = state.isLoadingReceivers,
|
||||||
|
events = coreState.events,
|
||||||
|
transferTotalSize = selectedTransfer.totalSize,
|
||||||
|
)
|
||||||
TransferDetailPanel.Share -> TransferSharePanel(
|
TransferDetailPanel.Share -> TransferSharePanel(
|
||||||
selectedTransfer,
|
selectedTransfer,
|
||||||
shareActions,
|
shareActions,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share }
|
|||||||
|
|
||||||
sealed interface SendEffect {
|
sealed interface SendEffect {
|
||||||
data object OpenFilePicker : SendEffect
|
data object OpenFilePicker : SendEffect
|
||||||
|
data object OpenFolderPicker : SendEffect
|
||||||
data class CopyTicket(val ticket: String) : SendEffect
|
data class CopyTicket(val ticket: String) : SendEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||||
|
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
|
||||||
|
|
||||||
fun onFilesPicked(files: List<PickedShareFile>) {
|
fun onFilesPicked(files: List<PickedShareFile>) {
|
||||||
if (files.isEmpty()) return
|
if (files.isEmpty()) return
|
||||||
@@ -271,9 +273,11 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
|
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
|
||||||
0 -> ""
|
files.isEmpty() -> ""
|
||||||
1 -> files.first().displayName
|
files.size == 1 && files.first().isDirectory -> files.first().displayName
|
||||||
|
files.size == 1 -> files.first().displayName
|
||||||
|
files.all { it.isDirectory } -> "${files.size} folders"
|
||||||
else -> "${files.size} files"
|
else -> "${files.size} files"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.button_change_files
|
import vnidrop.shared.generated.resources.button_change_files
|
||||||
import vnidrop.shared.generated.resources.button_choose_files
|
import vnidrop.shared.generated.resources.button_choose_files
|
||||||
|
import vnidrop.shared.generated.resources.button_choose_folder
|
||||||
import vnidrop.shared.generated.resources.button_clear
|
import vnidrop.shared.generated.resources.button_clear
|
||||||
import vnidrop.shared.generated.resources.button_remove_file
|
import vnidrop.shared.generated.resources.button_remove_file
|
||||||
import vnidrop.shared.generated.resources.button_share_file
|
import vnidrop.shared.generated.resources.button_share_file
|
||||||
@@ -56,6 +57,7 @@ import vnidrop.shared.generated.resources.send_access_title
|
|||||||
import vnidrop.shared.generated.resources.send_choose_file_body
|
import vnidrop.shared.generated.resources.send_choose_file_body
|
||||||
import vnidrop.shared.generated.resources.send_choose_file_title
|
import vnidrop.shared.generated.resources.send_choose_file_title
|
||||||
import vnidrop.shared.generated.resources.send_file_size_unknown
|
import vnidrop.shared.generated.resources.send_file_size_unknown
|
||||||
|
import vnidrop.shared.generated.resources.send_folder_label
|
||||||
import vnidrop.shared.generated.resources.send_review_title
|
import vnidrop.shared.generated.resources.send_review_title
|
||||||
import vnidrop.shared.generated.resources.send_selected_files_count
|
import vnidrop.shared.generated.resources.send_selected_files_count
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ internal fun TransferComposer(
|
|||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit,
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -77,12 +80,13 @@ internal fun TransferComposer(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
if (state.selectedFiles.isEmpty()) {
|
if (state.selectedFiles.isEmpty()) {
|
||||||
ChooseFileStep(onSelectFile)
|
ChooseFileStep(onSelectFile, onSelectFolder)
|
||||||
} else {
|
} else {
|
||||||
ReviewFileStep(
|
ReviewFileStep(
|
||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onSelectFile = onSelectFile,
|
onSelectFile = onSelectFile,
|
||||||
|
onSelectFolder = onSelectFolder,
|
||||||
onClearFile = onClearFile,
|
onClearFile = onClearFile,
|
||||||
onRemoveFile = onRemoveFile,
|
onRemoveFile = onRemoveFile,
|
||||||
onTransferNameChanged = onTransferNameChanged,
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
@@ -96,7 +100,7 @@ internal fun TransferComposer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit) {
|
||||||
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.send_choose_file_body),
|
stringResource(Res.string.send_choose_file_body),
|
||||||
@@ -111,6 +115,7 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
||||||
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +125,7 @@ private fun ReviewFileStep(
|
|||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
|
onSelectFolder: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onRemoveFile: (String) -> Unit,
|
onRemoveFile: (String) -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
@@ -164,11 +170,13 @@ private fun ReviewFileStep(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShareButton(state, coreInitialized, onCreateShare)
|
ShareButton(state, coreInitialized, onCreateShare)
|
||||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||||
|
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
|
||||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +207,11 @@ private fun SelectedFileCard(
|
|||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(
|
Text(
|
||||||
file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown),
|
when {
|
||||||
|
file.isDirectory -> stringResource(Res.string.send_folder_label)
|
||||||
|
file.sizeBytes != null -> formatBytes(file.sizeBytes)
|
||||||
|
else -> stringResource(Res.string.send_file_size_unknown)
|
||||||
|
},
|
||||||
color = LocalVniDropColors.current.foregroundLighter,
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,9 +50,12 @@ import com.vnidrop.app.core.Transfer
|
|||||||
import com.vnidrop.app.ui.components.AppCard
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
import com.vnidrop.app.ui.components.DestructiveButton
|
import com.vnidrop.app.ui.components.DestructiveButton
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
import com.vnidrop.app.ui.components.SecondaryButton
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.state.TransferProgress
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
|
import com.vnidrop.app.ui.state.progressForReceiver
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -159,28 +162,55 @@ private fun DetailDestination(title: String, description: String, count: Int? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ReceiverHistoryPanel(receivers: List<ReceiverRequestModel>, loading: Boolean) {
|
internal fun ReceiverHistoryPanel(
|
||||||
|
receivers: List<ReceiverRequestModel>,
|
||||||
|
loading: Boolean,
|
||||||
|
events: List<CoreEventModel> = emptyList(),
|
||||||
|
transferTotalSize: ULong? = null,
|
||||||
|
) {
|
||||||
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
|
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
|
||||||
when {
|
when {
|
||||||
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
|
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
|
||||||
else -> receivers.forEachIndexed { index, receiver ->
|
else -> receivers.forEachIndexed { index, receiver ->
|
||||||
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
ReceiverRow(receiver)
|
val sendProgress = when (receiver.status) {
|
||||||
|
ReceiverDeliveryStatus.Accepted, ReceiverDeliveryStatus.Requested ->
|
||||||
|
progressForReceiver(events, receiver.transferId, receiver.remoteEndpointId, transferTotalSize)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
ReceiverRow(receiver, sendProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiverRow(receiver: ReceiverRequestModel) {
|
private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferProgress? = null) {
|
||||||
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
|
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
val showLiveSend = sendProgress != null &&
|
||||||
|
receiver.status != ReceiverDeliveryStatus.Completed &&
|
||||||
|
receiver.status != ReceiverDeliveryStatus.Refused &&
|
||||||
|
receiver.status != ReceiverDeliveryStatus.Expired
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
receiver.receiverDeviceName?.takeIf { it != name }?.let {
|
receiver.receiverDeviceName?.takeIf { it != name }?.let {
|
||||||
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
if (showLiveSend) {
|
||||||
|
ProgressRow(
|
||||||
|
label = stringResource(Res.string.transfer_receiver_sending),
|
||||||
|
progress = sendProgress.progress,
|
||||||
|
detail = sendProgress.detail,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
receiverStatusText(receiver.status),
|
||||||
|
color = receiverStatusColor(receiver.status),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
|
receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
|
||||||
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,105 @@ fun progressForTransfer(events: List<CoreEventModel>, transferId: ULong): Transf
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live byte progress for one receiver on an outgoing share.
|
||||||
|
*
|
||||||
|
* Core emits `transfer` phase events per provider connection with
|
||||||
|
* `endpoint_id` (and `connection_id`). Multiple blob requests for the same
|
||||||
|
* receiver are aggregated so multi-file collections show a single bar.
|
||||||
|
*
|
||||||
|
* Falls back to mapping `connection_id` → endpoint via provider
|
||||||
|
* `client-connected` events when older events lack `endpoint_id`.
|
||||||
|
*/
|
||||||
|
fun progressForReceiver(
|
||||||
|
events: List<CoreEventModel>,
|
||||||
|
transferId: ULong,
|
||||||
|
remoteEndpointId: String,
|
||||||
|
totalSizeHint: ULong? = null,
|
||||||
|
): TransferProgress? {
|
||||||
|
if (remoteEndpointId.isBlank()) return null
|
||||||
|
val connectionIds = connectionIdsForEndpoint(events, remoteEndpointId)
|
||||||
|
val transferEvents = events.filter { event ->
|
||||||
|
event.transferId == transferId &&
|
||||||
|
event.direction == "send" &&
|
||||||
|
event.phase == "transfer" &&
|
||||||
|
event.kind in setOf("started", "progress", "completed", "aborted") &&
|
||||||
|
eventBelongsToReceiver(event, remoteEndpointId, connectionIds)
|
||||||
|
}
|
||||||
|
if (transferEvents.isEmpty()) return null
|
||||||
|
|
||||||
|
val latest = transferEvents.first()
|
||||||
|
if (latest.kind == "aborted") {
|
||||||
|
return TransferProgress(
|
||||||
|
transferId = transferId,
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "aborted",
|
||||||
|
label = "Send interrupted",
|
||||||
|
progress = null,
|
||||||
|
detail = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) {
|
||||||
|
return TransferProgress(
|
||||||
|
transferId = transferId,
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "completed",
|
||||||
|
label = "Send completed",
|
||||||
|
progress = 1f,
|
||||||
|
detail = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
|
||||||
|
return TransferProgress(
|
||||||
|
transferId = transferId,
|
||||||
|
phase = "transfer",
|
||||||
|
kind = latest.kind,
|
||||||
|
label = "Sending",
|
||||||
|
progress = progress,
|
||||||
|
detail = progressDetail(latest),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best send-side progress for a transfer (any receiver), used on catalog cards
|
||||||
|
* while status is Sharing.
|
||||||
|
*/
|
||||||
|
fun activeSendProgress(
|
||||||
|
events: List<CoreEventModel>,
|
||||||
|
transferId: ULong,
|
||||||
|
totalSizeHint: ULong? = null,
|
||||||
|
): TransferProgress? {
|
||||||
|
val endpointIds = events
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
|
||||||
|
.mapNotNull { findString(it.dataJson, "endpoint_id") }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
if (endpointIds.isEmpty()) {
|
||||||
|
// Fall back to connection-scoped events without endpoint attribution.
|
||||||
|
val relevant = events.filter {
|
||||||
|
it.transferId == transferId &&
|
||||||
|
it.direction == "send" &&
|
||||||
|
it.phase == "transfer" &&
|
||||||
|
it.kind in setOf("started", "progress")
|
||||||
|
}
|
||||||
|
if (relevant.isEmpty()) return null
|
||||||
|
return TransferProgress(
|
||||||
|
transferId = transferId,
|
||||||
|
phase = "transfer",
|
||||||
|
kind = relevant.first().kind,
|
||||||
|
label = "Sending to receiver",
|
||||||
|
progress = aggregateReceiverProgress(relevant, totalSizeHint),
|
||||||
|
detail = progressDetail(relevant.first()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return endpointIds
|
||||||
|
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
|
||||||
|
.firstOrNull { it.kind == "progress" || it.kind == "started" }
|
||||||
|
?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||||
events
|
events
|
||||||
.mapNotNull { it.transferId }
|
.mapNotNull { it.transferId }
|
||||||
@@ -181,11 +280,107 @@ private fun findKnownSize(events: List<CoreEventModel>, transferId: ULong): Doub
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate multi-blob provider progress for one receiver connection.
|
||||||
|
*
|
||||||
|
* For each `request_id`, take the latest known size/offset/completion, then
|
||||||
|
* sum transferred / sum sizes. Prefer the transfer's total size when the
|
||||||
|
* collection size is known and larger than the sum of observed blob sizes.
|
||||||
|
*/
|
||||||
|
private fun aggregateReceiverProgress(
|
||||||
|
events: List<CoreEventModel>,
|
||||||
|
totalSizeHint: ULong?,
|
||||||
|
): Float? {
|
||||||
|
// Events are newest-first; walk oldest→newest so later states win.
|
||||||
|
val chronological = events.asReversed()
|
||||||
|
data class BlobState(var size: Double? = null, var offset: Double = 0.0, var completed: Boolean = false, var aborted: Boolean = false)
|
||||||
|
val byRequest = linkedMapOf<String, BlobState>()
|
||||||
|
var connectionScopedOffset: Double? = null
|
||||||
|
var connectionScopedSize: Double? = null
|
||||||
|
|
||||||
|
for (event in chronological) {
|
||||||
|
val requestKey = findNumber(event.dataJson, "request_id")?.toLong()?.toString()
|
||||||
|
?: findString(event.dataJson, "request_id")
|
||||||
|
val size = findNumber(event.dataJson, "size")
|
||||||
|
val endOffset = findNumber(event.dataJson, "end_offset")
|
||||||
|
?: findNumber(event.dataJson, "offset")
|
||||||
|
?: findNumber(event.dataJson, "transferred")
|
||||||
|
|
||||||
|
if (requestKey != null) {
|
||||||
|
val state = byRequest.getOrPut(requestKey) { BlobState() }
|
||||||
|
if (size != null && size > 0) state.size = size
|
||||||
|
when (event.kind) {
|
||||||
|
"progress", "started" -> {
|
||||||
|
if (endOffset != null) state.offset = maxOf(state.offset, endOffset)
|
||||||
|
state.aborted = false
|
||||||
|
}
|
||||||
|
"completed" -> {
|
||||||
|
state.completed = true
|
||||||
|
state.size?.let { state.offset = it }
|
||||||
|
}
|
||||||
|
"aborted" -> state.aborted = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (size != null && size > 0) connectionScopedSize = size
|
||||||
|
if (endOffset != null) connectionScopedOffset = endOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byRequest.isNotEmpty()) {
|
||||||
|
val active = byRequest.values.filterNot { it.aborted }
|
||||||
|
if (active.isEmpty()) return null
|
||||||
|
val transferred = active.sumOf { state ->
|
||||||
|
when {
|
||||||
|
state.completed -> state.size ?: state.offset
|
||||||
|
else -> state.offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val observedSize = active.mapNotNull { it.size }.sum()
|
||||||
|
val total = totalSizeHint?.toDouble()?.takeIf { it > 0 }
|
||||||
|
?: observedSize.takeIf { it > 0 }
|
||||||
|
if (total == null || total <= 0.0) return null
|
||||||
|
return (transferred / total).toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val total = totalSizeHint?.toDouble()?.takeIf { it > 0 }
|
||||||
|
?: connectionScopedSize?.takeIf { it > 0 }
|
||||||
|
val transferred = connectionScopedOffset
|
||||||
|
if (transferred == null || total == null || total <= 0.0) return null
|
||||||
|
return (transferred / total).toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectionIdsForEndpoint(events: List<CoreEventModel>, remoteEndpointId: String): Set<String> {
|
||||||
|
val ids = mutableSetOf<String>()
|
||||||
|
for (event in events) {
|
||||||
|
val endpoint = findString(event.dataJson, "endpoint_id") ?: continue
|
||||||
|
if (endpoint != remoteEndpointId) continue
|
||||||
|
findNumber(event.dataJson, "connection_id")?.toLong()?.toString()?.let(ids::add)
|
||||||
|
findString(event.dataJson, "connection_id")?.let(ids::add)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventBelongsToReceiver(
|
||||||
|
event: CoreEventModel,
|
||||||
|
remoteEndpointId: String,
|
||||||
|
connectionIds: Set<String>,
|
||||||
|
): Boolean {
|
||||||
|
val endpoint = findString(event.dataJson, "endpoint_id")
|
||||||
|
if (endpoint != null) return endpoint == remoteEndpointId
|
||||||
|
val connectionId = findNumber(event.dataJson, "connection_id")?.toLong()?.toString()
|
||||||
|
?: findString(event.dataJson, "connection_id")
|
||||||
|
?: return false
|
||||||
|
return connectionId in connectionIds
|
||||||
|
}
|
||||||
|
|
||||||
private fun findNumber(json: String, key: String): Double? {
|
private fun findNumber(json: String, key: String): Double? {
|
||||||
val marker = "\"$key\":"
|
val marker = "\"$key\":"
|
||||||
val start = json.indexOf(marker)
|
val start = json.indexOf(marker)
|
||||||
if (start < 0) return null
|
if (start < 0) return null
|
||||||
val valueStart = start + marker.length
|
val valueStart = start + marker.length
|
||||||
|
val raw = json.substring(valueStart).trimStart()
|
||||||
|
// Skip JSON null so endpoint_id:null does not parse as a number key neighbor.
|
||||||
|
if (raw.startsWith("null")) return null
|
||||||
val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
|
val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
|
||||||
return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull()
|
return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull()
|
||||||
}
|
}
|
||||||
@@ -195,6 +390,7 @@ private fun findString(json: String, key: String): String? {
|
|||||||
val start = json.indexOf(marker)
|
val start = json.indexOf(marker)
|
||||||
if (start < 0) return null
|
if (start < 0) return null
|
||||||
val after = json.substring(start + marker.length).trimStart()
|
val after = json.substring(start + marker.length).trimStart()
|
||||||
|
if (after.startsWith("null")) return null
|
||||||
if (!after.startsWith('"')) return null
|
if (!after.startsWith('"')) return null
|
||||||
val end = after.indexOf('"', 1)
|
val end = after.indexOf('"', 1)
|
||||||
if (end <= 1) return null
|
if (end <= 1) return null
|
||||||
|
|||||||
@@ -246,6 +246,24 @@ class ViewModelsTest {
|
|||||||
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
|
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
com.vnidrop.app.core.PickedShareFile(
|
||||||
|
value = "/tmp/photos",
|
||||||
|
displayName = "photos",
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("photos", viewModel.state.value.transferName)
|
||||||
|
assertTrue(viewModel.state.value.selectedFiles.single().isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
|||||||
@@ -103,6 +103,109 @@ class AppUiModelsTest {
|
|||||||
assertEquals("a.bin", progress?.detail)
|
assertEquals("a.bin", progress?.detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressForReceiverAggregatesMultiBlobSendByEndpoint() {
|
||||||
|
val events = listOf(
|
||||||
|
// newest first
|
||||||
|
event(
|
||||||
|
id = "p2",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "progress",
|
||||||
|
data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","end_offset":40}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "s2",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "started",
|
||||||
|
data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","size":50}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "c1",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "completed",
|
||||||
|
data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a"}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "s1",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "started",
|
||||||
|
data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a","size":50}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
// different receiver should not mix in
|
||||||
|
event(
|
||||||
|
id = "other",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "progress",
|
||||||
|
data = """{"connection_id":3,"request_id":7,"endpoint_id":"peer-b","end_offset":99}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// blob1 complete 50 + blob2 40 = 90 / total hint 100
|
||||||
|
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
|
||||||
|
assertEquals(0.9f, progress?.progress)
|
||||||
|
assertEquals("Sending", progress?.label)
|
||||||
|
assertEquals(null, progressForReceiver(events, 7UL, "missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressForReceiverFallsBackToConnectionMap() {
|
||||||
|
val events = listOf(
|
||||||
|
event(
|
||||||
|
id = "prog",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "progress",
|
||||||
|
data = """{"connection_id":4,"request_id":1,"end_offset":25}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "start",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "started",
|
||||||
|
data = """{"connection_id":4,"request_id":1,"size":100}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
CoreEventModel(
|
||||||
|
id = "conn",
|
||||||
|
timestamp = 1L,
|
||||||
|
scope = "endpoint",
|
||||||
|
transferId = null,
|
||||||
|
direction = null,
|
||||||
|
phase = "provider",
|
||||||
|
kind = "client-connected",
|
||||||
|
dataJson = """{"connection_id":4,"endpoint_id":"peer-z"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val progress = progressForReceiver(events, 7UL, "peer-z")
|
||||||
|
assertEquals(0.25f, progress?.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeSendProgressPicksLiveReceiverSend() {
|
||||||
|
val events = listOf(
|
||||||
|
event(
|
||||||
|
id = "p",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "progress",
|
||||||
|
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":30}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "s",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "started",
|
||||||
|
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL)
|
||||||
|
assertEquals(0.3f, progress?.progress)
|
||||||
|
assertEquals("Sending", progress?.label)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canCancelOnlyActiveStatuses() {
|
fun canCancelOnlyActiveStatuses() {
|
||||||
assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer())
|
assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer())
|
||||||
@@ -133,12 +236,13 @@ class AppUiModelsTest {
|
|||||||
kind: String,
|
kind: String,
|
||||||
data: String,
|
data: String,
|
||||||
transferId: ULong = 7UL,
|
transferId: ULong = 7UL,
|
||||||
|
direction: String = "receive",
|
||||||
) = CoreEventModel(
|
) = CoreEventModel(
|
||||||
id = id,
|
id = id,
|
||||||
timestamp = 1L,
|
timestamp = 1L,
|
||||||
scope = "transfer",
|
scope = "transfer",
|
||||||
transferId = transferId,
|
transferId = transferId,
|
||||||
direction = "receive",
|
direction = direction,
|
||||||
phase = phase,
|
phase = phase,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
dataJson = data,
|
dataJson = data,
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ actual fun rememberShareFilePicker(
|
|||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
override fun pickFolder() {
|
||||||
|
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
|
if (presenter == null) {
|
||||||
|
onError("Could not find an iOS view controller for the folder picker")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
||||||
|
val delegate = DocumentPickerDelegate(
|
||||||
|
onFilesPicked = { folders ->
|
||||||
|
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
||||||
|
onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
folder.copy(isDirectory = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
forceDirectory = true,
|
||||||
|
)
|
||||||
|
retainedPickerDelegate = delegate
|
||||||
|
picker.delegate = delegate
|
||||||
|
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||||
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +114,7 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
private class DocumentPickerDelegate(
|
private class DocumentPickerDelegate(
|
||||||
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||||
private val onError: (String) -> Unit,
|
private val onError: (String) -> Unit,
|
||||||
|
private val forceDirectory: Boolean = false,
|
||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||||
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
||||||
@@ -95,8 +122,12 @@ private class DocumentPickerDelegate(
|
|||||||
val displayName = url.lastPathComponent ?: "transfer"
|
val displayName = url.lastPathComponent ?: "transfer"
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||||
val sizeBytes = try {
|
val sizeBytes = try {
|
||||||
|
if (forceDirectory) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
||||||
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
@@ -105,6 +136,7 @@ private class DocumentPickerDelegate(
|
|||||||
displayName,
|
displayName,
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
nativeFileIcon(url),
|
nativeFileIcon(url),
|
||||||
|
isDirectory = forceDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (files.isEmpty()) {
|
if (files.isEmpty()) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ private class IosFileSystemService : FileSystemService {
|
|||||||
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
||||||
value = file.value,
|
value = file.value,
|
||||||
displayName = file.displayName,
|
displayName = file.displayName,
|
||||||
isDirectory = false,
|
isDirectory = file.isDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
|||||||
@@ -2,30 +2,91 @@ package com.vnidrop.app.feature.receive
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCAction
|
||||||
|
import kotlinx.cinterop.ObjCObjectVar
|
||||||
|
import kotlinx.cinterop.alloc
|
||||||
|
import kotlinx.cinterop.memScoped
|
||||||
|
import kotlinx.cinterop.ptr
|
||||||
import kotlinx.cinterop.readBytes
|
import kotlinx.cinterop.readBytes
|
||||||
|
import kotlinx.cinterop.value
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusAuthorized
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusDenied
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusNotDetermined
|
||||||
|
import platform.AVFoundation.AVAuthorizationStatusRestricted
|
||||||
|
import platform.AVFoundation.AVCaptureDevice
|
||||||
|
import platform.AVFoundation.AVCaptureDeviceInput
|
||||||
|
import platform.AVFoundation.AVCaptureMetadataOutput
|
||||||
|
import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol
|
||||||
|
import platform.AVFoundation.AVCaptureOutput
|
||||||
|
import platform.AVFoundation.AVCaptureConnection
|
||||||
|
import platform.AVFoundation.AVCaptureSession
|
||||||
|
import platform.AVFoundation.AVCaptureSessionPresetHigh
|
||||||
|
import platform.AVFoundation.AVCaptureVideoPreviewLayer
|
||||||
|
import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill
|
||||||
|
import platform.AVFoundation.AVMediaTypeVideo
|
||||||
|
import platform.AVFoundation.AVMetadataMachineReadableCodeObject
|
||||||
|
import platform.AVFoundation.AVMetadataObjectTypeQRCode
|
||||||
|
import platform.AVFoundation.authorizationStatusForMediaType
|
||||||
|
import platform.AVFoundation.requestAccessForMediaType
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.CoreNFC.NFCNDEFMessage
|
||||||
|
import platform.CoreNFC.NFCNDEFPayload
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSession
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
||||||
|
import platform.CoreNFC.NFCTypeNameFormatMedia
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSError
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
import platform.Foundation.NSURL
|
import platform.Foundation.NSURL
|
||||||
|
import platform.UIKit.NSTextAlignmentCenter
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
|
import platform.UIKit.UIButton
|
||||||
|
import platform.UIKit.UIButtonTypeSystem
|
||||||
|
import platform.UIKit.UIColor
|
||||||
|
import platform.UIKit.UIControlEventTouchUpInside
|
||||||
|
import platform.UIKit.UIControlStateNormal
|
||||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
|
import platform.UIKit.UILabel
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
import platform.UIKit.UIModalPresentationFormSheet
|
||||||
|
import platform.UIKit.UIModalPresentationFullScreen
|
||||||
|
import platform.UIKit.UIViewAutoresizingFlexibleHeight
|
||||||
|
import platform.UIKit.UIViewAutoresizingFlexibleWidth
|
||||||
|
import platform.UIKit.UIViewController
|
||||||
import platform.UniformTypeIdentifiers.UTTypeData
|
import platform.UniformTypeIdentifiers.UTTypeData
|
||||||
|
import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT
|
||||||
import platform.darwin.NSObject
|
import platform.darwin.NSObject
|
||||||
|
import platform.darwin.dispatch_async
|
||||||
|
import platform.darwin.dispatch_get_global_queue
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
|
||||||
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
||||||
|
private var retainedQrScanner: QrScannerViewController? = null
|
||||||
|
private var retainedNfcReader: InvitationNfcReader? = null
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
||||||
object : ReceiveInvitationActions {
|
object : ReceiveInvitationActions {
|
||||||
override val fileAvailability = ReceiveMethodAvailability.Available
|
override val fileAvailability = ReceiveMethodAvailability.Available
|
||||||
// Hide unfinished iOS methods so the method list only shows what works.
|
override val qrAvailability =
|
||||||
override val qrAvailability = ReceiveMethodAvailability.Hidden
|
if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) {
|
||||||
override val nfcAvailability = ReceiveMethodAvailability.Hidden
|
ReceiveMethodAvailability.Available
|
||||||
|
} else {
|
||||||
|
ReceiveMethodAvailability.Unavailable
|
||||||
|
}
|
||||||
|
override val nfcAvailability =
|
||||||
|
if (NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
ReceiveMethodAvailability.Available
|
||||||
|
} else {
|
||||||
|
ReceiveMethodAvailability.Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
cancel()
|
||||||
|
val presenter = topPresenter()
|
||||||
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
|
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
|
||||||
val delegate = InvitationDocumentDelegate(onResult)
|
val delegate = InvitationDocumentDelegate(onResult)
|
||||||
@@ -35,13 +96,67 @@ actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = rememb
|
|||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
presenter.presentViewController(picker, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun scanQrCode(onResult: (Result<String>) -> Unit) =
|
override fun scanQrCode(onResult: (Result<String>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
|
cancel()
|
||||||
|
val presenter = topPresenter()
|
||||||
|
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
||||||
|
ensureCameraAccess { granted ->
|
||||||
|
if (!granted) {
|
||||||
|
onResult(Result.failure(IllegalStateException("Camera access is required to scan QR codes")))
|
||||||
|
return@ensureCameraAccess
|
||||||
|
}
|
||||||
|
val scanner = QrScannerViewController { result ->
|
||||||
|
retainedQrScanner = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedQrScanner = scanner
|
||||||
|
scanner.modalPresentationStyle = UIModalPresentationFullScreen
|
||||||
|
presenter.presentViewController(scanner, animated = true, completion = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) =
|
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build")))
|
cancel()
|
||||||
|
if (!NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
onResult(Result.failure(UnsupportedOperationException("NFC reading is unavailable on this device")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reader = InvitationNfcReader { result ->
|
||||||
|
retainedNfcReader = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedNfcReader = reader
|
||||||
|
reader.start()
|
||||||
|
}
|
||||||
|
|
||||||
override fun cancel() = Unit
|
override fun cancel() {
|
||||||
|
retainedNfcReader?.cancel()
|
||||||
|
retainedNfcReader = null
|
||||||
|
retainedQrScanner?.cancelScan()
|
||||||
|
retainedQrScanner = null
|
||||||
|
retainedInvitationDelegate = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun topPresenter(): UIViewController? {
|
||||||
|
var controller = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||||
|
while (controller?.presentedViewController != null) {
|
||||||
|
controller = controller?.presentedViewController
|
||||||
|
}
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCameraAccess(onResult: (Boolean) -> Unit) {
|
||||||
|
when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) {
|
||||||
|
AVAuthorizationStatusAuthorized -> onResult(true)
|
||||||
|
AVAuthorizationStatusNotDetermined -> {
|
||||||
|
AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted ->
|
||||||
|
dispatch_async(dispatch_get_main_queue()) { onResult(granted) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> onResult(false)
|
||||||
|
else -> onResult(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +165,9 @@ private class InvitationDocumentDelegate(
|
|||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||||
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
|
||||||
onResult(runCatching {
|
onResult(runCatching {
|
||||||
requireNotNull(url) { "The selected invitation URL was invalid" }
|
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
||||||
|
?: error("The selected invitation URL was invalid")
|
||||||
val path = url.path ?: error("The invitation path 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")
|
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
|
||||||
val length = data.length.toInt()
|
val length = data.length.toInt()
|
||||||
@@ -67,3 +182,203 @@ private class InvitationDocumentDelegate(
|
|||||||
retainedInvitationDelegate = null
|
retainedInvitationDelegate = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private class QrScannerViewController(
|
||||||
|
private val onResult: (Result<String>) -> Unit,
|
||||||
|
) : UIViewController(nibName = null, bundle = null), AVCaptureMetadataOutputObjectsDelegateProtocol {
|
||||||
|
private val session = AVCaptureSession()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer? = null
|
||||||
|
private var finished = false
|
||||||
|
private val closeTarget = ButtonTarget { cancelScan() }
|
||||||
|
|
||||||
|
override fun viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = UIColor.blackColor
|
||||||
|
|
||||||
|
val hint = UILabel(frame = view.bounds).apply {
|
||||||
|
text = "Point the camera at a VniDrop QR code"
|
||||||
|
textColor = UIColor.whiteColor
|
||||||
|
textAlignment = NSTextAlignmentCenter
|
||||||
|
numberOfLines = 0
|
||||||
|
autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight
|
||||||
|
}
|
||||||
|
view.addSubview(hint)
|
||||||
|
|
||||||
|
val close = UIButton.buttonWithType(UIButtonTypeSystem).apply {
|
||||||
|
setTitle("Cancel", forState = UIControlStateNormal)
|
||||||
|
setTitleColor(UIColor.whiteColor, forState = UIControlStateNormal)
|
||||||
|
addTarget(closeTarget, platform.objc.sel_registerName("invoke"), UIControlEventTouchUpInside)
|
||||||
|
setFrame(CGRectMake(16.0, 52.0, 88.0, 36.0))
|
||||||
|
}
|
||||||
|
view.addSubview(close)
|
||||||
|
configureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
previewLayer?.setFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewWillDisappear(animated: Boolean) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if (session.running) session.stopRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelScan() {
|
||||||
|
finish(Result.failure(IllegalStateException("QR scanning was cancelled")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureSession() {
|
||||||
|
val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
|
||||||
|
?: return finish(Result.failure(IllegalStateException("No camera is available")))
|
||||||
|
|
||||||
|
memScoped {
|
||||||
|
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
|
||||||
|
val input = AVCaptureDeviceInput.deviceInputWithDevice(device, errorPtr.ptr)
|
||||||
|
if (input == null) {
|
||||||
|
finish(
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(errorPtr.value?.localizedDescription ?: "Could not open the camera"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!session.canAddInput(input)) {
|
||||||
|
finish(Result.failure(IllegalStateException("Could not configure the camera input")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
val output = AVCaptureMetadataOutput()
|
||||||
|
if (!session.canAddOutput(output)) {
|
||||||
|
finish(Result.failure(IllegalStateException("Could not configure the QR scanner")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue())
|
||||||
|
output.metadataObjectTypes = listOf(AVMetadataObjectTypeQRCode)
|
||||||
|
|
||||||
|
val layer = AVCaptureVideoPreviewLayer(session = session).apply {
|
||||||
|
videoGravity = AVLayerVideoGravityResizeAspectFill
|
||||||
|
setFrame(view.bounds)
|
||||||
|
}
|
||||||
|
view.layer.insertSublayer(layer, atIndex = 0u)
|
||||||
|
previewLayer = layer
|
||||||
|
session.sessionPreset = AVCaptureSessionPresetHigh
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) {
|
||||||
|
session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureOutput(
|
||||||
|
output: AVCaptureOutput,
|
||||||
|
didOutputMetadataObjects: List<*>,
|
||||||
|
fromConnection: AVCaptureConnection,
|
||||||
|
) {
|
||||||
|
val code = didOutputMetadataObjects
|
||||||
|
.mapNotNull { it as? AVMetadataMachineReadableCodeObject }
|
||||||
|
.firstOrNull { it.type == AVMetadataObjectTypeQRCode }
|
||||||
|
val value = code?.stringValue?.trim().orEmpty()
|
||||||
|
if (value.isNotEmpty()) {
|
||||||
|
finish(Result.success(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
if (session.running) session.stopRunning()
|
||||||
|
if (presentingViewController != null) {
|
||||||
|
dismissViewControllerAnimated(true) { onResult(result) }
|
||||||
|
} else {
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(BetaInteropApi::class)
|
||||||
|
private class ButtonTarget(
|
||||||
|
private val onClick: () -> Unit,
|
||||||
|
) : NSObject() {
|
||||||
|
@ObjCAction
|
||||||
|
fun invoke() {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private class InvitationNfcReader(
|
||||||
|
private val onResult: (Result<String>) -> Unit,
|
||||||
|
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
||||||
|
private var session: NFCNDEFReaderSession? = null
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = true)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
||||||
|
session = reader
|
||||||
|
reader.beginSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
session?.invalidateSession()
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
||||||
|
if (finished) return
|
||||||
|
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
||||||
|
val cancelled = didInvalidateWithError.code == 200L
|
||||||
|
finish(
|
||||||
|
if (cancelled) {
|
||||||
|
Result.failure(IllegalStateException("NFC reading was cancelled"))
|
||||||
|
} else {
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(didInvalidateWithError.localizedDescription ?: "NFC reading failed"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
||||||
|
val ticket = runCatching {
|
||||||
|
val messages = didDetectNDEFs.mapNotNull { it as? NFCNDEFMessage }
|
||||||
|
messages
|
||||||
|
.flatMap { message -> message.records.mapNotNull { it as? NFCNDEFPayload } }
|
||||||
|
.firstNotNullOfOrNull(::payloadAsInvitation)
|
||||||
|
?: error("This NFC tag does not contain a VniDrop invitation")
|
||||||
|
}
|
||||||
|
session.invalidateSession()
|
||||||
|
finish(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
session = null
|
||||||
|
dispatch_async(dispatch_get_main_queue()) { onResult(result) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun payloadAsInvitation(payload: NFCNDEFPayload): String? {
|
||||||
|
val type = payload.type?.toByteArray()?.decodeToString() ?: return null
|
||||||
|
val data = payload.payload?.toByteArray() ?: return null
|
||||||
|
return when {
|
||||||
|
payload.typeNameFormat == NFCTypeNameFormatMedia && type == InvitationMimeType ->
|
||||||
|
decodeInvitationBytes(data)
|
||||||
|
payload.typeNameFormat == NFCTypeNameFormatMedia && type.startsWith("text/") ->
|
||||||
|
decodeInvitationBytes(data)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun NSData.toByteArray(): ByteArray {
|
||||||
|
val length = this.length.toInt()
|
||||||
|
if (length <= 0) return ByteArray(0)
|
||||||
|
return this.bytes?.readBytes(length) ?: ByteArray(0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,27 +2,47 @@ package com.vnidrop.app.feature.send
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import com.vnidrop.app.feature.receive.VniDropInvitationMimeType
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCSignatureOverride
|
||||||
|
import platform.CoreNFC.NFCNDEFMessage
|
||||||
|
import platform.CoreNFC.NFCNDEFPayload
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSession
|
||||||
|
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
||||||
|
import platform.CoreNFC.NFCNDEFStatusNotSupported
|
||||||
|
import platform.CoreNFC.NFCNDEFStatusReadOnly
|
||||||
|
import platform.CoreNFC.NFCNDEFTagProtocol
|
||||||
|
import platform.CoreNFC.NFCTypeNameFormatMedia
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSError
|
||||||
import platform.Foundation.NSString
|
import platform.Foundation.NSString
|
||||||
import platform.Foundation.NSTemporaryDirectory
|
import platform.Foundation.NSTemporaryDirectory
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.NSUTF8StringEncoding
|
import platform.Foundation.NSUTF8StringEncoding
|
||||||
|
import platform.Foundation.NSURL
|
||||||
import platform.Foundation.create
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataUsingEncoding
|
||||||
import platform.Foundation.writeToFile
|
import platform.Foundation.writeToFile
|
||||||
import platform.UIKit.UIActivityViewController
|
import platform.UIKit.UIActivityViewController
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
import platform.UIKit.UIModalPresentationFormSheet
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
|
||||||
|
private var retainedNfcWriter: InvitationNfcWriter? = null
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||||
object : TransferShareActions {
|
object : TransferShareActions {
|
||||||
override val canUseNativeShare = true
|
override val canUseNativeShare = true
|
||||||
// Core NFC tag writing requires the NFC entitlement. Keep the action
|
override val nfcAvailability =
|
||||||
// visible but disabled until that capability is provisioned for the app.
|
if (NFCNDEFReaderSession.readingAvailable) {
|
||||||
override val nfcAvailability = NfcShareAvailability.Unavailable
|
NfcShareAvailability.Available
|
||||||
|
} else {
|
||||||
|
NfcShareAvailability.Unavailable
|
||||||
|
}
|
||||||
|
|
||||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
onResult(runCatching {
|
onResult(runCatching {
|
||||||
@@ -42,9 +62,23 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build")))
|
cancelNfcWrite()
|
||||||
|
if (!NFCNDEFReaderSession.readingAvailable) {
|
||||||
|
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on this device")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val writer = InvitationNfcWriter(ticket) { result ->
|
||||||
|
retainedNfcWriter = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
retainedNfcWriter = writer
|
||||||
|
writer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelNfcWrite() {
|
||||||
|
retainedNfcWriter?.cancel()
|
||||||
|
retainedNfcWriter = null
|
||||||
}
|
}
|
||||||
override fun cancelNfcWrite() = Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,3 +98,106 @@ private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewCon
|
|||||||
private fun present(controller: platform.UIKit.UIViewController) {
|
private fun present(controller: platform.UIKit.UIViewController) {
|
||||||
presenter().presentViewController(controller, animated = true, completion = null)
|
presenter().presentViewController(controller, animated = true, completion = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private class InvitationNfcWriter(
|
||||||
|
private val ticket: String,
|
||||||
|
private val onResult: (Result<Unit>) -> Unit,
|
||||||
|
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
||||||
|
private var session: NFCNDEFReaderSession? = null
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = false)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
||||||
|
session = reader
|
||||||
|
reader.beginSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
session?.invalidateSession()
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
||||||
|
if (finished) return
|
||||||
|
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
||||||
|
val cancelled = didInvalidateWithError.code == 200L
|
||||||
|
finish(
|
||||||
|
if (cancelled) {
|
||||||
|
Result.failure(IllegalStateException("NFC writing was cancelled"))
|
||||||
|
} else {
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(didInvalidateWithError.localizedDescription),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
||||||
|
// Prefer tag-based write path via didDetectTags when available.
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun readerSession(session: NFCNDEFReaderSession, didDetectTags: List<*>) {
|
||||||
|
val tag = didDetectTags.firstOrNull() as? NFCNDEFTagProtocol
|
||||||
|
?: return finish(Result.failure(IllegalStateException("No NFC tag was detected")))
|
||||||
|
|
||||||
|
session.connectToTag(tag) { connectError ->
|
||||||
|
if (connectError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(connectError.localizedDescription)))
|
||||||
|
return@connectToTag
|
||||||
|
}
|
||||||
|
tag.queryNDEFStatusWithCompletionHandler { status, _, queryError ->
|
||||||
|
if (queryError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(queryError.localizedDescription)))
|
||||||
|
return@queryNDEFStatusWithCompletionHandler
|
||||||
|
}
|
||||||
|
when (status) {
|
||||||
|
NFCNDEFStatusNotSupported -> {
|
||||||
|
finish(Result.failure(IllegalStateException("This NFC tag does not support NDEF")))
|
||||||
|
}
|
||||||
|
NFCNDEFStatusReadOnly -> {
|
||||||
|
finish(Result.failure(IllegalStateException("This NFC tag is read-only")))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val message = invitationNdefMessage(ticket)
|
||||||
|
?: return@queryNDEFStatusWithCompletionHandler finish(
|
||||||
|
Result.failure(IllegalStateException("Could not encode the invitation for NFC")),
|
||||||
|
)
|
||||||
|
tag.writeNDEF(message) { writeError ->
|
||||||
|
if (writeError != null) {
|
||||||
|
finish(Result.failure(IllegalStateException(writeError.localizedDescription)))
|
||||||
|
} else {
|
||||||
|
session.alertMessage = "Invitation written"
|
||||||
|
session.invalidateSession()
|
||||||
|
finish(Result.success(Unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(result: Result<Unit>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
session = null
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private fun invitationNdefMessage(ticket: String): NFCNDEFMessage? {
|
||||||
|
val type = NSString.create(string = VniDropInvitationMimeType).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
||||||
|
val payload = NSString.create(string = ticket).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
||||||
|
val record = NFCNDEFPayload(
|
||||||
|
format = NFCTypeNameFormatMedia,
|
||||||
|
type = type,
|
||||||
|
identifier = NSData(),
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
return NFCNDEFMessage(nDEFRecords = listOf(record))
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ actual fun rememberShareFilePicker(
|
|||||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun pickFolder() {
|
||||||
|
openPicker(onError) {
|
||||||
|
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
|
||||||
|
onFilesPicked(
|
||||||
|
listOf(
|
||||||
|
PickedShareFile(
|
||||||
|
value = selected.absolutePath,
|
||||||
|
displayName = selected.name.ifBlank { selected.absolutePath },
|
||||||
|
sizeBytes = null,
|
||||||
|
thumbnailBytes = selected.systemIconPng(),
|
||||||
|
isDirectory = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +53,7 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
object : ReceiveFolderPicker {
|
object : ReceiveFolderPicker {
|
||||||
override fun pickFolder() {
|
override fun pickFolder() {
|
||||||
openPicker(onError) {
|
openPicker(onError) {
|
||||||
val selected = pickDirectory() ?: return@openPicker
|
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
||||||
onFolderPicked(
|
onFolderPicked(
|
||||||
ReceiveFolder(
|
ReceiveFolder(
|
||||||
kind = ReceiveFolderKind.FileSystemPath,
|
kind = ReceiveFolderKind.FileSystemPath,
|
||||||
@@ -111,10 +128,10 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
private fun pickDirectory(): File? =
|
private fun pickDirectory(title: String): File? =
|
||||||
if (isMacOs()) {
|
if (isMacOs()) {
|
||||||
val dialog = withMacDirectoryDialog {
|
val dialog = withMacDirectoryDialog {
|
||||||
nativeFileDialog("Select receive folder").apply { isVisible = true }
|
nativeFileDialog(title).apply { isVisible = true }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val directory = dialog.directory ?: return null
|
val directory = dialog.directory ?: return null
|
||||||
@@ -126,7 +143,7 @@ private fun pickDirectory(): File? =
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val chooser = JFileChooser().apply {
|
val chooser = JFileChooser().apply {
|
||||||
dialogTitle = "Select receive folder"
|
dialogTitle = title
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
isAcceptAllFileFilterUsed = false
|
isAcceptAllFileFilterUsed = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ private class JvmFileSystemService : FileSystemService {
|
|||||||
kind = uniffi.vnidrop.SourceKind.PATH,
|
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||||
value = file.value,
|
value = file.value,
|
||||||
displayName = file.displayName,
|
displayName = file.displayName,
|
||||||
isDirectory = false,
|
isDirectory = file.isDirectory || File(file.value).isDirectory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||||
|
|||||||
Reference in New Issue
Block a user