Merge pull request #3 from vnidrop/feat/ui-next

Harden transfer lifecycle and build the modular send experience
This commit is contained in:
Hammed Abass
2026-07-11 13:57:58 +02:00
committed by GitHub
128 changed files with 11410 additions and 2862 deletions

37
.github/workflows/rust-core.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Rust core
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- ".github/workflows/rust-core.yml"
push:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- ".github/workflows/rust-core.yml"
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install Rust quality components
run: rustup component add clippy rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run strict Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run unit and integration tests
run: cargo test --workspace --all-targets
- name: Check documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps

1
Cargo.lock generated
View File

@@ -5056,6 +5056,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-channel",
"blake3",
"bytes",
"data-encoding",
"futures",

View File

@@ -58,6 +58,6 @@ android {
tasks.configureEach {
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
dependsOn(":shared:cargoBuildAndroidArm64Debug")
dependsOn(":shared:copyAndroidAndroidArm64Debug")
}
}

View File

@@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application
android:allowBackup="true"
@@ -20,6 +23,13 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

View File

@@ -4,25 +4,13 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.vnidrop.app.core.attachAndroidFilePickerContext
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
attachAndroidFilePickerContext(this)
attachAndroidPlatformContext(this)
setContent {
App()
App(rememberAndroidAppDependencies(this))
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="transfer_invitations" path="transfer-invitations/"/>
</paths>

View File

@@ -48,3 +48,36 @@ bytes through Kotlin memory.
descriptor; Rust duplicates the descriptor before streaming.
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
Rust streams from the accessible file URL/path.
## Durability And Filesystem Policy
- SQLite records have a local UUID in addition to the protocol transfer ID.
Schema-v2 records are migrated in place and keep their tickets and history.
- Imports and receives are recorded before work begins. A process restart marks
interrupted work failed and expires approval requests that no longer have an
in-memory responder.
- Persisted shares are restored only when their root collection is complete and
readable. Missing or corrupt roots fail closed and emit a recovery event.
- Receive destinations use a no-overwrite policy. Rust writes a uniquely named
temporary file in the destination directory, syncs it, and atomically
publishes it with a no-clobber hard link. Failure or cancellation removes the
temporary file. Stale VniDrop temporary files are cleaned on later writes.
- Foreign output sinks receive exactly one terminal callback after a successful
`start_file`: `finish_file` or `abort_file`.
## Blob Retention Policy
Stopping a share immediately removes its provider mapping and approval state,
so neither VniDrop nor legacy blob tickets can read it. Physical blob chunks are
not force-deleted at stop time because content-addressed chunks may be shared by
another active collection. They remain eligible for the blob store's garbage
collection. Restart reconciliation never restores a stopped share.
## Resource Limits
`CoreLimits` controls source count, collection files and bytes, path and ticket
sizes, metadata, retained events, pending approvals, concurrent transfers, and
the event persistence queue. `initialize` uses conservative defaults;
`initialize_with_limits` supports stricter deployments and tests. Cheap limits
are checked before durable or network work, while remote collection limits are
checked before downloading file content.

View File

@@ -11,6 +11,7 @@ crate-type = ["cdylib", "staticlib", "rlib"]
anyhow = "1.0.102"
async-channel = "2.5.0"
bytes = "1.11.1"
blake3 = "1.8.3"
data-encoding = "2.11.0"
futures = "0.3"
futures-lite = "2.6.1"

View File

@@ -26,6 +26,13 @@ impl AccessPolicy {
self.modes.write().await.insert(transfer_id, mode);
}
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
matches!(
self.modes.read().await.get(&transfer_id),
Some(TransferAccessMode::Public)
)
}
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
self.modes.write().await.remove(&transfer_id);
self.approved_sessions
@@ -90,6 +97,20 @@ impl AccessPolicy {
}
}
pub(crate) fn mode_from_storage(value: &str) -> TransferAccessMode {
match value {
"public" => TransferAccessMode::Public,
_ => TransferAccessMode::ApprovalRequired,
}
}
pub(crate) fn mode_to_storage(mode: &TransferAccessMode) -> &'static str {
match mode {
TransferAccessMode::Public => "public",
TransferAccessMode::ApprovalRequired => "approval_required",
}
}
#[derive(Debug, Clone)]
struct ApprovalSession {
expires_at: Option<i64>,

View File

@@ -1,8 +1,96 @@
use anyhow::Context;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use crate::util::{non_empty, now_ms};
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreLimits {
pub max_sources: u64,
pub max_collection_files: u64,
pub max_total_bytes: u64,
pub max_path_bytes: u64,
pub max_ticket_bytes: u64,
pub max_metadata_bytes: u64,
pub max_events: u64,
pub max_pending_approvals: u64,
pub max_concurrent_transfers: u64,
pub event_queue_capacity: u64,
}
impl Default for CoreLimits {
fn default() -> Self {
Self {
max_sources: 128,
max_collection_files: 10_000,
max_total_bytes: 1024 * 1024 * 1024 * 1024,
max_path_bytes: 4_096,
max_ticket_bytes: 1024 * 1024,
max_metadata_bytes: 16 * 1024,
max_events: 500,
max_pending_approvals: 1_024,
max_concurrent_transfers: 8,
event_queue_capacity: 1_024,
}
}
}
impl CoreLimits {
pub(crate) fn validate(&self) -> anyhow::Result<()> {
let positive = [
("max_sources", self.max_sources),
("max_collection_files", self.max_collection_files),
("max_total_bytes", self.max_total_bytes),
("max_path_bytes", self.max_path_bytes),
("max_ticket_bytes", self.max_ticket_bytes),
("max_metadata_bytes", self.max_metadata_bytes),
("max_events", self.max_events),
("max_pending_approvals", self.max_pending_approvals),
("max_concurrent_transfers", self.max_concurrent_transfers),
("event_queue_capacity", self.event_queue_capacity),
];
for (name, value) in positive {
if value == 0 {
anyhow::bail!("core limit {name} must be greater than zero");
}
}
for (name, value) in [
("max_pending_approvals", self.max_pending_approvals),
("max_concurrent_transfers", self.max_concurrent_transfers),
("event_queue_capacity", self.event_queue_capacity),
] {
usize::try_from(value)
.with_context(|| format!("core limit {name} exceeds platform capacity"))?;
}
if self.max_total_bytes > i64::MAX as u64 || self.max_events > i64::MAX as u64 {
anyhow::bail!("SQLite-backed limits must fit in a signed 64-bit integer");
}
Ok(())
}
pub(crate) fn validate_metadata_text(
&self,
field: &str,
value: Option<&str>,
) -> anyhow::Result<()> {
if let Some(value) = value {
if value.len() as u64 > self.max_metadata_bytes {
anyhow::bail!(
"{field} is {} bytes, limit is {}",
value.len(),
self.max_metadata_bytes
);
}
}
Ok(())
}
}
#[uniffi::export]
pub fn default_core_limits() -> CoreLimits {
CoreLimits::default()
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreEvent {
pub id: String,
@@ -20,6 +108,22 @@ pub trait CoreEventSink: Send + Sync {
fn on_event(&self, event: CoreEvent);
}
#[uniffi::export(with_foreign)]
pub trait ReceiveOutputSink: Send + Sync {
fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>;
fn write_chunk(
&self,
relative_path: String,
bytes: Vec<u8>,
) -> Result<(), crate::error::VnidropError>;
fn finish_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>;
fn abort_file(
&self,
relative_path: String,
reason: String,
) -> Result<(), crate::error::VnidropError>;
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct RuntimeStatus {
pub endpoint_id: String,
@@ -49,9 +153,10 @@ pub struct ShareMetadataInput {
pub transfer_id: u64,
pub transfer_name: Option<String>,
pub sender_name: Option<String>,
pub access_mode: TransferAccessMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum TransferAccessMode {
Public,
ApprovalRequired,
@@ -59,7 +164,9 @@ pub enum TransferAccessMode {
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct StoredTransfer {
pub local_id: String,
pub transfer_id: u64,
pub peer_id: Option<String>,
pub direction: String,
pub status: String,
pub transfer_name: Option<String>,
@@ -67,6 +174,7 @@ pub struct StoredTransfer {
pub ticket: Option<String>,
pub file_count: u64,
pub total_size: u64,
pub access_mode: TransferAccessMode,
pub created_at: i64,
pub updated_at: i64,
}
@@ -136,4 +244,5 @@ pub struct ReceiverRequest {
pub reason: Option<String>,
pub requested_at: i64,
pub responded_at: Option<i64>,
pub completed_at: Option<i64>,
}

View File

@@ -8,8 +8,9 @@ use uuid::Uuid;
use crate::{
access_policy::AccessPolicy,
event_hub::EventHub,
handshake::{HandshakeResponse, RequestTransfer},
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
repository::{ReceiverRequestInsert, Repository},
transfer_state::ReceiverRequestStatus,
util::now_ms,
};
@@ -29,19 +30,60 @@ pub(crate) struct ApprovalService {
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
max_pending: usize,
max_metadata_bytes: u64,
}
impl ApprovalService {
pub(crate) async fn complete_delivery(
&self,
remote_endpoint_id: String,
receipt: DeliveryReceipt,
) -> DeliveryReceiptResponse {
let token_hash = receipt_token_hash(&receipt.token);
match self
.repository
.complete_receiver_delivery(
&receipt.request_id,
receipt.transfer_id,
&remote_endpoint_id,
&token_hash,
)
.await
{
Ok(()) => {
self.event_hub.emit_transfer(
receipt.transfer_id,
"send",
"delivery",
"receiver-completed",
json!({ "request_id": receipt.request_id, "remote_endpoint_id": remote_endpoint_id }),
);
DeliveryReceiptResponse::Recorded
}
Err(error) => {
tracing::warn!(%error, "rejected receiver delivery receipt");
DeliveryReceiptResponse::Rejected {
reason: "invalid-receipt".to_string(),
}
}
}
}
pub(crate) fn new(
repository: Repository,
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
max_pending: usize,
max_metadata_bytes: u64,
) -> Self {
Self {
repository,
event_hub,
access_policy,
pending: Arc::new(Mutex::new(HashMap::new())),
max_pending,
max_metadata_bytes,
}
}
@@ -52,7 +94,11 @@ impl ApprovalService {
reason: Option<String>,
) -> anyhow::Result<()> {
let sender = self.pending.lock().await.remove(&request_id);
let status = if accepted { "accepted" } else { "refused" };
let status = if accepted {
ReceiverRequestStatus::Accepted
} else {
ReceiverRequestStatus::Refused
};
self.repository
.update_receiver_request_status(&request_id, status, reason.as_deref())
.await?;
@@ -73,6 +119,25 @@ impl ApprovalService {
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
let metadata_values = [
request.transfer_hash.as_str(),
request.transfer_name.as_str(),
request.receiver_name.as_deref().unwrap_or_default(),
request.receiver_device_name.as_deref().unwrap_or_default(),
request.app_version.as_str(),
];
if metadata_values
.iter()
.any(|value| value.len() as u64 > self.max_metadata_bytes)
{
return self
.deny(
request.transfer_id,
remote_endpoint_id,
"metadata-too-large",
)
.await;
}
self.event_hub.emit_transfer(
request.transfer_id,
"send",
@@ -90,8 +155,17 @@ impl ApprovalService {
.await
{
Ok(true) => {
self.wait_for_sender_decision(remote_endpoint_id, request)
if self
.access_policy
.allows_without_approval(request.transfer_id)
.await
{
self.allow_without_sender_decision(remote_endpoint_id, request)
.await
} else {
self.wait_for_sender_decision(remote_endpoint_id, request)
.await
}
}
Ok(false) => {
self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer")
@@ -105,6 +179,64 @@ impl ApprovalService {
}
}
async fn allow_without_sender_decision(
&self,
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
let request_id = Uuid::new_v4().to_string();
if self
.repository
.insert_receiver_request(ReceiverRequestInsert {
id: &request_id,
transfer_id: request.transfer_id,
remote_endpoint_id: &remote_endpoint_id,
transfer_name: &request.transfer_name,
receiver_name: request.receiver_name.as_deref(),
receiver_device_name: request.receiver_device_name.as_deref(),
app_version: &request.app_version,
})
.await
.is_err()
|| self
.repository
.update_receiver_request_status(&request_id, ReceiverRequestStatus::Accepted, None)
.await
.is_err()
{
return self
.deny(request.transfer_id, remote_endpoint_id, "repository-error")
.await;
}
let token = Uuid::new_v4().to_string();
if self
.repository
.set_receiver_receipt_token(&request_id, &receipt_token_hash(&token))
.await
.is_err()
{
return self
.deny(request.transfer_id, remote_endpoint_id, "repository-error")
.await;
}
let expires_at = now_ms() + APPROVAL_TTL_MS;
self.event_hub.emit_transfer(
request.transfer_id,
"send",
"access",
"receiver-auto-approved",
json!({
"remote_endpoint_id": remote_endpoint_id,
"expires_at": expires_at,
}),
);
HandshakeResponse::Approved {
request_id,
token,
expires_at,
}
}
async fn wait_for_sender_decision(
&self,
remote_endpoint_id: String,
@@ -112,7 +244,19 @@ impl ApprovalService {
) -> HandshakeResponse {
let request_id = Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(request_id.clone(), tx);
let mut pending = self.pending.lock().await;
if pending.len() >= self.max_pending {
drop(pending);
return self
.deny(
request.transfer_id,
remote_endpoint_id,
"too-many-pending-approvals",
)
.await;
}
pending.insert(request_id.clone(), tx);
drop(pending);
let insert_result = self
.repository
@@ -171,7 +315,21 @@ impl ApprovalService {
"expires_at": expires_at,
}),
);
HandshakeResponse::Approved { token, expires_at }
if let Err(error) = self
.repository
.set_receiver_receipt_token(&decision.request_id, &receipt_token_hash(&token))
.await
{
tracing::error!(%error, "failed to attach delivery receipt token");
return HandshakeResponse::Denied {
reason: "repository-error".to_string(),
};
}
HandshakeResponse::Approved {
request_id: decision.request_id,
token,
expires_at,
}
}
Ok(Ok(decision)) => {
self.deny(
@@ -189,7 +347,7 @@ impl ApprovalService {
.repository
.update_receiver_request_status(
&request_id,
"expired",
ReceiverRequestStatus::Expired,
Some("approval timed out"),
)
.await;
@@ -219,3 +377,7 @@ impl ApprovalService {
HandshakeResponse::Denied { reason }
}
}
fn receipt_token_hash(token: &str) -> String {
blake3::hash(token.as_bytes()).to_hex().to_string()
}

View File

@@ -9,9 +9,100 @@ use tokio::{
use crate::{
api::{CoreEvent, CoreEventSink},
repository::Repository,
transfer_state::TransferDirection,
util::now_ms,
};
#[derive(Debug, Clone, Copy)]
enum EventScope {
Endpoint,
Transfer,
}
impl EventScope {
const fn as_str(self) -> &'static str {
match self {
Self::Endpoint => "endpoint",
Self::Transfer => "transfer",
}
}
}
#[derive(Debug, Clone, Copy)]
enum EventPhase {
Startup,
Recovery,
Shutdown,
Provider,
Error,
Import,
Ticket,
Lifecycle,
Network,
Download,
Export,
Access,
Handshake,
Approval,
Transfer,
}
impl EventPhase {
fn parse(value: &str) -> Option<Self> {
match value {
"startup" => Some(Self::Startup),
"recovery" => Some(Self::Recovery),
"shutdown" => Some(Self::Shutdown),
"provider" => Some(Self::Provider),
"error" => Some(Self::Error),
"import" => Some(Self::Import),
"ticket" => Some(Self::Ticket),
"lifecycle" => Some(Self::Lifecycle),
"network" => Some(Self::Network),
"download" => Some(Self::Download),
"export" => Some(Self::Export),
"access" => Some(Self::Access),
"handshake" => Some(Self::Handshake),
"approval" => Some(Self::Approval),
"transfer" => Some(Self::Transfer),
_ => None,
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Startup => "startup",
Self::Recovery => "recovery",
Self::Shutdown => "shutdown",
Self::Provider => "provider",
Self::Error => "error",
Self::Import => "import",
Self::Ticket => "ticket",
Self::Lifecycle => "lifecycle",
Self::Network => "network",
Self::Download => "download",
Self::Export => "export",
Self::Access => "access",
Self::Handshake => "handshake",
Self::Approval => "approval",
Self::Transfer => "transfer",
}
}
}
struct EventKind(String);
impl EventKind {
fn parse(value: &str) -> Option<Self> {
let valid = !value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
valid.then(|| Self(value.to_string()))
}
}
enum EventCommand {
Persist(CoreEvent),
Flush(oneshot::Sender<()>),
@@ -20,19 +111,24 @@ enum EventCommand {
pub(crate) struct EventHub {
sink: Arc<dyn CoreEventSink>,
tx: mpsc::UnboundedSender<EventCommand>,
tx: mpsc::Sender<EventCommand>,
join: TokioMutex<Option<JoinHandle<()>>>,
sequence: Mutex<u64>,
}
impl EventHub {
pub(crate) fn start(repository: Repository, sink: Arc<dyn CoreEventSink>) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel();
pub(crate) fn start(
repository: Repository,
sink: Arc<dyn CoreEventSink>,
queue_capacity: usize,
max_history: u64,
) -> Self {
let (tx, mut rx) = mpsc::channel(queue_capacity);
let join = tokio::spawn(async move {
while let Some(command) = rx.recv().await {
match command {
EventCommand::Persist(event) => {
if let Err(error) = repository.insert_event(&event).await {
if let Err(error) = repository.insert_event(&event, max_history).await {
tracing::warn!(%error, event_id = %event.id, "failed to persist core event");
}
}
@@ -56,7 +152,15 @@ impl EventHub {
}
pub(crate) fn emit_endpoint(&self, phase: &str, kind: &str, data: Value) {
self.emit("endpoint", None, None, phase, kind, data);
let Some(phase) = EventPhase::parse(phase) else {
tracing::warn!(phase, "dropped event with unknown phase");
return;
};
let Some(kind) = EventKind::parse(kind) else {
tracing::warn!(kind, "dropped event with invalid kind");
return;
};
self.emit(EventScope::Endpoint, None, None, phase, kind, data);
}
pub(crate) fn emit_transfer(
@@ -67,10 +171,22 @@ impl EventHub {
kind: &str,
data: Value,
) {
let Ok(direction) = TransferDirection::try_from(direction) else {
tracing::warn!(direction, "dropped event with unknown direction");
return;
};
let Some(phase) = EventPhase::parse(phase) else {
tracing::warn!(phase, "dropped event with unknown phase");
return;
};
let Some(kind) = EventKind::parse(kind) else {
tracing::warn!(kind, "dropped event with invalid kind");
return;
};
self.emit(
"transfer",
EventScope::Transfer,
Some(transfer_id),
Some(direction.to_string()),
Some(direction),
phase,
kind,
data,
@@ -79,14 +195,14 @@ impl EventHub {
pub(crate) async fn flush(&self) {
let (tx, rx) = oneshot::channel();
if self.tx.send(EventCommand::Flush(tx)).is_ok() {
if self.tx.send(EventCommand::Flush(tx)).await.is_ok() {
let _ = rx.await;
}
}
pub(crate) async fn shutdown(&self) {
let (tx, rx) = oneshot::channel();
if self.tx.send(EventCommand::Shutdown(tx)).is_ok() {
if self.tx.send(EventCommand::Shutdown(tx)).await.is_ok() {
let _ = rx.await;
}
if let Some(join) = self.join.lock().await.take() {
@@ -96,15 +212,20 @@ impl EventHub {
fn emit(
&self,
scope: &str,
scope: EventScope,
transfer_id: Option<u64>,
direction: Option<String>,
phase: &str,
kind: &str,
direction: Option<TransferDirection>,
phase: EventPhase,
kind: EventKind,
data: Value,
) {
let timestamp = now_ms();
let mut sequence = self.sequence.lock().expect("event sequence lock poisoned");
// A panic while formatting a previous event cannot invalidate an
// integer counter, so recover the guard instead of cascading a panic.
let mut sequence = self
.sequence
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let id = format!("{timestamp}-{}", *sequence);
*sequence += 1;
drop(sequence);
@@ -115,15 +236,15 @@ impl EventHub {
let event = CoreEvent {
id,
timestamp,
scope: scope.to_string(),
scope: scope.as_str().to_string(),
transfer_id,
direction,
phase: phase.to_string(),
kind: kind.to_string(),
direction: direction.map(|direction| direction.as_str().to_string()),
phase: phase.as_str().to_string(),
kind: kind.0,
data_json: data.to_string(),
};
if self.tx.send(EventCommand::Persist(event.clone())).is_err() {
tracing::warn!(event_id = %event.id, "event persistence queue is closed");
if let Err(error) = self.tx.try_send(EventCommand::Persist(event.clone())) {
tracing::warn!(event_id = %event.id, %error, "event persistence queue dropped event");
}
self.sink.on_event(event);
}

View File

@@ -1,21 +1,27 @@
#[cfg(unix)]
use std::os::fd::{FromRawFd, OwnedFd};
use std::{
fs::File,
fs::{File, OpenOptions},
io::{self, Read, Write},
path::{Component, Path, PathBuf},
time::{Duration, SystemTime},
};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh_blobs::{api::TempTag, Hash};
use uuid::Uuid;
use crate::{
api::{ShareSource, SourceKind},
api::{CoreLimits, ShareSource, SourceKind},
util::non_empty,
};
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
const STALE_PART_AGE: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug)]
pub(crate) struct TransferImport {
@@ -39,6 +45,109 @@ pub(crate) enum ImportSource {
FileDescriptor(OwnedFd),
}
pub(crate) struct AtomicOutputFile {
target: PathBuf,
temporary: PathBuf,
committed: bool,
}
impl AtomicOutputFile {
pub(crate) fn create(output_dir: &Path, relative_path: &str) -> Result<(Self, File)> {
let target = safe_output_path(output_dir, relative_path)?;
let parent = target
.parent()
.context("output file must have a parent directory")?;
std::fs::create_dir_all(parent)?;
let canonical_root = std::fs::canonicalize(output_dir)?;
let canonical_parent = std::fs::canonicalize(parent)?;
if !canonical_parent.starts_with(&canonical_root) {
anyhow::bail!("output path escapes the selected directory");
}
cleanup_stale_temporary_files(parent, STALE_PART_AGE)?;
if std::fs::symlink_metadata(&target).is_ok() {
anyhow::bail!("destination already exists: {}", target.display());
}
let final_name = target
.file_name()
.and_then(|name| name.to_str())
.context("output filename is not valid UTF-8")?;
let temporary = parent.join(format!(".{final_name}.vnidrop-{}.part", Uuid::new_v4()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW);
let file = options
.open(&temporary)
.with_context(|| format!("failed to create {}", temporary.display()))?;
// Recheck after opening the temporary file so a swapped ancestor is
// detected before bytes are published to the final destination.
let canonical_parent_after_open = std::fs::canonicalize(parent)?;
if canonical_parent_after_open != canonical_parent {
let _ = std::fs::remove_file(&temporary);
anyhow::bail!("output directory changed while opening destination");
}
Ok((
Self {
target,
temporary,
committed: false,
},
file,
))
}
pub(crate) fn commit(mut self) -> Result<()> {
// Creating a hard link is an atomic no-clobber publication on the same
// filesystem. It fails if another writer created the destination.
std::fs::hard_link(&self.temporary, &self.target)
.with_context(|| format!("failed to commit {}", self.target.display()))?;
self.committed = true;
if let Err(error) = std::fs::remove_file(&self.temporary) {
tracing::warn!(%error, path = %self.temporary.display(), "failed to remove committed temporary file");
}
Ok(())
}
}
pub(crate) fn cleanup_stale_temporary_files(
directory: &Path,
minimum_age: Duration,
) -> Result<u64> {
let now = SystemTime::now();
let mut removed = 0;
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.starts_with('.') && name.contains(".vnidrop-") && name.ends_with(".part")) {
continue;
}
let metadata = entry.metadata()?;
let age = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.unwrap_or_default();
if age >= minimum_age && metadata.is_file() {
std::fs::remove_file(entry.path())?;
removed += 1;
}
}
Ok(removed)
}
impl Drop for AtomicOutputFile {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_file(&self.temporary);
}
}
}
impl ImportSource {
pub(crate) fn open(self) -> Result<File> {
match self {
@@ -51,7 +160,22 @@ impl ImportSource {
}
}
#[cfg(test)]
pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> {
collect_import_files_with_limits(sources, &CoreLimits::default())
}
pub(crate) fn collect_import_files_with_limits(
sources: Vec<ShareSource>,
limits: &CoreLimits,
) -> Result<Vec<ImportSourceFile>> {
if sources.len() as u64 > limits.max_sources {
anyhow::bail!(
"source count {} exceeds limit {}",
sources.len(),
limits.max_sources
);
}
let mut files = Vec::new();
for source in sources {
match source.kind {
@@ -118,6 +242,34 @@ pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<Impo
if files.is_empty() {
anyhow::bail!("no files found in selected sources");
}
if files.len() as u64 > limits.max_collection_files {
anyhow::bail!(
"collection file count {} exceeds limit {}",
files.len(),
limits.max_collection_files
);
}
let mut known_total = 0u64;
for file in &files {
if file.collection_name.len() as u64 > limits.max_path_bytes {
anyhow::bail!(
"collection path exceeds {} bytes: {}",
limits.max_path_bytes,
file.collection_name
);
}
if let ImportSource::Path(path) = &file.source {
known_total = known_total
.checked_add(std::fs::metadata(path)?.len())
.context("collection size overflow")?;
if known_total > limits.max_total_bytes {
anyhow::bail!(
"collection size {known_total} exceeds limit {}",
limits.max_total_bytes
);
}
}
}
Ok(files)
}
@@ -212,7 +364,7 @@ where
pub(crate) fn write_stream_to_blocking_writer<W>(
mut writer: W,
rx: async_channel::Receiver<io::Result<Option<Bytes>>>,
) -> io::Result<()>
) -> io::Result<W>
where
W: Write,
{
@@ -222,12 +374,13 @@ where
None => break,
}
}
writer.flush()
writer.flush()?;
Ok(writer)
}
pub(crate) async fn wait_for_writer(
task: std::thread::JoinHandle<io::Result<()>>,
) -> Result<io::Result<()>> {
pub(crate) async fn wait_for_writer<T: Send + 'static>(
task: std::thread::JoinHandle<io::Result<T>>,
) -> Result<io::Result<T>> {
tokio::task::spawn_blocking(move || {
task.join()
.map_err(|_| anyhow::anyhow!("export writer thread panicked"))

View File

@@ -24,7 +24,7 @@ impl fmt::Debug for HandshakeService {
}
impl HandshakeService {
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/1";
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/2";
pub(crate) fn new(approval: crate::approval::ApprovalService) -> Self {
Self { approval }
@@ -64,6 +64,14 @@ impl ProtocolHandler for HandshakeService {
let response = self.handle_request(remote_endpoint_id.clone(), inner).await;
let _ = tx.send(response).await;
}
HandshakeMessage::ReportDelivery(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.approval
.complete_delivery(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
}
}
@@ -94,6 +102,13 @@ impl HandshakeClient {
})
.await
}
pub(crate) async fn report_delivery(
&self,
receipt: DeliveryReceipt,
) -> Result<DeliveryReceiptResponse, irpc::Error> {
self.inner.rpc(receipt).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -108,8 +123,27 @@ pub(crate) struct RequestTransfer {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum HandshakeResponse {
Approved { token: String, expires_at: i64 },
Denied { reason: String },
Approved {
request_id: String,
token: String,
expires_at: i64,
},
Denied {
reason: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveryReceipt {
pub(crate) request_id: String,
pub(crate) transfer_id: u64,
pub(crate) token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum DeliveryReceiptResponse {
Recorded,
Rejected { reason: String },
}
#[rpc_requests(message = HandshakeMessage)]
@@ -117,4 +151,6 @@ pub(crate) enum HandshakeResponse {
enum HandshakeProtocol {
#[rpc(tx=oneshot::Sender<HandshakeResponse>)]
RequestTransfer(RequestTransfer),
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
ReportDelivery(DeliveryReceipt),
}

View File

@@ -10,12 +10,13 @@ mod repository;
mod runtime;
mod secret;
mod ticket;
mod transfer_state;
mod util;
pub use api::{
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
TransferMetadata,
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest,
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TicketInspection, TransferAccessMode, TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;

View File

@@ -1,30 +1,59 @@
use std::{path::Path, str::FromStr};
use anyhow::Result;
#[cfg(test)]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use anyhow::{Context, Result};
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
Row, SqlitePool,
};
use uuid::Uuid;
use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer};
use crate::util::now_ms;
use crate::{
access_policy::mode_from_storage,
api::{CoreEvent, ReceiverRequest, StoredTransfer},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms,
};
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_VERSION: i64 = 4;
#[derive(Debug, Clone)]
pub(crate) struct Repository {
pool: SqlitePool,
#[cfg(test)]
fail_next_write: Arc<AtomicBool>,
}
pub(crate) struct TransferUpsert<'a> {
pub(crate) transfer_id: u64,
pub(crate) direction: &'a str,
pub(crate) status: &'a str,
pub(crate) peer_id: Option<&'a str>,
pub(crate) direction: TransferDirection,
pub(crate) status: TransferStatus,
pub(crate) transfer_name: Option<&'a str>,
pub(crate) content_hash: Option<&'a str>,
pub(crate) ticket: Option<&'a str>,
pub(crate) file_count: u64,
pub(crate) total_size: u64,
pub(crate) access_mode: &'a str,
}
#[derive(Debug, Clone)]
pub(crate) struct PersistedShare {
pub(crate) transfer_id: u64,
pub(crate) content_hash: String,
pub(crate) access_mode: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RecoveredTransfer {
pub(crate) transfer_id: u64,
pub(crate) direction: TransferDirection,
pub(crate) previous_status: TransferStatus,
}
pub(crate) struct ReceiverRequestInsert<'a> {
@@ -47,7 +76,11 @@ impl Repository {
.max_connections(4)
.connect_with(options)
.await?;
let repository = Self { pool };
let repository = Self {
pool,
#[cfg(test)]
fail_next_write: Arc::new(AtomicBool::new(false)),
};
repository.ensure_schema().await?;
Ok(repository)
}
@@ -59,6 +92,9 @@ impl Repository {
r#"
CREATE TABLE IF NOT EXISTS transfers (
transfer_id INTEGER PRIMARY KEY,
local_id TEXT NOT NULL,
protocol_transfer_id INTEGER NOT NULL,
peer_id TEXT,
direction TEXT NOT NULL,
status TEXT NOT NULL,
transfer_name TEXT,
@@ -66,6 +102,7 @@ impl Repository {
ticket TEXT,
file_count INTEGER NOT NULL DEFAULT 0,
total_size INTEGER NOT NULL DEFAULT 0,
access_mode TEXT NOT NULL DEFAULT 'approval_required',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -74,6 +111,60 @@ impl Repository {
.execute(&self.pool)
.await?;
let columns = sqlx::query("PRAGMA table_info(transfers)")
.fetch_all(&self.pool)
.await?;
let has_access_mode = columns
.iter()
.any(|row| row.get::<String, _>(1) == "access_mode");
if !has_access_mode {
sqlx::query(
"ALTER TABLE transfers ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'approval_required'",
)
.execute(&self.pool)
.await?;
}
let has_local_id = columns
.iter()
.any(|row| row.get::<String, _>(1) == "local_id");
if !has_local_id {
sqlx::query("ALTER TABLE transfers ADD COLUMN local_id TEXT")
.execute(&self.pool)
.await?;
}
let has_protocol_transfer_id = columns
.iter()
.any(|row| row.get::<String, _>(1) == "protocol_transfer_id");
if !has_protocol_transfer_id {
sqlx::query("ALTER TABLE transfers ADD COLUMN protocol_transfer_id INTEGER")
.execute(&self.pool)
.await?;
}
let has_peer_id = columns
.iter()
.any(|row| row.get::<String, _>(1) == "peer_id");
if !has_peer_id {
sqlx::query("ALTER TABLE transfers ADD COLUMN peer_id TEXT")
.execute(&self.pool)
.await?;
}
sqlx::query(
r#"
UPDATE transfers
SET local_id = COALESCE(local_id, 'legacy-' || transfer_id || '-' || direction),
protocol_transfer_id = COALESCE(protocol_transfer_id, transfer_id)
WHERE local_id IS NULL OR protocol_transfer_id IS NULL
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_transfers_local_id ON transfers(local_id)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS transfer_events (
@@ -111,12 +202,34 @@ impl Repository {
reason TEXT,
requested_at INTEGER NOT NULL,
responded_at INTEGER
,receipt_token_hash TEXT
,completed_at INTEGER
);
"#,
)
.execute(&self.pool)
.await?;
let receiver_columns = sqlx::query("PRAGMA table_info(receiver_requests)")
.fetch_all(&self.pool)
.await?;
if !receiver_columns
.iter()
.any(|row| row.get::<String, _>(1) == "receipt_token_hash")
{
sqlx::query("ALTER TABLE receiver_requests ADD COLUMN receipt_token_hash TEXT")
.execute(&self.pool)
.await?;
}
if !receiver_columns
.iter()
.any(|row| row.get::<String, _>(1) == "completed_at")
{
sqlx::query("ALTER TABLE receiver_requests ADD COLUMN completed_at INTEGER")
.execute(&self.pool)
.await?;
}
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_receiver_requests_transfer_id ON receiver_requests(transfer_id, requested_at DESC);",
)
@@ -137,55 +250,275 @@ impl Repository {
Ok(row.get(0))
}
pub(crate) async fn upsert_transfer(&self, transfer: TransferUpsert<'_>) -> Result<()> {
pub(crate) async fn insert_transfer(&self, transfer: TransferUpsert<'_>) -> Result<()> {
self.maybe_fail_write()?;
let now = now_ms();
sqlx::query(
r#"
INSERT INTO transfers (
transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, created_at, updated_at
transfer_id, local_id, protocol_transfer_id, peer_id, direction, status,
transfer_name, content_hash, ticket, file_count, total_size, access_mode,
created_at, updated_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
ON CONFLICT(transfer_id) DO UPDATE SET
direction = excluded.direction,
status = excluded.status,
transfer_name = excluded.transfer_name,
content_hash = excluded.content_hash,
ticket = excluded.ticket,
file_count = excluded.file_count,
total_size = excluded.total_size,
updated_at = excluded.updated_at;
VALUES (?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12);
"#,
)
.bind(transfer.transfer_id as i64)
.bind(transfer.direction)
.bind(transfer.status)
.bind(to_db_id(transfer.transfer_id)?)
.bind(Uuid::new_v4().to_string())
.bind(transfer.peer_id)
.bind(transfer.direction.as_str())
.bind(transfer.status.as_str())
.bind(transfer.transfer_name)
.bind(transfer.content_hash)
.bind(transfer.ticket)
.bind(transfer.file_count as i64)
.bind(transfer.total_size as i64)
.bind(to_db_id(transfer.file_count)?)
.bind(to_db_id(transfer.total_size)?)
.bind(transfer.access_mode)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn update_transfer_status(
&self,
transfer_id: u64,
status: &str,
) -> Result<()> {
sqlx::query("UPDATE transfers SET status = ?1, updated_at = ?2 WHERE transfer_id = ?3")
.bind(status)
.bind(now_ms())
.bind(transfer_id as i64)
.execute(&self.pool)
.await?;
pub(crate) async fn start_receive(&self, transfer: TransferUpsert<'_>) -> Result<()> {
self.maybe_fail_write()?;
if transfer.direction != TransferDirection::Receive
|| transfer.status != TransferStatus::Receiving
{
anyhow::bail!("receive must start in the receiving state");
}
let now = now_ms();
let result = sqlx::query(
r#"
INSERT INTO transfers (
transfer_id, local_id, protocol_transfer_id, peer_id, direction, status,
transfer_name, content_hash, ticket, file_count, total_size, access_mode,
created_at, updated_at
)
VALUES (?1, ?2, ?1, ?3, 'receive', 'receiving', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)
ON CONFLICT(transfer_id) DO UPDATE SET
status = 'receiving',
transfer_name = excluded.transfer_name,
content_hash = excluded.content_hash,
ticket = excluded.ticket,
file_count = excluded.file_count,
total_size = excluded.total_size,
access_mode = excluded.access_mode,
peer_id = excluded.peer_id,
updated_at = excluded.updated_at
WHERE transfers.direction = 'receive'
AND transfers.status IN ('done', 'failed', 'cancelled')
"#,
)
.bind(to_db_id(transfer.transfer_id)?)
.bind(Uuid::new_v4().to_string())
.bind(transfer.peer_id)
.bind(transfer.transfer_name)
.bind(transfer.content_hash)
.bind(transfer.ticket)
.bind(to_db_id(transfer.file_count)?)
.bind(to_db_id(transfer.total_size)?)
.bind(transfer.access_mode)
.bind(now)
.execute(&self.pool)
.await?;
require_one_changed(result.rows_affected(), "start receive")?;
Ok(())
}
pub(crate) async fn insert_event(&self, event: &CoreEvent) -> Result<()> {
pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> {
self.maybe_fail_write()?;
if transfer.direction != TransferDirection::Send
|| transfer.status != TransferStatus::Sharing
{
anyhow::bail!("share import must complete in the sharing state");
}
let result = sqlx::query(
r#"
UPDATE transfers
SET status = ?1,
transfer_name = ?2,
content_hash = ?3,
ticket = ?4,
file_count = ?5,
total_size = ?6,
access_mode = ?7,
updated_at = ?8
WHERE transfer_id = ?9
AND direction = 'send'
AND status = 'importing'
"#,
)
.bind(transfer.status.as_str())
.bind(transfer.transfer_name)
.bind(transfer.content_hash)
.bind(transfer.ticket)
.bind(to_db_id(transfer.file_count)?)
.bind(to_db_id(transfer.total_size)?)
.bind(transfer.access_mode)
.bind(now_ms())
.bind(to_db_id(transfer.transfer_id)?)
.execute(&self.pool)
.await?;
require_one_changed(result.rows_affected(), "complete share import")?;
Ok(())
}
pub(crate) async fn transition_transfer_status(
&self,
transfer_id: u64,
expected: TransferStatus,
next: TransferStatus,
) -> Result<()> {
self.maybe_fail_write()?;
if !expected.can_transition_to(next) {
anyhow::bail!(
"illegal transfer status transition: {} -> {}",
expected.as_str(),
next.as_str()
);
}
let result = sqlx::query(
r#"
UPDATE transfers
SET status = ?1, updated_at = ?2
WHERE transfer_id = ?3 AND status = ?4
"#,
)
.bind(next.as_str())
.bind(now_ms())
.bind(to_db_id(transfer_id)?)
.bind(expected.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
let current = sqlx::query("SELECT status FROM transfers WHERE transfer_id = ?1")
.bind(to_db_id(transfer_id)?)
.fetch_optional(&self.pool)
.await?;
if current
.as_ref()
.map(|row| row.get::<String, _>(0))
.as_deref()
== Some(next.as_str())
{
return Ok(());
}
}
require_one_changed(result.rows_affected(), "transition transfer status")?;
Ok(())
}
pub(crate) async fn update_active_share_access_mode(
&self,
transfer_id: u64,
access_mode: &str,
) -> Result<()> {
self.maybe_fail_write()?;
let result = sqlx::query(
r#"
UPDATE transfers
SET access_mode = ?1, updated_at = ?2
WHERE transfer_id = ?3
AND direction = 'send'
AND status = 'sharing'
"#,
)
.bind(access_mode)
.bind(now_ms())
.bind(to_db_id(transfer_id)?)
.execute(&self.pool)
.await?;
require_one_changed(result.rows_affected(), "update active share access mode")?;
Ok(())
}
pub(crate) async fn recover_interrupted_transfers(&self) -> Result<Vec<RecoveredTransfer>> {
self.maybe_fail_write()?;
let mut transaction = self.pool.begin().await?;
let rows = sqlx::query(
r#"
SELECT transfer_id, direction, status
FROM transfers
WHERE status IN ('importing', 'receiving')
ORDER BY created_at ASC
"#,
)
.fetch_all(&mut *transaction)
.await?;
let recovered = rows
.into_iter()
.map(|row| {
Ok(RecoveredTransfer {
transfer_id: row.get::<i64, _>("transfer_id") as u64,
direction: TransferDirection::try_from(
row.get::<String, _>("direction").as_str(),
)?,
previous_status: TransferStatus::try_from(
row.get::<String, _>("status").as_str(),
)?,
})
})
.collect::<Result<Vec<_>>>()?;
if !recovered.is_empty() {
sqlx::query(
r#"
UPDATE transfers
SET status = 'failed', updated_at = ?1
WHERE status IN ('importing', 'receiving')
"#,
)
.bind(now_ms())
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(recovered)
}
#[cfg(test)]
pub(crate) fn fail_next_write(&self) {
self.fail_next_write.store(true, Ordering::SeqCst);
}
#[cfg(test)]
fn maybe_fail_write(&self) -> Result<()> {
if self.fail_next_write.swap(false, Ordering::SeqCst) {
anyhow::bail!("injected repository write failure");
}
Ok(())
}
#[cfg(not(test))]
fn maybe_fail_write(&self) -> Result<()> {
Ok(())
}
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
let rows = sqlx::query(
r#"
SELECT transfer_id, content_hash, access_mode
FROM transfers
WHERE direction = 'send'
AND status = 'sharing'
AND content_hash IS NOT NULL
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| PersistedShare {
transfer_id: row.get::<i64, _>(0) as u64,
content_hash: row.get::<String, _>(1),
access_mode: row.get::<String, _>(2),
})
.collect())
}
pub(crate) async fn insert_event(&self, event: &CoreEvent, max_history: u64) -> Result<()> {
let mut transaction = self.pool.begin().await?;
sqlx::query(
r#"
INSERT OR REPLACE INTO transfer_events (
@@ -197,13 +530,25 @@ impl Repository {
.bind(&event.id)
.bind(event.timestamp)
.bind(&event.scope)
.bind(event.transfer_id.map(|value| value as i64))
.bind(event.transfer_id.map(to_db_id).transpose()?)
.bind(&event.direction)
.bind(&event.phase)
.bind(&event.kind)
.bind(&event.data_json)
.execute(&self.pool)
.execute(&mut *transaction)
.await?;
sqlx::query(
r#"
DELETE FROM transfer_events
WHERE id NOT IN (
SELECT id FROM transfer_events ORDER BY timestamp DESC, id DESC LIMIT ?1
)
"#,
)
.bind(to_db_id(max_history)?)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
@@ -222,7 +567,7 @@ impl Repository {
"#,
)
.bind(request.id)
.bind(request.transfer_id as i64)
.bind(to_db_id(request.transfer_id)?)
.bind(request.remote_endpoint_id)
.bind(request.transfer_name)
.bind(request.receiver_name)
@@ -237,7 +582,7 @@ impl Repository {
pub(crate) async fn update_receiver_request_status(
&self,
id: &str,
status: &str,
status: ReceiverRequestStatus,
reason: Option<&str>,
) -> Result<()> {
let result = sqlx::query(
@@ -248,7 +593,7 @@ impl Repository {
AND status = 'requested'
"#,
)
.bind(status)
.bind(status.as_str())
.bind(reason)
.bind(now_ms())
.bind(id)
@@ -260,6 +605,85 @@ impl Repository {
Ok(())
}
pub(crate) async fn set_receiver_receipt_token(
&self,
id: &str,
token_hash: &str,
) -> Result<()> {
let result = sqlx::query(
"UPDATE receiver_requests SET receipt_token_hash = ?1 WHERE id = ?2 AND status = 'accepted'",
)
.bind(token_hash)
.bind(id)
.execute(&self.pool)
.await?;
require_one_changed(result.rows_affected(), "attach receiver receipt token")
}
pub(crate) async fn complete_receiver_delivery(
&self,
id: &str,
transfer_id: u64,
remote_endpoint_id: &str,
token_hash: &str,
) -> Result<()> {
let result = sqlx::query(
r#"
UPDATE receiver_requests
SET status = 'completed', completed_at = ?1
WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5
AND status = 'accepted'
"#,
)
.bind(now_ms())
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
return Ok(());
}
let already_recorded = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1 FROM receiver_requests
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
AND receipt_token_hash = ?4 AND status = 'completed'
)
"#,
)
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.fetch_one(&self.pool)
.await?
.get::<i64, _>(0)
!= 0;
if already_recorded {
Ok(())
} else {
anyhow::bail!("delivery receipt did not match an accepted receiver request")
}
}
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
let result = sqlx::query(
r#"
UPDATE receiver_requests
SET status = 'expired', reason = ?1, responded_at = ?2
WHERE status = 'requested'
"#,
)
.bind(reason)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub(crate) async fn list_receiver_requests(
&self,
transfer_id: u64,
@@ -268,13 +692,13 @@ impl Repository {
r#"
SELECT id, transfer_id, remote_endpoint_id, transfer_name,
receiver_name, receiver_device_name, app_version, status,
reason, requested_at, responded_at
reason, requested_at, responded_at, completed_at
FROM receiver_requests
WHERE transfer_id = ?1
ORDER BY requested_at DESC
"#,
)
.bind(transfer_id as i64)
.bind(to_db_id(transfer_id)?)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_receiver_request).collect())
@@ -292,7 +716,7 @@ impl Repository {
)
"#,
)
.bind(transfer_id as i64)
.bind(to_db_id(transfer_id)?)
.bind(content_hash)
.fetch_one(&self.pool)
.await?;
@@ -303,17 +727,43 @@ impl Repository {
let rows = sqlx::query(
r#"
SELECT transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, created_at, updated_at
local_id, protocol_transfer_id, peer_id,
file_count, total_size, access_mode, created_at, updated_at
FROM transfers
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_transfer).collect())
rows.into_iter().map(row_to_transfer).collect()
}
pub(crate) async fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>> {
pub(crate) async fn delete_transfer(&self, transfer_id: u64) -> Result<()> {
self.maybe_fail_write()?;
let transfer_id = to_db_id(transfer_id)?;
let mut transaction = self.pool.begin().await?;
sqlx::query("DELETE FROM receiver_requests WHERE transfer_id = ?1")
.bind(transfer_id)
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM transfer_events WHERE transfer_id = ?1")
.bind(transfer_id)
.execute(&mut *transaction)
.await?;
let deleted = sqlx::query("DELETE FROM transfers WHERE transfer_id = ?1")
.bind(transfer_id)
.execute(&mut *transaction)
.await?;
require_one_changed(deleted.rows_affected(), "delete transfer")?;
transaction.commit().await?;
Ok(())
}
pub(crate) async fn list_events(
&self,
transfer_id: Option<u64>,
limit: u64,
) -> Result<Vec<CoreEvent>> {
let rows = if let Some(transfer_id) = transfer_id {
sqlx::query(
r#"
@@ -321,9 +771,11 @@ impl Repository {
FROM transfer_events
WHERE transfer_id = ?1
ORDER BY timestamp ASC
LIMIT ?2
"#,
)
.bind(transfer_id as i64)
.bind(to_db_id(transfer_id)?)
.bind(to_db_id(limit)?)
.fetch_all(&self.pool)
.await?
} else {
@@ -332,9 +784,10 @@ impl Repository {
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
FROM transfer_events
ORDER BY timestamp DESC
LIMIT 500
LIMIT ?1
"#,
)
.bind(to_db_id(limit)?)
.fetch_all(&self.pool)
.await?
};
@@ -342,19 +795,39 @@ impl Repository {
}
}
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> StoredTransfer {
StoredTransfer {
fn require_one_changed(rows_affected: u64, operation: &str) -> Result<()> {
if rows_affected != 1 {
anyhow::bail!("{operation} expected one matching transfer, changed {rows_affected}");
}
Ok(())
}
fn to_db_id(value: u64) -> Result<i64> {
i64::try_from(value).context("transfer id exceeds SQLite signed integer range")
}
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
let direction = row.get::<String, _>("direction");
let status = row.get::<String, _>("status");
Ok(StoredTransfer {
local_id: row.get("local_id"),
transfer_id: row.get::<i64, _>("transfer_id") as u64,
direction: row.get("direction"),
status: row.get("status"),
peer_id: row.get("peer_id"),
direction: TransferDirection::try_from(direction.as_str())?
.as_str()
.to_string(),
status: TransferStatus::try_from(status.as_str())?
.as_str()
.to_string(),
transfer_name: row.get("transfer_name"),
content_hash: row.get("content_hash"),
ticket: row.get("ticket"),
file_count: row.get::<i64, _>("file_count") as u64,
total_size: row.get::<i64, _>("total_size") as u64,
access_mode: mode_from_storage(&row.get::<String, _>("access_mode")),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
})
}
fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
@@ -373,6 +846,7 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
}
fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
let status = row.get::<String, _>("status");
ReceiverRequest {
id: row.get("id"),
transfer_id: row.get::<i64, _>("transfer_id") as u64,
@@ -381,9 +855,12 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
receiver_name: row.get("receiver_name"),
receiver_device_name: row.get("receiver_device_name"),
app_version: row.get("app_version"),
status: row.get("status"),
status: ReceiverRequestStatus::try_from(status.as_str())
.map(|status| status.as_str().to_string())
.unwrap_or_else(|_| "unknown".to_string()),
reason: row.get("reason"),
requested_at: row.get("requested_at"),
responded_at: row.get("responded_at"),
completed_at: row.get("completed_at"),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
use std::{io, path::Path, str::FromStr};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use anyhow::{Context, Result};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
@@ -18,13 +21,21 @@ pub(crate) async fn load_or_create_secret(app_data_dir: &Path) -> Result<SecretK
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?;
restrict_permissions(&path).await?;
Ok(SecretKey::from_bytes(&bytes))
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let secret = SecretKey::generate();
tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?;
restrict_permissions(&path).await?;
Ok(secret)
}
Err(error) => Err(error.into()),
}
}
async fn restrict_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
Ok(())
}

View File

@@ -1,406 +1,18 @@
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::fd::AsRawFd;
use std::{io::Read, path::Path, sync::Arc};
use data_encoding::BASE64URL_NOPAD;
use iroh::SecretKey;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use serde_json::json;
use crate::{
access_policy::{AccessDecision, AccessPolicy},
api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata},
error::VnidropError,
filesystem::{
collect_import_files, default_collection_name, path_to_string,
percent_decode_file_url_path, validated_relative_string,
},
repository::{ReceiverRequestInsert, Repository},
runtime::VnidropCore,
secret::load_or_create_secret,
ticket::{parse_transfer_ticket, VnidropTicket},
TransferAccessMode,
};
struct TestSink;
impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {}
}
#[test]
fn metadata_ticket_round_trips() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq);
let metadata = TransferMetadata::new(
42,
"Summer photos",
Some("hammed".to_string()),
blob_ticket.hash(),
3,
2048,
);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
.encode()
.unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
assert_eq!(
parsed.metadata.unwrap().transfer_name,
metadata.transfer_name
);
}
#[test]
fn metadata_ticket_round_trip_tolerates_wrapped_whitespace() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([9; 32]), BlobFormat::HashSeq);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
.encode()
.unwrap();
let wrapped = encoded
.as_bytes()
.chunks(8)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join("\n ");
let parsed = parse_transfer_ticket(&wrapped).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());
}
#[test]
fn ticket_rejects_unsupported_versions_and_mismatched_hashes() {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
let blob_ticket = BlobTicket::new(addr, Hash::new([5; 32]), BlobFormat::HashSeq);
let payload = json!({
"version": 2,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad version",
"sender_name": null,
"created_at": 1,
"content_hash": blob_ticket.hash().to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("unsupported VniDrop ticket version"));
let payload = json!({
"version": 1,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad hash",
"sender_name": null,
"created_at": 1,
"content_hash": Hash::new([6; 32]).to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("metadata hash does not match"));
}
#[tokio::test]
async fn secret_persists() {
let temp = tempfile::tempdir().unwrap();
let first = load_or_create_secret(temp.path()).await.unwrap();
let second = load_or_create_secret(temp.path()).await.unwrap();
assert_eq!(first.to_bytes(), second.to_bytes());
}
#[test]
fn path_validation_rejects_unsafe_paths() {
assert!(path_to_string(Path::new("../escape"), true).is_err());
assert!(path_to_string(Path::new("/absolute"), true).is_err());
assert!(validated_relative_string("bad\\name").is_err());
assert!(validated_relative_string("").is_err());
}
#[test]
fn file_url_decodes_spaces() {
assert_eq!(
percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(),
"/tmp/My File.txt"
);
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_duplicates_and_streams() {
let mut temp = tempfile::tempfile().unwrap();
std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap();
std::io::Seek::rewind(&mut temp).unwrap();
let files = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: temp.as_raw_fd().to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.unwrap();
let mut imported = files.into_iter().next().unwrap().source.open().unwrap();
let mut content = String::new();
imported.read_to_string(&mut content).unwrap();
assert_eq!(content, "fd-backed import");
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_rejects_invalid_values() {
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: "not-an-fd".to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: "-1".to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
}
#[test]
fn android_content_uri_must_be_opened_by_platform_code() {
let error = collect_import_files(vec![ShareSource {
kind: SourceKind::AndroidContentUri,
value: "content://media/item".to_string(),
display_name: Some("from-uri.txt".to_string()),
is_directory: false,
}])
.unwrap_err()
.to_string();
assert!(error.contains("ParcelFileDescriptor"));
}
#[test]
fn directory_sources_preserve_safe_relative_names() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("picked");
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap();
std::fs::write(root.join("b.txt"), b"b").unwrap();
let mut files = collect_import_files(vec![ShareSource {
kind: SourceKind::Path,
value: root.to_string_lossy().to_string(),
display_name: Some("Album".to_string()),
is_directory: true,
}])
.unwrap();
files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name));
assert_eq!(default_collection_name(&files), "Album");
assert_eq!(files[0].collection_name, "Album/b.txt");
assert_eq!(files[1].collection_name, "Album/nested/a.txt");
}
#[test]
fn can_initialize_core() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let status = core.status();
assert!(!status.endpoint_id.is_empty());
core.shutdown();
}
#[tokio::test]
async fn repository_persists_transfers_and_events() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 1);
repository
.upsert_transfer(crate::repository::TransferUpsert {
transfer_id: 7,
direction: "send",
status: "sharing",
transfer_name: Some("demo"),
content_hash: Some("hash"),
ticket: Some("ticket"),
file_count: 1,
total_size: 12,
})
.await
.unwrap();
repository
.insert_event(&CoreEvent {
id: "event-1".to_string(),
timestamp: 10,
scope: "transfer".to_string(),
transfer_id: Some(7),
direction: Some("send".to_string()),
phase: "ticket".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
})
.await
.unwrap();
let transfers = repository.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo"));
let events = repository.list_events(Some(7)).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "created");
let reopened = Repository::open(temp.path()).await.unwrap();
let transfers = reopened.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
let events = reopened.list_events(Some(7)).await.unwrap();
assert_eq!(events[0].id, "event-1");
}
#[tokio::test]
async fn repository_persists_receiver_requests() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-1",
transfer_id: 77,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: Some("phone"),
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-1", "accepted", None)
.await
.unwrap();
assert!(repository
.update_receiver_request_status("request-1", "refused", Some("late"))
.await
.is_err());
assert!(repository
.update_receiver_request_status("missing", "accepted", None)
.await
.is_err());
let requests = repository.list_receiver_requests(77).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "accepted");
assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver"));
assert!(requests[0].responded_at.is_some());
}
#[tokio::test]
async fn access_policy_requires_approved_endpoint_when_locked() {
let policy = AccessPolicy::new();
policy
.set_mode(99, TransferAccessMode::ApprovalRequired)
.await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
policy.approve_endpoint(99, "node-a".to_string()).await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Allow
);
assert_eq!(
policy.decide(99, None).await,
AccessDecision::Deny {
reason: "missing-endpoint-id"
}
);
}
#[tokio::test]
async fn access_policy_rejects_expired_approval_sessions() {
let policy = AccessPolicy::new();
policy
.set_mode(100, TransferAccessMode::ApprovalRequired)
.await;
policy
.approve_endpoint_until(100, "node-a".to_string(), Some(crate::util::now_ms() - 1))
.await;
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-expired"
}
);
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let error = core
.receive(
"not-a-ticket".to_string(),
temp.path().to_string_lossy().to_string(),
None,
)
.unwrap_err();
assert!(matches!(error, VnidropError::Ticket { .. }));
let events = core.list_events(None).unwrap();
assert!(events
.iter()
.any(|event| event.phase == "error" && event.kind == "invalid-ticket"));
core.shutdown();
}
}
#[path = "tests/access_policy.rs"]
mod access_policy_tests;
#[path = "tests/filesystem.rs"]
mod filesystem_tests;
#[path = "tests/handshake.rs"]
mod handshake_tests;
#[path = "tests/limits.rs"]
mod limits_tests;
#[path = "tests/repository.rs"]
mod repository_tests;
#[path = "tests/runtime.rs"]
mod runtime_tests;
#[path = "tests/secret.rs"]
mod secret_tests;
#[path = "tests/ticket.rs"]
mod ticket_tests;
#[path = "tests/transfer_state.rs"]
mod transfer_state_tests;

View File

@@ -0,0 +1,56 @@
use crate::{
access_policy::{AccessDecision, AccessPolicy},
util::now_ms,
TransferAccessMode,
};
#[tokio::test]
async fn requires_approved_endpoint_when_locked() {
let policy = AccessPolicy::new();
policy
.set_mode(99, TransferAccessMode::ApprovalRequired)
.await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
policy.approve_endpoint(99, "node-a".to_string()).await;
assert_eq!(
policy.decide(99, Some("node-a")).await,
AccessDecision::Allow
);
assert_eq!(
policy.decide(99, None).await,
AccessDecision::Deny {
reason: "missing-endpoint-id"
}
);
}
#[tokio::test]
async fn rejects_expired_approval_sessions() {
let policy = AccessPolicy::new();
policy
.set_mode(100, TransferAccessMode::ApprovalRequired)
.await;
policy
.approve_endpoint_until(100, "node-a".to_string(), Some(now_ms() - 1))
.await;
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-expired"
}
);
assert_eq!(
policy.decide(100, Some("node-a")).await,
AccessDecision::Deny {
reason: "approval-required"
}
);
}

View File

@@ -0,0 +1,233 @@
#[cfg(unix)]
use std::os::fd::AsRawFd;
use std::{io::Read, path::Path};
use crate::{
api::{CoreLimits, ShareSource, SourceKind},
filesystem::{
cleanup_stale_temporary_files, collect_import_files, collect_import_files_with_limits,
default_collection_name, path_to_string, percent_decode_file_url_path,
validated_relative_string, AtomicOutputFile,
},
};
#[test]
fn path_validation_rejects_unsafe_paths() {
assert!(path_to_string(Path::new("../escape"), true).is_err());
assert!(path_to_string(Path::new("/absolute"), true).is_err());
assert!(validated_relative_string("bad\\name").is_err());
assert!(validated_relative_string("").is_err());
}
#[test]
fn file_url_decodes_spaces() {
assert_eq!(
percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(),
"/tmp/My File.txt"
);
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_duplicates_and_streams() {
let mut temp = tempfile::tempfile().unwrap();
std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap();
std::io::Seek::rewind(&mut temp).unwrap();
let files = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: temp.as_raw_fd().to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.unwrap();
let mut imported = files.into_iter().next().unwrap().source.open().unwrap();
let mut content = String::new();
imported.read_to_string(&mut content).unwrap();
assert_eq!(content, "fd-backed import");
}
#[cfg(unix)]
#[test]
fn file_descriptor_source_rejects_invalid_values() {
for value in ["not-an-fd", "-1"] {
assert!(collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: value.to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.is_err());
}
}
#[test]
fn android_content_uri_must_be_opened_by_platform_code() {
let error = collect_import_files(vec![ShareSource {
kind: SourceKind::AndroidContentUri,
value: "content://media/item".to_string(),
display_name: Some("from-uri.txt".to_string()),
is_directory: false,
}])
.unwrap_err()
.to_string();
assert!(error.contains("ParcelFileDescriptor"));
}
#[test]
fn directory_sources_preserve_safe_relative_names() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("picked");
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap();
std::fs::write(root.join("b.txt"), b"b").unwrap();
let mut files = collect_import_files(vec![ShareSource {
kind: SourceKind::Path,
value: root.to_string_lossy().to_string(),
display_name: Some("Album".to_string()),
is_directory: true,
}])
.unwrap();
files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name));
assert_eq!(default_collection_name(&files), "Album");
assert_eq!(files[0].collection_name, "Album/b.txt");
assert_eq!(files[1].collection_name, "Album/nested/a.txt");
}
#[test]
fn atomic_output_commits_without_overwriting() {
let output = tempfile::tempdir().unwrap();
let (pending, mut file) = AtomicOutputFile::create(output.path(), "nested/file.txt").unwrap();
std::io::Write::write_all(&mut file, b"complete").unwrap();
file.sync_all().unwrap();
drop(file);
pending.commit().unwrap();
assert_eq!(
std::fs::read(output.path().join("nested/file.txt")).unwrap(),
b"complete"
);
assert!(AtomicOutputFile::create(output.path(), "nested/file.txt").is_err());
assert_eq!(
std::fs::read(output.path().join("nested/file.txt")).unwrap(),
b"complete"
);
}
#[test]
fn dropped_atomic_output_removes_partial_file() {
let output = tempfile::tempdir().unwrap();
let (pending, mut file) = AtomicOutputFile::create(output.path(), "partial.txt").unwrap();
std::io::Write::write_all(&mut file, b"partial").unwrap();
drop(file);
drop(pending);
assert!(!output.path().join("partial.txt").exists());
assert!(std::fs::read_dir(output.path()).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".part")));
}
#[cfg(unix)]
#[test]
fn atomic_output_rejects_symlinked_parent() {
use std::os::unix::fs::symlink;
let output = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), output.path().join("link")).unwrap();
assert!(AtomicOutputFile::create(output.path(), "link/escape.txt").is_err());
assert!(!outside.path().join("escape.txt").exists());
}
#[cfg(unix)]
#[test]
fn atomic_output_never_replaces_symlink_destination() {
use std::os::unix::fs::symlink;
let output = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::fs::write(outside.path(), b"outside").unwrap();
symlink(outside.path(), output.path().join("target.txt")).unwrap();
assert!(AtomicOutputFile::create(output.path(), "target.txt").is_err());
assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside");
}
#[test]
fn cleanup_removes_only_vnidrop_temporary_files() {
let output = tempfile::tempdir().unwrap();
std::fs::write(output.path().join(".file.vnidrop-old.part"), b"partial").unwrap();
std::fs::write(output.path().join("keep.part"), b"keep").unwrap();
assert_eq!(
cleanup_stale_temporary_files(output.path(), std::time::Duration::ZERO).unwrap(),
1
);
assert!(!output.path().join(".file.vnidrop-old.part").exists());
assert!(output.path().join("keep.part").exists());
}
#[test]
fn import_collection_limits_are_enforced_before_streaming() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("one.txt"), b"one").unwrap();
std::fs::write(temp.path().join("two.txt"), b"two").unwrap();
let source = ShareSource {
kind: SourceKind::Path,
value: temp.path().to_string_lossy().to_string(),
display_name: Some("folder".to_string()),
is_directory: true,
};
let file_limits = CoreLimits {
max_collection_files: 1,
..CoreLimits::default()
};
assert!(collect_import_files_with_limits(vec![source.clone()], &file_limits).is_err());
let size_limits = CoreLimits {
max_collection_files: 10,
max_total_bytes: 5,
..CoreLimits::default()
};
assert!(collect_import_files_with_limits(vec![source], &size_limits).is_err());
let path_limits = CoreLimits {
max_path_bytes: 4,
..CoreLimits::default()
};
let file = temp.path().join("long-name.txt");
std::fs::write(&file, b"x").unwrap();
assert!(collect_import_files_with_limits(
vec![ShareSource {
kind: SourceKind::Path,
value: file.to_string_lossy().to_string(),
display_name: Some("long-name.txt".to_string()),
is_directory: false,
}],
&path_limits,
)
.is_err());
}
#[test]
fn generated_relative_paths_never_accept_traversal_components() {
for prefix in ["", "folder/", "a/b/"] {
for traversal in ["..", "../escape", "..\\escape"] {
let candidate = format!("{prefix}{traversal}");
assert!(
validated_relative_string(&candidate).is_err(),
"{candidate}"
);
}
}
assert!(validated_relative_string(".").is_err());
assert!(validated_relative_string("/absolute").is_err());
}

View File

@@ -0,0 +1,13 @@
use crate::handshake::HandshakeResponse;
#[test]
fn malformed_handshake_response_is_rejected() {
for payload in [
r#"{"Approved":{"token":7,"expires_at":"later"}}"#,
r#"{"Denied":{}}"#,
r#"{"Unknown":{"reason":"no"}}"#,
"not-json",
] {
assert!(serde_json::from_str::<HandshakeResponse>(payload).is_err());
}
}

View File

@@ -0,0 +1,15 @@
use crate::api::CoreLimits;
#[test]
fn default_limits_are_valid() {
CoreLimits::default().validate().unwrap();
}
#[test]
fn zero_limit_is_rejected() {
let limits = CoreLimits {
max_sources: 0,
..CoreLimits::default()
};
assert!(limits.validate().is_err());
}

View File

@@ -0,0 +1,590 @@
use crate::{
api::CoreEvent,
repository::{ReceiverRequestInsert, Repository, TransferUpsert},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
};
fn transfer(
transfer_id: u64,
direction: TransferDirection,
status: TransferStatus,
) -> TransferUpsert<'static> {
TransferUpsert {
transfer_id,
peer_id: None,
direction,
status,
transfer_name: Some("demo"),
content_hash: Some("hash"),
ticket: Some("ticket"),
file_count: 1,
total_size: 12,
access_mode: "approval_required",
}
}
#[tokio::test]
async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 4);
repository
.insert_transfer(transfer(
7,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let shares = repository.list_active_shares().await.unwrap();
assert_eq!(shares.len(), 1);
assert_eq!(shares[0].transfer_id, 7);
assert_eq!(shares[0].content_hash, "hash");
assert_eq!(shares[0].access_mode, "approval_required");
repository
.insert_event(
&CoreEvent {
id: "event-1".to_string(),
timestamp: 10,
scope: "transfer".to_string(),
transfer_id: Some(7),
direction: Some("send".to_string()),
phase: "ticket".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
let transfers = repository.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo"));
let events = repository.list_events(Some(7), 500).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "created");
drop(repository);
let reopened = Repository::open(temp.path()).await.unwrap();
let transfers = reopened.list_transfers().await.unwrap();
assert_eq!(transfers.len(), 1);
let events = reopened.list_events(Some(7), 500).await.unwrap();
assert_eq!(events[0].id, "event-1");
}
#[tokio::test]
async fn receiver_request_can_only_be_resolved_once() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-1",
transfer_id: 77,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: Some("phone"),
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-1", ReceiverRequestStatus::Accepted, None)
.await
.unwrap();
repository
.set_receiver_receipt_token("request-1", "token-hash")
.await
.unwrap();
repository
.complete_receiver_delivery("request-1", 77, "node-a", "token-hash")
.await
.unwrap();
repository
.complete_receiver_delivery("request-1", 77, "node-a", "token-hash")
.await
.unwrap();
assert!(repository
.complete_receiver_delivery("request-1", 77, "node-b", "token-hash")
.await
.is_err());
assert!(repository
.update_receiver_request_status("request-1", ReceiverRequestStatus::Refused, Some("late"),)
.await
.is_err());
assert!(repository
.update_receiver_request_status("missing", ReceiverRequestStatus::Accepted, None)
.await
.is_err());
let requests = repository.list_receiver_requests(77).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "completed");
assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver"));
assert!(requests[0].responded_at.is_some());
assert!(requests[0].completed_at.is_some());
}
#[tokio::test]
async fn startup_expiration_is_idempotent_for_pending_requests() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "pending-1",
transfer_id: 78,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
assert_eq!(
repository
.expire_pending_receiver_requests("restart")
.await
.unwrap(),
1
);
assert_eq!(
repository
.expire_pending_receiver_requests("restart")
.await
.unwrap(),
0
);
let requests = repository.list_receiver_requests(78).await.unwrap();
assert_eq!(requests[0].status, "expired");
assert_eq!(requests[0].reason.as_deref(), Some("restart"));
}
#[tokio::test]
async fn concurrent_approval_responses_have_single_winner() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "race-1",
transfer_id: 79,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
let accepted_repository = repository.clone();
let refused_repository = repository.clone();
let (accepted, refused) = tokio::join!(
accepted_repository.update_receiver_request_status(
"race-1",
ReceiverRequestStatus::Accepted,
None,
),
refused_repository.update_receiver_request_status(
"race-1",
ReceiverRequestStatus::Refused,
Some("race"),
),
);
assert_ne!(accepted.is_ok(), refused.is_ok());
let requests = repository.list_receiver_requests(79).await.unwrap();
assert!(matches!(
requests[0].status.as_str(),
"accepted" | "refused"
));
}
#[tokio::test]
async fn conditional_transition_rejects_stale_state() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
81,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let error = repository
.transition_transfer_status(81, TransferStatus::Receiving, TransferStatus::Done)
.await
.unwrap_err();
assert!(error.to_string().contains("expected one matching transfer"));
assert_eq!(
repository.list_transfers().await.unwrap()[0].status,
"sharing"
);
}
#[tokio::test]
async fn repeated_terminal_transition_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
88,
TransferDirection::Send,
TransferStatus::Importing,
))
.await
.unwrap();
repository
.transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed)
.await
.unwrap();
repository
.transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed)
.await
.unwrap();
assert_eq!(
repository.list_transfers().await.unwrap()[0].status,
"failed"
);
}
#[tokio::test]
async fn duplicate_transfer_does_not_overwrite_existing_record() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
82,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
assert!(repository
.insert_transfer(transfer(
82,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.is_err());
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.direction, "send");
assert_eq!(stored.status, "sharing");
}
#[tokio::test]
async fn recovery_fails_only_interrupted_states() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
83,
TransferDirection::Send,
TransferStatus::Importing,
))
.await
.unwrap();
repository
.insert_transfer(transfer(
84,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.unwrap();
repository
.insert_transfer(transfer(
85,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.unwrap();
let recovered = repository.recover_interrupted_transfers().await.unwrap();
assert_eq!(recovered.len(), 2);
assert_eq!(recovered[0].transfer_id, 83);
assert_eq!(recovered[0].previous_status, TransferStatus::Importing);
assert_eq!(recovered[1].transfer_id, 84);
assert_eq!(recovered[1].previous_status, TransferStatus::Receiving);
let transfers = repository.list_transfers().await.unwrap();
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 83)
.unwrap()
.status,
"failed"
);
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 84)
.unwrap()
.status,
"failed"
);
assert_eq!(
transfers
.iter()
.find(|transfer| transfer.transfer_id == 85)
.unwrap()
.status,
"sharing"
);
assert!(repository
.recover_interrupted_transfers()
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn share_completion_is_conditional_and_atomic() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(TransferUpsert {
transfer_id: 86,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Importing,
transfer_name: Some("pending"),
content_hash: None,
ticket: None,
file_count: 0,
total_size: 0,
access_mode: "approval_required",
})
.await
.unwrap();
repository
.complete_share_import(TransferUpsert {
transfer_id: 86,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Sharing,
transfer_name: Some("complete"),
content_hash: Some("final-hash"),
ticket: Some("final-ticket"),
file_count: 2,
total_size: 24,
access_mode: "approval_required",
})
.await
.unwrap();
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.status, "sharing");
assert_eq!(stored.transfer_name.as_deref(), Some("complete"));
assert_eq!(stored.content_hash.as_deref(), Some("final-hash"));
assert_eq!(stored.ticket.as_deref(), Some("final-ticket"));
assert_eq!(stored.file_count, 2);
assert_eq!(stored.total_size, 24);
assert!(repository
.complete_share_import(transfer(
86,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.is_err());
}
#[tokio::test]
async fn injected_write_failure_preserves_previous_state() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(TransferUpsert {
transfer_id: 87,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Importing,
transfer_name: Some("pending"),
content_hash: None,
ticket: None,
file_count: 0,
total_size: 0,
access_mode: "approval_required",
})
.await
.unwrap();
repository.fail_next_write();
assert!(repository
.complete_share_import(transfer(
87,
TransferDirection::Send,
TransferStatus::Sharing,
))
.await
.is_err());
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.status, "importing");
assert_eq!(stored.content_hash, None);
assert_eq!(stored.ticket, None);
}
#[tokio::test]
async fn migrates_schema_v2_identity_without_losing_transfer() {
let temp = tempfile::tempdir().unwrap();
let database = temp.path().join("vnidrop.sqlite3");
let options = SqliteConnectOptions::from_str("sqlite://")
.unwrap()
.filename(&database)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE transfers (
transfer_id INTEGER PRIMARY KEY,
direction TEXT NOT NULL,
status TEXT NOT NULL,
transfer_name TEXT,
content_hash TEXT,
ticket TEXT,
file_count INTEGER NOT NULL DEFAULT 0,
total_size INTEGER NOT NULL DEFAULT 0,
access_mode TEXT NOT NULL DEFAULT 'approval_required',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
INSERT INTO transfers (
transfer_id, direction, status, transfer_name, content_hash, ticket,
file_count, total_size, access_mode, created_at, updated_at
) VALUES (7, 'send', 'stopped', 'legacy', 'hash', 'ticket', 1, 12,
'approval_required', 10, 11)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 2")
.execute(&pool)
.await
.unwrap();
pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 4);
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send");
assert_eq!(stored.transfer_name.as_deref(), Some("legacy"));
assert_eq!(stored.ticket.as_deref(), Some("ticket"));
assert_eq!(stored.peer_id, None);
}
#[tokio::test]
async fn event_reads_respect_configured_history_limit() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
for sequence in 0..3 {
repository
.insert_event(
&CoreEvent {
id: format!("event-{sequence}"),
timestamp: sequence,
scope: "endpoint".to_string(),
transfer_id: None,
direction: None,
phase: "test".to_string(),
kind: "generated".to_string(),
data_json: "{}".to_string(),
},
2,
)
.await
.unwrap();
}
let events = repository.list_events(None, 2).await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].id, "event-2");
assert_eq!(events[1].id, "event-1");
}
#[tokio::test]
async fn deleting_transfer_removes_related_history_transactionally() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
88,
TransferDirection::Send,
TransferStatus::Stopped,
))
.await
.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-delete",
transfer_id: 88,
remote_endpoint_id: "receiver",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "1.0",
})
.await
.unwrap();
repository
.insert_event(
&CoreEvent {
id: "event-delete".to_string(),
timestamp: 1,
scope: "transfer".to_string(),
transfer_id: Some(88),
direction: Some("send".to_string()),
phase: "test".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
repository.delete_transfer(88).await.unwrap();
assert!(repository.list_transfers().await.unwrap().is_empty());
assert!(repository
.list_events(Some(88), 500)
.await
.unwrap()
.is_empty());
assert!(repository
.list_receiver_requests(88)
.await
.unwrap()
.is_empty());
assert!(repository.delete_transfer(88).await.is_err());
}
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};

View File

@@ -0,0 +1,143 @@
use std::sync::Arc;
use iroh_blobs::Hash;
use crate::{
repository::{Repository, TransferUpsert},
transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
};
struct TestSink;
impl CoreEventSink for TestSink {
fn on_event(&self, _event: CoreEvent) {}
}
#[test]
fn initializes_and_reports_endpoint() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
assert!(!core.status().endpoint_id.is_empty());
core.shutdown();
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let error = core
.receive(
"not-a-ticket".to_string(),
temp.path().to_string_lossy().to_string(),
None,
)
.unwrap_err();
assert!(matches!(error, VnidropError::Ticket { .. }));
let events = core.list_events(None).unwrap();
assert!(events
.iter()
.any(|event| event.phase == "error" && event.kind == "invalid-ticket"));
core.shutdown();
}
#[test]
fn startup_recovers_interrupted_transfer_and_persists_event() {
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
.insert_transfer(TransferUpsert {
transfer_id: 91,
peer_id: None,
direction: TransferDirection::Receive,
status: TransferStatus::Receiving,
transfer_name: Some("interrupted"),
content_hash: Some("hash"),
ticket: None,
file_count: 1,
total_size: 5,
access_mode: "approval_required",
})
.await
.unwrap();
});
drop(preparation_runtime);
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let transfer = core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 91)
.unwrap();
assert_eq!(transfer.status, "failed");
let events = core.list_events(Some(91)).unwrap();
assert!(events.iter().any(|event| {
event.phase == "recovery"
&& event.kind == "interrupted-transfer-failed"
&& event.data_json.contains("receiving")
}));
core.shutdown();
}
#[test]
fn startup_fails_persisted_share_when_root_blob_is_missing() {
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();
let missing_hash = Hash::new([42; 32]).to_string();
repository
.insert_transfer(TransferUpsert {
transfer_id: 92,
peer_id: None,
direction: TransferDirection::Send,
status: TransferStatus::Sharing,
transfer_name: Some("missing blob"),
content_hash: Some(&missing_hash),
ticket: Some("ticket"),
file_count: 1,
total_size: 5,
access_mode: "approval_required",
})
.await
.unwrap();
});
drop(preparation_runtime);
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let transfer = core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 92)
.unwrap();
assert_eq!(transfer.status, "failed");
assert_eq!(core.status().active_shares, 0);
assert!(core.list_events(Some(92)).unwrap().iter().any(|event| {
event.phase == "recovery" && event.kind == "share-root-missing-or-corrupt"
}));
core.shutdown();
}

View File

@@ -0,0 +1,22 @@
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::secret::load_or_create_secret;
#[tokio::test]
async fn persists_with_restricted_permissions() {
let temp = tempfile::tempdir().unwrap();
let first = load_or_create_secret(temp.path()).await.unwrap();
let second = load_or_create_secret(temp.path()).await.unwrap();
assert_eq!(first.to_bytes(), second.to_bytes());
#[cfg(unix)]
assert_eq!(
std::fs::metadata(temp.path().join("iroh.secret"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}

View File

@@ -0,0 +1,134 @@
use data_encoding::BASE64URL_NOPAD;
use iroh::SecretKey;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use serde_json::json;
use crate::{
api::{CoreLimits, TransferMetadata},
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket},
};
fn blob_ticket(hash_byte: u8) -> BlobTicket {
let secret = SecretKey::generate();
let addr = iroh::EndpointAddr::new(secret.public());
BlobTicket::new(addr, Hash::new([hash_byte; 32]), BlobFormat::HashSeq)
}
#[test]
fn metadata_ticket_round_trips() {
let blob_ticket = blob_ticket(7);
let metadata = TransferMetadata::new(
42,
"Summer photos",
Some("hammed".to_string()),
blob_ticket.hash(),
3,
2048,
);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
.encode()
.unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
assert_eq!(
parsed.metadata.unwrap().transfer_name,
metadata.transfer_name
);
}
#[test]
fn metadata_ticket_tolerates_wrapped_whitespace() {
let blob_ticket = blob_ticket(9);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
.encode()
.unwrap();
let wrapped = encoded
.as_bytes()
.chunks(8)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join("\n ");
let parsed = parse_transfer_ticket(&wrapped).unwrap();
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());
}
#[test]
fn rejects_unsupported_versions_and_mismatched_hashes() {
let blob_ticket = blob_ticket(5);
let payload = json!({
"version": 2,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad version",
"sender_name": null,
"created_at": 1,
"content_hash": blob_ticket.hash().to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("unsupported VniDrop ticket version"));
let payload = json!({
"version": 1,
"blob_ticket": blob_ticket.to_string(),
"metadata": {
"version": 1,
"transfer_id": 7,
"transfer_name": "bad hash",
"sender_name": null,
"created_at": 1,
"content_hash": Hash::new([6; 32]).to_string(),
"file_count": 1,
"total_size": 10
}
});
let encoded = format!(
"vnd1:{}",
BASE64URL_NOPAD.encode(payload.to_string().as_bytes())
);
assert!(parse_transfer_ticket(&encoded)
.unwrap_err()
.to_string()
.contains("metadata hash does not match"));
}
#[test]
fn rejects_ticket_over_configured_size_limit() {
let limits = CoreLimits {
max_ticket_bytes: 8,
..CoreLimits::default()
};
assert!(parse_transfer_ticket_with_limits("not-a-ticket", &limits).is_err());
}
#[test]
fn parser_rejects_or_parses_generated_inputs_without_panicking() {
let alphabet = b"vnd1:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ /\\\n";
let mut state = 0x9e37_79b9u32;
for len in 0..512usize {
let mut input = String::with_capacity(len);
for _ in 0..len {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
input.push(alphabet[state as usize % alphabet.len()] as char);
}
let _ = parse_transfer_ticket(&input);
}
}

View File

@@ -0,0 +1,31 @@
use crate::transfer_state::{TransferDirection, TransferStatus};
#[test]
fn parses_only_known_persisted_values() {
assert_eq!(
TransferDirection::try_from("send").unwrap(),
TransferDirection::Send
);
assert_eq!(
TransferStatus::try_from("receiving").unwrap(),
TransferStatus::Receiving
);
assert!(TransferDirection::try_from("sideways").is_err());
assert!(TransferStatus::try_from("pending-ish").is_err());
}
#[test]
fn permits_only_defined_lifecycle_transitions() {
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Sharing));
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Cancelled));
assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Stopped));
assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Done));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Failed));
assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Cancelled));
assert!(!TransferStatus::Sharing.can_transition_to(TransferStatus::Done));
assert!(!TransferStatus::Done.can_transition_to(TransferStatus::Receiving));
assert!(!TransferStatus::Failed.can_transition_to(TransferStatus::Sharing));
}

View File

@@ -5,7 +5,7 @@ use data_encoding::BASE64URL_NOPAD;
use iroh_blobs::ticket::BlobTicket;
use serde::{Deserialize, Serialize};
use crate::api::TransferMetadata;
use crate::api::{CoreLimits, TransferMetadata};
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
const VNIDROP_TICKET_VERSION: u8 = 1;
@@ -52,7 +52,22 @@ pub(crate) struct ParsedTransferTicket {
pub(crate) metadata: Option<TransferMetadata>,
}
#[cfg(test)]
pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket> {
parse_transfer_ticket_with_limits(value, &CoreLimits::default())
}
pub(crate) fn parse_transfer_ticket_with_limits(
value: &str,
limits: &CoreLimits,
) -> Result<ParsedTransferTicket> {
if value.len() as u64 > limits.max_ticket_bytes {
anyhow::bail!(
"ticket is {} bytes, limit is {}",
value.len(),
limits.max_ticket_bytes
);
}
let normalized = normalize_ticket_input(value);
if normalized.starts_with(VNIDROP_TICKET_PREFIX) {
let ticket = VnidropTicket::decode(&normalized)?;
@@ -71,6 +86,11 @@ pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket>
if ticket.metadata.transfer_name.trim().is_empty() {
anyhow::bail!("VniDrop ticket metadata is missing a transfer name");
}
limits.validate_metadata_text(
"transfer name",
Some(ticket.metadata.transfer_name.as_str()),
)?;
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?;
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
.context("invalid BlobTicket inside VniDrop ticket")?;
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {

View File

@@ -0,0 +1,117 @@
use anyhow::{bail, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TransferDirection {
Send,
Receive,
}
impl TransferDirection {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Send => "send",
Self::Receive => "receive",
}
}
}
impl TryFrom<&str> for TransferDirection {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self> {
match value {
"send" => Ok(Self::Send),
"receive" => Ok(Self::Receive),
_ => bail!("unknown transfer direction: {value}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TransferStatus {
Importing,
Sharing,
Receiving,
Done,
Failed,
Cancelled,
Stopped,
}
impl TransferStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Importing => "importing",
Self::Sharing => "sharing",
Self::Receiving => "receiving",
Self::Done => "done",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::Stopped => "stopped",
}
}
pub(crate) const fn can_transition_to(self, next: Self) -> bool {
matches!(
(self, next),
(
Self::Importing,
Self::Sharing | Self::Failed | Self::Cancelled
) | (Self::Sharing, Self::Stopped | Self::Failed)
| (Self::Receiving, Self::Done | Self::Failed | Self::Cancelled)
)
}
}
impl TryFrom<&str> for TransferStatus {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self> {
match value {
"importing" => Ok(Self::Importing),
"sharing" => Ok(Self::Sharing),
"receiving" => Ok(Self::Receiving),
"done" => Ok(Self::Done),
"failed" => Ok(Self::Failed),
"cancelled" => Ok(Self::Cancelled),
"stopped" => Ok(Self::Stopped),
_ => bail!("unknown transfer status: {value}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReceiverRequestStatus {
Requested,
Accepted,
Refused,
Expired,
Completed,
}
impl ReceiverRequestStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Requested => "requested",
Self::Accepted => "accepted",
Self::Refused => "refused",
Self::Expired => "expired",
Self::Completed => "completed",
}
}
}
impl TryFrom<&str> for ReceiverRequestStatus {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self> {
match value {
"requested" => Ok(Self::Requested),
"accepted" => Ok(Self::Accepted),
"refused" => Ok(Self::Refused),
"expired" => Ok(Self::Expired),
"completed" => Ok(Self::Completed),
_ => bail!("unknown receiver request status: {value}"),
}
}
}

View File

@@ -0,0 +1,44 @@
# Core test organization
VniDrop uses two complementary Rust test layers.
## Internal tests
Tests under `src/tests/` can exercise crate-private invariants without widening
the production API:
- `access_policy.rs`: authorization and approval-session rules.
- `filesystem.rs`: source collection and path validation.
- `handshake.rs`: malformed protocol response handling.
- `limits.rs`: core-limit validation.
- `repository.rs`: schema, persistence, and transition invariants.
- `runtime.rs`: public error mapping and runtime orchestration.
- `secret.rs`: node identity persistence and file permissions.
- `ticket.rs`: ticket encoding, parsing, and metadata validation.
- `transfer_state.rs`: persisted enum parsing and legal lifecycle transitions.
## Integration tests
Files directly under `tests/` are black-box scenarios. They must use the public
`vnidrop` API and should be organized by behavior rather than implementation
module:
- `approval.rs`: receiver authorization flows.
- `lifecycle.rs`: stop, restart, recovery, and revocation behavior.
- `output_sink.rs`: foreign output-sink contracts and failures.
- `transfer.rs`: end-to-end file and directory transfers.
Reusable fixtures live in `tests/support/`. `CoreGuard` shuts down a test core
on drop, while `RecordingSink` and `MemoryOutputSink` keep assertions focused
on externally observable behavior.
## Test requirements
- Every bug fix must include a regression test.
- Avoid arbitrary sleeps. When polling an asynchronous boundary is unavoidable,
use a short interval and a bounded timeout with a useful failure message.
- Prefer deterministic IDs, inputs, and clocks.
- Do not expose production internals solely for integration tests.
- Failure tests must verify durable status and emitted events when applicable,
not only the returned error.
- Recovery tests must close the original core and reopen the same data directory.

View File

@@ -0,0 +1,186 @@
mod support;
use std::sync::Arc;
use support::{
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
TestNode,
};
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
#[test]
fn public_share_receives_without_sender_approval() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("public.txt");
std::fs::write(&source_path, b"public content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = sender
.core
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("public.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 30,
transfer_name: Some("Public file".to_string()),
sender_name: Some("Sender".to_string()),
access_mode: TransferAccessMode::Public,
},
)
.unwrap();
receiver
.core
.receive(
share.ticket,
output_dir.path().to_string_lossy().into_owned(),
Some("Receiver".to_string()),
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("public.txt")).unwrap(),
b"public content"
);
let deliveries = sender
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
assert_eq!(deliveries.len(), 1);
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
assert_eq!(deliveries[0].status, "completed");
assert!(deliveries[0].completed_at.is_some());
}
#[test]
fn approval_required_denies_then_allows_receiver() {
let source_dir = tempfile::tempdir().unwrap();
let denied_output = tempfile::tempdir().unwrap();
let allowed_output = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("private.txt");
std::fs::write(&source_path, b"approved content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 9, "private.txt", false);
assert!(receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket.clone(),
denied_output.path(),
false,
)
.is_err());
assert!(sender
.sink
.events()
.iter()
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
allowed_output.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
b"approved content"
);
let completed = sender
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
assert!(completed
.iter()
.any(|request| request.status == "completed"));
}
#[test]
fn receiver_can_cancel_while_waiting_for_approval() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("waiting.txt");
std::fs::write(&source_path, b"waiting").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 26, "waiting.txt", false);
let receiver_core = receiver.core.arc();
let ticket = share.ticket;
let output = output_dir.path().to_string_lossy().to_string();
let worker = std::thread::spawn(move || {
receiver_core.receive(ticket, output, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
receiver.core.cancel_transfer(share.transfer_id).unwrap();
let _ = sender.core.respond_receiver_request(
request.id,
false,
Some("receiver-cancelled".to_string()),
);
assert!(worker.join().unwrap().is_err());
let transfer = receiver
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.transfer_id)
.unwrap();
assert_eq!(transfer.status, "cancelled");
}
#[test]
fn pending_approval_limit_denies_excess_receiver() {
let sender_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let output_one = tempfile::tempdir().unwrap();
let output_two = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("limited.txt");
std::fs::write(&source_path, b"limited").unwrap();
let limits = CoreLimits {
max_pending_approvals: 1,
..CoreLimits::default()
};
let sender = CoreGuard::start_with_limits(
sender_dir.path(),
Arc::new(RecordingSink::default()),
limits,
);
let receiver_one = TestNode::new();
let receiver_two = TestNode::new();
let share = share_path(&sender, &source_path, 28, "limited.txt", false);
let first_core = receiver_one.core.arc();
let first_ticket = share.ticket.clone();
let first_output = output_one.path().to_string_lossy().to_string();
let first = std::thread::spawn(move || {
first_core.receive(first_ticket, first_output, Some("first".to_string()))
});
let request = wait_for_receiver_request(&sender, share.transfer_id);
let second = receiver_two.core.receive(
share.ticket,
output_two.path().to_string_lossy().to_string(),
Some("second".to_string()),
);
assert!(second
.unwrap_err()
.to_string()
.contains("too-many-pending-approvals"));
sender
.respond_receiver_request(request.id, false, Some("test complete".to_string()))
.unwrap();
assert!(first.join().unwrap().is_err());
}

View File

@@ -0,0 +1,304 @@
mod support;
use std::sync::Arc;
use std::time::{Duration, Instant};
use support::{share_path, CoreGuard, RecordingSink, TestNode};
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
#[test]
fn share_creation_persists_selected_access_mode_atomically() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("public.txt");
std::fs::write(&source_path, b"public share").unwrap();
let sender = TestNode::new();
sender
.core
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("public.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 9,
transfer_name: Some("Public file".to_string()),
sender_name: Some("Sender".to_string()),
access_mode: TransferAccessMode::Public,
},
)
.unwrap();
let transfer = sender.core.list_transfers().unwrap().remove(0);
assert_eq!(transfer.access_mode, TransferAccessMode::Public);
}
#[test]
fn cancelling_share_updates_status_and_events() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("cancel.txt");
std::fs::write(&source_path, b"cancel me").unwrap();
let sender = TestNode::new();
let share = share_path(&sender.core, &source_path, 10, "cancel.txt", false);
sender.core.cancel_transfer(share.transfer_id).unwrap();
let transfers = sender.core.list_transfers().unwrap();
assert_eq!(transfers[0].status, "stopped");
assert!(sender
.sink
.events()
.iter()
.any(|event| event.kind == "share-stopped"));
}
#[test]
fn deleting_share_revokes_it_and_removes_persisted_history() {
let source_dir = tempfile::tempdir().unwrap();
let core_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("delete.txt");
std::fs::write(&source_path, b"delete me").unwrap();
let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
let share = share_path(&sender, &source_path, 101, "delete.txt", false);
sender.delete_transfer(share.transfer_id).unwrap();
assert!(sender.list_transfers().unwrap().is_empty());
assert_eq!(sender.status().active_shares, 0);
drop(sender);
let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
assert!(restarted.list_transfers().unwrap().is_empty());
assert_eq!(restarted.status().active_shares, 0);
}
#[test]
fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
let source_dir = tempfile::tempdir().unwrap();
let core_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("persistent.txt");
std::fs::write(&source_path, b"survives restart").unwrap();
let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
let share = share_path(&sender, &source_path, 20, "persistent.txt", false);
drop(sender);
let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
assert_eq!(restarted.status().active_shares, 1);
restarted.cancel_transfer(share.transfer_id).unwrap();
let transfer = restarted
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.transfer_id)
.unwrap();
assert_eq!(transfer.status, "stopped");
assert_eq!(restarted.status().active_shares, 0);
}
#[test]
fn stopped_share_rejects_direct_legacy_blob_ticket() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("revoked.txt");
std::fs::write(&source_path, b"must not be served").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 21, "revoked.txt", false);
sender.core.cancel_transfer(share.transfer_id).unwrap();
let result = receiver.core.receive(
share.blob_ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
);
assert!(result.is_err(), "a stopped share must not serve blob bytes");
assert!(!output_dir.path().join("revoked.txt").exists());
}
#[test]
fn failed_import_leaves_durable_failed_transfer() {
let source_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let transfer_id = 22;
let result = sender.core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_dir
.path()
.join("missing.txt")
.to_string_lossy()
.to_string(),
display_name: Some("missing.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("missing".to_string()),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
);
assert!(result.is_err());
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == transfer_id)
.unwrap();
assert_eq!(transfer.status, "failed");
assert_eq!(sender.core.status().active_shares, 0);
}
#[test]
fn duplicate_transfer_id_does_not_replace_active_share() {
let source_dir = tempfile::tempdir().unwrap();
let first_path = source_dir.path().join("first.txt");
let second_path = source_dir.path().join("second.txt");
std::fs::write(&first_path, b"first").unwrap();
std::fs::write(&second_path, b"second").unwrap();
let sender = TestNode::new();
let first = share_path(&sender.core, &first_path, 23, "first.txt", false);
let duplicate = sender.core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: second_path.to_string_lossy().to_string(),
display_name: Some("second.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: first.transfer_id,
transfer_name: Some("second".to_string()),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
);
assert!(duplicate.is_err());
assert_eq!(sender.core.status().active_shares, 1);
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == first.transfer_id)
.unwrap();
assert_eq!(transfer.status, "sharing");
assert_eq!(transfer.transfer_name.as_deref(), Some("first.txt"));
}
#[test]
fn access_mode_update_requires_active_persisted_share() {
let sender = TestNode::new();
assert!(sender
.core
.set_transfer_access_mode(999, TransferAccessMode::Public)
.is_err());
}
#[test]
fn source_limit_rejection_creates_no_transfer_state() {
let core_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let first = source_dir.path().join("one.txt");
let second = source_dir.path().join("two.txt");
std::fs::write(&first, b"one").unwrap();
std::fs::write(&second, b"two").unwrap();
let limits = CoreLimits {
max_sources: 1,
..CoreLimits::default()
};
let sender =
CoreGuard::start_with_limits(core_dir.path(), Arc::new(RecordingSink::default()), limits);
let result = sender.share_files(
vec![
ShareSource {
kind: SourceKind::Path,
value: first.to_string_lossy().to_string(),
display_name: Some("one.txt".to_string()),
is_directory: false,
},
ShareSource {
kind: SourceKind::Path,
value: second.to_string_lossy().to_string(),
display_name: Some("two.txt".to_string()),
is_directory: false,
},
],
ShareMetadataInput {
transfer_id: 24,
transfer_name: Some("too many".to_string()),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
);
assert!(result.is_err());
assert!(sender.list_transfers().unwrap().is_empty());
}
#[test]
fn cancellation_during_import_is_durable() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("large.bin");
std::fs::File::create(&source_path)
.unwrap()
.set_len(256 * 1024 * 1024)
.unwrap();
let sender = TestNode::new();
let core = sender.core.arc();
let worker = std::thread::spawn(move || {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("large.bin".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 25,
transfer_name: Some("large".to_string()),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
)
});
let started = Instant::now();
loop {
if sender
.core
.list_transfers()
.unwrap()
.iter()
.any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing")
{
break;
}
assert!(started.elapsed() < Duration::from_secs(10));
std::thread::sleep(Duration::from_millis(10));
}
sender.core.cancel_transfer(25).unwrap();
assert!(worker.join().unwrap().is_err());
let transfer = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 25)
.unwrap();
assert_eq!(transfer.status, "cancelled");
assert_eq!(sender.core.status().active_transfers, 0);
assert_eq!(sender.core.status().active_shares, 0);
}

View File

@@ -1,314 +0,0 @@
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind,
VnidropCore,
};
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if let Some(request) = requests
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(50));
}
}
fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: String,
receiver_name: Option<String>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive(ticket, output_dir, receiver_name)
.map_err(|error| error.to_string())
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
#[test]
fn two_local_cores_transfer_file() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello from vnidrop").unwrap();
let sender_sink = Arc::new(RecordingSink::default());
let receiver_sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sender_sink.clone(),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
receiver_sink.clone(),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 7,
transfer_name: Some("hello".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
b"hello from vnidrop"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn two_local_cores_transfer_directory() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_root = sender_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap();
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_root.to_string_lossy().to_string(),
display_name: Some("photos".to_string()),
is_directory: true,
}],
ShareMetadataInput {
transfer_id: 8,
transfer_name: Some("photos".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_dir.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(),
b"cover"
);
assert_eq!(
std::fs::read(
output_dir
.path()
.join("photos")
.join("nested")
.join("inside.txt")
)
.unwrap(),
b"inside"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn approval_required_denies_then_allows_receiver() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let denied_output = tempfile::tempdir().unwrap();
let allowed_output = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("private.txt");
std::fs::write(&source_path, b"approved content").unwrap();
let sender_sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sender_sink.clone(),
)
.unwrap();
let receiver = VnidropCore::initialize(
receiver_dir
.path()
.join("core")
.to_string_lossy()
.to_string(),
Arc::new(RecordingSink::default()),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("private.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 9,
transfer_name: Some("private".to_string()),
sender_name: None,
},
)
.unwrap();
assert!(receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket.clone(),
denied_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
false,
)
.is_err());
assert!(sender_sink
.events()
.iter()
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
receive_with_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
allowed_output.path().to_string_lossy().to_string(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
b"approved content"
);
sender.shutdown();
receiver.shutdown();
}
#[test]
fn cancelling_share_updates_status_and_events() {
let sender_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("cancel.txt");
std::fs::write(&source_path, b"cancel me").unwrap();
let sink = Arc::new(RecordingSink::default());
let sender = VnidropCore::initialize(
sender_dir.path().join("core").to_string_lossy().to_string(),
sink.clone(),
)
.unwrap();
let share = sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().to_string(),
display_name: Some("cancel.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 10,
transfer_name: Some("cancel".to_string()),
sender_name: None,
},
)
.unwrap();
sender.cancel_transfer(share.transfer_id).unwrap();
let transfers = sender.list_transfers().unwrap();
assert_eq!(transfers[0].status, "stopped");
assert!(sink
.events()
.iter()
.any(|event| event.kind == "share-stopped"));
sender.shutdown();
}

View File

@@ -0,0 +1,104 @@
mod support;
use std::sync::Arc;
use std::time::{Duration, Instant};
use support::{
receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode,
};
#[test]
fn exports_nested_files_to_output_sink() {
let source_dir = tempfile::tempdir().unwrap();
let source_root = source_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_root, 18, "photos", true);
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap();
assert_eq!(output_sink.file("photos/cover.txt"), b"cover");
assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside");
assert_eq!(
output_sink.terminal_state("photos/cover.txt"),
Some("finished")
);
assert_eq!(
output_sink.terminal_state("photos/nested/inside.txt"),
Some("finished")
);
}
#[test]
fn reports_output_sink_write_failure() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 19, "hello.txt", false);
let output_sink = Arc::new(MemoryOutputSink::failing_writes());
let error = receive_with_sink_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink.clone(),
true,
)
.unwrap_err();
assert!(error.contains("sink write failed"));
assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted"));
}
#[test]
fn cancellation_during_export_aborts_open_sink_file() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("slow.bin");
std::fs::File::create(&source_path)
.unwrap()
.set_len(32 * 1024 * 1024)
.unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 29, "slow.bin", false);
let output_sink = Arc::new(MemoryOutputSink::slow_writes(Duration::from_millis(25)));
let receiver_core = receiver.core.arc();
let sink_for_worker = output_sink.clone();
let worker = std::thread::spawn(move || {
receiver_core.receive_with_output_sink(
share.ticket,
sink_for_worker,
Some("receiver".to_string()),
)
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
let started = Instant::now();
while !output_sink.has_started("slow.bin") {
assert!(started.elapsed() < Duration::from_secs(15));
std::thread::sleep(Duration::from_millis(10));
}
receiver.core.cancel_transfer(share.transfer_id).unwrap();
assert!(worker.join().unwrap().is_err());
assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted"));
}

View File

@@ -0,0 +1,263 @@
// Cargo compiles every file in `tests/` as a separate crate, and each scenario
// intentionally uses only a subset of this shared harness.
#![allow(dead_code)]
use std::{
collections::HashMap,
ops::Deref,
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput,
ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
};
#[derive(Default)]
pub struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
pub fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
pub struct CoreGuard(Arc<VnidropCore>);
impl CoreGuard {
pub fn start(path: &Path, sink: Arc<dyn CoreEventSink>) -> Self {
Self(
VnidropCore::initialize(path.to_string_lossy().to_string(), sink)
.expect("test core should initialize"),
)
}
pub fn start_with_limits(
path: &Path,
sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
) -> Self {
Self(
VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits)
.expect("test core should initialize with limits"),
)
}
pub fn arc(&self) -> Arc<VnidropCore> {
self.0.clone()
}
}
impl Deref for CoreGuard {
type Target = VnidropCore;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for CoreGuard {
fn drop(&mut self) {
self.0.shutdown();
}
}
pub struct TestNode {
_data_dir: tempfile::TempDir,
pub core: CoreGuard,
pub sink: Arc<RecordingSink>,
}
impl TestNode {
pub fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink::default());
let core = CoreGuard::start(data_dir.path(), sink.clone());
Self {
_data_dir: data_dir,
core,
sink,
}
}
}
#[derive(Default)]
pub struct MemoryOutputSink {
files: Mutex<HashMap<String, Vec<u8>>>,
terminal: Mutex<HashMap<String, &'static str>>,
fail_writes: bool,
write_delay: Duration,
}
impl MemoryOutputSink {
pub fn failing_writes() -> Self {
Self {
files: Mutex::new(HashMap::new()),
terminal: Mutex::new(HashMap::new()),
fail_writes: true,
write_delay: Duration::ZERO,
}
}
pub fn slow_writes(delay: Duration) -> Self {
Self {
files: Mutex::new(HashMap::new()),
terminal: Mutex::new(HashMap::new()),
fail_writes: false,
write_delay: delay,
}
}
pub fn file(&self, relative_path: &str) -> Vec<u8> {
self.files.lock().unwrap()[relative_path].clone()
}
pub fn terminal_state(&self, relative_path: &str) -> Option<&'static str> {
self.terminal.lock().unwrap().get(relative_path).copied()
}
pub fn has_started(&self, relative_path: &str) -> bool {
self.files.lock().unwrap().contains_key(relative_path)
}
}
impl ReceiveOutputSink for MemoryOutputSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().insert(relative_path, Vec::new());
Ok(())
}
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
if !self.write_delay.is_zero() {
std::thread::sleep(self.write_delay);
}
if self.fail_writes {
return Err(VnidropError::Filesystem {
reason: "sink write failed".to_string(),
});
}
self.files
.lock()
.unwrap()
.get_mut(&relative_path)
.expect("file was not started")
.extend(bytes);
Ok(())
}
fn finish_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.terminal
.lock()
.unwrap()
.insert(relative_path, "finished");
Ok(())
}
fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().remove(&relative_path);
self.terminal
.lock()
.unwrap()
.insert(relative_path, "aborted");
Ok(())
}
}
pub fn share_path(
sender: &VnidropCore,
source: &Path,
transfer_id: u64,
display_name: &str,
is_directory: bool,
) -> ShareResult {
sender
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().to_string(),
display_name: Some(display_name.to_string()),
is_directory,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some(display_name.to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.expect("test share should be created")
}
pub fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if let Some(request) = requests
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
pub fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: &Path,
accepted: bool,
) -> Result<(), String> {
let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || {
receiver
.receive(ticket, output_dir, Some("receiver".to_string()))
.map_err(|error| error.to_string())
});
respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap()
}
pub fn receive_with_sink_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
.map_err(|error| error.to_string())
});
respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap()
}
fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) {
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
}

View File

@@ -0,0 +1,104 @@
mod support;
use support::{receive_with_response, share_path, TestNode};
#[test]
fn transfers_file_between_two_cores() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello from vnidrop").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 7, "hello.txt", false);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
b"hello from vnidrop"
);
let received = receiver
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 7)
.unwrap();
assert!(!received.local_id.is_empty());
assert_eq!(
received.peer_id.as_deref(),
Some(sender.core.status().endpoint_id.as_str())
);
}
#[test]
fn transfers_directory_between_two_cores() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_root = source_dir.path().join("photos");
std::fs::create_dir_all(source_root.join("nested")).unwrap();
std::fs::write(source_root.join("cover.txt"), b"cover").unwrap();
std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_root, 8, "photos", true);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("photos/cover.txt")).unwrap(),
b"cover"
);
assert_eq!(
std::fs::read(output_dir.path().join("photos/nested/inside.txt")).unwrap(),
b"inside"
);
}
#[test]
fn receive_refuses_to_overwrite_existing_destination() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("existing.txt");
let output_path = output_dir.path().join("existing.txt");
std::fs::write(&source_path, b"new content").unwrap();
std::fs::write(&output_path, b"keep content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 27, "existing.txt", false);
assert!(receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.is_err());
assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content");
assert!(std::fs::read_dir(output_dir.path())
.unwrap()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.contains(".part")));
}

View File

@@ -14,6 +14,7 @@ dependencies {
implementation(libs.jna)
implementation(libs.compose.uiToolingPreview)
testImplementation(libs.kotlin.testJunit)
}
compose.desktop {

View File

@@ -1,9 +1,12 @@
package com.vnidrop.app
import com.sun.jna.Callback
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.NativeLibrary
import com.sun.jna.Pointer
import com.sun.jna.Structure
import java.io.File
internal object MacOsAppKitAppearance {
private val objc: ObjCRuntime? by lazy {
@@ -42,10 +45,112 @@ internal object MacOsAppKitAppearance {
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
}
internal object MacOsShareSheet {
private val objc: ObjCRuntime? by lazy {
runCatching {
NativeLibrary.getInstance("AppKit")
Native.load("objc", ObjCRuntime::class.java)
}.getOrNull()
}
private var retainedPicker: Pointer? = null
private val systemLibrary: NativeLibrary? by lazy {
runCatching { NativeLibrary.getInstance("System") }.getOrNull()
}
private val dispatch: DispatchRuntime? by lazy {
runCatching { Native.load("System", DispatchRuntime::class.java) }.getOrNull()
}
fun share(file: File): Result<Unit> = runCatching {
require(file.isFile) { "The invitation file could not be created" }
var failure: Throwable? = null
val runtime = dispatch ?: error("The macOS main queue is unavailable")
// dispatch_get_main_queue() is a C macro on Darwin, so there is no
// function for dlsym/JNA to resolve. The macro returns this exported
// queue object directly.
val queue = systemLibrary?.getGlobalVariableAddress("_dispatch_main_q")
?: error("The macOS main queue is unavailable")
runtime.dispatch_sync_f(queue, null, DispatchWork { failure = runCatching { show(file) }.exceptionOrNull() })
failure?.let { throw it }
}
private fun show(file: File) {
val runtime = objc ?: error("AppKit is unavailable")
val applicationClass = runtime.objc_getClass("NSApplication") ?: error("NSApplication is unavailable")
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication"))
?: error("NSApplication could not be opened")
val window = runtime.objc_msgSend(application, runtime.sel_registerName("keyWindow"))
?: runtime.objc_msgSend(application, runtime.sel_registerName("mainWindow"))
?: error("No active macOS window")
val contentView = runtime.objc_msgSend(window, runtime.sel_registerName("contentView"))
?: error("The active window has no content view")
val path = nsString(runtime, file.absolutePath) ?: error("The invitation path is invalid")
val urlClass = runtime.objc_getClass("NSURL") ?: error("NSURL is unavailable")
val url = runtime.objc_msgSend(urlClass, runtime.sel_registerName("fileURLWithPath:"), path)
?: error("The invitation URL could not be created")
val arrayClass = runtime.objc_getClass("NSArray") ?: error("NSArray is unavailable")
val items = runtime.objc_msgSend(arrayClass, runtime.sel_registerName("arrayWithObject:"), url)
?: error("The share item could not be created")
val pickerClass = runtime.objc_getClass("NSSharingServicePicker") ?: error("The macOS share sheet is unavailable")
val allocated = runtime.objc_msgSend(pickerClass, runtime.sel_registerName("alloc"))
?: error("The macOS share sheet could not be allocated")
val picker = runtime.objc_msgSend(allocated, runtime.sel_registerName("initWithItems:"), items)
?: error("The macOS share sheet could not be created")
retainedPicker?.let { runtime.objc_msgSend(it, runtime.sel_registerName("release")) }
retainedPicker = picker
runtime.objc_msgSend(
picker,
runtime.sel_registerName("showRelativeToRect:ofView:preferredEdge:"),
anchorRect(),
contentView,
3L,
)
}
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
val stringClass = runtime.objc_getClass("NSString") ?: return null
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
}
internal fun validateNativeRectMapping(): Int = anchorRect().size()
internal fun hasNativeMainQueue(): Boolean =
runCatching { systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") != null }.getOrDefault(false)
private fun anchorRect() = NSRectByValue().apply {
x = 0.0
y = 0.0
width = 1.0
height = 1.0
write()
}
}
@Structure.FieldOrder("x", "y", "width", "height")
internal class NSRectByValue : Structure(), Structure.ByValue {
@JvmField var x: Double = 0.0
@JvmField var y: Double = 0.0
@JvmField var width: Double = 0.0
@JvmField var height: Double = 0.0
}
private interface ObjCRuntime : Library {
fun objc_getClass(name: String): Pointer?
fun sel_registerName(name: String): Pointer
fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer?
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer?
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer?
fun objc_msgSend(
receiver: Pointer?,
selector: Pointer?,
rect: NSRectByValue,
view: Pointer?,
edge: Long,
): Pointer?
}
private fun interface DispatchWork : Callback {
fun invoke(context: Pointer?)
}
private interface DispatchRuntime : Library {
fun dispatch_sync_f(queue: Pointer?, context: Pointer?, work: DispatchWork)
}

View File

@@ -3,16 +3,20 @@ package com.vnidrop.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge
fun main() {
configureMacOsNativeAppearance()
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
DesktopShareBridge.shareFile = MacOsShareSheet::share
}
application {
Window(
onCloseRequest = ::exitApplication,
title = "vnidrop",
) {
App()
App(rememberJvmAppDependencies())
}
}
}

View File

@@ -0,0 +1,17 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MacOsShareSheetTest {
@Test
fun nativeAnchorRectHasTheCocoaLayout() {
assertEquals(32, MacOsShareSheet.validateNativeRectMapping())
}
@Test
fun nativeMainDispatchQueueCanBeResolved() {
assertTrue(MacOsShareSheet.hasNativeMainQueue())
}
}

View File

@@ -5,9 +5,10 @@ android-minSdk = "24"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.19.0"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0-beta01"
androidx-datastore = "1.2.1"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.11.1"
gobley = "0.3.7"
@@ -15,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0"
material3 = "1.11.0-alpha07"
qrcode = "4.5.0"
jna = "5.17.0"
[libraries]
@@ -29,14 +31,19 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
[plugins]

View File

@@ -2,6 +2,7 @@
import gobley.gradle.cargo.dsl.appleMobile
import gobley.gradle.rust.targets.RustAndroidTarget
import org.gradle.api.tasks.PathSensitivity
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -36,6 +37,7 @@ kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {
@@ -47,10 +49,18 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutinesCore)
implementation(libs.qrcode.kotlin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutinesTest)
}
jvmTest.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.compose.uiTest)
}
}
}
@@ -95,6 +105,18 @@ uniffi {
}
tasks.configureEach {
// Gobley does not currently treat every Rust source/API change as an input of
// all platform cargo tasks. Without these inputs an incremental Android build
// can package an older .so next to freshly generated UniFFI Kotlin bindings.
if (name.startsWith("cargoBuild")) {
inputs.files(
fileTree(layout.projectDirectory.dir("../crates/vnidrop")) {
include("Cargo.toml", "build.rs", "src/**/*.rs")
},
layout.projectDirectory.file("../Cargo.toml"),
layout.projectDirectory.file("../Cargo.lock"),
).withPathSensitivity(PathSensitivity.RELATIVE)
}
if (name.contains("Linux") || name.contains("MinGW") || name.contains("MacOSX64")) {
enabled = false
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View File

@@ -5,60 +5,71 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import java.net.NetworkInterface
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
override val defaultCoreDataDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
override val defaultReceiveDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive"
override val deviceInfo: DeviceInfo = DeviceInfo(
@Composable
fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies {
val context = activity.applicationContext
val fileSystemService = rememberFileSystemService()
val notificationService = rememberAndroidLocalNotificationService(activity)
return remember(context, fileSystemService, notificationService) {
AppDependencies(
environment = PlatformEnvironment(
name = "Android ${Build.VERSION.SDK_INT}",
appVersion = context.appVersion(),
defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath,
defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver",
),
deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService,
localNotificationService = notificationService,
)
}
}
private class AndroidDeviceInfoProvider(
private val context: Context,
) : DeviceInfoProvider {
override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = Build.DEVICE,
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
.filter { it.isNotBlank() }
.filter(String::isNotBlank)
.joinToString(" ")
.ifBlank { null },
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
network = activeNetworkSummary(),
batteryLevel = batteryLevel(),
network = context.activeNetworkSummary(),
batteryLevel = context.batteryLevel(),
)
}
actual fun getPlatform(): Platform = AndroidPlatform()
private fun Context.appVersion(): String = runCatching {
packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0"
fun attachAndroidPlatformContext(context: Context) {
AndroidPlatformContextHolder.context = context.applicationContext
}
private fun Context.activeNetworkSummary(): String? = runCatching {
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return@runCatching networkInterfaceName()
val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
else -> networkInterfaceName()
}
}.getOrNull()
private object AndroidPlatformContextHolder {
var context: Context? = null
}
private fun activeNetworkSummary(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching networkInterfaceName()
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return@runCatching networkInterfaceName()
val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
else -> networkInterfaceName()
}
}.getOrNull()
private fun batteryLevel(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching null
val manager = context.getSystemService(BatteryManager::class.java)
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level.takeIf { it >= 0 }?.let { "$it%" }
}.getOrNull()
private fun Context.batteryLevel(): String? = runCatching {
val manager = getSystemService(BatteryManager::class.java)
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level.takeIf { it >= 0 }?.let { "$it%" }
}.getOrNull()
private fun networkInterfaceName(): String? =
NetworkInterface.getNetworkInterfaces()

View File

@@ -1,7 +1,12 @@
package com.vnidrop.app.core
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Point
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -17,7 +22,7 @@ actual fun rememberShareFilePicker(
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri)))
onFilePicked(context.pickedShareFile(uri))
}
}
return remember(launcher) {
@@ -29,44 +34,65 @@ actual fun rememberShareFilePicker(
}
}
actual suspend fun sharePickedFile(
repository: CoreRepository,
file: PickedShareFile,
transferName: String,
senderName: String,
) {
val context = AndroidContextHolder.context
errorIfNull(context, "Android context has not been attached")
.contentResolver
.openFileDescriptor(Uri.parse(file.value), "r")
.use { descriptor ->
checkNotNull(descriptor) { "Could not open selected file descriptor" }
repository.shareFileDescriptor(
fd = descriptor.fd,
displayName = file.displayName,
transferName = transferName,
senderName = senderName,
@Composable
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.AndroidTreeUri,
value = uri.toString(),
displayName = uri.lastPathSegment ?: "Downloads",
),
)
}
}
private object AndroidContextHolder {
var context: Context? = null
}
fun attachAndroidFilePickerContext(context: Context) {
AndroidContextHolder.context = context.applicationContext
}
private fun Context.displayName(uri: Uri): String {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
return cursor.getString(nameIndex)
}
return remember(launcher) {
object : ReceiveFolderPicker {
override fun pickFolder() {
launcher.launch(null)
}
}
}
return uri.lastPathSegment ?: "transfer"
}
private fun <T : Any> errorIfNull(value: T?, message: String): T =
value ?: error(message)
private fun Context.pickedShareFile(uri: Uri): PickedShareFile {
var displayName: String? = null
var sizeBytes: ULong? = null
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
sizeBytes = cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
}
}
}
return PickedShareFile(
value = uri.toString(),
displayName = displayName ?: uri.lastPathSegment ?: "transfer",
sizeBytes = sizeBytes,
thumbnailBytes = runCatching {
val bitmap = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentResolver.loadThumbnail(uri, android.util.Size(192, 192), null)
} else {
DocumentsContract.getDocumentThumbnail(contentResolver, uri, Point(192, 192), null)
}) ?: error("The document provider did not return a thumbnail")
java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray()
}
}.getOrNull(),
)
}

View File

@@ -0,0 +1,187 @@
package com.vnidrop.app.core
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import androidx.core.net.toUri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import uniffi.vnidrop.ReceiveOutputSink
import java.io.OutputStream
import java.util.UUID
@Composable
actual fun rememberFileSystemService(): FileSystemService {
val context = LocalContext.current.applicationContext
return remember(context) { AndroidFileSystemService(context) }
}
private class AndroidFileSystemService(
private val context: Context,
) : FileSystemService {
override fun defaultReceiveFolder(): ReceiveFolder {
val path = context
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.absolutePath
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
return ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = path,
displayName = "Downloads",
)
}
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? {
if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null
return AndroidTreeReceiveOutputSink(context, folder.value.toUri())
}
override suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = runCatching {
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor ->
checkNotNull(descriptor) { "Could not open selected file descriptor" }
repository.shareFileDescriptor(
fd = descriptor.fd,
displayName = file.displayName,
transferName = transferName,
senderName = senderName,
accessPolicy = accessPolicy,
).getOrThrow()
}
}
private fun validatePath(path: String): FolderAccessStatus =
runCatching {
val directory = java.io.File(path)
if (!directory.exists()) directory.mkdirs()
if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validateTreeUri(value: String): FolderAccessStatus {
val uri = Uri.parse(value)
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission ->
permission.uri == uri && permission.isWritePermission
}
if (!hasPermission) return FolderAccessStatus.PermissionRequired
return runCatching {
val probe = AndroidTreeReceiveOutputSink(context, uri)
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
probe.startFile(probeName)
probe.writeChunk(probeName, byteArrayOf())
probe.abortFile(probeName, "write probe complete")
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
}
}
private class AndroidTreeReceiveOutputSink(
private val context: Context,
private val treeUri: Uri,
) : ReceiveOutputSink {
private data class PendingDocument(
val stream: OutputStream,
val temporaryUri: Uri,
val parentUri: Uri,
val finalName: String,
)
private val pending = mutableMapOf<String, PendingDocument>()
override fun startFile(relativePath: String) {
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
val (parent, finalName) = resolveParent(relativePath)
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" }
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
val temporaryUri = DocumentsContract.createDocument(
context.contentResolver,
parent,
"application/octet-stream",
temporaryName,
) ?: error("Could not create temporary file for $relativePath")
val stream = context.contentResolver.openOutputStream(temporaryUri, "w")
?: error("Could not open output stream for $relativePath")
pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName)
}
override fun writeChunk(relativePath: String, bytes: ByteArray) {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes)
}
override fun finishFile(relativePath: String) {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try {
document.stream.close()
check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" }
checkNotNull(
DocumentsContract.renameDocument(
context.contentResolver,
document.temporaryUri,
document.finalName,
),
) { "Could not commit received file $relativePath" }
} catch (error: Throwable) {
runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
throw error
}
}
override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return
runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
}
private fun resolveParent(relativePath: String): Pair<Uri, String> {
val parts = relativePath.split('/').filter { it.isNotBlank() }
require(parts.isNotEmpty()) { "relative path must not be empty" }
var parent = DocumentsContract.buildDocumentUriUsingTree(
treeUri,
DocumentsContract.getTreeDocumentId(treeUri),
)
parts.dropLast(1).forEach { name ->
parent = findChild(parent, name)
?: DocumentsContract.createDocument(context.contentResolver, parent, DocumentsContract.Document.MIME_TYPE_DIR, name)
?: error("Could not create directory $name")
}
return parent to parts.last()
}
private fun findChild(parent: Uri, name: String): Uri? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri,
DocumentsContract.getDocumentId(parent),
)
context.contentResolver.query(
childrenUri,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
while (cursor.moveToNext()) {
if (cursor.getString(nameIndex) == name) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex))
}
}
}
return null
}
}

View File

@@ -0,0 +1,22 @@
package com.vnidrop.app.feature.send
import java.io.File
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmLikePreviewStore(File(appDataDir, "ui/previews"))
private class JvmLikePreviewStore(private val directory: File) : PlatformPreviewStore {
override fun list(): List<PreviewFileInfo> = directory.listFiles().orEmpty().mapNotNull { file ->
file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) }
}
override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull()
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching {
directory.mkdirs()
val target = file(transferId)
if (target.isFile) return@runCatching true
val temporary = File(directory, ".${target.name}.tmp")
temporary.writeBytes(bytes)
temporary.renameTo(target).also { if (!it) temporary.delete() }
}.getOrDefault(false)
override fun delete(transferId: ULong) { file(transferId).delete() }
private fun file(transferId: ULong) = File(directory, "$transferId.preview")
}

View File

@@ -0,0 +1,105 @@
package com.vnidrop.app.feature.send
import android.content.ClipData
import android.content.Intent
import android.nfc.NdefMessage
import android.nfc.NdefRecord
import android.nfc.NfcAdapter
import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import java.io.File
@Composable
actual fun rememberTransferShareActions(): TransferShareActions {
val context = LocalContext.current
val activity = context as? ComponentActivity
val nfcEnabled = activity?.let { NfcAdapter.getDefaultAdapter(it)?.isEnabled == true } == true
var pendingExport by remember { mutableStateOf<PendingExport?>(null) }
val exporter = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(InvitationMimeType),
) { uri ->
val pending = pendingExport
pendingExport = null
if (pending != null && uri != null) {
pending.callback(runCatching {
context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.ticket.encodeToByteArray()) }
?: error("The selected destination could not be opened")
})
}
}
return remember(activity, exporter, nfcEnabled) {
object : TransferShareActions {
override val canUseNativeShare = activity != null
override val nfcAvailability = when {
activity == null -> NfcShareAvailability.Unavailable
nfcEnabled -> NfcShareAvailability.Available
else -> NfcShareAvailability.Unavailable
}
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
pendingExport = PendingExport(ticket, onResult)
exporter.launch(invitationFileName(transferName))
}
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
onResult(runCatching {
val directory = File(context.cacheDir, "transfer-invitations").apply { mkdirs() }
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = InvitationMimeType
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(file.name, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
})
}
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
val adapter = NfcAdapter.getDefaultAdapter(host)
if (adapter?.isEnabled != true) return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
adapter.enableReaderMode(host, { tag ->
val result = runCatching {
val message = NdefMessage(arrayOf(NdefRecord.createMime(InvitationMimeType, ticket.encodeToByteArray())))
val ndef = Ndef.get(tag)
if (ndef != null) {
ndef.connect()
try {
require(ndef.isWritable) { "This NFC tag is read-only" }
require(ndef.maxSize >= message.toByteArray().size) { "This NFC tag is too small" }
ndef.writeNdefMessage(message)
} finally { ndef.close() }
} else {
val formatable = NdefFormatable.get(tag) ?: error("This NFC tag cannot store an invitation")
formatable.connect()
try { formatable.format(message) } finally { formatable.close() }
}
}
host.runOnUiThread {
adapter.disableReaderMode(host)
onResult(result)
}
}, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null)
}
override fun cancelNfcWrite() {
activity?.let { host -> NfcAdapter.getDefaultAdapter(host)?.disableReaderMode(host) }
}
}
}
}
private data class PendingExport(val ticket: String, val callback: (Result<Unit>) -> Unit)
private const val InvitationMimeType = "application/vnd.vnidrop.transfer"

View File

@@ -0,0 +1,168 @@
package com.vnidrop.app.notifications
import android.annotation.SuppressLint
import android.Manifest
import android.app.NotificationChannel
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@Composable
fun rememberAndroidLocalNotificationService(activity: ComponentActivity): LocalNotificationService {
val holder = viewModel { AndroidNotificationServiceHolder(activity.applicationContext) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
holder.service.completePermissionRequest(granted)
}
SideEffect {
holder.service.attachPermissionLauncher {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
return holder.service
}
private class AndroidNotificationServiceHolder(context: Context) : ViewModel() {
val service = AndroidLocalNotificationService(context)
}
private class AndroidLocalNotificationService(
private val context: Context,
) : LocalNotificationService {
private val _permission = MutableStateFlow(currentPermission())
override val permission: StateFlow<NotificationPermission> = _permission.asStateFlow()
private var permissionContinuation: CancellableContinuation<NotificationPermission>? = null
private var launchPermissionRequest: (() -> Unit)? = null
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH),
)
}
}
override suspend fun refreshPermission(): NotificationPermission = currentPermission().also { _permission.value = it }
override suspend fun requestPermission(): NotificationPermission {
val current = refreshPermission()
if (current != NotificationPermission.NotDetermined) return current
return suspendCancellableCoroutine { continuation ->
permissionContinuation?.cancel()
permissionContinuation = continuation
continuation.invokeOnCancellation { permissionContinuation = null }
val launcher = launchPermissionRequest
if (launcher == null) {
permissionContinuation = null
continuation.resume(NotificationPermission.Denied)
} else {
markPermissionRequested()
launcher()
}
}
}
override suspend fun openSettings(): Result<Unit> = runCatching {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
}
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@SuppressLint("MissingPermission")
override suspend fun publish(notification: LocalNotification): Result<Unit> = runCatching {
check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pendingIntent = launchIntent?.let {
PendingIntent.getActivity(
context,
notification.id.hashCode(),
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, ChannelId)
} else {
@Suppress("DEPRECATION")
Notification.Builder(context)
}
val built = builder
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setStyle(Notification.BigTextStyle().bigText(notification.body))
.setAutoCancel(true)
.setPriority(Notification.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.build()
context.getSystemService(NotificationManager::class.java).notify(notification.id.hashCode(), built)
}
override suspend fun cancel(id: String) {
context.getSystemService(NotificationManager::class.java).cancel(id.hashCode())
}
override suspend fun cancelAll() {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun completePermissionRequest(granted: Boolean) {
markPermissionRequested()
val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
_permission.value = result
permissionContinuation?.takeIf { it.isActive }?.resume(result)
permissionContinuation = null
}
fun attachPermissionLauncher(launcher: () -> Unit) {
launchPermissionRequest = launcher
}
private fun currentPermission(): NotificationPermission {
val manager = context.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return if (manager.areNotificationsEnabled()) NotificationPermission.Granted else NotificationPermission.Denied
}
val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return when {
granted && manager.areNotificationsEnabled() -> NotificationPermission.Granted
wasPermissionRequested() -> NotificationPermission.Denied
else -> NotificationPermission.NotDetermined
}
}
private fun markPermissionRequested() {
context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).edit().putBoolean(PermissionRequestedKey, true).apply()
}
private fun wasPermissionRequested(): Boolean =
context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE).getBoolean(PermissionRequestedKey, false)
private companion object {
const val ChannelId = "vnidrop-connection-requests"
const val PreferencesName = "vnidrop-notifications"
const val PermissionRequestedKey = "permission-requested"
}
}

View File

@@ -3,7 +3,65 @@
<string name="nav_receive">Receive</string>
<string name="nav_settings">Settings</string>
<string name="send_title">Send</string>
<string name="send_subtitle">Create a VniDrop ticket and approve receivers when required.</string>
<string name="send_subtitle">Files you have made available from this device.</string>
<string name="send_empty_title">Share your first file</string>
<string name="send_empty_body">Choose a file, decide who can receive it, then share it directly from this device.</string>
<string name="button_create_new_transfer">New transfer</string>
<string name="send_transfers_title">Your transfers</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_body">Select one file from this device. You can review its details before creating the transfer.</string>
<string name="send_review_title">Review transfer</string>
<string name="send_access_title">Who can receive it?</string>
<string name="send_access_approval">Ask before each download</string>
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
<string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string>
<string name="transfer_activity_title">Activity</string>
<string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_receivers_title">Receivers</string>
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
<string name="transfer_share_title">Share</string>
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
<string name="transfer_delete_title">Delete transfer?</string>
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
<string name="transfer_deleting">Deleting…</string>
<string name="transfer_deleted">Transfer deleted.</string>
<string name="transfer_no_activity">There is no activity to show yet.</string>
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_requested">Waiting for your approval</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_unknown">Status unavailable</string>
<string name="transfer_nearby_device">Nearby device</string>
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
<string name="button_write_nfc">Write to NFC tag</string>
<string name="button_download_invitation">Save .vnd file</string>
<string name="button_native_share">Share invitation</string>
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
<string name="transfer_invitation_saved">Invitation saved.</string>
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
<string name="transfer_event_preparing">Preparing the selected files</string>
<string name="transfer_event_ready">Transfer ready to share</string>
<string name="transfer_event_requested">A receiver requested access</string>
<string name="transfer_event_approved">Receiver access approved</string>
<string name="transfer_event_refused">Receiver access refused</string>
<string name="transfer_event_completed">A receiver completed the transfer</string>
<string name="transfer_event_stopped">Sharing stopped</string>
<string name="transfer_event_failed">The transfer encountered a problem</string>
<string name="transfer_event_updated">Transfer updated</string>
<string name="button_choose_file">Choose file</string>
<string name="button_change_file">Change file</string>
<string name="button_share_file">Share file</string>
<string name="button_sharing_file">Preparing transfer…</string>
<string name="button_copy_ticket">Copy transfer link</string>
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="source_title">Source</string>
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
<string name="button_select_file">Select file</string>
@@ -11,9 +69,9 @@
<string name="transfer_details_title">Transfer details</string>
<string name="field_transfer_name">Transfer name</string>
<string name="field_sender_name">Sender name</string>
<string name="button_create_share_ticket">Create share ticket</string>
<string name="button_creating_ticket">Creating ticket...</string>
<string name="share_ticket_title">Share ticket</string>
<string name="button_create_share">Create share</string>
<string name="button_creating_share">Creating share...</string>
<string name="share_details_title">Share details</string>
<string name="receiver_requests_title">Receiver requests</string>
<string name="button_copy">Copy</string>
<string name="button_use_locally">Use locally</string>
@@ -35,6 +93,19 @@
<string name="settings_subtitle">Configure the local node and app appearance.</string>
<string name="node_title">Node</string>
<string name="appearance_title">Appearance</string>
<string name="preferences_title">Preferences</string>
<string name="field_username">Username</string>
<string name="preferences_receive_folder_title">Receive folder</string>
<string name="button_choose_folder">Choose folder</string>
<string name="button_reset_default">Reset default</string>
<string name="button_back">Back</string>
<string name="button_close">Close</string>
<string name="button_cancel">Cancel</string>
<string name="button_delete_transfer">Delete transfer</string>
<string name="folder_status_writable">Writable</string>
<string name="folder_status_permission_required">Permission required</string>
<string name="folder_status_unavailable">Unavailable</string>
<string name="folder_status_validating">Checking folder...</string>
<string name="appearance_mode_title">Display mode</string>
<string name="appearance_system_mode">System</string>
<string name="appearance_dark_mode">Dark mode</string>
@@ -50,6 +121,19 @@
<string name="network_title">Network</string>
<string name="battery_level_title">Battery level</string>
<string name="value_unavailable">Not available</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_local_title">Allow notifications</string>
<string name="notifications_description">Let VniDrop notify you about new connection requests while the app is running in the background.</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string>
<string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="button_open_settings">Open Settings</string>
<string name="snackbar_dismiss">Dismiss</string>
<string name="value_on">On</string>
<string name="value_off">Off</string>
<string name="approval_connection_request">Connection request</string>
<string name="approval_pending_count">%1$d requests are waiting</string>
<string name="core_status_ready">Ready</string>
<string name="event_log_title">Event log</string>
<string name="no_events">No events have been emitted yet.</string>

View File

@@ -1,99 +1,132 @@
package com.vnidrop.app
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.app.AppGraphViewModel
import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.receive.ReceiveRoute
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsRoute
import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.screens.ReceiveScreen
import com.vnidrop.app.ui.screens.SendScreen
import com.vnidrop.app.ui.screens.SettingsScreen
import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
@Composable
@Preview
fun App() {
val platform = remember { getPlatform() }
val viewModel = viewModel {
VniDropAppViewModel(
appDataDir = platform.defaultCoreDataDir,
defaultReceiveDir = platform.defaultReceiveDir,
platformName = platform.name,
fun App(dependencies: AppDependencies) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph
val appViewModel = viewModel {
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
}
val sendViewModel = viewModel {
SendViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.filePreviewRepository,
graph.messages,
)
}
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(
onFilePicked = { file ->
viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file))
},
onError = { error ->
viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
},
)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
VniDropAppEffect.OpenShareFilePicker -> {
AppLogger.info("file-picker", "open share file picker")
picker.pickFile()
}
is VniDropAppEffect.CopyTicket -> {
AppLogger.info("send", "ticket copied")
clipboard.setText(AnnotatedString(effect.ticket))
val receiveViewModel = viewModel {
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
}
val settingsViewModel = viewModel {
SettingsViewModel(
dependencies.environment,
dependencies.deviceInfoProvider,
dependencies.fileSystemService,
graph.preferencesRepository,
dependencies.localNotificationService,
graph.messages,
)
}
val appState by appViewModel.state.collectAsStateWithLifecycle()
val sendState by sendViewModel.state.collectAsStateWithLifecycle()
val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle()
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
graph.visibility.setForeground(true)
settingsViewModel.refreshNotificationPermission()
}
Lifecycle.Event.ON_STOP -> graph.visibility.setForeground(false)
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val isDarkTheme = rememberResolvedDarkTheme(state.app.themeMode)
PlatformSystemAppearance(isDarkTheme)
LaunchedEffect(isDarkTheme) {
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
}
VniDropTheme(isDarkTheme = isDarkTheme) {
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme)
VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value)
val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone &&
sendState.selectedTransferId?.let { selectedId ->
sendCoreState.transfers.any { it.transferId == selectedId }
} != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send }
AppShell(
selectedDestination = state.app.destination,
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
windowClass = windowClass,
onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) },
onDestinationSelected = appViewModel::selectDestination,
overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
},
floatingAction = if (showSendAction) {
{
SendFloatingAction(
onClick = sendViewModel::openComposer,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else {
null
},
) {
when (state.app.destination) {
AppDestination.Send -> SendScreen(
coreState = coreState,
sendState = state.send,
onEvent = viewModel::onEvent,
)
AppDestination.Receive -> ReceiveScreen(
coreState = coreState,
receiveState = state.receive,
onEvent = viewModel::onEvent,
)
AppDestination.Settings -> SettingsScreen(
deviceInfo = platform.deviceInfo,
coreState = coreState,
themeMode = state.app.themeMode,
windowClass = windowClass,
onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
)
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) }
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
}
}
ApprovalModalHost(
state = approvalState,
onAccept = graph.approvalCoordinator::accept,
onRefuse = graph.approvalCoordinator::refuse,
)
}
}
}

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults
import com.vnidrop.app.preferences.AppPreferencesRepository
import com.vnidrop.app.preferences.createAppPreferencesDataStore
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
class AppGraph(
val dependencies: AppDependencies,
private val applicationScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
val coreRepository: CoreGateway = CoreRepository(),
) {
val visibility = AppVisibility()
val messages = UiMessageController()
val filePreviewRepository = AppFilePreviewRepository(
createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir),
)
val preferencesRepository = AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
defaults = AppPreferencesDefaults(
username = dependencies.environment.defaultUsername,
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
themeMode = ThemeMode.System,
notificationsEnabled = false,
),
)
val approvalCoordinator = ApprovalCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
notifications = dependencies.localNotificationService,
visibility = visibility,
messages = messages,
scope = applicationScope,
)
init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
}
fun close() {
coreRepository.shutdown()
applicationScope.cancel()
}
}

View File

@@ -1,9 +0,0 @@
package com.vnidrop.app
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return sayHello(platform.name)
}
}

View File

@@ -1,4 +0,0 @@
package com.vnidrop.app
fun sayHello(to: String): String =
"Hello, $to!"

View File

@@ -1,11 +1,14 @@
package com.vnidrop.app
interface Platform {
val name: String
val defaultCoreDataDir: String
val defaultReceiveDir: String
val deviceInfo: DeviceInfo
}
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService
data class PlatformEnvironment(
val name: String,
val appVersion: String,
val defaultCoreDataDir: String,
val defaultUsername: String = "Receiver",
)
data class DeviceInfo(
val deviceName: String?,
@@ -15,4 +18,13 @@ data class DeviceInfo(
val batteryLevel: String?,
)
expect fun getPlatform(): Platform
fun interface DeviceInfoProvider {
suspend fun load(): DeviceInfo
}
data class AppDependencies(
val environment: PlatformEnvironment,
val deviceInfoProvider: DeviceInfoProvider,
val fileSystemService: FileSystemService,
val localNotificationService: LocalNotificationService,
)

View File

@@ -1,230 +0,0 @@
package com.vnidrop.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.state.AppUiState
import com.vnidrop.app.ui.state.ReceiveUiState
import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class VniDropAppState(
val app: AppUiState = AppUiState(),
val send: SendUiState = SendUiState(),
val receive: ReceiveUiState = ReceiveUiState(),
)
sealed interface VniDropAppEvent {
data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent
data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent
data object SelectFileClicked : VniDropAppEvent
data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent
data class ShareFilePickFailed(val reason: String) : VniDropAppEvent
data object ClearSelectedSourceClicked : VniDropAppEvent
data class TransferNameChanged(val value: String) : VniDropAppEvent
data class SenderNameChanged(val value: String) : VniDropAppEvent
data object CreateShareClicked : VniDropAppEvent
data class CopyTicketClicked(val ticket: String) : VniDropAppEvent
data class UseTicketLocallyClicked(val ticket: String) : VniDropAppEvent
data class RefreshReceiverRequestsClicked(val transferId: ULong) : VniDropAppEvent
data class RespondReceiverRequestClicked(val requestId: String, val accepted: Boolean) : VniDropAppEvent
data class ReceiveTicketChanged(val value: String) : VniDropAppEvent
data class OutputDirectoryChanged(val value: String) : VniDropAppEvent
data class ReceiverNameChanged(val value: String) : VniDropAppEvent
data object InspectTicketClicked : VniDropAppEvent
data object ReceiveClicked : VniDropAppEvent
}
sealed interface VniDropAppEffect {
data object OpenShareFilePicker : VniDropAppEffect
data class CopyTicket(val ticket: String) : VniDropAppEffect
}
class VniDropAppViewModel(
appDataDir: String,
defaultReceiveDir: String,
platformName: String,
private val repository: CoreRepository = CoreRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir)))
val state: StateFlow<VniDropAppState> = _state
val coreState: StateFlow<CoreUiState> = repository.state
private val effects = Channel<VniDropAppEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var selectedFile: PickedShareFile? = null
init {
AppLogger.initialize(appDataDir)
AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName))
AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir))
viewModelScope.launch {
repository.initialize(appDataDir)
}
}
fun onEvent(event: VniDropAppEvent) {
when (event) {
is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) }
is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode)
VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker)
is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file)
is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason)
VniDropAppEvent.ClearSelectedSourceClicked -> clearSelectedSource()
is VniDropAppEvent.TransferNameChanged -> updateSendState { copy(transferName = event.value) }
is VniDropAppEvent.SenderNameChanged -> updateSendState { copy(senderName = event.value) }
VniDropAppEvent.CreateShareClicked -> createShare()
is VniDropAppEvent.CopyTicketClicked -> sendEffect(VniDropAppEffect.CopyTicket(event.ticket))
is VniDropAppEvent.UseTicketLocallyClicked -> useTicketLocally(event.ticket)
is VniDropAppEvent.RefreshReceiverRequestsClicked -> refreshReceiverRequests(event.transferId)
is VniDropAppEvent.RespondReceiverRequestClicked -> respondReceiverRequest(event.requestId, event.accepted)
is VniDropAppEvent.ReceiveTicketChanged -> updateReceiveState { copy(ticket = event.value) }
is VniDropAppEvent.OutputDirectoryChanged -> updateReceiveState { copy(outputDirectory = event.value) }
is VniDropAppEvent.ReceiverNameChanged -> updateReceiveState { copy(receiverName = event.value) }
VniDropAppEvent.InspectTicketClicked -> inspectTicket()
VniDropAppEvent.ReceiveClicked -> receive()
}
}
private fun setThemeMode(mode: ThemeMode) {
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name))
updateAppState { copy(themeMode = mode) }
}
private fun setSelectedFile(file: PickedShareFile) {
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
selectedFile = file
updateSendState { withSelectedFile(file) }
}
private fun setFilePickerError(reason: String) {
AppLogger.warn("file-picker", "file picker error", mapOf("reason" to reason))
viewModelScope.launch { repository.setError(reason) }
}
private fun clearSelectedSource() {
selectedFile = null
updateSendState {
copy(
selectedSource = "",
selectedDisplayName = "",
)
}
}
private fun createShare() {
val sendState = state.value.send
if (!sendState.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch {
AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource))
updateSendState { copy(isSharing = true) }
try {
val file = selectedFile
if (file == null) {
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
} else {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
}
repository.state.value.lastShare?.let { share ->
repository.refreshReceiverRequests(share.transferId)
}
} finally {
updateSendState { copy(isSharing = false) }
}
}
}
private fun useTicketLocally(ticket: String) {
updateReceiveState { copy(ticket = ticket) }
updateAppState { copy(destination = AppDestination.Receive) }
}
private fun refreshReceiverRequests(transferId: ULong) {
viewModelScope.launch {
repository.refreshReceiverRequests(transferId)
}
}
private fun respondReceiverRequest(requestId: String, accepted: Boolean) {
viewModelScope.launch {
repository.respondReceiverRequest(
requestId = requestId,
accepted = accepted,
reason = if (accepted) null else "sender-refused",
)
}
}
private fun inspectTicket() {
val receiveState = state.value.receive
if (!receiveState.canInspect(coreState.value.isInitialized)) return
viewModelScope.launch {
repository.inspectTicket(receiveState.ticket)
}
}
private fun receive() {
val receiveState = state.value.receive
if (!receiveState.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch {
AppLogger.info("receive", "receive requested")
updateReceiveState { copy(isReceiving = true) }
try {
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
} finally {
updateReceiveState { copy(isReceiving = false) }
}
}
}
private fun sendEffect(effect: VniDropAppEffect) {
viewModelScope.launch {
effects.send(effect)
}
}
private fun updateAppState(reducer: AppUiState.() -> AppUiState) {
_state.update { current ->
val next = current.app.reducer()
if (next == current.app) current else current.copy(app = next)
}
}
private fun updateSendState(reducer: SendUiState.() -> SendUiState) {
_state.update { current ->
val next = current.send.reducer()
if (next == current.send) current else current.copy(send = next)
}
}
private fun updateReceiveState(reducer: ReceiveUiState.() -> ReceiveUiState) {
_state.update { current ->
val next = current.receive.reducer()
if (next == current.receive) current else current.copy(receive = next)
}
}
}
private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
copy(
selectedSource = file.value,
selectedDisplayName = file.displayName,
transferName = if (transferName == DefaultTransferName || transferName.isBlank()) file.displayName else transferName,
)
private const val DefaultTransferName = "VniDrop transfer"

View File

@@ -0,0 +1,152 @@
package com.vnidrop.app.core
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import uniffi.vnidrop.ReceiveOutputSink
data class CoreStatus(
val endpointId: String,
val activeTransfers: ULong,
val activeShares: ULong,
)
data class CoreEventModel(
val id: String,
val timestamp: Long,
val scope: String,
val transferId: ULong?,
val direction: String?,
val phase: String,
val kind: String,
val dataJson: String,
)
enum class ShareAccessPolicy {
RequireApproval,
AnyoneWithTransfer,
}
enum class TransferDirection {
Send,
Receive,
}
enum class TransferStatus {
Importing,
Sharing,
Receiving,
Done,
Failed,
Cancelled,
Stopped,
}
data class Transfer(
val localId: String,
val transferId: ULong,
val direction: TransferDirection,
val status: TransferStatus,
val peerId: String?,
val transferName: String?,
val contentHash: String?,
val fileCount: ULong,
val totalSize: ULong,
val ticket: String?,
val accessPolicy: ShareAccessPolicy,
val createdAt: Long,
val updatedAt: Long,
)
data class Share(
val transferId: ULong,
val ticket: String,
val transferName: String,
val contentHash: String,
val fileCount: ULong,
val totalSize: ULong,
)
data class TransferMetadataModel(
val transferId: ULong,
val transferName: String,
val senderName: String?,
val contentHash: String,
val fileCount: ULong,
val totalSize: ULong,
)
data class TicketInspectionModel(
val kind: String,
val blobTicket: String,
val metadata: TransferMetadataModel?,
)
data class ReceiverRequestModel(
val id: String,
val transferId: ULong,
val remoteEndpointId: String,
val transferName: String,
val receiverName: String?,
val receiverDeviceName: String?,
val appVersion: String,
val status: ReceiverDeliveryStatus,
val reason: String?,
val requestedAt: Long,
val respondedAt: Long?,
val completedAt: Long?,
)
enum class ReceiverDeliveryStatus {
Requested,
Accepted,
Refused,
Expired,
Completed,
Unknown,
}
data class CoreState(
val isInitialized: Boolean = false,
val status: CoreStatus? = null,
val events: List<CoreEventModel> = emptyList(),
val transfers: List<Transfer> = emptyList(),
val lastShare: Share? = null,
val lastInspection: TicketInspectionModel? = null,
)
sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
}
interface CoreGateway {
val state: StateFlow<CoreState>
val signals: SharedFlow<CoreSignal>
suspend fun initialize(appDataDir: String): Result<Unit>
fun shutdown()
suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share>
suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>
}

View File

@@ -1,15 +1,22 @@
package com.vnidrop.app.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlin.random.Random
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult
@@ -17,45 +24,56 @@ import uniffi.vnidrop.ShareSource
import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.TransferAccessMode
import uniffi.vnidrop.VnidropCore
data class CoreUiState(
val isInitialized: Boolean = false,
val status: String = "Not initialized",
val events: List<CoreEvent> = emptyList(),
val transfers: List<StoredTransfer> = emptyList(),
val lastShare: ShareResult? = null,
val lastInspection: TicketInspection? = null,
val receiverRequests: List<ReceiverRequest> = emptyList(),
val error: String? = null,
)
class CoreRepository(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val _state = MutableStateFlow(CoreUiState())
val state: StateFlow<CoreUiState> = _state
) : CoreGateway {
private val _state = MutableStateFlow(CoreState())
override val state: StateFlow<CoreState> = _state.asStateFlow()
private val _signals = MutableSharedFlow<CoreSignal>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow()
private var core: VnidropCore? = null
private val sink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) {
_state.update { current ->
current.copy(events = (listOf(event) + current.events).take(200))
val model = event.toModel()
_state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
if (model.phase == "approval" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
}
if (model.phase == "delivery" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
}
}
}
suspend fun initialize(appDataDir: String) = runCore {
override suspend fun initialize(appDataDir: String): Result<Unit> = runCore {
core?.shutdown()
core = VnidropCore.initialize(appDataDir, sink)
refreshStatus()
loadTransfers()
loadEvents()
_state.update { it.copy(isInitialized = true, error = null) }
refreshSnapshot()
_state.update { it.copy(isInitialized = true) }
}
suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore {
override fun shutdown() {
core?.shutdown()
core = null
_state.value = CoreState()
}
override suspend fun sharePath(
path: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> =
shareSources(
sources = listOf(
ShareSource(
@@ -67,18 +85,16 @@ class CoreRepository(
),
transferName = transferName,
senderName = senderName,
accessPolicy = accessPolicy,
)
}
suspend fun shareFileDescriptor(
override suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
) = runCore {
// The fd is borrowed from platform code. Rust duplicates it before
// starting the import, so Android may close the ParcelFileDescriptor
// once this suspend call returns.
accessPolicy: ShareAccessPolicy,
): Result<Share> =
shareSources(
sources = listOf(
ShareSource(
@@ -90,17 +106,16 @@ class CoreRepository(
),
transferName = transferName,
senderName = senderName,
accessPolicy = accessPolicy,
)
}
suspend fun shareSecurityScopedFileUrl(
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
) = runCore {
// The iOS actual for withPlatformPathAccess starts and stops the
// security-scoped URL lease around this entire shareFiles call.
accessPolicy: ShareAccessPolicy,
): Result<Share> =
shareSources(
sources = listOf(
ShareSource(
@@ -112,94 +127,86 @@ class CoreRepository(
),
transferName = transferName,
senderName = senderName,
accessPolicy = accessPolicy,
)
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
requireCore().inspectTicket(ticket).toModel().also { inspection ->
_state.update { it.copy(lastInspection = inspection) }
}
}
suspend fun inspectTicket(ticket: String) = runCore {
val inspection = requireCore().inspectTicket(ticket)
_state.update { it.copy(lastInspection = inspection, error = null) }
}
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore {
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
refreshStatus()
loadTransfers()
refreshSnapshot()
}
suspend fun receiveIntoSecurityScopedDirectory(
override suspend fun receiveWithOutputSink(
ticket: String,
outputSink: ReceiveOutputSink,
receiverName: String,
): Result<Unit> = runCore {
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
refreshSnapshot()
}
override suspend fun receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String,
) = runCore {
): Result<Unit> = runCore {
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
}
refreshStatus()
loadTransfers()
refreshSnapshot()
}
suspend fun cancel(transferId: ULong) = runCore {
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
requireCore().cancelTransfer(transferId)
refreshStatus()
loadTransfers()
refreshSnapshot()
}
suspend fun refreshReceiverRequests(transferId: ULong) = runCore {
val requests = requireCore().listReceiverRequests(transferId)
_state.update { it.copy(receiverRequests = requests, error = null) }
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
requireCore().deleteTransfer(transferId)
refreshSnapshot()
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
}
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore {
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
}
override suspend fun respondReceiverRequest(
requestId: String,
accepted: Boolean,
reason: String?,
): Result<Unit> = runCore {
requireCore().respondReceiverRequest(requestId, accepted, reason)
state.value.lastShare?.let { share ->
val requests = requireCore().listReceiverRequests(share.transferId)
_state.update { it.copy(receiverRequests = requests, error = null) }
}
}
suspend fun refreshTransfers() = runCore {
loadTransfers()
}
suspend fun refreshEvents() = runCore {
loadEvents()
}
suspend fun setError(message: String) {
_state.update { it.copy(error = message) }
}
private suspend fun runCore(block: suspend () -> Unit) {
withContext(dispatcher) {
try {
block()
} catch (error: Throwable) {
_state.update { it.copy(error = error.message ?: error.toString()) }
}
}
}
private fun requireCore(): VnidropCore =
core ?: error("Initialize the core first.")
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
private suspend fun shareSources(
sources: List<ShareSource>,
transferName: String,
senderName: String,
) {
accessPolicy: ShareAccessPolicy,
): Result<Share> = runCore {
withPlatformPathAccess(sources) {
val result = requireCore().shareFiles(
requireCore().shareFiles(
sources = sources,
metadata = ShareMetadataInput(
transferId = nextTransferId(),
transferName = transferName.ifBlank { null },
senderName = senderName.ifBlank { null },
accessMode = accessPolicy.toNative(),
),
)
_state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
).toModel()
}.also { share ->
refreshSnapshot()
_state.update { it.copy(lastShare = share) }
}
refreshStatus()
loadTransfers()
}
private suspend fun <T> withPlatformPathAccess(
@@ -207,36 +214,140 @@ class CoreRepository(
index: Int = 0,
block: suspend () -> T,
): T {
if (index >= sources.size) {
return block()
}
if (index >= sources.size) return block()
val source = sources[index]
return withPlatformPathAccess(source.kind, source.value) {
withPlatformPathAccess(sources, index + 1, block)
}
}
private fun refreshStatus() {
val status = core?.status()
private fun refreshSnapshot() {
val activeCore = requireCore()
val status = activeCore.status()
_state.update {
it.copy(
status = status?.let { value ->
"Endpoint ${value.endpointId.take(12)}... | active=${value.activeTransfers} shares=${value.activeShares}"
} ?: "Not initialized",
status = CoreStatus(status.endpointId, status.activeTransfers, status.activeShares),
transfers = activeCore.listTransfers().map(StoredTransfer::toModel),
events = activeCore.listEvents(null).map(CoreEvent::toModel).take(MaxEvents),
)
}
}
private fun loadTransfers() {
val transfers = core?.listTransfers().orEmpty()
_state.update { it.copy(transfers = transfers, error = null) }
}
private suspend fun <T> runCore(block: suspend () -> T): Result<T> =
withContext(dispatcher) {
try {
Result.success(block())
} catch (error: Throwable) {
if (error is CancellationException) throw error
Result.failure(error)
}
}
private fun loadEvents() {
val events = core?.listEvents(null).orEmpty()
_state.update { it.copy(events = events.take(200), error = null) }
}
private fun requireCore(): VnidropCore = core ?: error("Initialize the core first.")
private fun nextTransferId(): ULong =
Random.nextLong(1, Long.MAX_VALUE).toULong()
private fun nextTransferId(): ULong = Random.nextLong(1, Long.MAX_VALUE).toULong()
private companion object {
const val MaxEvents = 200
}
}
private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
id = id,
timestamp = timestamp,
scope = scope,
transferId = transferId,
direction = direction,
phase = phase,
kind = kind,
dataJson = dataJson,
)
private fun StoredTransfer.toModel(): Transfer = Transfer(
localId = localId,
transferId = transferId,
direction = direction.toTransferDirection(),
status = status.toTransferStatus(),
peerId = peerId,
transferName = transferName,
contentHash = contentHash,
fileCount = fileCount,
totalSize = totalSize,
ticket = ticket,
accessPolicy = accessMode.toModel(),
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun ShareAccessPolicy.toNative(): TransferAccessMode = when (this) {
ShareAccessPolicy.RequireApproval -> TransferAccessMode.APPROVAL_REQUIRED
ShareAccessPolicy.AnyoneWithTransfer -> TransferAccessMode.PUBLIC
}
private fun TransferAccessMode.toModel(): ShareAccessPolicy = when (this) {
TransferAccessMode.APPROVAL_REQUIRED -> ShareAccessPolicy.RequireApproval
TransferAccessMode.PUBLIC -> ShareAccessPolicy.AnyoneWithTransfer
}
private fun String.toTransferDirection(): TransferDirection = when (this) {
"send" -> TransferDirection.Send
"receive" -> TransferDirection.Receive
else -> error("Unknown transfer direction: $this")
}
private fun String.toTransferStatus(): TransferStatus = when (this) {
"importing" -> TransferStatus.Importing
"sharing" -> TransferStatus.Sharing
"receiving" -> TransferStatus.Receiving
"done" -> TransferStatus.Done
"failed" -> TransferStatus.Failed
"cancelled" -> TransferStatus.Cancelled
"stopped" -> TransferStatus.Stopped
else -> error("Unknown transfer status: $this")
}
private fun ShareResult.toModel(): Share = Share(
transferId = transferId,
ticket = ticket,
transferName = transferName,
contentHash = hash,
fileCount = fileCount,
totalSize = totalSize,
)
private fun TicketInspection.toModel(): TicketInspectionModel = TicketInspectionModel(
kind = kind,
blobTicket = blobTicket,
metadata = metadata?.toModel(),
)
private fun TransferMetadata.toModel(): TransferMetadataModel = TransferMetadataModel(
transferId = transferId,
transferName = transferName,
senderName = senderName,
contentHash = contentHash,
fileCount = fileCount,
totalSize = totalSize,
)
private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestModel(
id = id,
transferId = transferId,
remoteEndpointId = remoteEndpointId,
transferName = transferName,
receiverName = receiverName,
receiverDeviceName = receiverDeviceName,
appVersion = appVersion,
status = when (status) {
"requested" -> ReceiverDeliveryStatus.Requested
"accepted" -> ReceiverDeliveryStatus.Accepted
"refused" -> ReceiverDeliveryStatus.Refused
"expired" -> ReceiverDeliveryStatus.Expired
"completed" -> ReceiverDeliveryStatus.Completed
else -> ReceiverDeliveryStatus.Unknown
},
reason = reason,
requestedAt = requestedAt,
respondedAt = respondedAt,
completedAt = completedAt,
)

View File

@@ -5,21 +5,26 @@ import androidx.compose.runtime.Composable
data class PickedShareFile(
val value: String,
val displayName: String,
val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = null,
)
interface ShareFilePicker {
fun pickFile()
}
interface ReceiveFolderPicker {
fun pickFolder()
}
@Composable
expect fun rememberShareFilePicker(
onFilePicked: (PickedShareFile) -> Unit,
onError: (String) -> Unit,
): ShareFilePicker
expect suspend fun sharePickedFile(
repository: CoreRepository,
file: PickedShareFile,
transferName: String,
senderName: String,
)
@Composable
expect fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker

View File

@@ -0,0 +1,41 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import uniffi.vnidrop.ReceiveOutputSink
enum class ReceiveFolderKind {
FileSystemPath,
AndroidTreeUri,
IosSecurityScopedUrl,
}
data class ReceiveFolder(
val kind: ReceiveFolderKind,
val value: String,
val displayName: String,
)
enum class FolderAccessStatus {
Writable,
PermissionRequired,
Unavailable,
}
interface FileSystemService {
fun defaultReceiveFolder(): ReceiveFolder
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink?
suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
}
@Composable
expect fun rememberFileSystemService(): FileSystemService
fun ReceiveFolder.isFileSystemPath(): Boolean =
kind == ReceiveFolderKind.FileSystemPath

View File

@@ -3,7 +3,7 @@ package com.vnidrop.app.core
import uniffi.vnidrop.SourceKind
// Platform file handles have different lifetime rules. Desktop paths need no
// extra work, Android fd sources are duplicated immediately by Rust, and iOS
// extra work, Rust duplicates Android fd sources immediately, and iOS
// security-scoped URLs must remain leased while Rust performs the blocking
// import/export call.
internal expect suspend fun <T> withPlatformPathAccess(

View File

@@ -0,0 +1,58 @@
package com.vnidrop.app.feature.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.AppDependencies
import com.vnidrop.app.AppGraph
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AppState(
val destination: AppDestination = AppDestination.Send,
val themeMode: ThemeMode = ThemeMode.System,
)
class AppGraphViewModel(dependencies: AppDependencies) : ViewModel() {
val graph = AppGraph(dependencies)
override fun onCleared() {
graph.close()
super.onCleared()
}
}
class AppViewModel(
private val environment: PlatformEnvironment,
private val repository: CoreGateway,
preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow()
init {
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
viewModelScope.launch {
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { it.copy(themeMode = preferences.themeMode) }
}
}
}
fun selectDestination(destination: AppDestination) {
_state.update { it.copy(destination = destination) }
}
}

View File

@@ -0,0 +1,169 @@
package com.vnidrop.app.feature.approvals
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class PendingApproval(
val id: String,
val transferId: ULong,
val transferName: String,
val receiverName: String?,
val receiverDeviceName: String?,
val requestedAt: Long,
)
data class ApprovalState(
val pending: List<PendingApproval> = emptyList(),
val respondingIds: Set<String> = emptySet(),
) {
val current: PendingApproval?
get() = pending.firstOrNull()
}
class ApprovalCoordinator(
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService,
private val visibility: AppVisibility,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(ApprovalState())
val state: StateFlow<ApprovalState> = _state.asStateFlow()
private val publishedNotificationIds = mutableSetOf<String>()
init {
scope.launch {
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
is CoreSignal.ReceiverHistoryChanged -> Unit
}
}
}
scope.launch {
repository.state.collectLatest { core ->
if (core.isInitialized) {
core.transfers
.filter { it.direction == TransferDirection.Send && it.status == TransferStatus.Sharing }
.forEach { refresh(it.transferId) }
}
}
}
scope.launch {
combine(
preferencesRepository.preferences,
visibility.isForeground,
state,
notifications.permission,
) { preferences, foreground, approvalState, permission ->
NotificationContext(preferences.notificationsEnabled, foreground, approvalState.pending, permission)
}.collect { context ->
synchronizeNotifications(context)
}
}
}
fun accept(requestId: String) = respond(requestId, accepted = true)
fun refuse(requestId: String) = respond(requestId, accepted = false)
private fun respond(requestId: String, accepted: Boolean) {
if (!_state.value.respondingIds.addable(requestId)) return
_state.update { it.copy(respondingIds = it.respondingIds + requestId) }
scope.launch {
val request = _state.value.pending.firstOrNull { it.id == requestId }
val result = repository.respondReceiverRequest(
requestId = requestId,
accepted = accepted,
reason = if (accepted) null else "sender-refused",
)
_state.update { it.copy(respondingIds = it.respondingIds - requestId) }
result.fold(
onSuccess = {
if (request != null) refresh(request.transferId)
},
onFailure = messages::error,
)
}
}
private suspend fun refresh(transferId: ULong) {
repository.receiverRequests(transferId).fold(
onSuccess = { requests ->
val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending)
val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
removed.forEach { id ->
notifications.cancel(notificationId(id))
publishedNotificationIds.remove(id)
}
_state.update { current ->
current.copy(
pending = (current.pending.filterNot { it.transferId == transferId } + refreshed)
.distinctBy(PendingApproval::id)
.sortedBy(PendingApproval::requestedAt),
)
}
},
onFailure = messages::error,
)
}
private suspend fun synchronizeNotifications(context: NotificationContext) {
if (context.foreground || !context.enabled || context.permission != NotificationPermission.Granted) {
notifications.cancelAll()
return
}
context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request ->
val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
notifications.publish(
LocalNotification(
id = notificationId(request.id),
title = "Connection request",
body = "$receiver wants to receive ${request.transferName}",
),
).onSuccess {
publishedNotificationIds += request.id
}.onFailure(messages::error)
}
}
}
private data class NotificationContext(
val enabled: Boolean,
val foreground: Boolean,
val pending: List<PendingApproval>,
val permission: NotificationPermission,
)
private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
id = id,
transferId = transferId,
transferName = transferName,
receiverName = receiverName,
receiverDeviceName = receiverDeviceName,
requestedAt = requestedAt,
)
private fun Set<String>.addable(value: String): Boolean = value !in this
private fun notificationId(requestId: String): String = "approval-$requestId"

View File

@@ -0,0 +1,122 @@
package com.vnidrop.app.feature.approvals
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_connection_request
import vnidrop.shared.generated.resources.approval_pending_count
import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_refuse
@Composable
fun ApprovalModalHost(
state: ApprovalState,
onAccept: (String) -> Unit,
onRefuse: (String) -> Unit,
) {
val request = state.current ?: return
val busy = request.id in state.respondingIds
val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
val colors = LocalVniDropColors.current
Dialog(
onDismissRequest = {},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Surface(
modifier = Modifier.padding(24.dp).widthIn(max = 440.dp).fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = colors.backgroundDialog,
shadowElevation = 16.dp,
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Surface(shape = RoundedCornerShape(14.dp), color = colors.backgroundSelection) {
Icon(
ApprovalIcon,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier.padding(11.dp).size(24.dp),
)
}
Text(
stringResource(Res.string.approval_connection_request),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
"$receiver wants to receive ${request.transferName}.",
style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight,
)
if (state.pending.size > 1) {
Text(
stringResource(Res.string.approval_pending_count, state.pending.size),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
}
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth < 330.dp) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, Modifier.fillMaxWidth(), !busy)
SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, Modifier.fillMaxWidth(), !busy)
}
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.weight(1f))
SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, enabled = !busy)
Spacer(Modifier.width(10.dp))
PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, enabled = !busy)
}
}
}
}
}
}
}
private val ApprovalIcon: ImageVector = ImageVector.Builder(
name = "Approval",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
path {
moveTo(12f, 2f); lineTo(20f, 5.5f); verticalLineTo(11f)
curveTo(20f, 16.1f, 16.6f, 20.7f, 12f, 22f)
curveTo(7.4f, 20.7f, 4f, 16.1f, 4f, 11f); verticalLineTo(5.5f); close()
moveTo(8.2f, 11.8f); lineTo(10.7f, 14.3f); lineTo(15.9f, 9.1f)
}
}.build()

View File

@@ -0,0 +1,19 @@
package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun ReceiveRoute(viewModel: ReceiveViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
ReceiveScreen(
coreState = coreState,
state = state,
onTicketChanged = viewModel::setTicket,
onReceiverNameChanged = viewModel::setReceiverName,
onInspectTicket = viewModel::inspectTicket,
onReceive = viewModel::receive,
)
}

View File

@@ -0,0 +1,76 @@
package com.vnidrop.app.feature.receive
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.screens.ProgressSection
import com.vnidrop.app.ui.screens.ScreenHeader
import com.vnidrop.app.ui.screens.TicketInspectionCard
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive
import vnidrop.shared.generated.resources.button_receiving
import vnidrop.shared.generated.resources.field_output_directory
import vnidrop.shared.generated.resources.field_receiver_name
import vnidrop.shared.generated.resources.field_ticket
import vnidrop.shared.generated.resources.folder_status_permission_required
import vnidrop.shared.generated.resources.folder_status_unavailable
import vnidrop.shared.generated.resources.folder_status_writable
import vnidrop.shared.generated.resources.metadata_status
import vnidrop.shared.generated.resources.receive_subtitle
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title
@Composable
fun ReceiveScreen(
coreState: CoreState,
state: ReceiveState,
onTicketChanged: (String) -> Unit,
onReceiverNameChanged: (String) -> Unit,
onInspectTicket: () -> Unit,
onReceive: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
AppCard(title = stringResource(Res.string.ticket_card_title)) {
Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4)
MetadataRow(
stringResource(Res.string.field_output_directory),
state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory,
)
MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName())
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
stringResource(Res.string.button_inspect_ticket),
onClick = onInspectTicket,
enabled = state.canInspect(coreState.isInitialized),
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
enabled = state.canReceive(coreState.isInitialized),
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}
@Composable
private fun FolderAccessStatus.displayName(): String = when (this) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
}

View File

@@ -0,0 +1,91 @@
package com.vnidrop.app.feature.receive
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ReceiveState(
val ticket: String = "",
val outputDirectory: String = "",
val receiverName: String = "",
val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isReceiving: Boolean = false,
) {
fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank()
fun canReceive(coreInitialized: Boolean): Boolean =
coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() &&
folderAccessStatus == FolderAccessStatus.Writable && !isReceiving
}
class ReceiveViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(ReceiveState())
val state: StateFlow<ReceiveState> = _state.asStateFlow()
val coreState = repository.state
init {
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder)
_state.update { current ->
current.copy(
receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder,
outputDirectory = preferences.receiveFolder.value,
folderAccessStatus = status,
)
}
}
}
}
fun setTicket(value: String) = _state.update { it.copy(ticket = value) }
fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) }
fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) }
fun inspectTicket() {
val current = state.value
if (!current.canInspect(coreState.value.isInitialized)) return
viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) }
}
fun receive() {
val current = state.value
val folder = current.receiveFolder ?: return
if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isReceiving = true) }
try {
val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
current.ticket,
folder.value,
current.receiverName,
)
else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName)
}
result.onFailure(messages::error)
} finally {
_state.update { it.copy(isReceiving = false) }
}
}
}
}

View File

@@ -0,0 +1,111 @@
package com.vnidrop.app.feature.send
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class PreviewFileInfo(
val transferId: ULong,
val byteSize: Long,
val modifiedAtMillis: Long,
)
interface PlatformPreviewStore {
fun list(): List<PreviewFileInfo>
fun read(transferId: ULong): ByteArray?
fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean
fun delete(transferId: ULong)
}
expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore
interface FilePreviewRepository {
val previews: StateFlow<Map<ULong, ByteArray>>
suspend fun restore(activeTransferIds: Set<ULong>)
suspend fun save(transferId: ULong, bytes: ByteArray)
suspend fun remove(transferId: ULong)
}
data class PreviewStoragePolicy(
val maxEntryBytes: Int = 512 * 1024,
val maxTotalBytes: Long = 20L * 1024L * 1024L,
) {
init {
require(maxEntryBytes > 0)
require(maxTotalBytes >= maxEntryBytes)
}
}
class AppFilePreviewRepository(
private val store: PlatformPreviewStore,
private val policy: PreviewStoragePolicy = PreviewStoragePolicy(),
) : FilePreviewRepository {
private val mutex = Mutex()
private val _previews = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
override val previews: StateFlow<Map<ULong, ByteArray>> = _previews.asStateFlow()
override suspend fun restore(activeTransferIds: Set<ULong>) = withContext(Dispatchers.Default) {
mutex.withLock {
val files = store.list()
for (file in files) {
if (file.transferId !in activeTransferIds || file.byteSize !in 1..policy.maxEntryBytes.toLong()) {
store.delete(file.transferId)
}
}
enforceQuota()
_previews.value = store.list()
.filter { it.transferId in activeTransferIds }
.mapNotNull { file ->
store.read(file.transferId)
?.takeIf { it.isSupportedPreview() && it.size <= policy.maxEntryBytes }
?.let { file.transferId to it }
?: run {
store.delete(file.transferId)
null
}
}
.toMap()
}
}
override suspend fun save(transferId: ULong, bytes: ByteArray) = withContext(Dispatchers.Default) {
mutex.withLock {
if (bytes.size !in 1..policy.maxEntryBytes || !bytes.isSupportedPreview()) return@withLock
if (!store.writeAtomically(transferId, bytes)) return@withLock
enforceQuota(protectedTransferId = transferId)
if (store.read(transferId) != null) {
_previews.value = _previews.value + (transferId to bytes.copyOf())
}
}
}
override suspend fun remove(transferId: ULong) = withContext(Dispatchers.Default) {
mutex.withLock {
store.delete(transferId)
_previews.value = _previews.value - transferId
}
}
private fun enforceQuota(protectedTransferId: ULong? = null) {
val files = store.list().sortedBy { it.modifiedAtMillis }
var total = files.sumOf(PreviewFileInfo::byteSize)
for (file in files) {
if (total <= policy.maxTotalBytes) break
if (file.transferId == protectedTransferId) continue
store.delete(file.transferId)
total -= file.byteSize
_previews.value = _previews.value - file.transferId
}
}
}
private fun ByteArray.isSupportedPreview(): Boolean {
val png = size >= 8 && this[0] == 0x89.toByte() && decodeToString(1, 4) == "PNG"
val jpeg = size >= 3 && this[0] == 0xff.toByte() && this[1] == 0xd8.toByte() && this[2] == 0xff.toByte()
val webp = size >= 12 && decodeToString(0, 4) == "RIFF" && decodeToString(8, 12) == "WEBP"
return png || jpeg || webp
}

View File

@@ -0,0 +1,221 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_create_new_transfer
import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
import vnidrop.shared.generated.resources.send_new_transfer_description
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.send_transfers_title
@Composable
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
FloatingActionButton(
onClick = onClick,
modifier = modifier,
containerColor = LocalVniDropColors.current.brandButton,
contentColor = Color.White,
) {
Icon(SendIcons.Plus, contentDescription = stringResource(Res.string.send_new_transfer_description))
}
}
@Composable
internal fun TransferCatalog(
transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>,
windowClass: WindowClass,
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) }
if (transfers.isEmpty()) {
item { SendEmptyState(onOpenComposer) }
} else {
item {
Text(
stringResource(Res.string.send_transfers_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
items(transfers, key = Transfer::localId) { transfer ->
TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) }
}
}
}
}
@Composable
private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(Res.string.send_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
if (showAction) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_create_new_transfer), onClick = onOpenComposer)
}
}
}
@Composable
private fun SendEmptyState(onOpenComposer: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier.size(68.dp).clip(RoundedCornerShape(22.dp)).background(colors.brandLink.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(SendIcons.File, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(30.dp))
}
Text(
stringResource(Res.string.send_empty_title),
modifier = Modifier.padding(top = 22.dp),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
stringResource(Res.string.send_empty_body),
modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
PrimaryButton(
stringResource(Res.string.button_create_new_transfer),
onClick = onOpenComposer,
modifier = Modifier.padding(top = 22.dp),
)
}
}
@Composable
private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) {
val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(44.dp).background(colors.backgroundSurface300, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
}
Text(
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
}
@Composable
internal fun FileArtwork(thumbnailBytes: ByteArray?, modifier: Modifier = Modifier) {
val bitmap = remember(thumbnailBytes) { thumbnailBytes?.let { runCatching { it.decodeToImageBitmap() }.getOrNull() } }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(modifier, contentAlignment = Alignment.Center) {
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.foregroundLight, modifier = Modifier.size(22.dp))
}
}
}
private fun TransferStatus.pillTone(): PillTone = when (this) {
TransferStatus.Sharing, TransferStatus.Done -> PillTone.Brand
TransferStatus.Importing, TransferStatus.Receiving -> PillTone.Warning
TransferStatus.Failed, TransferStatus.Cancelled -> PillTone.Destructive
TransferStatus.Stopped -> PillTone.Neutral
}

View File

@@ -0,0 +1,82 @@
package com.vnidrop.app.feature.send
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
internal object SendIcons {
val Plus = lineIcon("Plus") {
moveTo(12f, 5f)
lineTo(12f, 19f)
moveTo(5f, 12f)
lineTo(19f, 12f)
}
val File = lineIcon("File") {
moveTo(14f, 2f)
lineTo(6f, 2f)
lineTo(6f, 22f)
lineTo(18f, 22f)
lineTo(18f, 6f)
close()
moveTo(14f, 2f)
lineTo(14f, 6f)
lineTo(18f, 6f)
}
val Back = lineIcon("Back") {
moveTo(19f, 12f)
lineTo(5f, 12f)
moveTo(12f, 19f)
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val Delete = lineIcon("Delete") {
moveTo(4f, 7f); lineTo(20f, 7f)
moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f)
moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f)
moveTo(10f, 11f); lineTo(10f, 17f)
moveTo(14f, 11f); lineTo(14f, 17f)
}
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)
lineTo(9f, 6f)
}
val Shield = lineIcon("Shield") {
moveTo(12f, 2f)
lineTo(20f, 6f)
lineTo(20f, 12f)
arcTo(9f, 9f, 0f, false, true, 12f, 22f)
arcTo(9f, 9f, 0f, false, true, 4f, 12f)
lineTo(4f, 6f)
close()
}
val Globe = lineIcon("Globe") {
moveTo(21f, 12f)
arcTo(9f, 9f, 0f, true, true, 3f, 12f)
arcTo(9f, 9f, 0f, true, true, 21f, 12f)
moveTo(3f, 12f)
lineTo(21f, 12f)
moveTo(12f, 3f)
arcTo(14f, 14f, 0f, false, true, 12f, 21f)
arcTo(14f, 14f, 0f, false, true, 12f, 3f)
}
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
pathBuilder = block,
)
}.build()

View File

@@ -0,0 +1,57 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SendRoute(
viewModel: SendViewModel,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
SendEffect.OpenFilePicker -> picker.pickFile()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
}
}
}
SendScreen(
coreState = coreState,
state = state,
windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
onClearFile = viewModel::clearSelectedSource,
onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare,
onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket,
onActivity = viewModel::openActivity,
onReceivers = viewModel::openReceivers,
onShare = viewModel::openShare,
onCloseDetailPanel = viewModel::closeDetailPanel,
onInvitationResult = viewModel::onInvitationResult,
onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)
}

View File

@@ -0,0 +1,115 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SendScreen(
coreState: CoreState,
state: SendState,
windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit,
onActivity: () -> Unit = {},
onReceivers: () -> Unit = {},
onShare: () -> Unit = {},
onCloseDetailPanel: () -> Unit = {},
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
}
Box(Modifier.fillMaxSize()) {
if (selectedTransfer != null) {
TransferDetails(
transfer = selectedTransfer,
events = coreState.events,
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
onBack = onCloseTransferDetails,
onActivity = onActivity,
onReceivers = onReceivers,
onShare = onShare,
onDelete = onRequestDelete,
)
} else {
TransferCatalog(
transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails,
windowClass = windowClass,
onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected,
)
}
}
if (state.isComposerOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onClearFile = onClearFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
)
}
}
if (selectedTransfer != null && state.detailPanel != null) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
when (state.detailPanel) {
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
TransferDetailPanel.Share -> TransferSharePanel(
selectedTransfer,
shareActions,
qrBitmap = selectedTransfer.ticket?.let(qrCache::get),
onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap },
onResult = onInvitationResult,
)
}
}
}
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
DeleteTransferPanel(
transferName = selectedTransfer.transferName,
isDeleting = state.isDeleting,
onCancel = onDismissDelete,
onConfirm = onConfirmDelete,
)
}
}
}

View File

@@ -0,0 +1,264 @@
package com.vnidrop.app.feature.send
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_nfc_written
import vnidrop.shared.generated.resources.transfer_deleted
data class SendState(
val isComposerOpen: Boolean = false,
val selectedFile: PickedShareFile? = null,
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false,
) {
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
}
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
}
class SendViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SendState())
val state: StateFlow<SendState> = _state.asStateFlow()
val coreState = repository.state
private val effects = Channel<SendEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
repository.signals.collect { signal ->
val transferId = when (signal) {
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
is CoreSignal.ApprovalChanged -> signal.transferId
}
if (transferId == _state.value.selectedTransferId &&
_state.value.detailPanel == TransferDetailPanel.Receivers
) refreshReceivers(transferId)
}
}
viewModelScope.launch {
filePreviewRepository.previews.collect { previews ->
_state.update { it.copy(transferThumbnails = previews) }
}
}
viewModelScope.launch {
coreState.map { core ->
core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet()
}.distinctUntilChanged().collect { activeIds ->
if (activeIds != null) filePreviewRepository.restore(activeIds)
}
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { current ->
if (current.senderName.isBlank()) current.copy(senderName = preferences.username) else current
}
}
}
}
fun openComposer() {
if (_state.value.isSharing) return
_state.update {
it.copy(
isComposerOpen = true,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
}
fun dismissComposer() {
if (_state.value.isSharing) return
_state.update {
it.copy(
isComposerOpen = false,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
}
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun onFilePicked(file: PickedShareFile) {
_state.update {
it.copy(
isComposerOpen = true,
selectedFile = file,
transferName = file.displayName,
)
}
}
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
fun clearSelectedSource() {
_state.update { it.copy(selectedFile = null, transferName = "") }
}
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) {
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
refreshReceivers(transferId)
}
fun closeTransferDetails() = _state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
)
}
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
fun openReceivers() {
val transferId = _state.value.selectedTransferId ?: return
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
refreshReceivers(transferId)
}
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
fun dismissDeleteTransfer() {
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
}
fun confirmDeleteTransfer() {
val transferId = _state.value.selectedTransferId ?: return
if (_state.value.isDeleting) return
viewModelScope.launch {
_state.update { it.copy(isDeleting = true) }
repository.delete(transferId).fold(
onSuccess = {
filePreviewRepository.remove(transferId)
_state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
isDeleting = false,
)
}
messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isDeleting = false) }
messages.error(error)
},
)
}
}
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
fun onInvitationResult(action: InvitationAction, result: Result<Unit>) {
result.fold(
onSuccess = {
val message = when (action) {
InvitationAction.Export -> null
InvitationAction.Nfc -> Res.string.transfer_nfc_written
InvitationAction.Share -> null
}
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
},
onFailure = messages::error,
)
}
fun createShare() {
val current = state.value
val file = current.selectedFile ?: return
if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isSharing = true) }
val result = fileSystemService.sharePickedFile(
repository = repository,
file = file,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
)
result.fold(
onSuccess = { share ->
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
_state.update {
it.copy(
isComposerOpen = false,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
)
}
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isSharing = false) }
messages.error(error)
},
)
}
}
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) }
repository.receiverRequests(transferId).fold(
onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } },
onFailure = { error ->
_state.update { it.copy(isLoadingReceivers = false) }
messages.error(error)
},
)
}
}
}

View File

@@ -0,0 +1,218 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.QuietButton
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_change_file
import vnidrop.shared.generated.resources.button_choose_file
import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_share_file
import vnidrop.shared.generated.resources.button_sharing_file
import vnidrop.shared.generated.resources.field_sender_name
import vnidrop.shared.generated.resources.field_transfer_name
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_anyone_description
import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_approval_description
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_title
import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_review_title
@Composable
internal fun TransferComposer(
coreInitialized: Boolean,
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val file = state.selectedFile
if (file == null) {
ChooseFileStep(onSelectFile)
} else {
ReviewFileStep(
file = file,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onClearFile = onClearFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
coreInitialized = coreInitialized,
)
}
}
}
@Composable
private fun ChooseFileStep(onSelectFile: () -> Unit) {
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(
stringResource(Res.string.send_choose_file_body),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
PrimaryButton(stringResource(Res.string.button_choose_file), onClick = onSelectFile)
}
}
}
@Composable
private fun ReviewFileStep(
file: PickedShareFile,
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
coreInitialized: Boolean,
) {
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
SelectedFileCard(file)
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
PolicyOption(
icon = SendIcons.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = SendIcons.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
if (windowClass == WindowClass.Phone) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare)
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
}
}
}
@Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton(
if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file),
onClick = onCreateShare,
modifier = modifier,
enabled = state.canCreateShare(coreInitialized),
)
}
@Composable
private fun SelectedFileCard(file: PickedShareFile) {
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) {
FileArtwork(file.thumbnailBytes, Modifier.fillMaxSize())
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun PolicyOption(
icon: ImageVector,
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(if (selected) colors.backgroundSelection else colors.backgroundSurface200)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = if (selected) colors.brandLink else colors.foregroundLight, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
RadioButton(selected = selected, onClick = null)
}
}

View File

@@ -0,0 +1,347 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource
import qrcode.QRCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import vnidrop.shared.generated.resources.*
enum class InvitationAction { Export, Share, Nfc }
@Composable
internal fun TransferDetails(
transfer: Transfer,
events: List<CoreEventModel>,
completedReceivers: Int,
onBack: () -> Unit,
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
Text(
stringResource(Res.string.send_transfer_details_title),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onDelete) {
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
}
}
}
item {
AppCard(title = transfer.transferName ?: stringResource(Res.string.send_new_transfer_title)) {
DetailValue(stringResource(Res.string.metadata_status), displayNameForStatus(transfer.status))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
}
}
item {
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column {
DetailDestination(
title = stringResource(Res.string.transfer_activity_title),
description = stringResource(Res.string.transfer_activity_description),
count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() },
onClick = onActivity,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_receivers_title),
description = stringResource(Res.string.transfer_receivers_description),
count = completedReceivers,
onClick = onReceivers,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_share_title),
description = stringResource(Res.string.transfer_share_description),
onClick = onShare,
)
}
}
}
}
}
@Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (count != null && count > 0) {
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
Spacer(Modifier.width(8.dp))
}
Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
@Composable
internal fun ReceiverHistoryPanel(receivers: List<ReceiverRequestModel>, loading: Boolean) {
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
when {
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
else -> receivers.forEachIndexed { index, receiver ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
ReceiverRow(receiver)
}
}
}
}
@Composable
private fun ReceiverRow(receiver: ReceiverRequestModel) {
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
receiver.receiverDeviceName?.takeIf { it != name }?.let {
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)
}
}
@Composable
internal fun TransferActivityPanel(events: List<CoreEventModel>, transferId: ULong) {
val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp)
PanelContainer(stringResource(Res.string.transfer_activity_title)) {
if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter)
else visible.forEachIndexed { index, event ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium)
}
}
}
@Composable
internal fun TransferSharePanel(
transfer: Transfer,
actions: TransferShareActions,
qrBitmap: androidx.compose.ui.graphics.ImageBitmap?,
onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit,
onResult: (InvitationAction, Result<Unit>) -> Unit,
) {
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
val ticket = transfer.ticket
PanelContainer(stringResource(Res.string.transfer_share_title)) {
if (ticket == null) {
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
return@PanelContainer
}
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
if (value == null) {
value = withContext(Dispatchers.Default) {
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
}
value?.let { onQrRendered(ticket, it) }
}
}
val renderedQr = renderedBitmap
Surface(
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White,
) {
if (renderedQr != null) {
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
}
}
Text(
stringResource(Res.string.transfer_scan_qr),
modifier = Modifier.align(Alignment.CenterHorizontally),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
var writingNfc by remember(ticket) { mutableStateOf(false) }
SecondaryButton(
if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc),
onClick = {
writingNfc = true
actions.writeInvitationToNfc(ticket) {
writingNfc = false
onResult(InvitationAction.Nfc, it)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc,
)
if (actions.nfcAvailability == NfcShareAvailability.Unavailable) {
Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
}
SecondaryButton(
stringResource(Res.string.button_download_invitation),
onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } },
modifier = Modifier.fillMaxWidth(),
)
PrimaryButton(
stringResource(Res.string.button_native_share),
onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } },
modifier = Modifier.fillMaxWidth(),
enabled = actions.canUseNativeShare,
)
}
}
@Composable
internal fun DeleteTransferPanel(
transferName: String?,
isDeleting: Boolean,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
PanelContainer(stringResource(Res.string.transfer_delete_title)) {
Text(
stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
DestructiveButton(
if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer),
onClick = onConfirm,
enabled = !isDeleting,
)
}
}
}
@Composable
private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
content()
}
}
@Composable
private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) {
ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested
ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
})
@Composable
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
else -> LocalVniDropColors.current.foregroundLighter
}
private fun CoreEventModel.isMeaningfulActivity() =
(phase == "import" && kind == "started") ||
(phase == "ticket" && kind == "created") ||
kind in setOf(
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed",
)
@Composable
private fun eventTitle(event: CoreEventModel) = stringResource(when {
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
event.kind == "failed" -> Res.string.transfer_event_failed
else -> Res.string.transfer_event_updated
})
@Composable
private fun DetailValue(label: String, value: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
internal fun accessPolicyLabel(policy: ShareAccessPolicy): String = when (policy) {
ShareAccessPolicy.RequireApproval -> stringResource(Res.string.send_access_approval)
ShareAccessPolicy.AnyoneWithTransfer -> stringResource(Res.string.send_access_anyone)
}

View File

@@ -0,0 +1,38 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
enum class NfcShareAvailability { Available, Unavailable, Hidden }
interface TransferShareActions {
val canUseNativeShare: Boolean
val nfcAvailability: NfcShareAvailability
fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit)
fun cancelNfcWrite()
}
object UnavailableTransferShareActions : TransferShareActions {
override val canUseNativeShare = false
override val nfcAvailability = NfcShareAvailability.Hidden
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable")))
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable")))
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
override fun cancelNfcWrite() = Unit
}
@Composable
expect fun rememberTransferShareActions(): TransferShareActions
internal fun invitationFileName(transferName: String): String {
val safe = transferName.trim()
.map { character -> if (character.isLetterOrDigit() || character in "-_. ") character else '_' }
.joinToString("")
.trim('.', ' ')
.ifBlank { "VniDrop transfer" }
return if (safe.endsWith(".vnd", ignoreCase = true)) safe else "$safe.vnd"
}

View File

@@ -0,0 +1,55 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report
import vnidrop.shared.generated.resources.about_privacy
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
@Composable
internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) {
val unavailable = stringResource(Res.string.value_unavailable)
val info = state.deviceInfo
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack)
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Document,
title = stringResource(Res.string.about_privacy),
iconTone = SettingsIconTone.Neutral,
)
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Bug,
title = stringResource(Res.string.about_bug_report),
iconTone = SettingsIconTone.Neutral,
)
}
SettingsGroup {
InfoItem(stringResource(Res.string.version_title), state.appVersion)
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.network_title), info?.network.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.battery_level_title), info?.batteryLevel.orUnavailable(unavailable))
}
}
}
private fun String?.orUnavailable(fallback: String): String = this?.takeIf(String::isNotBlank) ?: fallback

View File

@@ -0,0 +1,66 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.appearance_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title
@Composable
internal fun AppearanceSettings(
mode: ThemeMode,
onModeChanged: (ThemeMode) -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.appearance_title), onBack, showBack)
SettingsGroup {
ThemeSettingsRow(SettingsIcons.Device, stringResource(Res.string.appearance_system_mode), mode == ThemeMode.System) {
onModeChanged(ThemeMode.System)
}
SettingsDivider()
ThemeSettingsRow(SettingsIcons.Moon, stringResource(Res.string.appearance_dark_mode), mode == ThemeMode.Dark) {
onModeChanged(ThemeMode.Dark)
}
SettingsDivider()
ThemeSettingsRow(SettingsIcons.Sun, stringResource(Res.string.appearance_light_mode), mode == ThemeMode.Light) {
onModeChanged(ThemeMode.Light)
}
}
}
}
@Composable
private fun ThemeSettingsRow(icon: ImageVector, title: String, selected: Boolean, onClick: () -> Unit) {
SettingsRow(
icon = icon,
title = title,
selected = selected,
onClick = onClick,
showsDisclosure = false,
trailing = if (selected) {
{
Icon(
SettingsIcons.Check,
contentDescription = null,
tint = LocalVniDropColors.current.brandLink,
modifier = Modifier.size(20.dp),
)
}
} else {
null
},
)
}

View File

@@ -0,0 +1,65 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.notifications_description
import vnidrop.shared.generated.resources.notifications_local_title
import vnidrop.shared.generated.resources.notifications_permission_denied
import vnidrop.shared.generated.resources.notifications_unsupported
import vnidrop.shared.generated.resources.notifications_title
@Composable
internal fun NotificationSettings(
state: SettingsState,
onEnabledChanged: (Boolean) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.notifications_title), onBack, showBack)
SettingsGroup {
SettingsToggleRow(
icon = SettingsIcons.Bell,
title = stringResource(Res.string.notifications_local_title),
description = stringResource(Res.string.notifications_description),
checked = state.notificationsEnabled,
enabled = state.notificationPermission != NotificationPermission.Unsupported,
onCheckedChange = onEnabledChanged,
)
}
when (state.notificationPermission) {
NotificationPermission.Denied -> NotificationPermissionHelp(onOpenSettings)
NotificationPermission.Unsupported -> NotificationSupportText(stringResource(Res.string.notifications_unsupported))
else -> Unit
}
}
}
@Composable
private fun NotificationPermissionHelp(onOpenSettings: () -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
NotificationSupportText(stringResource(Res.string.notifications_permission_denied))
SecondaryButton(stringResource(Res.string.button_open_settings), onClick = onOpenSettings)
}
}
@Composable
private fun NotificationSupportText(text: String) {
Text(text = text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}

View File

@@ -0,0 +1,66 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_choose_folder
import vnidrop.shared.generated.resources.button_reset_default
import vnidrop.shared.generated.resources.field_username
import vnidrop.shared.generated.resources.folder_status_permission_required
import vnidrop.shared.generated.resources.folder_status_unavailable
import vnidrop.shared.generated.resources.folder_status_validating
import vnidrop.shared.generated.resources.folder_status_writable
import vnidrop.shared.generated.resources.preferences_receive_folder_title
import vnidrop.shared.generated.resources.preferences_title
@Composable
internal fun PreferencesSettings(
state: SettingsState,
onUsernameChanged: (String) -> Unit,
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.preferences_title), onBack, showBack)
Field(state.username, onUsernameChanged, stringResource(Res.string.field_username))
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Folder,
title = stringResource(Res.string.preferences_receive_folder_title),
value = state.receiveFolder?.displayName?.ifBlank { state.receiveFolder.value },
iconTone = SettingsIconTone.Neutral,
)
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Check,
title = state.folderStatusLabel(),
iconTone = SettingsIconTone.Neutral,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder)
SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder)
}
}
}
@Composable
private fun SettingsState.folderStatusLabel(): String = if (isValidatingFolder) {
stringResource(Res.string.folder_status_validating)
} else {
when (folderAccessStatus) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
}
}

View File

@@ -0,0 +1,216 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_back
internal enum class SettingsIconTone { Brand, Neutral }
@Composable
internal fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showBack) {
IconButton(onClick = onBack) {
Icon(
SettingsIcons.Back,
contentDescription = stringResource(Res.string.button_back),
tint = LocalVniDropColors.current.foregroundDefault,
)
}
}
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
}
@Composable
internal fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(content = content)
}
}
@Composable
internal fun SettingsRow(
icon: ImageVector,
title: String,
value: String? = null,
subtitle: String? = null,
selected: Boolean = false,
iconTone: SettingsIconTone = SettingsIconTone.Brand,
onClick: (() -> Unit)? = null,
showsDisclosure: Boolean = onClick != null,
trailing: @Composable (() -> Unit)? = null,
) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.background(if (selected) colors.backgroundSelection else Color.Transparent)
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsLeadingIcon(icon, iconTone)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
subtitle?.let {
Text(
it,
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
value?.let {
Text(
it,
modifier = Modifier.padding(start = 12.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when {
trailing != null -> {
Spacer(Modifier.width(10.dp))
trailing()
}
onClick != null && showsDisclosure -> {
Spacer(Modifier.width(8.dp))
Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
}
}
@Composable
internal fun SettingsToggleRow(
icon: ImageVector,
title: String,
description: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(if (enabled) 1f else 0.55f)
.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
onValueChange = onCheckedChange,
)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsLeadingIcon(icon, SettingsIconTone.Brand)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = colors.brandButton,
),
)
}
}
@Composable
internal fun SettingsDivider(startPadding: Dp = 60.dp) {
HorizontalDivider(
modifier = Modifier.padding(start = startPadding),
color = LocalVniDropColors.current.borderDefault.copy(alpha = 0.72f),
)
}
@Composable
internal fun InfoItem(title: String, value: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(title, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelLarge)
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun SettingsLeadingIcon(icon: ImageVector, tone: SettingsIconTone) {
val colors = LocalVniDropColors.current
val foreground = if (tone == SettingsIconTone.Brand) colors.brandLink else colors.foregroundLight
val background = if (tone == SettingsIconTone.Brand) colors.brandLink.copy(alpha = 0.13f) else colors.backgroundSurface300
Box(
modifier = Modifier.size(34.dp).background(background, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(19.dp))
}
}

View File

@@ -0,0 +1,143 @@
package com.vnidrop.app.feature.settings
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
internal object SettingsIcons {
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)
lineTo(9f, 6f)
}
val Back = lineIcon("Back") {
moveTo(19f, 12f)
lineTo(5f, 12f)
moveTo(12f, 19f)
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val Check = lineIcon("Check") {
moveTo(20f, 6f)
lineTo(9f, 17f)
lineTo(4f, 12f)
}
val Sun = lineIcon("Sun") {
moveTo(12f, 4f)
lineTo(12f, 2f)
moveTo(12f, 22f)
lineTo(12f, 20f)
moveTo(4.93f, 4.93f)
lineTo(6.34f, 6.34f)
moveTo(17.66f, 17.66f)
lineTo(19.07f, 19.07f)
moveTo(2f, 12f)
lineTo(4f, 12f)
moveTo(20f, 12f)
lineTo(22f, 12f)
moveTo(4.93f, 19.07f)
lineTo(6.34f, 17.66f)
moveTo(17.66f, 6.34f)
lineTo(19.07f, 4.93f)
moveTo(16f, 12f)
arcTo(4f, 4f, 0f, true, true, 8f, 12f)
arcTo(4f, 4f, 0f, true, true, 16f, 12f)
}
val Moon = lineIcon("Moon") {
moveTo(21f, 12.79f)
arcTo(9f, 9f, 0f, true, true, 11.21f, 3f)
arcTo(7f, 7f, 0f, false, false, 21f, 12.79f)
}
val Device = lineIcon("Device") {
roundRect(7f, 2f, 10f, 20f, 2.5f)
moveTo(11f, 18f)
lineTo(13f, 18f)
}
val Folder = lineIcon("Folder") {
moveTo(3f, 7f)
lineTo(9f, 7f)
lineTo(11f, 9f)
lineTo(21f, 9f)
lineTo(21f, 19f)
lineTo(3f, 19f)
close()
}
val Info = lineIcon("Info") {
moveTo(12f, 16f)
lineTo(12f, 12f)
moveTo(12f, 8f)
lineTo(12.01f, 8f)
moveTo(21f, 12f)
arcTo(9f, 9f, 0f, true, true, 3f, 12f)
arcTo(9f, 9f, 0f, true, true, 21f, 12f)
}
val Bell = lineIcon("Bell") {
moveTo(18f, 8f)
arcTo(6f, 6f, 0f, false, false, 6f, 8f)
lineTo(6f, 13f)
lineTo(4f, 17f)
lineTo(20f, 17f)
lineTo(18f, 13f)
close()
moveTo(10f, 21f)
arcTo(2f, 2f, 0f, false, false, 14f, 21f)
}
val Document = lineIcon("Document") {
moveTo(14f, 2f)
lineTo(6f, 2f)
arcTo(2f, 2f, 0f, false, false, 4f, 4f)
lineTo(4f, 20f)
arcTo(2f, 2f, 0f, false, false, 6f, 22f)
lineTo(18f, 22f)
arcTo(2f, 2f, 0f, false, false, 20f, 20f)
lineTo(20f, 8f)
lineTo(14f, 2f)
moveTo(14f, 2f)
lineTo(14f, 8f)
lineTo(20f, 8f)
}
val Bug = lineIcon("Bug") {
roundRect(7f, 6f, 10f, 14f, 5f)
moveTo(3f, 10f)
lineTo(7f, 10f)
moveTo(17f, 10f)
lineTo(21f, 10f)
moveTo(3f, 16f)
lineTo(7f, 16f)
moveTo(17f, 16f)
lineTo(21f, 16f)
moveTo(12f, 6f)
lineTo(12f, 20f)
}
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
pathBuilder = block,
)
}.build()
private fun PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) {
moveTo(x + radius, y)
lineTo(x + width - radius, y)
arcTo(radius, radius, 0f, false, true, x + width, y + radius)
lineTo(x + width, y + height - radius)
arcTo(radius, radius, 0f, false, true, x + width - radius, y + height)
lineTo(x + radius, y + height)
arcTo(radius, radius, 0f, false, true, x, y + height - radius)
lineTo(x, y + radius)
arcTo(radius, radius, 0f, false, true, x + radius, y)
}

View File

@@ -0,0 +1,75 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.settings_title
@Composable
internal fun SettingsOverview(
state: SettingsState,
onSectionSelected: (SettingsSection) -> Unit,
largeTitle: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
stringResource(Res.string.settings_title),
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Device,
title = stringResource(Res.string.preferences_title),
value = state.username,
selected = state.selectedSection == SettingsSection.Preferences,
onClick = { onSectionSelected(SettingsSection.Preferences) },
)
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Sun,
title = stringResource(Res.string.appearance_title),
value = themeModeLabel(state.themeMode),
selected = state.selectedSection == SettingsSection.Appearance,
onClick = { onSectionSelected(SettingsSection.Appearance) },
)
}
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Bell,
title = stringResource(Res.string.notifications_title),
selected = state.selectedSection == SettingsSection.Notifications,
onClick = { onSectionSelected(SettingsSection.Notifications) },
)
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Info,
title = stringResource(Res.string.about_title),
selected = state.selectedSection == SettingsSection.About,
iconTone = SettingsIconTone.Neutral,
onClick = { onSectionSelected(SettingsSection.About) },
)
}
}
}
@Composable
private fun themeModeLabel(mode: ThemeMode): String = when (mode) {
ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
ThemeMode.Light -> stringResource(Res.string.appearance_light_mode)
ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
}

View File

@@ -0,0 +1,32 @@
package com.vnidrop.app.feature.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberReceiveFolderPicker
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
val state by viewModel.state.collectAsStateWithLifecycle()
val picker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
SettingsEffect.OpenReceiveFolderPicker -> picker.pickFolder()
}
}
}
SettingsScreen(
state = state,
windowClass = windowClass,
onSectionSelected = viewModel::selectSection,
onUsernameChanged = viewModel::setUsername,
onThemeModeChanged = viewModel::setThemeMode,
onChooseFolder = viewModel::chooseReceiveFolder,
onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled,
onOpenNotificationSettings = viewModel::openNotificationSettings,
)
}

View File

@@ -0,0 +1,88 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.ThemeMode
@Composable
fun SettingsScreen(
state: SettingsState,
windowClass: WindowClass,
onSectionSelected: (SettingsSection) -> Unit,
onUsernameChanged: (String) -> Unit,
onThemeModeChanged: (ThemeMode) -> Unit,
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit,
) {
if (windowClass == WindowClass.Desktop) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(Modifier.widthIn(min = 280.dp, max = 340.dp)) {
SettingsOverview(state, onSectionSelected, largeTitle = false)
}
Column(Modifier.weight(1f)) {
SettingsSectionContent(
state = state,
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
onBack = {},
showBack = false,
onUsernameChanged = onUsernameChanged,
onThemeModeChanged = onThemeModeChanged,
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings,
)
}
}
} else {
when (state.selectedSection) {
SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true)
else -> SettingsSectionContent(
state = state,
section = state.selectedSection,
onBack = { onSectionSelected(SettingsSection.Overview) },
showBack = true,
onUsernameChanged = onUsernameChanged,
onThemeModeChanged = onThemeModeChanged,
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onOpenNotificationSettings = onOpenNotificationSettings,
)
}
}
}
@Composable
private fun SettingsSectionContent(
state: SettingsState,
section: SettingsSection,
onBack: () -> Unit,
showBack: Boolean,
onUsernameChanged: (String) -> Unit,
onThemeModeChanged: (ThemeMode) -> Unit,
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit,
) {
when (section) {
SettingsSection.Overview -> Unit
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.About -> AboutSettings(state, onBack, showBack)
}
}

View File

@@ -0,0 +1,206 @@
package com.vnidrop.app.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.DeviceInfoProvider
import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.notifications_enabled_message
import vnidrop.shared.generated.resources.notifications_permission_denied
import vnidrop.shared.generated.resources.notifications_settings_open_failed
import vnidrop.shared.generated.resources.notifications_unsupported
enum class SettingsSection {
Overview,
Preferences,
Appearance,
Notifications,
About,
}
data class SettingsState(
val selectedSection: SettingsSection = SettingsSection.Overview,
val username: String = "",
val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isValidatingFolder: Boolean = false,
val themeMode: ThemeMode = ThemeMode.System,
val notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val deviceInfo: DeviceInfo? = null,
val appVersion: String = "",
val isLoadingDeviceInfo: Boolean = false,
)
sealed interface SettingsEffect {
data object OpenReceiveFolderPicker : SettingsEffect
}
class SettingsViewModel(
private val environment: PlatformEnvironment,
private val deviceInfoProvider: DeviceInfoProvider,
private val fileSystemService: FileSystemService,
private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
val state: StateFlow<SettingsState> = _state.asStateFlow()
private val effects = Channel<SettingsEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var enableNotificationsAfterSettings = false
init {
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update {
it.copy(
username = preferences.username,
receiveFolder = preferences.receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
)
}
validateFolder(preferences.receiveFolder)
}
}
refreshNotificationPermission()
loadDeviceInfo()
}
fun selectSection(section: SettingsSection) {
_state.update { it.copy(selectedSection = section) }
if (section == SettingsSection.About) loadDeviceInfo()
}
fun setUsername(value: String) {
viewModelScope.launch { preferencesRepository.setUsername(value) }
}
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { preferencesRepository.setThemeMode(mode) }
}
fun chooseReceiveFolder() {
viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) }
}
fun onReceiveFolderPicked(folder: ReceiveFolder) {
viewModelScope.launch { preferencesRepository.setReceiveFolder(folder) }
}
fun onReceiveFolderPickFailed(reason: String) = messages.error(IllegalStateException(reason))
fun resetReceiveFolder() {
viewModelScope.launch { preferencesRepository.resetReceiveFolder() }
}
fun setNotificationsEnabled(enabled: Boolean) {
viewModelScope.launch {
if (!enabled) {
preferencesRepository.setNotificationsEnabled(false)
notifications.cancelAll()
return@launch
}
val permission = notifications.requestPermission()
_state.update { it.copy(notificationPermission = permission) }
if (permission == NotificationPermission.Granted) {
enableNotifications()
} else {
preferencesRepository.setNotificationsEnabled(false)
messages.show(
UiMessage(
UiText.Resource(
if (permission == NotificationPermission.Unsupported) Res.string.notifications_unsupported
else Res.string.notifications_permission_denied,
),
UiMessageTone.Warning,
actionLabel = if (permission == NotificationPermission.Denied) {
UiText.Resource(Res.string.button_open_settings)
} else {
null
},
onAction = if (permission == NotificationPermission.Denied) ::openNotificationSettings else null,
),
)
}
}
}
fun openNotificationSettings() {
viewModelScope.launch {
enableNotificationsAfterSettings = true
notifications.openSettings().onFailure {
enableNotificationsAfterSettings = false
messages.show(
UiMessage(UiText.Resource(Res.string.notifications_settings_open_failed), UiMessageTone.Error),
)
}
}
}
fun refreshNotificationPermission() {
viewModelScope.launch {
val permission = notifications.refreshPermission()
_state.update { it.copy(notificationPermission = permission) }
if (enableNotificationsAfterSettings) {
enableNotificationsAfterSettings = false
if (permission == NotificationPermission.Granted) enableNotifications()
} else if (permission != NotificationPermission.Granted && _state.value.notificationsEnabled) {
preferencesRepository.setNotificationsEnabled(false)
notifications.cancelAll()
}
}
}
private suspend fun enableNotifications() {
preferencesRepository.setNotificationsEnabled(true)
messages.show(
UiMessage(UiText.Resource(Res.string.notifications_enabled_message), UiMessageTone.Success),
)
}
private fun loadDeviceInfo() {
if (_state.value.isLoadingDeviceInfo) return
viewModelScope.launch {
_state.update { it.copy(isLoadingDeviceInfo = true) }
try {
val info = deviceInfoProvider.load()
_state.update { it.copy(deviceInfo = info, isLoadingDeviceInfo = false) }
} catch (error: Throwable) {
if (error is CancellationException) throw error
_state.update { it.copy(isLoadingDeviceInfo = false) }
messages.error(error, "Could not load device information.")
}
}
}
private suspend fun validateFolder(folder: ReceiveFolder) {
_state.update { it.copy(isValidatingFolder = true) }
val status = fileSystemService.validateReceiveFolder(folder)
_state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) }
}
}

View File

@@ -0,0 +1,27 @@
package com.vnidrop.app.notifications
import kotlinx.coroutines.flow.StateFlow
enum class NotificationPermission {
NotDetermined,
Granted,
Denied,
Unsupported,
}
data class LocalNotification(
val id: String,
val title: String,
val body: String,
)
interface LocalNotificationService {
val permission: StateFlow<NotificationPermission>
suspend fun refreshPermission(): NotificationPermission
suspend fun requestPermission(): NotificationPermission
suspend fun openSettings(): Result<Unit>
suspend fun publish(notification: LocalNotification): Result<Unit>
suspend fun cancel(id: String)
suspend fun cancelAll()
}

View File

@@ -0,0 +1,14 @@
package com.vnidrop.app.platform
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AppVisibility(initiallyForeground: Boolean = true) {
private val _isForeground = MutableStateFlow(initiallyForeground)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
fun setForeground(value: Boolean) {
_isForeground.value = value
}
}

View File

@@ -0,0 +1,114 @@
package com.vnidrop.app.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.booleanPreferencesKey
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import okio.Path.Companion.toPath
data class AppPreferences(
val username: String,
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
val notificationsEnabled: Boolean,
)
class AppPreferencesDefaults(
val username: String,
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false,
)
interface PreferencesRepository {
val preferences: Flow<AppPreferences>
suspend fun setUsername(username: String)
suspend fun setReceiveFolder(folder: ReceiveFolder)
suspend fun resetReceiveFolder()
suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean)
}
class AppPreferencesRepository(
private val dataStore: DataStore<Preferences>,
private val defaults: AppPreferencesDefaults,
) : PreferencesRepository {
override val preferences: Flow<AppPreferences> = dataStore.data
.catch { emit(emptyPreferences()) }
.map { prefs ->
AppPreferences(
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
receiveFolder = ReceiveFolder(
kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) }
?: defaults.receiveFolder.kind,
value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() }
?: defaults.receiveFolder.value,
displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() }
?: defaults.receiveFolder.displayName,
),
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
)
}
override suspend fun setUsername(username: String) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.Username] = username.trim()
}
}
override suspend fun setReceiveFolder(folder: ReceiveFolder) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name
prefs[PreferenceKeys.ReceiveFolderValue] = folder.value
prefs[PreferenceKeys.ReceiveFolderDisplayName] = folder.displayName
}
}
override suspend fun resetReceiveFolder() {
setReceiveFolder(defaults.receiveFolder)
}
override suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ThemeMode] = mode.name
}
}
override suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.NotificationsEnabled] = enabled
}
}
}
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
produceFile = { "$appDataDir/$AppPreferencesFileName".toPath() },
)
private object PreferenceKeys {
val Username = stringPreferencesKey("username")
val ReceiveFolderKind = stringPreferencesKey("receive_folder_kind")
val ReceiveFolderValue = stringPreferencesKey("receive_folder_value")
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode")
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
}
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()
private fun themeModeOrNull(raw: String): ThemeMode? =
runCatching { ThemeMode.valueOf(raw) }.getOrNull()
private const val AppPreferencesFileName = "app_preferences.preferences_pb"

View File

@@ -0,0 +1,84 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_close
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveDrawer(
windowClass: WindowClass,
onDismissRequest: () -> Unit,
content: @Composable () -> Unit,
) {
if (windowClass == WindowClass.Phone) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = LocalVniDropColors.current.backgroundDialog,
) {
ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content)
}
} else {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.86f).widthIn(max = 560.dp),
shape = RoundedCornerShape(20.dp),
color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp,
) { ClosableModalContent(onDismissRequest, content = content) }
}
}
}
@Composable
private fun ClosableModalContent(
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(modifier) {
content()
IconButton(
onClick = onClose,
modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp),
) {
Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight)
}
}
}
private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) {
moveTo(6f, 6f); lineTo(18f, 18f)
moveTo(18f, 6f); lineTo(6f, 18f)
}
}.build()

View File

@@ -0,0 +1,44 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun AppCard(
title: String,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalVniDropColors.current
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
border = BorderStroke(1.dp, colors.borderDefault),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
trailing?.invoke()
}
HorizontalDivider(color = colors.borderDefault)
content()
}
}
}

View File

@@ -0,0 +1,58 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, contentColor = Color.White),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun SecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
OutlinedButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp)) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = LocalVniDropColors.current.destructiveDefault,
contentColor = Color.White,
),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View File

@@ -0,0 +1,29 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Field(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
enabled: Boolean = true,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
minLines = minLines,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
)
}

View File

@@ -0,0 +1,40 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
enum class PillTone { Neutral, Success, Warning, Destructive, Brand }
@Composable
fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = PillTone.Neutral) {
val colors = LocalVniDropColors.current
val color = when (tone) {
PillTone.Neutral -> colors.foregroundLighter
PillTone.Success, PillTone.Brand -> colors.brandLink
PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructiveDefault
}
val shape = RoundedCornerShape(7.dp)
Row(
modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(color))
Text(label, modifier = Modifier.padding(start = 6.dp), color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
}
}

View File

@@ -0,0 +1,36 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun ProgressRow(label: String, progress: Float?, modifier: Modifier = Modifier) {
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
@Composable
fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Text(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall)
}
}

View File

@@ -1,232 +0,0 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun AppCard(
title: String,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalVniDropColors.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
border = BorderStroke(1.dp, colors.borderDefault),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
trailing?.invoke()
}
HorizontalDivider(color = colors.borderDefault)
content()
}
}
}
@Composable
fun Field(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
enabled: Boolean = true,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
minLines = minLines,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
)
}
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val colors = LocalVniDropColors.current
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = colors.brandButton, contentColor = Color.White),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun SecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun QuietButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun StatusPill(
label: String,
modifier: Modifier = Modifier,
tone: PillTone = PillTone.Neutral,
) {
val colors = LocalVniDropColors.current
val color = when (tone) {
PillTone.Neutral -> colors.foregroundLighter
PillTone.Success -> colors.brandLink
PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructiveDefault
PillTone.Brand -> colors.brandLink
}
Row(
modifier = modifier
.clip(RoundedCornerShape(999.dp))
.background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(CircleShape)
.background(color),
)
Text(label, color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
}
}
enum class PillTone {
Neutral,
Success,
Warning,
Destructive,
Brand,
}
@Composable
fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
val colors = LocalVniDropColors.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.destructive200),
border = BorderStroke(1.dp, colors.destructive400),
) {
Text(
text = message,
modifier = Modifier.padding(14.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
fun ProgressRow(
label: String,
progress: Float?,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (progress == null) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
modifier = Modifier.weight(0.35f),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
Text(
text = value,
modifier = Modifier.weight(0.65f),
style = MaterialTheme.typography.bodySmall,
)
}
}

View File

@@ -0,0 +1,54 @@
package com.vnidrop.app.ui.feedback
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.jetbrains.compose.resources.StringResource
sealed interface UiText {
data class Resource(val resource: StringResource) : UiText
data class Dynamic(val value: String) : UiText
}
enum class UiMessageTone {
Info,
Success,
Warning,
Error,
}
data class UiMessage(
val text: UiText,
val tone: UiMessageTone = UiMessageTone.Info,
val actionLabel: UiText? = null,
val onAction: (() -> Unit)? = null,
)
class UiMessageController {
private val channel = Channel<UiMessage>(Channel.BUFFERED)
val messages: Flow<UiMessage> = channel.receiveAsFlow()
private val _dismissals = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val dismissals: SharedFlow<Unit> = _dismissals.asSharedFlow()
suspend fun show(message: UiMessage) {
channel.send(message)
}
fun tryShow(message: UiMessage): Boolean = channel.trySend(message).isSuccess
fun dismissCurrent() {
_dismissals.tryEmit(Unit)
}
fun error(error: Throwable, fallback: String = "Something went wrong.") {
tryShow(
UiMessage(
text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback),
tone = UiMessageTone.Error,
),
)
}
}

View File

@@ -0,0 +1,161 @@
package com.vnidrop.app.ui.feedback
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.snackbar_dismiss
@Composable
fun VniDropSnackbarHost(
controller: UiMessageController,
modifier: Modifier = Modifier,
) {
val hostState = remember { SnackbarHostState() }
var tone by remember { mutableStateOf(UiMessageTone.Info) }
LaunchedEffect(controller) {
controller.messages.collect { message ->
tone = message.tone
val result = hostState.showSnackbar(
message = message.text.resolve(),
actionLabel = message.actionLabel?.resolve(),
withDismissAction = true,
duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke()
}
}
LaunchedEffect(controller, hostState) {
controller.dismissals.collect {
hostState.currentSnackbarData?.dismiss()
}
}
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
val colors = LocalVniDropColors.current
val accent = when (tone) {
UiMessageTone.Info -> colors.brandLink
UiMessageTone.Success -> colors.brandDefault
UiMessageTone.Warning -> colors.warningDefault
UiMessageTone.Error -> colors.destructiveDefault
}
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp).widthIn(max = 520.dp).fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = colors.backgroundSurface200,
contentColor = colors.foregroundDefault,
shadowElevation = 6.dp,
) {
SnackbarContent(data, accent)
}
}
}
@Composable
private fun SnackbarContent(data: SnackbarData, actionColor: Color) {
BoxWithConstraints {
val actionLabel = data.visuals.actionLabel
if (actionLabel != null && maxWidth < 420.dp) {
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 6.dp)) {
MessageAndDismiss(data)
TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) {
Text(actionLabel, color = actionColor)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SnackbarMessage(data.visuals.message, Modifier.weight(1f))
actionLabel?.let { label ->
TextButton(onClick = data::performAction) { Text(label, color = actionColor) }
}
DismissButton(data::dismiss)
}
}
}
}
@Composable
private fun MessageAndDismiss(data: SnackbarData) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
SnackbarMessage(data.visuals.message, Modifier.weight(1f))
DismissButton(data::dismiss)
}
}
@Composable
private fun SnackbarMessage(message: String, modifier: Modifier = Modifier) {
Text(
text = message,
modifier = modifier.padding(vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
@Composable
private fun DismissButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = CloseIcon,
contentDescription = stringResource(Res.string.snackbar_dismiss),
tint = LocalVniDropColors.current.foregroundLighter,
modifier = Modifier.size(18.dp),
)
}
}
private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
pathFillType = PathFillType.NonZero,
) {
moveTo(6f, 6f)
lineTo(18f, 18f)
moveTo(18f, 6f)
lineTo(6f, 18f)
}
}.build()
private suspend fun UiText.resolve(): String = when (this) {
is UiText.Dynamic -> value
is UiText.Resource -> getString(resource)
}

View File

@@ -1,69 +0,0 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.VniDropAppEvent
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.ReceiveUiState
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive
import vnidrop.shared.generated.resources.button_receiving
import vnidrop.shared.generated.resources.field_output_directory
import vnidrop.shared.generated.resources.field_receiver_name
import vnidrop.shared.generated.resources.field_ticket
import vnidrop.shared.generated.resources.receive_subtitle
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title
@Composable
fun ReceiveScreen(
coreState: CoreUiState,
receiveState: ReceiveUiState,
onEvent: (VniDropAppEvent) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
ErrorSection(coreState)
AppCard(title = stringResource(Res.string.ticket_card_title)) {
Field(
value = receiveState.ticket,
onValueChange = { onEvent(VniDropAppEvent.ReceiveTicketChanged(it)) },
label = stringResource(Res.string.field_ticket),
minLines = 4,
)
Field(
value = receiveState.outputDirectory,
onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) },
label = stringResource(Res.string.field_output_directory),
)
Field(
value = receiveState.receiverName,
onValueChange = { onEvent(VniDropAppEvent.ReceiverNameChanged(it)) },
label = stringResource(Res.string.field_receiver_name),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
text = stringResource(Res.string.button_inspect_ticket),
onClick = { onEvent(VniDropAppEvent.InspectTicketClicked) },
enabled = receiveState.canInspect(coreState.isInitialized),
)
PrimaryButton(
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = { onEvent(VniDropAppEvent.ReceiveClicked) },
enabled = receiveState.canReceive(coreState.isInitialized),
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}

Some files were not shown because too many files have changed in this diff Show More