mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 18:39:55 +02:00
Compare commits
5 Commits
ad9ffd63f5
...
5939489432
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5939489432 | ||
|
|
90a06e9151 | ||
| 6cf6644c09 | |||
| f0b06ad1cf | |||
| e7c70c9314 |
@@ -44,6 +44,24 @@ final class ProgressDerivationTests: XCTestCase {
|
|||||||
XCTAssertEqual(progress?.progress, 0.3)
|
XCTAssertEqual(progress?.progress, 0.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReceiverCompletionAfterProgressIsTerminal() {
|
||||||
|
let events = [
|
||||||
|
receiverEvent(kind: "completed", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\"}"),
|
||||||
|
receiverEvent(kind: "progress", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"end_offset\":100}"),
|
||||||
|
receiverEvent(kind: "started", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"size\":100}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let progress = progressForReceiver(
|
||||||
|
events: events,
|
||||||
|
transferId: 1,
|
||||||
|
remoteEndpointId: "peer-a",
|
||||||
|
totalSizeHint: 100
|
||||||
|
)
|
||||||
|
XCTAssertEqual(progress?.kind, "completed")
|
||||||
|
XCTAssertEqual(progress?.labelKey, "progress_completed")
|
||||||
|
XCTAssertEqual(progress?.progress, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testStatusLabelKeys() {
|
func testStatusLabelKeys() {
|
||||||
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
||||||
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
||||||
@@ -56,4 +74,8 @@ final class ProgressDerivationTests: XCTestCase {
|
|||||||
direction: "send", phase: phase, kind: kind, dataJson: json
|
direction: "send", phase: phase, kind: kind, dataJson: json
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func receiverEvent(kind: String, json: String) -> CoreEventModel {
|
||||||
|
event(phase: "transfer", kind: kind, json: json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
|
|
||||||
guard let transferId = model.transferId else { return }
|
guard let transferId = model.transferId else { return }
|
||||||
switch model.phase {
|
switch model.phase {
|
||||||
case "approval": signalsSubject.send(.approvalChanged(transferId: transferId))
|
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,14 +96,14 @@ func progressForReceiver(
|
|||||||
labelKey: "progress_interrupted", progress: nil, detail: nil
|
labelKey: "progress_interrupted", progress: nil, detail: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) {
|
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
||||||
|
if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) {
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: "transfer", kind: "completed",
|
transferId: transferId, phase: "transfer", kind: "completed",
|
||||||
labelKey: "progress_completed", progress: 1, detail: nil
|
labelKey: "progress_completed", progress: 1, detail: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: "transfer", kind: latest.kind,
|
transferId: transferId, phase: "transfer", kind: latest.kind,
|
||||||
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
||||||
|
|||||||
@@ -127,11 +127,14 @@ struct SendScreen: View {
|
|||||||
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
||||||
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
||||||
if active.isEmpty { return nil }
|
if active.isEmpty { return nil }
|
||||||
let fractions = active.compactMap {
|
let fractions: [Double] = active.compactMap { receiver -> Double? in
|
||||||
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||||
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize)
|
||||||
|
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
|
||||||
|
return progress?.progress
|
||||||
}
|
}
|
||||||
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
guard !fractions.isEmpty else { return nil }
|
||||||
|
let combined = fractions.reduce(0, +) / Double(fractions.count)
|
||||||
if active.count == 1 {
|
if active.count == 1 {
|
||||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||||
labelKey: "progress_sending", progress: combined)
|
labelKey: "progress_sending", progress: combined)
|
||||||
|
|||||||
9
artifacts/microsoft-store/captions.md
Normal file
9
artifacts/microsoft-store/captions.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Microsoft Store screenshot order and captions
|
||||||
|
|
||||||
|
1. **Review transfer** — Choose a file, name the transfer, and decide whether every receiver needs your approval.
|
||||||
|
2. **Share with QR** — Invite another device with a QR code or a portable `.vnd` invitation file.
|
||||||
|
3. **Transfer details** — See availability, size, access policy, receiver activity, and sharing options in one place.
|
||||||
|
4. **Receive invitation** — Open a private VniDrop invitation to receive files directly on your PC.
|
||||||
|
5. **Privacy and security** — Direct, account-free, end-to-end encrypted transfer with no cloud copy left behind.
|
||||||
|
|
||||||
|
All final screenshots are 1920 × 1080 PNG files. The QR shown in screenshot 2 is a deliberately non-functional demo pattern; it does not contain a live transfer capability.
|
||||||
BIN
artifacts/microsoft-store/final/01-review-transfer.png
Normal file
BIN
artifacts/microsoft-store/final/01-review-transfer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
artifacts/microsoft-store/final/02-share-with-qr.png
Normal file
BIN
artifacts/microsoft-store/final/02-share-with-qr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/microsoft-store/final/03-transfer-details.png
Normal file
BIN
artifacts/microsoft-store/final/03-transfer-details.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/microsoft-store/final/04-receive-invitation.png
Normal file
BIN
artifacts/microsoft-store/final/04-receive-invitation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/microsoft-store/final/05-privacy-and-security.png
Normal file
BIN
artifacts/microsoft-store/final/05-privacy-and-security.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 5;
|
const SCHEMA_VERSION: i64 = 6;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
@@ -78,6 +78,23 @@ pub(crate) struct ReceiverRequestInsert<'a> {
|
|||||||
pub(crate) app_version: &'a str,
|
pub(crate) app_version: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct PendingDeliveryReceipt {
|
||||||
|
pub(crate) local_transfer_id: u64,
|
||||||
|
pub(crate) sender_blob_ticket: String,
|
||||||
|
pub(crate) request_id: String,
|
||||||
|
pub(crate) sender_transfer_id: u64,
|
||||||
|
pub(crate) token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||||
|
pub(crate) local_transfer_id: u64,
|
||||||
|
pub(crate) sender_blob_ticket: &'a str,
|
||||||
|
pub(crate) request_id: &'a str,
|
||||||
|
pub(crate) sender_transfer_id: u64,
|
||||||
|
pub(crate) token: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
impl Repository {
|
impl Repository {
|
||||||
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
||||||
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
||||||
@@ -268,6 +285,21 @@ impl Repository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_delivery_receipts (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
local_transfer_id INTEGER NOT NULL,
|
||||||
|
sender_blob_ticket TEXT NOT NULL,
|
||||||
|
sender_transfer_id INTEGER NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -506,6 +538,82 @@ impl Repository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn complete_receive_with_pending_receipt(
|
||||||
|
&self,
|
||||||
|
receipt: PendingDeliveryReceiptInsert<'_>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.maybe_fail_write()?;
|
||||||
|
let mut transaction = self.pool.begin().await?;
|
||||||
|
let updated = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE transfers
|
||||||
|
SET status = 'done', updated_at = ?1
|
||||||
|
WHERE transfer_id = ?2 AND direction = 'receive' AND status = 'receiving'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(now_ms())
|
||||||
|
.bind(to_db_id(receipt.local_transfer_id)?)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
require_one_changed(updated.rows_affected(), "complete receive")?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO pending_delivery_receipts (
|
||||||
|
request_id, local_transfer_id, sender_blob_ticket,
|
||||||
|
sender_transfer_id, token, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(request_id) DO UPDATE SET
|
||||||
|
local_transfer_id = excluded.local_transfer_id,
|
||||||
|
sender_blob_ticket = excluded.sender_blob_ticket,
|
||||||
|
sender_transfer_id = excluded.sender_transfer_id,
|
||||||
|
token = excluded.token
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(receipt.request_id)
|
||||||
|
.bind(to_db_id(receipt.local_transfer_id)?)
|
||||||
|
.bind(receipt.sender_blob_ticket)
|
||||||
|
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||||
|
.bind(receipt.token)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_pending_delivery_receipts(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<PendingDeliveryReceipt>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT local_transfer_id, sender_blob_ticket, request_id,
|
||||||
|
sender_transfer_id, token
|
||||||
|
FROM pending_delivery_receipts
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| PendingDeliveryReceipt {
|
||||||
|
local_transfer_id: row.get::<i64, _>("local_transfer_id") as u64,
|
||||||
|
sender_blob_ticket: row.get("sender_blob_ticket"),
|
||||||
|
request_id: row.get("request_id"),
|
||||||
|
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
|
||||||
|
token: row.get("token"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_pending_delivery_receipt(&self, request_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM pending_delivery_receipts WHERE request_id = ?1")
|
||||||
|
.bind(request_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_active_share_access_mode(
|
pub(crate) async fn update_active_share_access_mode(
|
||||||
&self,
|
&self,
|
||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
|
|||||||
135
crates/vnidrop/src/runtime/delivery.rs
Normal file
135
crates/vnidrop/src/runtime/delivery.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use iroh_blobs::ticket::BlobTicket;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::CoreInner;
|
||||||
|
use crate::{
|
||||||
|
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
|
||||||
|
repository::PendingDeliveryReceipt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const DELIVERY_RECEIPT_RETRY_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
const DELIVERY_RECEIPT_MAX_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
impl CoreInner {
|
||||||
|
pub(super) async fn spawn_delivery_receipt_task(self: &Arc<Self>) {
|
||||||
|
let core = Arc::downgrade(self);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
|
||||||
|
loop {
|
||||||
|
let Some(core) = core.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let has_pending = core.deliver_pending_receipts().await;
|
||||||
|
if has_pending {
|
||||||
|
let notified = tokio::select! {
|
||||||
|
() = core.delivery_receipt_notify.notified() => true,
|
||||||
|
() = tokio::time::sleep(retry_interval) => false,
|
||||||
|
};
|
||||||
|
if notified {
|
||||||
|
retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
|
||||||
|
} else {
|
||||||
|
retry_interval = retry_interval
|
||||||
|
.saturating_mul(2)
|
||||||
|
.min(DELIVERY_RECEIPT_MAX_RETRY_INTERVAL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
core.delivery_receipt_notify.notified().await;
|
||||||
|
retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*self.delivery_receipt_task.lock().await = Some(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver_pending_receipts(&self) -> bool {
|
||||||
|
let receipts = match self.repository.list_pending_delivery_receipts().await {
|
||||||
|
Ok(receipts) => receipts,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "failed to load pending delivery receipts");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let has_pending = !receipts.is_empty();
|
||||||
|
for receipt in receipts {
|
||||||
|
self.deliver_pending_receipt(receipt).await;
|
||||||
|
}
|
||||||
|
has_pending
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
|
||||||
|
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) {
|
||||||
|
Ok(ticket) => ticket,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.delete_pending_delivery_receipt(&pending.request_id)
|
||||||
|
.await;
|
||||||
|
self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-rejected",
|
||||||
|
json!({ "reason": "invalid-sender-ticket" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone());
|
||||||
|
let receipt = DeliveryReceipt {
|
||||||
|
request_id: pending.request_id.clone(),
|
||||||
|
transfer_id: pending.sender_transfer_id,
|
||||||
|
token: pending.token,
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
|
||||||
|
if let Err(error) = self
|
||||||
|
.repository
|
||||||
|
.delete_pending_delivery_receipt(&pending.request_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, request_id = %pending.request_id, "failed to clear recorded delivery receipt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-recorded",
|
||||||
|
json!({ "sender_transfer_id": pending.sender_transfer_id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(DeliveryReceiptResponse::Rejected { reason })) => {
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.delete_pending_delivery_receipt(&pending.request_id)
|
||||||
|
.await;
|
||||||
|
self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-rejected",
|
||||||
|
json!({ "reason": reason }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-failed",
|
||||||
|
json!({ "reason": error.to_string() }),
|
||||||
|
),
|
||||||
|
Err(_) => self.emit_transfer(
|
||||||
|
pending.local_transfer_id,
|
||||||
|
"receive",
|
||||||
|
"delivery",
|
||||||
|
"receipt-failed",
|
||||||
|
json!({ "reason": "delivery receipt timed out" }),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,10 @@ impl CoreInner {
|
|||||||
// Flush before stopping the router so the app can show the shutdown
|
// Flush before stopping the router so the app can show the shutdown
|
||||||
// event even if the process exits soon after Compose disposes the core.
|
// event even if the process exits soon after Compose disposes the core.
|
||||||
self.event_hub.flush().await;
|
self.event_hub.flush().await;
|
||||||
|
if let Some(task) = self.delivery_receipt_task.lock().await.take() {
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
if let Err(error) = self.router.shutdown().await {
|
if let Err(error) = self.router.shutdown().await {
|
||||||
self.emit_endpoint(
|
self.emit_endpoint(
|
||||||
"shutdown",
|
"shutdown",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
||||||
//! - [`provider`] — blob provider events and per-connection send progress
|
//! - [`provider`] — blob provider events and per-connection send progress
|
||||||
|
|
||||||
|
mod delivery;
|
||||||
mod facade;
|
mod facade;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod provider;
|
mod provider;
|
||||||
@@ -38,7 +39,7 @@ use iroh_blobs::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::{oneshot, Mutex as TokioMutex, Semaphore},
|
sync::{oneshot, Mutex as TokioMutex, Notify, Semaphore},
|
||||||
task::JoinHandle,
|
task::JoinHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +78,8 @@ pub(super) struct CoreInner {
|
|||||||
pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>,
|
pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>,
|
||||||
pub(super) connection_endpoints: TokioMutex<HashMap<u64, String>>,
|
pub(super) connection_endpoints: TokioMutex<HashMap<u64, String>>,
|
||||||
pub(super) provider_task: TokioMutex<Option<JoinHandle<()>>>,
|
pub(super) provider_task: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
|
pub(super) delivery_receipt_notify: Notify,
|
||||||
|
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
pub(super) shutdown_started: AtomicBool,
|
pub(super) shutdown_started: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +247,8 @@ impl CoreInner {
|
|||||||
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()),
|
||||||
provider_task: TokioMutex::new(None),
|
provider_task: TokioMutex::new(None),
|
||||||
|
delivery_receipt_notify: Notify::new(),
|
||||||
|
delivery_receipt_task: TokioMutex::new(None),
|
||||||
shutdown_started: AtomicBool::new(false),
|
shutdown_started: AtomicBool::new(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +262,7 @@ impl CoreInner {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
inner.spawn_provider_event_task(event_rx).await;
|
inner.spawn_provider_event_task(event_rx).await;
|
||||||
|
inner.spawn_delivery_receipt_task().await;
|
||||||
Ok(inner)
|
Ok(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use crate::{
|
|||||||
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
|
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
|
||||||
AtomicOutputFile,
|
AtomicOutputFile,
|
||||||
},
|
},
|
||||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService},
|
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
||||||
repository::{ReceivedArtifactInsert, TransferUpsert},
|
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||||
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
|
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
@@ -271,6 +271,7 @@ impl CoreInner {
|
|||||||
.map_err(VnidropError::filesystem)?;
|
.map_err(VnidropError::filesystem)?;
|
||||||
}
|
}
|
||||||
let sender_addr = parsed.blob_ticket.addr().clone();
|
let sender_addr = parsed.blob_ticket.addr().clone();
|
||||||
|
let sender_blob_ticket = parsed.blob_ticket.to_string();
|
||||||
|
|
||||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
||||||
// Every VniDrop ticket carries metadata and must complete the handshake.
|
// Every VniDrop ticket carries metadata and must complete the handshake.
|
||||||
@@ -352,40 +353,18 @@ impl CoreInner {
|
|||||||
self.export_collection(transfer_id, total_files, target, collection)
|
self.export_collection(transfer_id, total_files, target, collection)
|
||||||
.await?;
|
.await?;
|
||||||
self.repository
|
self.repository
|
||||||
.transition_transfer_status(
|
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
||||||
transfer_id,
|
local_transfer_id: transfer_id,
|
||||||
TransferStatus::Receiving,
|
sender_blob_ticket: &sender_blob_ticket,
|
||||||
TransferStatus::Done,
|
request_id: &delivery_receipt.request_id,
|
||||||
)
|
sender_transfer_id: delivery_receipt.transfer_id,
|
||||||
|
token: &delivery_receipt.token,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(VnidropError::repository)?;
|
.map_err(VnidropError::repository)?;
|
||||||
drop(download_tag);
|
drop(download_tag);
|
||||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||||
let sender_transfer_id = delivery_receipt.transfer_id;
|
self.delivery_receipt_notify.notify_one();
|
||||||
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
|
|
||||||
match client.report_delivery(delivery_receipt).await {
|
|
||||||
Ok(DeliveryReceiptResponse::Recorded) => self.emit_transfer(
|
|
||||||
transfer_id,
|
|
||||||
"receive",
|
|
||||||
"delivery",
|
|
||||||
"receipt-recorded",
|
|
||||||
json!({ "sender_transfer_id": sender_transfer_id }),
|
|
||||||
),
|
|
||||||
Ok(DeliveryReceiptResponse::Rejected { reason }) => self.emit_transfer(
|
|
||||||
transfer_id,
|
|
||||||
"receive",
|
|
||||||
"delivery",
|
|
||||||
"receipt-rejected",
|
|
||||||
json!({ "reason": reason }),
|
|
||||||
),
|
|
||||||
Err(error) => self.emit_transfer(
|
|
||||||
transfer_id,
|
|
||||||
"receive",
|
|
||||||
"delivery",
|
|
||||||
"receipt-failed",
|
|
||||||
json!({ "reason": error.to_string() }),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{CoreEvent, ReceivedLocatorKind},
|
api::{CoreEvent, ReceivedLocatorKind},
|
||||||
repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert},
|
repository::{
|
||||||
|
PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository,
|
||||||
|
TransferUpsert,
|
||||||
|
},
|
||||||
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
|||||||
async fn persists_transfers_and_events_across_reopen() {
|
async fn persists_transfers_and_events_across_reopen() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 5);
|
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
||||||
repository
|
repository
|
||||||
.insert_transfer(transfer(
|
.insert_transfer(transfer(
|
||||||
7,
|
7,
|
||||||
@@ -112,6 +115,57 @@ async fn persists_transfers_and_events_across_reopen() {
|
|||||||
assert_eq!(events[0].id, "event-1");
|
assert_eq!(events[0].id, "event-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn receive_completion_persists_delivery_receipt_until_recorded() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
repository
|
||||||
|
.start_receive(transfer(
|
||||||
|
93,
|
||||||
|
TransferDirection::Receive,
|
||||||
|
TransferStatus::Receiving,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
||||||
|
local_transfer_id: 93,
|
||||||
|
sender_blob_ticket: "blob-ticket",
|
||||||
|
request_id: "request-93",
|
||||||
|
sender_transfer_id: 39,
|
||||||
|
token: "receipt-token",
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let transfer = repository
|
||||||
|
.list_transfers()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|transfer| transfer.transfer_id == 93)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(transfer.status, "done");
|
||||||
|
drop(repository);
|
||||||
|
|
||||||
|
let reopened = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let pending = reopened.list_pending_delivery_receipts().await.unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].local_transfer_id, 93);
|
||||||
|
assert_eq!(pending[0].sender_transfer_id, 39);
|
||||||
|
assert_eq!(pending[0].request_id, "request-93");
|
||||||
|
assert_eq!(pending[0].token, "receipt-token");
|
||||||
|
reopened
|
||||||
|
.delete_pending_delivery_receipt("request-93")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(reopened
|
||||||
|
.list_pending_delivery_receipts()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn receiver_request_can_only_be_resolved_once() {
|
async fn receiver_request_can_only_be_resolved_once() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -527,7 +581,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
|||||||
pool.close().await;
|
pool.close().await;
|
||||||
|
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 5);
|
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
||||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||||
assert_eq!(stored.transfer_id, 7);
|
assert_eq!(stored.transfer_id, 7);
|
||||||
assert_eq!(stored.local_id, "legacy-7-send");
|
assert_eq!(stored.local_id, "legacy-7-send");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
use iroh_blobs::Hash;
|
use iroh_blobs::Hash;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
repository::{Repository, TransferUpsert},
|
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
@@ -98,6 +98,63 @@ fn startup_recovers_interrupted_transfer_and_persists_event() {
|
|||||||
core.shutdown();
|
core.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_processes_persisted_delivery_receipts() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
preparation_runtime.block_on(async {
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
repository
|
||||||
|
.start_receive(TransferUpsert {
|
||||||
|
transfer_id: 94,
|
||||||
|
peer_id: None,
|
||||||
|
direction: TransferDirection::Receive,
|
||||||
|
status: TransferStatus::Receiving,
|
||||||
|
transfer_name: Some("completed receive"),
|
||||||
|
content_hash: Some("hash"),
|
||||||
|
ticket: None,
|
||||||
|
file_count: 1,
|
||||||
|
total_size: 5,
|
||||||
|
access_mode: "approval_required",
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
||||||
|
local_transfer_id: 94,
|
||||||
|
sender_blob_ticket: "invalid-ticket",
|
||||||
|
request_id: "request-94",
|
||||||
|
sender_transfer_id: 49,
|
||||||
|
token: "receipt-token",
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
drop(preparation_runtime);
|
||||||
|
|
||||||
|
let core = VnidropCore::initialize(
|
||||||
|
temp.path().to_string_lossy().to_string(),
|
||||||
|
Arc::new(TestSink),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
if core.list_events(Some(94)).unwrap().iter().any(|event| {
|
||||||
|
event.phase == "delivery"
|
||||||
|
&& event.kind == "receipt-rejected"
|
||||||
|
&& event.data_json.contains("invalid-sender-ticket")
|
||||||
|
}) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_secs(2),
|
||||||
|
"startup did not process the persisted delivery receipt"
|
||||||
|
);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
core.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_fails_persisted_share_when_root_blob_is_missing() {
|
fn startup_fails_persisted_share_when_root_blob_is_missing() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use support::{
|
use support::{
|
||||||
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
|
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
|
||||||
@@ -8,6 +11,24 @@ use support::{
|
|||||||
};
|
};
|
||||||
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
|
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
|
||||||
|
|
||||||
|
fn wait_for_completed_delivery(
|
||||||
|
sender: &vnidrop::VnidropCore,
|
||||||
|
transfer_id: u64,
|
||||||
|
) -> Vec<vnidrop::ReceiverRequest> {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let requests = sender.list_receiver_requests(transfer_id).unwrap();
|
||||||
|
if requests.iter().any(|request| request.status == "completed") {
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"delivery receipt was not recorded"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_share_receives_without_sender_approval() {
|
fn public_share_receives_without_sender_approval() {
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
@@ -47,10 +68,7 @@ fn public_share_receives_without_sender_approval() {
|
|||||||
std::fs::read(output_dir.path().join("public.txt")).unwrap(),
|
std::fs::read(output_dir.path().join("public.txt")).unwrap(),
|
||||||
b"public content"
|
b"public content"
|
||||||
);
|
);
|
||||||
let deliveries = sender
|
let deliveries = wait_for_completed_delivery(&sender.core, share.transfer_id);
|
||||||
.core
|
|
||||||
.list_receiver_requests(share.transfer_id)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(deliveries.len(), 1);
|
assert_eq!(deliveries.len(), 1);
|
||||||
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
||||||
assert_eq!(deliveries[0].status, "completed");
|
assert_eq!(deliveries[0].status, "completed");
|
||||||
@@ -104,10 +122,7 @@ fn approval_required_denies_then_allows_receiver() {
|
|||||||
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
|
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
|
||||||
b"approved content"
|
b"approved content"
|
||||||
);
|
);
|
||||||
let completed = sender
|
let completed = wait_for_completed_delivery(&sender.core, share.transfer_id);
|
||||||
.core
|
|
||||||
.list_receiver_requests(share.transfer_id)
|
|
||||||
.unwrap();
|
|
||||||
assert!(completed
|
assert!(completed
|
||||||
.iter()
|
.iter()
|
||||||
.any(|request| request.status == "completed"));
|
.any(|request| request.status == "completed"));
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.filekit.dialogs)
|
implementation(libs.filekit.dialogs)
|
||||||
|
implementation(libs.jna.platform)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class CoreRepository(
|
|||||||
val transferId = model.transferId
|
val transferId = model.transferId
|
||||||
if (transferId != null) {
|
if (transferId != null) {
|
||||||
when (model.phase) {
|
when (model.phase) {
|
||||||
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
"approval", "access" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||||
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||||
}
|
}
|
||||||
if (model.shouldRefreshTransfers()) {
|
if (model.shouldRefreshTransfers()) {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.core.CoreEventModel
|
import com.vnidrop.app.core.CoreEventModel
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.core.TransferStatus
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.ui.components.PillTone
|
import com.vnidrop.app.ui.components.PillTone
|
||||||
@@ -79,6 +81,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
|
|||||||
internal fun TransferCatalog(
|
internal fun TransferCatalog(
|
||||||
transfers: List<Transfer>,
|
transfers: List<Transfer>,
|
||||||
transferThumbnails: Map<ULong, ByteArray>,
|
transferThumbnails: Map<ULong, ByteArray>,
|
||||||
|
receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||||
events: List<CoreEventModel> = emptyList(),
|
events: List<CoreEventModel> = emptyList(),
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onOpenComposer: () -> Unit,
|
onOpenComposer: () -> Unit,
|
||||||
@@ -107,9 +110,18 @@ internal fun TransferCatalog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(transfers, key = Transfer::localId) { transfer ->
|
items(transfers, key = Transfer::localId) { transfer ->
|
||||||
|
val activeReceiverEndpointIds = receiversByTransfer[transfer.transferId]
|
||||||
|
.orEmpty()
|
||||||
|
.filter { it.status == ReceiverDeliveryStatus.Accepted }
|
||||||
|
.mapTo(mutableSetOf()) { it.remoteEndpointId }
|
||||||
val progress = when (transfer.status) {
|
val progress = when (transfer.status) {
|
||||||
TransferStatus.Importing -> progressForTransfer(events, transfer.transferId)
|
TransferStatus.Importing -> progressForTransfer(events, transfer.transferId)
|
||||||
TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize)
|
TransferStatus.Sharing -> activeSendProgress(
|
||||||
|
events,
|
||||||
|
transfer.transferId,
|
||||||
|
activeReceiverEndpointIds,
|
||||||
|
transfer.totalSize,
|
||||||
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
TransferListItem(
|
TransferListItem(
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fun SendScreen(
|
|||||||
TransferCatalog(
|
TransferCatalog(
|
||||||
transfers = outgoingTransfers,
|
transfers = outgoingTransfers,
|
||||||
transferThumbnails = state.transferThumbnails,
|
transferThumbnails = state.transferThumbnails,
|
||||||
|
receiversByTransfer = state.receiversByTransfer,
|
||||||
events = coreState.events,
|
events = coreState.events,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onOpenComposer = onOpenComposer,
|
onOpenComposer = onOpenComposer,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.vnidrop.app.core.FileSystemService
|
|||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.ReceiverRequestModel
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
|
import com.vnidrop.app.core.TransferDirection
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessage
|
import com.vnidrop.app.ui.feedback.UiMessage
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
@@ -39,6 +41,7 @@ data class SendState(
|
|||||||
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||||
val detailPanel: TransferDetailPanel? = null,
|
val detailPanel: TransferDetailPanel? = null,
|
||||||
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
|
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
|
||||||
|
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||||
val isLoadingReceivers: Boolean = false,
|
val isLoadingReceivers: Boolean = false,
|
||||||
val isDeleteConfirmationOpen: Boolean = false,
|
val isDeleteConfirmationOpen: Boolean = false,
|
||||||
val isDeleting: Boolean = false,
|
val isDeleting: Boolean = false,
|
||||||
@@ -82,15 +85,24 @@ class SendViewModel(
|
|||||||
if (signal.transferId == _state.value.selectedTransferId) {
|
if (signal.transferId == _state.value.selectedTransferId) {
|
||||||
refreshReceivers(signal.transferId)
|
refreshReceivers(signal.transferId)
|
||||||
}
|
}
|
||||||
|
refreshReceiverStatuses(signal.transferId)
|
||||||
}
|
}
|
||||||
is CoreSignal.ApprovalChanged -> {
|
is CoreSignal.ApprovalChanged -> {
|
||||||
if (signal.transferId == _state.value.selectedTransferId) {
|
if (signal.transferId == _state.value.selectedTransferId) {
|
||||||
refreshReceivers(signal.transferId)
|
refreshReceivers(signal.transferId)
|
||||||
}
|
}
|
||||||
|
refreshReceiverStatuses(signal.transferId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
coreState.map { core ->
|
||||||
|
core.transfers
|
||||||
|
.filter { it.direction == TransferDirection.Send && it.status in setOf(TransferStatus.Importing, TransferStatus.Sharing) }
|
||||||
|
.mapTo(mutableSetOf()) { it.transferId }
|
||||||
|
}.distinctUntilChanged().collect(::syncSharingReceivers)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
filePreviewRepository.previews.collect { previews ->
|
filePreviewRepository.previews.collect { previews ->
|
||||||
_state.update { it.copy(transferThumbnails = previews) }
|
_state.update { it.copy(transferThumbnails = previews) }
|
||||||
@@ -316,4 +328,21 @@ class SendViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun syncSharingReceivers(transferIds: Set<ULong>) {
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(receiversByTransfer = current.receiversByTransfer.filterKeys { it in transferIds })
|
||||||
|
}
|
||||||
|
transferIds.forEach(::refreshReceiverStatuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshReceiverStatuses(transferId: ULong) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.receiverRequests(transferId).onSuccess { requests ->
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(receiversByTransfer = current.receiversByTransfer + (transferId to requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ internal fun SettingsOverview(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
SettingsRow(
|
|
||||||
icon = AppIcon.Storage,
|
|
||||||
title = stringResource(Res.string.storage_title),
|
|
||||||
selected = state.selectedSection == SettingsSection.Storage,
|
|
||||||
onClick = { onSectionSelected(SettingsSection.Storage) },
|
|
||||||
)
|
|
||||||
SettingsDivider()
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = AppIcon.User,
|
icon = AppIcon.User,
|
||||||
title = stringResource(Res.string.preferences_title),
|
title = stringResource(Res.string.preferences_title),
|
||||||
@@ -65,6 +58,13 @@ internal fun SettingsOverview(
|
|||||||
onClick = { onSectionSelected(SettingsSection.Notifications) },
|
onClick = { onSectionSelected(SettingsSection.Notifications) },
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
icon = AppIcon.Storage,
|
||||||
|
title = stringResource(Res.string.storage_title),
|
||||||
|
selected = state.selectedSection == SettingsSection.Storage,
|
||||||
|
onClick = { onSectionSelected(SettingsSection.Storage) },
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = AppIcon.Info,
|
icon = AppIcon.Info,
|
||||||
title = stringResource(Res.string.about_title),
|
title = stringResource(Res.string.about_title),
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ fun progressForReceiver(
|
|||||||
detail = null,
|
detail = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) {
|
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
|
||||||
|
if (latest.kind == "completed" && (progress == null || progress >= 0.999f)) {
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId = transferId,
|
transferId = transferId,
|
||||||
phase = "transfer",
|
phase = "transfer",
|
||||||
@@ -149,7 +150,6 @@ fun progressForReceiver(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
|
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId = transferId,
|
transferId = transferId,
|
||||||
phase = "transfer",
|
phase = "transfer",
|
||||||
@@ -167,36 +167,21 @@ fun progressForReceiver(
|
|||||||
fun activeSendProgress(
|
fun activeSendProgress(
|
||||||
events: List<CoreEventModel>,
|
events: List<CoreEventModel>,
|
||||||
transferId: ULong,
|
transferId: ULong,
|
||||||
|
activeReceiverEndpointIds: Set<String>,
|
||||||
totalSizeHint: ULong? = null,
|
totalSizeHint: ULong? = null,
|
||||||
): TransferProgress? {
|
): TransferProgress? {
|
||||||
|
if (activeReceiverEndpointIds.isEmpty()) return null
|
||||||
val endpointIds = events
|
val endpointIds = events
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
|
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
|
||||||
.mapNotNull { findString(it.dataJson, "endpoint_id") }
|
.mapNotNull { findString(it.dataJson, "endpoint_id") }
|
||||||
|
.filter { it in activeReceiverEndpointIds }
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList()
|
.toList()
|
||||||
if (endpointIds.isEmpty()) {
|
if (endpointIds.isEmpty()) return null
|
||||||
// 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 = Res.string.progress_sending,
|
|
||||||
progress = aggregateReceiverProgress(relevant, totalSizeHint),
|
|
||||||
detail = progressDetail(relevant.first()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return endpointIds
|
return endpointIds
|
||||||
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
|
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
|
||||||
.firstOrNull { it.kind == "progress" || it.kind == "started" }
|
.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> =
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package com.vnidrop.app.feature
|
|||||||
import com.vnidrop.app.DeviceInfo
|
import com.vnidrop.app.DeviceInfo
|
||||||
import com.vnidrop.app.PlatformEnvironment
|
import com.vnidrop.app.PlatformEnvironment
|
||||||
import com.vnidrop.app.core.CoreState
|
import com.vnidrop.app.core.CoreState
|
||||||
|
import com.vnidrop.app.core.CoreSignal
|
||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
import com.vnidrop.app.core.ReceiveFolderKind
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
import com.vnidrop.app.core.Share
|
import com.vnidrop.app.core.Share
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
@@ -251,6 +254,38 @@ class ViewModelsTest {
|
|||||||
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
|
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendViewModelTracksReceiverCompletionForCatalogProgress() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val accepted = ReceiverRequestModel(
|
||||||
|
id = "request-7",
|
||||||
|
transferId = 7UL,
|
||||||
|
remoteEndpointId = "peer-a",
|
||||||
|
transferName = "Photo",
|
||||||
|
receiverName = "Receiver",
|
||||||
|
receiverDeviceName = null,
|
||||||
|
appVersion = "1.0",
|
||||||
|
status = ReceiverDeliveryStatus.Accepted,
|
||||||
|
reason = null,
|
||||||
|
requestedAt = 1L,
|
||||||
|
respondedAt = 2L,
|
||||||
|
completedAt = null,
|
||||||
|
)
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
|
||||||
|
requests[7UL] = listOf(accepted)
|
||||||
|
}
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(ReceiverDeliveryStatus.Accepted, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
|
||||||
|
|
||||||
|
core.requests[7UL] = listOf(accepted.copy(status = ReceiverDeliveryStatus.Completed, completedAt = 3L))
|
||||||
|
core.mutableSignals.emit(CoreSignal.ReceiverHistoryChanged(7UL))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(ReceiverDeliveryStatus.Completed, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
|
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
@@ -722,6 +757,22 @@ class ViewModelsTest {
|
|||||||
updatedAt = 1L,
|
updatedAt = 1L,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun sentTransfer(id: ULong) = Transfer(
|
||||||
|
localId = "send-$id",
|
||||||
|
transferId = id,
|
||||||
|
direction = TransferDirection.Send,
|
||||||
|
status = TransferStatus.Sharing,
|
||||||
|
peerId = null,
|
||||||
|
transferName = "Sent $id",
|
||||||
|
contentHash = "hash-$id",
|
||||||
|
fileCount = 1UL,
|
||||||
|
totalSize = 42UL,
|
||||||
|
ticket = "ticket-$id",
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
|
createdAt = 1L,
|
||||||
|
updatedAt = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
private fun sampleTicketInspection() = com.vnidrop.app.core.TicketInspectionModel(
|
private fun sampleTicketInspection() = com.vnidrop.app.core.TicketInspectionModel(
|
||||||
kind = "vnidrop",
|
kind = "vnidrop",
|
||||||
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
|
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
|
||||||
|
|||||||
@@ -211,9 +211,10 @@ class AppUiModelsTest {
|
|||||||
direction = "send",
|
direction = "send",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL)
|
val progress = activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL)
|
||||||
assertEquals(0.3f, progress?.progress)
|
assertEquals(0.3f, progress?.progress)
|
||||||
assertEquals(Res.string.progress_sending, progress?.label)
|
assertEquals(Res.string.progress_sending, progress?.label)
|
||||||
|
assertEquals(null, activeSendProgress(events, 7UL, emptySet(), totalSizeHint = 100UL))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -270,6 +271,39 @@ class AppUiModelsTest {
|
|||||||
assertEquals(1f, progress?.progress)
|
assertEquals(1f, progress?.progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressForReceiverCompletedAfterProgressUsesCompletedLabel() {
|
||||||
|
val events = listOf(
|
||||||
|
event(
|
||||||
|
id = "done",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "completed",
|
||||||
|
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "progress",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "progress",
|
||||||
|
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":100}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = "started",
|
||||||
|
phase = "transfer",
|
||||||
|
kind = "started",
|
||||||
|
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""",
|
||||||
|
direction = "send",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
|
||||||
|
assertEquals("completed", progress?.kind)
|
||||||
|
assertEquals(Res.string.progress_completed, progress?.label)
|
||||||
|
assertEquals(1f, progress?.progress)
|
||||||
|
assertEquals(null, activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL))
|
||||||
|
}
|
||||||
|
|
||||||
private fun storedTransfer(status: TransferStatus): Transfer =
|
private fun storedTransfer(status: TransferStatus): Transfer =
|
||||||
Transfer(
|
Transfer(
|
||||||
localId = "local-1",
|
localId = "local-1",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ actual fun rememberShareFilePicker(
|
|||||||
return remember(onFilesPicked, onError, scope) {
|
return remember(onFilesPicked, onError, scope) {
|
||||||
object : ShareFilePicker {
|
object : ShareFilePicker {
|
||||||
override fun pickFiles() {
|
override fun pickFiles() {
|
||||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||||
scope.launch {
|
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||||
try {
|
try {
|
||||||
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
|
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
|
||||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
@@ -43,8 +43,20 @@ actual fun rememberShareFilePicker(
|
|||||||
onError(error.message ?: error.toString())
|
onError(error.message ?: error.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
JvmFilePickerBackend.WindowsNative -> {
|
||||||
openPicker(onError) {
|
val owner = activeFrame()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val selected = withContext(Dispatchers.IO) { pickWindowsFiles(owner) }
|
||||||
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
onError(error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||||
val selected = pickShareFiles()
|
val selected = pickShareFiles()
|
||||||
if (selected.isNotEmpty()) onFilesPicked(selected)
|
if (selected.isNotEmpty()) onFilesPicked(selected)
|
||||||
}
|
}
|
||||||
@@ -52,8 +64,8 @@ actual fun rememberShareFilePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun pickFolder() {
|
override fun pickFolder() {
|
||||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||||
scope.launch {
|
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||||
try {
|
try {
|
||||||
val selected = withContext(Dispatchers.IO) {
|
val selected = withContext(Dispatchers.IO) {
|
||||||
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
|
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
|
||||||
@@ -65,8 +77,22 @@ actual fun rememberShareFilePicker(
|
|||||||
onError(error.message ?: error.toString())
|
onError(error.message ?: error.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
JvmFilePickerBackend.WindowsNative -> {
|
||||||
openPicker(onError) {
|
val owner = activeFrame()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val selected = withContext(Dispatchers.IO) {
|
||||||
|
pickWindowsFolder("Select folder to share", owner)
|
||||||
|
} ?: return@launch
|
||||||
|
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
onError(error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||||
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
|
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
|
||||||
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
|
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
|
||||||
}
|
}
|
||||||
@@ -85,8 +111,8 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
return remember(onFolderPicked, onError, scope) {
|
return remember(onFolderPicked, onError, scope) {
|
||||||
object : ReceiveFolderPicker {
|
object : ReceiveFolderPicker {
|
||||||
override fun pickFolder() {
|
override fun pickFolder() {
|
||||||
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
|
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
|
||||||
scope.launch {
|
JvmFilePickerBackend.XdgPortal -> scope.launch {
|
||||||
try {
|
try {
|
||||||
val selected = withContext(Dispatchers.IO) {
|
val selected = withContext(Dispatchers.IO) {
|
||||||
pickDirectoryWithPortal("Select receive folder")
|
pickDirectoryWithPortal("Select receive folder")
|
||||||
@@ -98,8 +124,22 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
onError(error.message ?: error.toString())
|
onError(error.message ?: error.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
JvmFilePickerBackend.WindowsNative -> {
|
||||||
openPicker(onError) {
|
val owner = activeFrame()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val selected = withContext(Dispatchers.IO) {
|
||||||
|
pickWindowsFolder("Select receive folder", owner)
|
||||||
|
} ?: return@launch
|
||||||
|
onFolderPicked(selected.toReceiveFolder())
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
onError(error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
|
||||||
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
||||||
onFolderPicked(selected.toReceiveFolder())
|
onFolderPicked(selected.toReceiveFolder())
|
||||||
}
|
}
|
||||||
@@ -111,11 +151,16 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
|
|
||||||
internal enum class JvmFilePickerBackend {
|
internal enum class JvmFilePickerBackend {
|
||||||
XdgPortal,
|
XdgPortal,
|
||||||
|
WindowsNative,
|
||||||
AwtSwing,
|
AwtSwing,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
|
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
|
||||||
if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing
|
when {
|
||||||
|
osName.orEmpty().startsWith("Linux", ignoreCase = true) -> JvmFilePickerBackend.XdgPortal
|
||||||
|
osName.orEmpty().startsWith("Windows", ignoreCase = true) -> JvmFilePickerBackend.WindowsNative
|
||||||
|
else -> JvmFilePickerBackend.AwtSwing
|
||||||
|
}
|
||||||
|
|
||||||
private fun openPicker(
|
private fun openPicker(
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
@@ -167,7 +212,7 @@ private suspend fun pickDirectoryWithPortal(title: String): File? =
|
|||||||
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
|
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
|
||||||
)?.file
|
)?.file
|
||||||
|
|
||||||
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
|
internal fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
|
||||||
PickedShareFile(
|
PickedShareFile(
|
||||||
value = absolutePath,
|
value = absolutePath,
|
||||||
displayName = name.ifBlank { absolutePath },
|
displayName = name.ifBlank { absolutePath },
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.vnidrop.app.core
|
||||||
|
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.WString
|
||||||
|
import com.sun.jna.platform.win32.COM.COMUtils
|
||||||
|
import com.sun.jna.platform.win32.COM.Unknown
|
||||||
|
import com.sun.jna.platform.win32.Guid.GUID
|
||||||
|
import com.sun.jna.platform.win32.Ole32
|
||||||
|
import com.sun.jna.platform.win32.WinDef.HWND
|
||||||
|
import com.sun.jna.platform.win32.WinNT.HRESULT
|
||||||
|
import com.sun.jna.ptr.IntByReference
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal enum class WindowsFilePickerMode {
|
||||||
|
Files,
|
||||||
|
Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val FOS_NOCHANGEDIR = 0x00000008
|
||||||
|
internal const val FOS_PICKFOLDERS = 0x00000020
|
||||||
|
internal const val FOS_FORCEFILESYSTEM = 0x00000040
|
||||||
|
internal const val FOS_ALLOWMULTISELECT = 0x00000200
|
||||||
|
internal const val FOS_PATHMUSTEXIST = 0x00000800
|
||||||
|
internal const val FOS_FILEMUSTEXIST = 0x00001000
|
||||||
|
|
||||||
|
internal fun windowsFilePickerOptions(mode: WindowsFilePickerMode): Int =
|
||||||
|
FOS_NOCHANGEDIR or FOS_FORCEFILESYSTEM or FOS_PATHMUSTEXIST or when (mode) {
|
||||||
|
WindowsFilePickerMode.Files -> FOS_ALLOWMULTISELECT or FOS_FILEMUSTEXIST
|
||||||
|
WindowsFilePickerMode.Folder -> FOS_PICKFOLDERS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun pickWindowsFiles(owner: Frame?): List<PickedShareFile> =
|
||||||
|
showWindowsFilePicker(
|
||||||
|
title = "Select files to share",
|
||||||
|
mode = WindowsFilePickerMode.Files,
|
||||||
|
owner = owner,
|
||||||
|
).map { it.toPickedShareFile(isDirectory = false) }
|
||||||
|
|
||||||
|
internal fun pickWindowsFolder(title: String, owner: Frame?): File? =
|
||||||
|
showWindowsFilePicker(title, WindowsFilePickerMode.Folder, owner).singleOrNull()
|
||||||
|
|
||||||
|
private fun showWindowsFilePicker(
|
||||||
|
title: String,
|
||||||
|
mode: WindowsFilePickerMode,
|
||||||
|
owner: Frame?,
|
||||||
|
): List<File> {
|
||||||
|
val ole32 = Ole32.INSTANCE
|
||||||
|
val initialization = ole32.CoInitializeEx(Pointer.NULL, Ole32.COINIT_APARTMENTTHREADED)
|
||||||
|
COMUtils.checkRC(initialization)
|
||||||
|
try {
|
||||||
|
val dialogReference = PointerByReference()
|
||||||
|
COMUtils.checkRC(
|
||||||
|
ole32.CoCreateInstance(
|
||||||
|
GUID(CLSID_FILE_OPEN_DIALOG),
|
||||||
|
Pointer.NULL,
|
||||||
|
CLSCTX_INPROC_SERVER,
|
||||||
|
GUID(IID_FILE_OPEN_DIALOG),
|
||||||
|
dialogReference,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val dialog = FileOpenDialog(dialogReference.value)
|
||||||
|
try {
|
||||||
|
val existingOptions = IntByReference()
|
||||||
|
COMUtils.checkRC(dialog.getOptions(existingOptions))
|
||||||
|
COMUtils.checkRC(dialog.setOptions(existingOptions.value or windowsFilePickerOptions(mode)))
|
||||||
|
COMUtils.checkRC(dialog.setTitle(WString(title)))
|
||||||
|
|
||||||
|
val ownerHandle = owner
|
||||||
|
?.takeIf { it.isDisplayable }
|
||||||
|
?.let { HWND(Native.getWindowPointer(it)) }
|
||||||
|
val showResult = dialog.show(ownerHandle)
|
||||||
|
if (showResult.toInt() == HRESULT_CANCELLED) return emptyList()
|
||||||
|
COMUtils.checkRC(showResult)
|
||||||
|
return dialog.results(ole32)
|
||||||
|
} finally {
|
||||||
|
dialog.Release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ole32.CoUninitialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FileOpenDialog(pointer: Pointer) : Unknown(pointer) {
|
||||||
|
fun show(owner: HWND?): HRESULT = invokeHResult(3, owner)
|
||||||
|
|
||||||
|
fun setOptions(options: Int): HRESULT = invokeHResult(9, options)
|
||||||
|
|
||||||
|
fun getOptions(options: IntByReference): HRESULT = invokeHResult(10, options)
|
||||||
|
|
||||||
|
fun setTitle(title: WString): HRESULT = invokeHResult(17, title)
|
||||||
|
|
||||||
|
private fun getResults(results: PointerByReference): HRESULT = invokeHResult(27, results)
|
||||||
|
|
||||||
|
fun results(ole32: Ole32): List<File> {
|
||||||
|
val resultsReference = PointerByReference()
|
||||||
|
COMUtils.checkRC(getResults(resultsReference))
|
||||||
|
val results = ShellItemArray(resultsReference.value)
|
||||||
|
try {
|
||||||
|
val count = IntByReference()
|
||||||
|
COMUtils.checkRC(results.getCount(count))
|
||||||
|
return List(count.value) { index -> results.fileAt(index, ole32) }
|
||||||
|
} finally {
|
||||||
|
results.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||||
|
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ShellItemArray(pointer: Pointer) : Unknown(pointer) {
|
||||||
|
fun getCount(count: IntByReference): HRESULT = invokeHResult(7, count)
|
||||||
|
|
||||||
|
private fun getItemAt(index: Int, item: PointerByReference): HRESULT = invokeHResult(8, index, item)
|
||||||
|
|
||||||
|
fun fileAt(index: Int, ole32: Ole32): File {
|
||||||
|
val itemReference = PointerByReference()
|
||||||
|
COMUtils.checkRC(getItemAt(index, itemReference))
|
||||||
|
val item = ShellItem(itemReference.value)
|
||||||
|
try {
|
||||||
|
return item.file(ole32)
|
||||||
|
} finally {
|
||||||
|
item.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||||
|
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ShellItem(pointer: Pointer) : Unknown(pointer) {
|
||||||
|
private fun getDisplayName(name: PointerByReference): HRESULT =
|
||||||
|
invokeHResult(5, SIGDN_FILESYSPATH, name)
|
||||||
|
|
||||||
|
fun file(ole32: Ole32): File {
|
||||||
|
val nameReference = PointerByReference()
|
||||||
|
COMUtils.checkRC(getDisplayName(nameReference))
|
||||||
|
val name = nameReference.value
|
||||||
|
try {
|
||||||
|
return File(name.getWideString(0))
|
||||||
|
} finally {
|
||||||
|
ole32.CoTaskMemFree(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
|
||||||
|
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CLSCTX_INPROC_SERVER = 0x1
|
||||||
|
private const val HRESULT_CANCELLED = 0x800704C7.toInt()
|
||||||
|
private const val SIGDN_FILESYSPATH = 0x80058000.toInt()
|
||||||
|
private const val CLSID_FILE_OPEN_DIALOG = "{DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7}"
|
||||||
|
private const val IID_FILE_OPEN_DIALOG = "{D57C7288-D4AD-4768-BE02-9D969532D960}"
|
||||||
@@ -2,6 +2,8 @@ package com.vnidrop.app.core
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class FilePickerJvmTest {
|
class FilePickerJvmTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -10,9 +12,34 @@ class FilePickerJvmTest {
|
|||||||
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
|
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowsUsesTheModernNativePicker() {
|
||||||
|
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("Windows 11"))
|
||||||
|
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("windows 10"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun otherDesktopPlatformsKeepTheirExistingPickers() {
|
fun otherDesktopPlatformsKeepTheirExistingPickers() {
|
||||||
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11"))
|
|
||||||
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X"))
|
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowsFilePickerAllowsMultipleFilesystemFiles() {
|
||||||
|
val options = windowsFilePickerOptions(WindowsFilePickerMode.Files)
|
||||||
|
|
||||||
|
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
|
||||||
|
assertTrue(options and FOS_ALLOWMULTISELECT != 0)
|
||||||
|
assertTrue(options and FOS_FILEMUSTEXIST != 0)
|
||||||
|
assertFalse(options and FOS_PICKFOLDERS != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowsFolderPickerSelectsOneFilesystemFolder() {
|
||||||
|
val options = windowsFilePickerOptions(WindowsFilePickerMode.Folder)
|
||||||
|
|
||||||
|
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
|
||||||
|
assertTrue(options and FOS_PICKFOLDERS != 0)
|
||||||
|
assertTrue(options and FOS_PATHMUSTEXIST != 0)
|
||||||
|
assertFalse(options and FOS_ALLOWMULTISELECT != 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user