mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 10:49:56 +02:00
Merge pull request #3 from vnidrop/feat/ui-next
Harden transfer lifecycle and build the modular send experience
This commit is contained in:
37
.github/workflows/rust-core.yml
vendored
Normal file
37
.github/workflows/rust-core.yml
vendored
Normal 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
1
Cargo.lock
generated
@@ -5056,6 +5056,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -58,6 +58,6 @@ android {
|
|||||||
|
|
||||||
tasks.configureEach {
|
tasks.configureEach {
|
||||||
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
|
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
|
||||||
dependsOn(":shared:cargoBuildAndroidArm64Debug")
|
dependsOn(":shared:copyAndroidAndroidArm64Debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -20,6 +23,13 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,25 +4,13 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
attachAndroidFilePickerContext(this)
|
|
||||||
attachAndroidPlatformContext(this)
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(rememberAndroidAppDependencies(this))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AppAndroidPreview() {
|
|
||||||
App()
|
|
||||||
}
|
|
||||||
|
|||||||
4
androidApp/src/main/res/xml/file_paths.xml
Normal file
4
androidApp/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
@@ -48,3 +48,36 @@ bytes through Kotlin memory.
|
|||||||
descriptor; Rust duplicates the descriptor before streaming.
|
descriptor; Rust duplicates the descriptor before streaming.
|
||||||
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
|
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
|
||||||
Rust streams from the accessible file URL/path.
|
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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ crate-type = ["cdylib", "staticlib", "rlib"]
|
|||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
|
blake3 = "1.8.3"
|
||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ impl AccessPolicy {
|
|||||||
self.modes.write().await.insert(transfer_id, mode);
|
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) {
|
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
||||||
self.modes.write().await.remove(&transfer_id);
|
self.modes.write().await.remove(&transfer_id);
|
||||||
self.approved_sessions
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct ApprovalSession {
|
struct ApprovalSession {
|
||||||
expires_at: Option<i64>,
|
expires_at: Option<i64>,
|
||||||
|
|||||||
@@ -1,8 +1,96 @@
|
|||||||
|
use anyhow::Context;
|
||||||
use iroh_blobs::Hash;
|
use iroh_blobs::Hash;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::util::{non_empty, now_ms};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct CoreEvent {
|
pub struct CoreEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -20,6 +108,22 @@ pub trait CoreEventSink: Send + Sync {
|
|||||||
fn on_event(&self, event: CoreEvent);
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct RuntimeStatus {
|
pub struct RuntimeStatus {
|
||||||
pub endpoint_id: String,
|
pub endpoint_id: String,
|
||||||
@@ -49,9 +153,10 @@ pub struct ShareMetadataInput {
|
|||||||
pub transfer_id: u64,
|
pub transfer_id: u64,
|
||||||
pub transfer_name: Option<String>,
|
pub transfer_name: Option<String>,
|
||||||
pub sender_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 {
|
pub enum TransferAccessMode {
|
||||||
Public,
|
Public,
|
||||||
ApprovalRequired,
|
ApprovalRequired,
|
||||||
@@ -59,7 +164,9 @@ pub enum TransferAccessMode {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct StoredTransfer {
|
pub struct StoredTransfer {
|
||||||
|
pub local_id: String,
|
||||||
pub transfer_id: u64,
|
pub transfer_id: u64,
|
||||||
|
pub peer_id: Option<String>,
|
||||||
pub direction: String,
|
pub direction: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub transfer_name: Option<String>,
|
pub transfer_name: Option<String>,
|
||||||
@@ -67,6 +174,7 @@ pub struct StoredTransfer {
|
|||||||
pub ticket: Option<String>,
|
pub ticket: Option<String>,
|
||||||
pub file_count: u64,
|
pub file_count: u64,
|
||||||
pub total_size: u64,
|
pub total_size: u64,
|
||||||
|
pub access_mode: TransferAccessMode,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
}
|
}
|
||||||
@@ -136,4 +244,5 @@ pub struct ReceiverRequest {
|
|||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub requested_at: i64,
|
pub requested_at: i64,
|
||||||
pub responded_at: Option<i64>,
|
pub responded_at: Option<i64>,
|
||||||
|
pub completed_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
access_policy::AccessPolicy,
|
access_policy::AccessPolicy,
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::{HandshakeResponse, RequestTransfer},
|
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
||||||
repository::{ReceiverRequestInsert, Repository},
|
repository::{ReceiverRequestInsert, Repository},
|
||||||
|
transfer_state::ReceiverRequestStatus,
|
||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,19 +30,60 @@ pub(crate) struct ApprovalService {
|
|||||||
event_hub: Arc<EventHub>,
|
event_hub: Arc<EventHub>,
|
||||||
access_policy: Arc<AccessPolicy>,
|
access_policy: Arc<AccessPolicy>,
|
||||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
||||||
|
max_pending: usize,
|
||||||
|
max_metadata_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalService {
|
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(
|
pub(crate) fn new(
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
event_hub: Arc<EventHub>,
|
event_hub: Arc<EventHub>,
|
||||||
access_policy: Arc<AccessPolicy>,
|
access_policy: Arc<AccessPolicy>,
|
||||||
|
max_pending: usize,
|
||||||
|
max_metadata_bytes: u64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
repository,
|
repository,
|
||||||
event_hub,
|
event_hub,
|
||||||
access_policy,
|
access_policy,
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_pending,
|
||||||
|
max_metadata_bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +94,11 @@ impl ApprovalService {
|
|||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let sender = self.pending.lock().await.remove(&request_id);
|
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
|
self.repository
|
||||||
.update_receiver_request_status(&request_id, status, reason.as_deref())
|
.update_receiver_request_status(&request_id, status, reason.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -73,6 +119,25 @@ impl ApprovalService {
|
|||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
request: RequestTransfer,
|
request: RequestTransfer,
|
||||||
) -> HandshakeResponse {
|
) -> 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(
|
self.event_hub.emit_transfer(
|
||||||
request.transfer_id,
|
request.transfer_id,
|
||||||
"send",
|
"send",
|
||||||
@@ -90,8 +155,17 @@ impl ApprovalService {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.wait_for_sender_decision(remote_endpoint_id, request)
|
if self
|
||||||
|
.access_policy
|
||||||
|
.allows_without_approval(request.transfer_id)
|
||||||
.await
|
.await
|
||||||
|
{
|
||||||
|
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.wait_for_sender_decision(remote_endpoint_id, request)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer")
|
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(
|
async fn wait_for_sender_decision(
|
||||||
&self,
|
&self,
|
||||||
remote_endpoint_id: String,
|
remote_endpoint_id: String,
|
||||||
@@ -112,7 +244,19 @@ impl ApprovalService {
|
|||||||
) -> HandshakeResponse {
|
) -> HandshakeResponse {
|
||||||
let request_id = Uuid::new_v4().to_string();
|
let request_id = Uuid::new_v4().to_string();
|
||||||
let (tx, rx) = oneshot::channel();
|
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
|
let insert_result = self
|
||||||
.repository
|
.repository
|
||||||
@@ -171,7 +315,21 @@ impl ApprovalService {
|
|||||||
"expires_at": expires_at,
|
"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)) => {
|
Ok(Ok(decision)) => {
|
||||||
self.deny(
|
self.deny(
|
||||||
@@ -189,7 +347,7 @@ impl ApprovalService {
|
|||||||
.repository
|
.repository
|
||||||
.update_receiver_request_status(
|
.update_receiver_request_status(
|
||||||
&request_id,
|
&request_id,
|
||||||
"expired",
|
ReceiverRequestStatus::Expired,
|
||||||
Some("approval timed out"),
|
Some("approval timed out"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -219,3 +377,7 @@ impl ApprovalService {
|
|||||||
HandshakeResponse::Denied { reason }
|
HandshakeResponse::Denied { reason }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn receipt_token_hash(token: &str) -> String {
|
||||||
|
blake3::hash(token.as_bytes()).to_hex().to_string()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,100 @@ use tokio::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{CoreEvent, CoreEventSink},
|
api::{CoreEvent, CoreEventSink},
|
||||||
repository::Repository,
|
repository::Repository,
|
||||||
|
transfer_state::TransferDirection,
|
||||||
util::now_ms,
|
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 {
|
enum EventCommand {
|
||||||
Persist(CoreEvent),
|
Persist(CoreEvent),
|
||||||
Flush(oneshot::Sender<()>),
|
Flush(oneshot::Sender<()>),
|
||||||
@@ -20,19 +111,24 @@ enum EventCommand {
|
|||||||
|
|
||||||
pub(crate) struct EventHub {
|
pub(crate) struct EventHub {
|
||||||
sink: Arc<dyn CoreEventSink>,
|
sink: Arc<dyn CoreEventSink>,
|
||||||
tx: mpsc::UnboundedSender<EventCommand>,
|
tx: mpsc::Sender<EventCommand>,
|
||||||
join: TokioMutex<Option<JoinHandle<()>>>,
|
join: TokioMutex<Option<JoinHandle<()>>>,
|
||||||
sequence: Mutex<u64>,
|
sequence: Mutex<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHub {
|
impl EventHub {
|
||||||
pub(crate) fn start(repository: Repository, sink: Arc<dyn CoreEventSink>) -> Self {
|
pub(crate) fn start(
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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 {
|
let join = tokio::spawn(async move {
|
||||||
while let Some(command) = rx.recv().await {
|
while let Some(command) = rx.recv().await {
|
||||||
match command {
|
match command {
|
||||||
EventCommand::Persist(event) => {
|
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");
|
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) {
|
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(
|
pub(crate) fn emit_transfer(
|
||||||
@@ -67,10 +171,22 @@ impl EventHub {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
data: Value,
|
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(
|
self.emit(
|
||||||
"transfer",
|
EventScope::Transfer,
|
||||||
Some(transfer_id),
|
Some(transfer_id),
|
||||||
Some(direction.to_string()),
|
Some(direction),
|
||||||
phase,
|
phase,
|
||||||
kind,
|
kind,
|
||||||
data,
|
data,
|
||||||
@@ -79,14 +195,14 @@ impl EventHub {
|
|||||||
|
|
||||||
pub(crate) async fn flush(&self) {
|
pub(crate) async fn flush(&self) {
|
||||||
let (tx, rx) = oneshot::channel();
|
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;
|
let _ = rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn shutdown(&self) {
|
pub(crate) async fn shutdown(&self) {
|
||||||
let (tx, rx) = oneshot::channel();
|
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;
|
let _ = rx.await;
|
||||||
}
|
}
|
||||||
if let Some(join) = self.join.lock().await.take() {
|
if let Some(join) = self.join.lock().await.take() {
|
||||||
@@ -96,15 +212,20 @@ impl EventHub {
|
|||||||
|
|
||||||
fn emit(
|
fn emit(
|
||||||
&self,
|
&self,
|
||||||
scope: &str,
|
scope: EventScope,
|
||||||
transfer_id: Option<u64>,
|
transfer_id: Option<u64>,
|
||||||
direction: Option<String>,
|
direction: Option<TransferDirection>,
|
||||||
phase: &str,
|
phase: EventPhase,
|
||||||
kind: &str,
|
kind: EventKind,
|
||||||
data: Value,
|
data: Value,
|
||||||
) {
|
) {
|
||||||
let timestamp = now_ms();
|
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);
|
let id = format!("{timestamp}-{}", *sequence);
|
||||||
*sequence += 1;
|
*sequence += 1;
|
||||||
drop(sequence);
|
drop(sequence);
|
||||||
@@ -115,15 +236,15 @@ impl EventHub {
|
|||||||
let event = CoreEvent {
|
let event = CoreEvent {
|
||||||
id,
|
id,
|
||||||
timestamp,
|
timestamp,
|
||||||
scope: scope.to_string(),
|
scope: scope.as_str().to_string(),
|
||||||
transfer_id,
|
transfer_id,
|
||||||
direction,
|
direction: direction.map(|direction| direction.as_str().to_string()),
|
||||||
phase: phase.to_string(),
|
phase: phase.as_str().to_string(),
|
||||||
kind: kind.to_string(),
|
kind: kind.0,
|
||||||
data_json: data.to_string(),
|
data_json: data.to_string(),
|
||||||
};
|
};
|
||||||
if self.tx.send(EventCommand::Persist(event.clone())).is_err() {
|
if let Err(error) = self.tx.try_send(EventCommand::Persist(event.clone())) {
|
||||||
tracing::warn!(event_id = %event.id, "event persistence queue is closed");
|
tracing::warn!(event_id = %event.id, %error, "event persistence queue dropped event");
|
||||||
}
|
}
|
||||||
self.sink.on_event(event);
|
self.sink.on_event(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::fd::{FromRawFd, OwnedFd};
|
use std::os::fd::{FromRawFd, OwnedFd};
|
||||||
use std::{
|
use std::{
|
||||||
fs::File,
|
fs::{File, OpenOptions},
|
||||||
io::{self, Read, Write},
|
io::{self, Read, Write},
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
|
time::{Duration, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use iroh_blobs::{api::TempTag, Hash};
|
use iroh_blobs::{api::TempTag, Hash};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{ShareSource, SourceKind},
|
api::{CoreLimits, ShareSource, SourceKind},
|
||||||
util::non_empty,
|
util::non_empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
|
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
|
||||||
|
const STALE_PART_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct TransferImport {
|
pub(crate) struct TransferImport {
|
||||||
@@ -39,6 +45,109 @@ pub(crate) enum ImportSource {
|
|||||||
FileDescriptor(OwnedFd),
|
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 {
|
impl ImportSource {
|
||||||
pub(crate) fn open(self) -> Result<File> {
|
pub(crate) fn open(self) -> Result<File> {
|
||||||
match self {
|
match self {
|
||||||
@@ -51,7 +160,22 @@ impl ImportSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> {
|
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();
|
let mut files = Vec::new();
|
||||||
for source in sources {
|
for source in sources {
|
||||||
match source.kind {
|
match source.kind {
|
||||||
@@ -118,6 +242,34 @@ pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<Impo
|
|||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
anyhow::bail!("no files found in selected sources");
|
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)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +364,7 @@ where
|
|||||||
pub(crate) fn write_stream_to_blocking_writer<W>(
|
pub(crate) fn write_stream_to_blocking_writer<W>(
|
||||||
mut writer: W,
|
mut writer: W,
|
||||||
rx: async_channel::Receiver<io::Result<Option<Bytes>>>,
|
rx: async_channel::Receiver<io::Result<Option<Bytes>>>,
|
||||||
) -> io::Result<()>
|
) -> io::Result<W>
|
||||||
where
|
where
|
||||||
W: Write,
|
W: Write,
|
||||||
{
|
{
|
||||||
@@ -222,12 +374,13 @@ where
|
|||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writer.flush()
|
writer.flush()?;
|
||||||
|
Ok(writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_for_writer(
|
pub(crate) async fn wait_for_writer<T: Send + 'static>(
|
||||||
task: std::thread::JoinHandle<io::Result<()>>,
|
task: std::thread::JoinHandle<io::Result<T>>,
|
||||||
) -> Result<io::Result<()>> {
|
) -> Result<io::Result<T>> {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
task.join()
|
task.join()
|
||||||
.map_err(|_| anyhow::anyhow!("export writer thread panicked"))
|
.map_err(|_| anyhow::anyhow!("export writer thread panicked"))
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl fmt::Debug for HandshakeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn new(approval: crate::approval::ApprovalService) -> Self {
|
||||||
Self { approval }
|
Self { approval }
|
||||||
@@ -64,6 +64,14 @@ impl ProtocolHandler for HandshakeService {
|
|||||||
let response = self.handle_request(remote_endpoint_id.clone(), inner).await;
|
let response = self.handle_request(remote_endpoint_id.clone(), inner).await;
|
||||||
let _ = tx.send(response).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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn report_delivery(
|
||||||
|
&self,
|
||||||
|
receipt: DeliveryReceipt,
|
||||||
|
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(receipt).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -108,8 +123,27 @@ pub(crate) struct RequestTransfer {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub(crate) enum HandshakeResponse {
|
pub(crate) enum HandshakeResponse {
|
||||||
Approved { token: String, expires_at: i64 },
|
Approved {
|
||||||
Denied { reason: String },
|
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)]
|
#[rpc_requests(message = HandshakeMessage)]
|
||||||
@@ -117,4 +151,6 @@ pub(crate) enum HandshakeResponse {
|
|||||||
enum HandshakeProtocol {
|
enum HandshakeProtocol {
|
||||||
#[rpc(tx=oneshot::Sender<HandshakeResponse>)]
|
#[rpc(tx=oneshot::Sender<HandshakeResponse>)]
|
||||||
RequestTransfer(RequestTransfer),
|
RequestTransfer(RequestTransfer),
|
||||||
|
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||||
|
ReportDelivery(DeliveryReceipt),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ mod repository;
|
|||||||
mod runtime;
|
mod runtime;
|
||||||
mod secret;
|
mod secret;
|
||||||
mod ticket;
|
mod ticket;
|
||||||
|
mod transfer_state;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest,
|
||||||
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
|
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||||
TransferMetadata,
|
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
pub use runtime::VnidropCore;
|
pub use runtime::VnidropCore;
|
||||||
|
|||||||
@@ -1,30 +1,59 @@
|
|||||||
use std::{path::Path, str::FromStr};
|
use std::{path::Path, str::FromStr};
|
||||||
|
|
||||||
use anyhow::Result;
|
#[cfg(test)]
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||||
Row, SqlitePool,
|
Row, SqlitePool,
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer};
|
use crate::{
|
||||||
use crate::util::now_ms;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
|
#[cfg(test)]
|
||||||
|
fail_next_write: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TransferUpsert<'a> {
|
pub(crate) struct TransferUpsert<'a> {
|
||||||
pub(crate) transfer_id: u64,
|
pub(crate) transfer_id: u64,
|
||||||
pub(crate) direction: &'a str,
|
pub(crate) peer_id: Option<&'a str>,
|
||||||
pub(crate) status: &'a str,
|
pub(crate) direction: TransferDirection,
|
||||||
|
pub(crate) status: TransferStatus,
|
||||||
pub(crate) transfer_name: Option<&'a str>,
|
pub(crate) transfer_name: Option<&'a str>,
|
||||||
pub(crate) content_hash: Option<&'a str>,
|
pub(crate) content_hash: Option<&'a str>,
|
||||||
pub(crate) ticket: Option<&'a str>,
|
pub(crate) ticket: Option<&'a str>,
|
||||||
pub(crate) file_count: u64,
|
pub(crate) file_count: u64,
|
||||||
pub(crate) total_size: 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> {
|
pub(crate) struct ReceiverRequestInsert<'a> {
|
||||||
@@ -47,7 +76,11 @@ impl Repository {
|
|||||||
.max_connections(4)
|
.max_connections(4)
|
||||||
.connect_with(options)
|
.connect_with(options)
|
||||||
.await?;
|
.await?;
|
||||||
let repository = Self { pool };
|
let repository = Self {
|
||||||
|
pool,
|
||||||
|
#[cfg(test)]
|
||||||
|
fail_next_write: Arc::new(AtomicBool::new(false)),
|
||||||
|
};
|
||||||
repository.ensure_schema().await?;
|
repository.ensure_schema().await?;
|
||||||
Ok(repository)
|
Ok(repository)
|
||||||
}
|
}
|
||||||
@@ -59,6 +92,9 @@ impl Repository {
|
|||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS transfers (
|
CREATE TABLE IF NOT EXISTS transfers (
|
||||||
transfer_id INTEGER PRIMARY KEY,
|
transfer_id INTEGER PRIMARY KEY,
|
||||||
|
local_id TEXT NOT NULL,
|
||||||
|
protocol_transfer_id INTEGER NOT NULL,
|
||||||
|
peer_id TEXT,
|
||||||
direction TEXT NOT NULL,
|
direction TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
transfer_name TEXT,
|
transfer_name TEXT,
|
||||||
@@ -66,6 +102,7 @@ impl Repository {
|
|||||||
ticket TEXT,
|
ticket TEXT,
|
||||||
file_count INTEGER NOT NULL DEFAULT 0,
|
file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
total_size 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,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -74,6 +111,60 @@ impl Repository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS transfer_events (
|
CREATE TABLE IF NOT EXISTS transfer_events (
|
||||||
@@ -111,12 +202,34 @@ impl Repository {
|
|||||||
reason TEXT,
|
reason TEXT,
|
||||||
requested_at INTEGER NOT NULL,
|
requested_at INTEGER NOT NULL,
|
||||||
responded_at INTEGER
|
responded_at INTEGER
|
||||||
|
,receipt_token_hash TEXT
|
||||||
|
,completed_at INTEGER
|
||||||
);
|
);
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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(
|
sqlx::query(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_receiver_requests_transfer_id ON receiver_requests(transfer_id, requested_at DESC);",
|
"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))
|
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();
|
let now = now_ms();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO transfers (
|
INSERT INTO transfers (
|
||||||
transfer_id, direction, status, transfer_name, content_hash, ticket,
|
transfer_id, local_id, protocol_transfer_id, peer_id, direction, status,
|
||||||
file_count, total_size, created_at, updated_at
|
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)
|
VALUES (?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12);
|
||||||
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;
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(transfer.transfer_id as i64)
|
.bind(to_db_id(transfer.transfer_id)?)
|
||||||
.bind(transfer.direction)
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(transfer.status)
|
.bind(transfer.peer_id)
|
||||||
|
.bind(transfer.direction.as_str())
|
||||||
|
.bind(transfer.status.as_str())
|
||||||
.bind(transfer.transfer_name)
|
.bind(transfer.transfer_name)
|
||||||
.bind(transfer.content_hash)
|
.bind(transfer.content_hash)
|
||||||
.bind(transfer.ticket)
|
.bind(transfer.ticket)
|
||||||
.bind(transfer.file_count as i64)
|
.bind(to_db_id(transfer.file_count)?)
|
||||||
.bind(transfer.total_size as i64)
|
.bind(to_db_id(transfer.total_size)?)
|
||||||
|
.bind(transfer.access_mode)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_transfer_status(
|
pub(crate) async fn start_receive(&self, transfer: TransferUpsert<'_>) -> Result<()> {
|
||||||
&self,
|
self.maybe_fail_write()?;
|
||||||
transfer_id: u64,
|
if transfer.direction != TransferDirection::Receive
|
||||||
status: &str,
|
|| transfer.status != TransferStatus::Receiving
|
||||||
) -> Result<()> {
|
{
|
||||||
sqlx::query("UPDATE transfers SET status = ?1, updated_at = ?2 WHERE transfer_id = ?3")
|
anyhow::bail!("receive must start in the receiving state");
|
||||||
.bind(status)
|
}
|
||||||
.bind(now_ms())
|
let now = now_ms();
|
||||||
.bind(transfer_id as i64)
|
let result = sqlx::query(
|
||||||
.execute(&self.pool)
|
r#"
|
||||||
.await?;
|
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(())
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT OR REPLACE INTO transfer_events (
|
INSERT OR REPLACE INTO transfer_events (
|
||||||
@@ -197,13 +530,25 @@ impl Repository {
|
|||||||
.bind(&event.id)
|
.bind(&event.id)
|
||||||
.bind(event.timestamp)
|
.bind(event.timestamp)
|
||||||
.bind(&event.scope)
|
.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.direction)
|
||||||
.bind(&event.phase)
|
.bind(&event.phase)
|
||||||
.bind(&event.kind)
|
.bind(&event.kind)
|
||||||
.bind(&event.data_json)
|
.bind(&event.data_json)
|
||||||
.execute(&self.pool)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +567,7 @@ impl Repository {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(request.id)
|
.bind(request.id)
|
||||||
.bind(request.transfer_id as i64)
|
.bind(to_db_id(request.transfer_id)?)
|
||||||
.bind(request.remote_endpoint_id)
|
.bind(request.remote_endpoint_id)
|
||||||
.bind(request.transfer_name)
|
.bind(request.transfer_name)
|
||||||
.bind(request.receiver_name)
|
.bind(request.receiver_name)
|
||||||
@@ -237,7 +582,7 @@ impl Repository {
|
|||||||
pub(crate) async fn update_receiver_request_status(
|
pub(crate) async fn update_receiver_request_status(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
status: &str,
|
status: ReceiverRequestStatus,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
@@ -248,7 +593,7 @@ impl Repository {
|
|||||||
AND status = 'requested'
|
AND status = 'requested'
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(status)
|
.bind(status.as_str())
|
||||||
.bind(reason)
|
.bind(reason)
|
||||||
.bind(now_ms())
|
.bind(now_ms())
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -260,6 +605,85 @@ impl Repository {
|
|||||||
Ok(())
|
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(
|
pub(crate) async fn list_receiver_requests(
|
||||||
&self,
|
&self,
|
||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
@@ -268,13 +692,13 @@ impl Repository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id, transfer_id, remote_endpoint_id, transfer_name,
|
SELECT id, transfer_id, remote_endpoint_id, transfer_name,
|
||||||
receiver_name, receiver_device_name, app_version, status,
|
receiver_name, receiver_device_name, app_version, status,
|
||||||
reason, requested_at, responded_at
|
reason, requested_at, responded_at, completed_at
|
||||||
FROM receiver_requests
|
FROM receiver_requests
|
||||||
WHERE transfer_id = ?1
|
WHERE transfer_id = ?1
|
||||||
ORDER BY requested_at DESC
|
ORDER BY requested_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(transfer_id as i64)
|
.bind(to_db_id(transfer_id)?)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(row_to_receiver_request).collect())
|
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)
|
.bind(content_hash)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -303,17 +727,43 @@ impl Repository {
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT transfer_id, direction, status, transfer_name, content_hash, ticket,
|
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
|
FROM transfers
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.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 {
|
let rows = if let Some(transfer_id) = transfer_id {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -321,9 +771,11 @@ impl Repository {
|
|||||||
FROM transfer_events
|
FROM transfer_events
|
||||||
WHERE transfer_id = ?1
|
WHERE transfer_id = ?1
|
||||||
ORDER BY timestamp ASC
|
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)
|
.fetch_all(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
@@ -332,9 +784,10 @@ impl Repository {
|
|||||||
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||||
FROM transfer_events
|
FROM transfer_events
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT 500
|
LIMIT ?1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(to_db_id(limit)?)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
@@ -342,19 +795,39 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> StoredTransfer {
|
fn require_one_changed(rows_affected: u64, operation: &str) -> Result<()> {
|
||||||
StoredTransfer {
|
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,
|
transfer_id: row.get::<i64, _>("transfer_id") as u64,
|
||||||
direction: row.get("direction"),
|
peer_id: row.get("peer_id"),
|
||||||
status: row.get("status"),
|
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"),
|
transfer_name: row.get("transfer_name"),
|
||||||
content_hash: row.get("content_hash"),
|
content_hash: row.get("content_hash"),
|
||||||
ticket: row.get("ticket"),
|
ticket: row.get("ticket"),
|
||||||
file_count: row.get::<i64, _>("file_count") as u64,
|
file_count: row.get::<i64, _>("file_count") as u64,
|
||||||
total_size: row.get::<i64, _>("total_size") 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"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
|
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 {
|
fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
|
||||||
|
let status = row.get::<String, _>("status");
|
||||||
ReceiverRequest {
|
ReceiverRequest {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
transfer_id: row.get::<i64, _>("transfer_id") as u64,
|
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_name: row.get("receiver_name"),
|
||||||
receiver_device_name: row.get("receiver_device_name"),
|
receiver_device_name: row.get("receiver_device_name"),
|
||||||
app_version: row.get("app_version"),
|
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"),
|
reason: row.get("reason"),
|
||||||
requested_at: row.get("requested_at"),
|
requested_at: row.get("requested_at"),
|
||||||
responded_at: row.get("responded_at"),
|
responded_at: row.get("responded_at"),
|
||||||
|
completed_at: row.get("completed_at"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
|||||||
use std::{io, path::Path, str::FromStr};
|
use std::{io, path::Path, str::FromStr};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use iroh::SecretKey;
|
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
|
let bytes: [u8; 32] = bytes
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?;
|
.map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?;
|
||||||
|
restrict_permissions(&path).await?;
|
||||||
Ok(SecretKey::from_bytes(&bytes))
|
Ok(SecretKey::from_bytes(&bytes))
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?;
|
tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?;
|
||||||
|
restrict_permissions(&path).await?;
|
||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
Err(error) => Err(error.into()),
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,406 +1,18 @@
|
|||||||
#[cfg(test)]
|
#[path = "tests/access_policy.rs"]
|
||||||
mod tests {
|
mod access_policy_tests;
|
||||||
#[cfg(unix)]
|
#[path = "tests/filesystem.rs"]
|
||||||
use std::os::fd::AsRawFd;
|
mod filesystem_tests;
|
||||||
use std::{io::Read, path::Path, sync::Arc};
|
#[path = "tests/handshake.rs"]
|
||||||
|
mod handshake_tests;
|
||||||
use data_encoding::BASE64URL_NOPAD;
|
#[path = "tests/limits.rs"]
|
||||||
use iroh::SecretKey;
|
mod limits_tests;
|
||||||
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
#[path = "tests/repository.rs"]
|
||||||
use serde_json::json;
|
mod repository_tests;
|
||||||
|
#[path = "tests/runtime.rs"]
|
||||||
use crate::{
|
mod runtime_tests;
|
||||||
access_policy::{AccessDecision, AccessPolicy},
|
#[path = "tests/secret.rs"]
|
||||||
api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata},
|
mod secret_tests;
|
||||||
error::VnidropError,
|
#[path = "tests/ticket.rs"]
|
||||||
filesystem::{
|
mod ticket_tests;
|
||||||
collect_import_files, default_collection_name, path_to_string,
|
#[path = "tests/transfer_state.rs"]
|
||||||
percent_decode_file_url_path, validated_relative_string,
|
mod transfer_state_tests;
|
||||||
},
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
56
crates/vnidrop/src/tests/access_policy.rs
Normal file
56
crates/vnidrop/src/tests/access_policy.rs
Normal 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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
233
crates/vnidrop/src/tests/filesystem.rs
Normal file
233
crates/vnidrop/src/tests/filesystem.rs
Normal 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());
|
||||||
|
}
|
||||||
13
crates/vnidrop/src/tests/handshake.rs
Normal file
13
crates/vnidrop/src/tests/handshake.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/vnidrop/src/tests/limits.rs
Normal file
15
crates/vnidrop/src/tests/limits.rs
Normal 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());
|
||||||
|
}
|
||||||
590
crates/vnidrop/src/tests/repository.rs
Normal file
590
crates/vnidrop/src/tests/repository.rs
Normal 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};
|
||||||
143
crates/vnidrop/src/tests/runtime.rs
Normal file
143
crates/vnidrop/src/tests/runtime.rs
Normal 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();
|
||||||
|
}
|
||||||
22
crates/vnidrop/src/tests/secret.rs
Normal file
22
crates/vnidrop/src/tests/secret.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
134
crates/vnidrop/src/tests/ticket.rs
Normal file
134
crates/vnidrop/src/tests/ticket.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/vnidrop/src/tests/transfer_state.rs
Normal file
31
crates/vnidrop/src/tests/transfer_state.rs
Normal 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));
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use data_encoding::BASE64URL_NOPAD;
|
|||||||
use iroh_blobs::ticket::BlobTicket;
|
use iroh_blobs::ticket::BlobTicket;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::api::TransferMetadata;
|
use crate::api::{CoreLimits, TransferMetadata};
|
||||||
|
|
||||||
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
||||||
const VNIDROP_TICKET_VERSION: u8 = 1;
|
const VNIDROP_TICKET_VERSION: u8 = 1;
|
||||||
@@ -52,7 +52,22 @@ pub(crate) struct ParsedTransferTicket {
|
|||||||
pub(crate) metadata: Option<TransferMetadata>,
|
pub(crate) metadata: Option<TransferMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn parse_transfer_ticket(value: &str) -> Result<ParsedTransferTicket> {
|
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);
|
let normalized = normalize_ticket_input(value);
|
||||||
if normalized.starts_with(VNIDROP_TICKET_PREFIX) {
|
if normalized.starts_with(VNIDROP_TICKET_PREFIX) {
|
||||||
let ticket = VnidropTicket::decode(&normalized)?;
|
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() {
|
if ticket.metadata.transfer_name.trim().is_empty() {
|
||||||
anyhow::bail!("VniDrop ticket metadata is missing a transfer name");
|
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)
|
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
||||||
.context("invalid BlobTicket inside VniDrop ticket")?;
|
.context("invalid BlobTicket inside VniDrop ticket")?;
|
||||||
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
||||||
|
|||||||
117
crates/vnidrop/src/transfer_state.rs
Normal file
117
crates/vnidrop/src/transfer_state.rs
Normal 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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/vnidrop/tests/README.md
Normal file
44
crates/vnidrop/tests/README.md
Normal 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.
|
||||||
186
crates/vnidrop/tests/approval.rs
Normal file
186
crates/vnidrop/tests/approval.rs
Normal 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());
|
||||||
|
}
|
||||||
304
crates/vnidrop/tests/lifecycle.rs
Normal file
304
crates/vnidrop/tests/lifecycle.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
104
crates/vnidrop/tests/output_sink.rs
Normal file
104
crates/vnidrop/tests/output_sink.rs
Normal 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"));
|
||||||
|
}
|
||||||
263
crates/vnidrop/tests/support/mod.rs
Normal file
263
crates/vnidrop/tests/support/mod.rs
Normal 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();
|
||||||
|
}
|
||||||
104
crates/vnidrop/tests/transfer.rs
Normal file
104
crates/vnidrop/tests/transfer.rs
Normal 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")));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ dependencies {
|
|||||||
implementation(libs.jna)
|
implementation(libs.jna)
|
||||||
|
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
|
testImplementation(libs.kotlin.testJunit)
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.vnidrop.app
|
package com.vnidrop.app
|
||||||
|
|
||||||
|
import com.sun.jna.Callback
|
||||||
import com.sun.jna.Library
|
import com.sun.jna.Library
|
||||||
import com.sun.jna.Native
|
import com.sun.jna.Native
|
||||||
import com.sun.jna.NativeLibrary
|
import com.sun.jna.NativeLibrary
|
||||||
import com.sun.jna.Pointer
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.Structure
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
internal object MacOsAppKitAppearance {
|
internal object MacOsAppKitAppearance {
|
||||||
private val objc: ObjCRuntime? by lazy {
|
private val objc: ObjCRuntime? by lazy {
|
||||||
@@ -42,10 +45,112 @@ internal object MacOsAppKitAppearance {
|
|||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
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 {
|
private interface ObjCRuntime : Library {
|
||||||
fun objc_getClass(name: String): Pointer?
|
fun objc_getClass(name: String): Pointer?
|
||||||
fun sel_registerName(name: String): Pointer
|
fun sel_registerName(name: String): Pointer
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?): 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: Pointer?): Pointer?
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ package com.vnidrop.app
|
|||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
||||||
|
import com.vnidrop.app.feature.send.DesktopShareBridge
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
configureMacOsNativeAppearance()
|
configureMacOsNativeAppearance()
|
||||||
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
||||||
|
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
|
||||||
|
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
||||||
|
}
|
||||||
application {
|
application {
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "vnidrop",
|
title = "vnidrop",
|
||||||
) {
|
) {
|
||||||
App()
|
App(rememberJvmAppDependencies())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ android-minSdk = "24"
|
|||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.19.0"
|
androidx-core = "1.18.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.11.0-beta01"
|
androidx-lifecycle = "2.11.0-beta01"
|
||||||
|
androidx-datastore = "1.2.1"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
composeMultiplatform = "1.11.1"
|
composeMultiplatform = "1.11.1"
|
||||||
gobley = "0.3.7"
|
gobley = "0.3.7"
|
||||||
@@ -15,6 +16,7 @@ junit = "4.13.2"
|
|||||||
kotlin = "2.4.0"
|
kotlin = "2.4.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
|
qrcode = "4.5.0"
|
||||||
jna = "5.17.0"
|
jna = "5.17.0"
|
||||||
|
|
||||||
[libraries]
|
[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" }
|
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-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-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-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", 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-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
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-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" }
|
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-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" }
|
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" }
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import gobley.gradle.cargo.dsl.appleMobile
|
import gobley.gradle.cargo.dsl.appleMobile
|
||||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||||
|
import org.gradle.api.tasks.PathSensitivity
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -36,6 +37,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
@@ -47,10 +49,18 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation(libs.androidx.datastore)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.kotlinx.coroutinesCore)
|
implementation(libs.kotlinx.coroutinesCore)
|
||||||
|
implementation(libs.qrcode.kotlin)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
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 {
|
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")) {
|
if (name.contains("Linux") || name.contains("MinGW") || name.contains("MacOSX64")) {
|
||||||
enabled = false
|
enabled = false
|
||||||
}
|
}
|
||||||
|
|||||||
5
shared/src/androidMain/AndroidManifest.xml
Normal file
5
shared/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||||
@@ -5,60 +5,71 @@ import android.net.ConnectivityManager
|
|||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
import android.os.Build
|
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
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
class AndroidPlatform : Platform {
|
@Composable
|
||||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies {
|
||||||
override val defaultCoreDataDir: String =
|
val context = activity.applicationContext
|
||||||
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
|
val fileSystemService = rememberFileSystemService()
|
||||||
override val defaultReceiveDir: String =
|
val notificationService = rememberAndroidLocalNotificationService(activity)
|
||||||
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive"
|
return remember(context, fileSystemService, notificationService) {
|
||||||
override val deviceInfo: DeviceInfo = DeviceInfo(
|
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,
|
deviceName = Build.DEVICE,
|
||||||
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
|
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
|
||||||
.filter { it.isNotBlank() }
|
.filter(String::isNotBlank)
|
||||||
.joinToString(" ")
|
.joinToString(" ")
|
||||||
.ifBlank { null },
|
.ifBlank { null },
|
||||||
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
|
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
|
||||||
network = activeNetworkSummary(),
|
network = context.activeNetworkSummary(),
|
||||||
batteryLevel = batteryLevel(),
|
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) {
|
private fun Context.activeNetworkSummary(): String? = runCatching {
|
||||||
AndroidPlatformContextHolder.context = context.applicationContext
|
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 {
|
private fun Context.batteryLevel(): String? = runCatching {
|
||||||
var context: Context? = null
|
val manager = getSystemService(BatteryManager::class.java)
|
||||||
}
|
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||||
|
level.takeIf { it >= 0 }?.let { "$it%" }
|
||||||
private fun activeNetworkSummary(): String? =
|
}.getOrNull()
|
||||||
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 networkInterfaceName(): String? =
|
private fun networkInterfaceName(): String? =
|
||||||
NetworkInterface.getNetworkInterfaces()
|
NetworkInterface.getNetworkInterfaces()
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package com.vnidrop.app.core
|
package com.vnidrop.app.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Point
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -17,7 +22,7 @@ actual fun rememberShareFilePicker(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri)))
|
onFilePicked(context.pickedShareFile(uri))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remember(launcher) {
|
return remember(launcher) {
|
||||||
@@ -29,44 +34,65 @@ actual fun rememberShareFilePicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun sharePickedFile(
|
@Composable
|
||||||
repository: CoreRepository,
|
actual fun rememberReceiveFolderPicker(
|
||||||
file: PickedShareFile,
|
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||||
transferName: String,
|
onError: (String) -> Unit,
|
||||||
senderName: String,
|
): ReceiveFolderPicker {
|
||||||
) {
|
val context = LocalContext.current
|
||||||
val context = AndroidContextHolder.context
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
errorIfNull(context, "Android context has not been attached")
|
if (uri != null) {
|
||||||
.contentResolver
|
runCatching {
|
||||||
.openFileDescriptor(Uri.parse(file.value), "r")
|
context.contentResolver.takePersistableUriPermission(
|
||||||
.use { descriptor ->
|
uri,
|
||||||
checkNotNull(descriptor) { "Could not open selected file descriptor" }
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
repository.shareFileDescriptor(
|
)
|
||||||
fd = descriptor.fd,
|
}
|
||||||
displayName = file.displayName,
|
onFolderPicked(
|
||||||
transferName = transferName,
|
ReceiveFolder(
|
||||||
senderName = senderName,
|
kind = ReceiveFolderKind.AndroidTreeUri,
|
||||||
|
value = uri.toString(),
|
||||||
|
displayName = uri.lastPathSegment ?: "Downloads",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return remember(launcher) {
|
||||||
private object AndroidContextHolder {
|
object : ReceiveFolderPicker {
|
||||||
var context: Context? = null
|
override fun pickFolder() {
|
||||||
}
|
launcher.launch(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 uri.lastPathSegment ?: "transfer"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Any> errorIfNull(value: T?, message: String): T =
|
private fun Context.pickedShareFile(uri: Uri): PickedShareFile {
|
||||||
value ?: error(message)
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,65 @@
|
|||||||
<string name="nav_receive">Receive</string>
|
<string name="nav_receive">Receive</string>
|
||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="send_title">Send</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="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="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>
|
<string name="button_select_file">Select file</string>
|
||||||
@@ -11,9 +69,9 @@
|
|||||||
<string name="transfer_details_title">Transfer details</string>
|
<string name="transfer_details_title">Transfer details</string>
|
||||||
<string name="field_transfer_name">Transfer name</string>
|
<string name="field_transfer_name">Transfer name</string>
|
||||||
<string name="field_sender_name">Sender name</string>
|
<string name="field_sender_name">Sender name</string>
|
||||||
<string name="button_create_share_ticket">Create share ticket</string>
|
<string name="button_create_share">Create share</string>
|
||||||
<string name="button_creating_ticket">Creating ticket...</string>
|
<string name="button_creating_share">Creating share...</string>
|
||||||
<string name="share_ticket_title">Share ticket</string>
|
<string name="share_details_title">Share details</string>
|
||||||
<string name="receiver_requests_title">Receiver requests</string>
|
<string name="receiver_requests_title">Receiver requests</string>
|
||||||
<string name="button_copy">Copy</string>
|
<string name="button_copy">Copy</string>
|
||||||
<string name="button_use_locally">Use locally</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="settings_subtitle">Configure the local node and app appearance.</string>
|
||||||
<string name="node_title">Node</string>
|
<string name="node_title">Node</string>
|
||||||
<string name="appearance_title">Appearance</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_mode_title">Display mode</string>
|
||||||
<string name="appearance_system_mode">System</string>
|
<string name="appearance_system_mode">System</string>
|
||||||
<string name="appearance_dark_mode">Dark mode</string>
|
<string name="appearance_dark_mode">Dark mode</string>
|
||||||
@@ -50,6 +121,19 @@
|
|||||||
<string name="network_title">Network</string>
|
<string name="network_title">Network</string>
|
||||||
<string name="battery_level_title">Battery level</string>
|
<string name="battery_level_title">Battery level</string>
|
||||||
<string name="value_unavailable">Not available</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="core_status_ready">Ready</string>
|
||||||
<string name="event_log_title">Event log</string>
|
<string name="event_log_title">Event log</string>
|
||||||
<string name="no_events">No events have been emitted yet.</string>
|
<string name="no_events">No events have been emitted yet.</string>
|
||||||
|
|||||||
@@ -1,99 +1,132 @@
|
|||||||
package com.vnidrop.app
|
package com.vnidrop.app
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vnidrop.app.core.rememberShareFilePicker
|
import com.vnidrop.app.feature.app.AppViewModel
|
||||||
import com.vnidrop.app.logging.AppLogger
|
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.platform.PlatformSystemAppearance
|
||||||
|
import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
|
||||||
import com.vnidrop.app.ui.navigation.AppDestination
|
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.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.state.windowClassFor
|
||||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||||
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
fun App(dependencies: AppDependencies) {
|
||||||
fun App() {
|
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
|
||||||
val platform = remember { getPlatform() }
|
val graph = graphHolder.graph
|
||||||
val viewModel = viewModel {
|
|
||||||
VniDropAppViewModel(
|
val appViewModel = viewModel {
|
||||||
appDataDir = platform.defaultCoreDataDir,
|
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
||||||
defaultReceiveDir = platform.defaultReceiveDir,
|
}
|
||||||
platformName = platform.name,
|
val sendViewModel = viewModel {
|
||||||
|
SendViewModel(
|
||||||
|
graph.coreRepository,
|
||||||
|
dependencies.fileSystemService,
|
||||||
|
graph.preferencesRepository,
|
||||||
|
graph.filePreviewRepository,
|
||||||
|
graph.messages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val receiveViewModel = viewModel {
|
||||||
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
|
||||||
val clipboard = LocalClipboardManager.current
|
}
|
||||||
val picker = rememberShareFilePicker(
|
val settingsViewModel = viewModel {
|
||||||
onFilePicked = { file ->
|
SettingsViewModel(
|
||||||
viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file))
|
dependencies.environment,
|
||||||
},
|
dependencies.deviceInfoProvider,
|
||||||
onError = { error ->
|
dependencies.fileSystemService,
|
||||||
viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
|
graph.preferencesRepository,
|
||||||
},
|
dependencies.localNotificationService,
|
||||||
)
|
graph.messages,
|
||||||
|
)
|
||||||
LaunchedEffect(viewModel) {
|
}
|
||||||
viewModel.effectFlow.collect { effect ->
|
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
when (effect) {
|
val sendState by sendViewModel.state.collectAsStateWithLifecycle()
|
||||||
VniDropAppEffect.OpenShareFilePicker -> {
|
val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle()
|
||||||
AppLogger.info("file-picker", "open share file picker")
|
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
|
||||||
picker.pickFile()
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
}
|
DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
|
||||||
is VniDropAppEffect.CopyTicket -> {
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
AppLogger.info("send", "ticket copied")
|
when (event) {
|
||||||
clipboard.setText(AnnotatedString(effect.ticket))
|
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)
|
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
|
||||||
PlatformSystemAppearance(isDarkTheme)
|
PlatformSystemAppearance(darkTheme)
|
||||||
LaunchedEffect(isDarkTheme) {
|
VniDropTheme(isDarkTheme = darkTheme) {
|
||||||
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
|
|
||||||
}
|
|
||||||
|
|
||||||
VniDropTheme(isDarkTheme = isDarkTheme) {
|
|
||||||
BoxWithConstraints {
|
BoxWithConstraints {
|
||||||
val windowClass = windowClassFor(maxWidth.value)
|
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(
|
AppShell(
|
||||||
selectedDestination = state.app.destination,
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
selectedDestination = appState.destination,
|
||||||
windowClass = windowClass,
|
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) {
|
when (appState.destination) {
|
||||||
AppDestination.Send -> SendScreen(
|
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
|
||||||
coreState = coreState,
|
AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) }
|
||||||
sendState = state.send,
|
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
|
||||||
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)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ApprovalModalHost(
|
||||||
|
state = approvalState,
|
||||||
|
onAccept = graph.approvalCoordinator::accept,
|
||||||
|
onRefuse = graph.approvalCoordinator::refuse,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
Normal file
56
shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
class Greeting {
|
|
||||||
private val platform = getPlatform()
|
|
||||||
|
|
||||||
fun greet(): String {
|
|
||||||
return sayHello(platform.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
fun sayHello(to: String): String =
|
|
||||||
"Hello, $to!"
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.vnidrop.app
|
package com.vnidrop.app
|
||||||
|
|
||||||
interface Platform {
|
import com.vnidrop.app.core.FileSystemService
|
||||||
val name: String
|
import com.vnidrop.app.notifications.LocalNotificationService
|
||||||
val defaultCoreDataDir: String
|
|
||||||
val defaultReceiveDir: String
|
data class PlatformEnvironment(
|
||||||
val deviceInfo: DeviceInfo
|
val name: String,
|
||||||
}
|
val appVersion: String,
|
||||||
|
val defaultCoreDataDir: String,
|
||||||
|
val defaultUsername: String = "Receiver",
|
||||||
|
)
|
||||||
|
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
val deviceName: String?,
|
val deviceName: String?,
|
||||||
@@ -15,4 +18,13 @@ data class DeviceInfo(
|
|||||||
val batteryLevel: String?,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
152
shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
Normal file
152
shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
Normal 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>
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
package com.vnidrop.app.core
|
package com.vnidrop.app.core
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import uniffi.vnidrop.CoreEvent
|
import uniffi.vnidrop.CoreEvent
|
||||||
import uniffi.vnidrop.CoreEventSink
|
import uniffi.vnidrop.CoreEventSink
|
||||||
|
import uniffi.vnidrop.ReceiveOutputSink
|
||||||
import uniffi.vnidrop.ReceiverRequest
|
import uniffi.vnidrop.ReceiverRequest
|
||||||
import uniffi.vnidrop.ShareMetadataInput
|
import uniffi.vnidrop.ShareMetadataInput
|
||||||
import uniffi.vnidrop.ShareResult
|
import uniffi.vnidrop.ShareResult
|
||||||
@@ -17,45 +24,56 @@ import uniffi.vnidrop.ShareSource
|
|||||||
import uniffi.vnidrop.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
import uniffi.vnidrop.StoredTransfer
|
import uniffi.vnidrop.StoredTransfer
|
||||||
import uniffi.vnidrop.TicketInspection
|
import uniffi.vnidrop.TicketInspection
|
||||||
|
import uniffi.vnidrop.TransferMetadata
|
||||||
|
import uniffi.vnidrop.TransferAccessMode
|
||||||
import uniffi.vnidrop.VnidropCore
|
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(
|
class CoreRepository(
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) : CoreGateway {
|
||||||
private val _state = MutableStateFlow(CoreUiState())
|
private val _state = MutableStateFlow(CoreState())
|
||||||
val state: StateFlow<CoreUiState> = _state
|
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 var core: VnidropCore? = null
|
||||||
|
|
||||||
private val sink = object : CoreEventSink {
|
private val sink = object : CoreEventSink {
|
||||||
override fun onEvent(event: CoreEvent) {
|
override fun onEvent(event: CoreEvent) {
|
||||||
_state.update { current ->
|
val model = event.toModel()
|
||||||
current.copy(events = (listOf(event) + current.events).take(200))
|
_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?.shutdown()
|
||||||
core = VnidropCore.initialize(appDataDir, sink)
|
core = VnidropCore.initialize(appDataDir, sink)
|
||||||
refreshStatus()
|
refreshSnapshot()
|
||||||
loadTransfers()
|
_state.update { it.copy(isInitialized = true) }
|
||||||
loadEvents()
|
|
||||||
_state.update { it.copy(isInitialized = true, error = null) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
shareSources(
|
||||||
sources = listOf(
|
sources = listOf(
|
||||||
ShareSource(
|
ShareSource(
|
||||||
@@ -67,18 +85,16 @@ class CoreRepository(
|
|||||||
),
|
),
|
||||||
transferName = transferName,
|
transferName = transferName,
|
||||||
senderName = senderName,
|
senderName = senderName,
|
||||||
|
accessPolicy = accessPolicy,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun shareFileDescriptor(
|
override suspend fun shareFileDescriptor(
|
||||||
fd: Int,
|
fd: Int,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
) = runCore {
|
accessPolicy: ShareAccessPolicy,
|
||||||
// The fd is borrowed from platform code. Rust duplicates it before
|
): Result<Share> =
|
||||||
// starting the import, so Android may close the ParcelFileDescriptor
|
|
||||||
// once this suspend call returns.
|
|
||||||
shareSources(
|
shareSources(
|
||||||
sources = listOf(
|
sources = listOf(
|
||||||
ShareSource(
|
ShareSource(
|
||||||
@@ -90,17 +106,16 @@ class CoreRepository(
|
|||||||
),
|
),
|
||||||
transferName = transferName,
|
transferName = transferName,
|
||||||
senderName = senderName,
|
senderName = senderName,
|
||||||
|
accessPolicy = accessPolicy,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun shareSecurityScopedFileUrl(
|
override suspend fun shareSecurityScopedFileUrl(
|
||||||
fileUrl: String,
|
fileUrl: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
) = runCore {
|
accessPolicy: ShareAccessPolicy,
|
||||||
// The iOS actual for withPlatformPathAccess starts and stops the
|
): Result<Share> =
|
||||||
// security-scoped URL lease around this entire shareFiles call.
|
|
||||||
shareSources(
|
shareSources(
|
||||||
sources = listOf(
|
sources = listOf(
|
||||||
ShareSource(
|
ShareSource(
|
||||||
@@ -112,94 +127,86 @@ class CoreRepository(
|
|||||||
),
|
),
|
||||||
transferName = transferName,
|
transferName = transferName,
|
||||||
senderName = senderName,
|
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 {
|
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore {
|
||||||
val inspection = requireCore().inspectTicket(ticket)
|
|
||||||
_state.update { it.copy(lastInspection = inspection, error = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
|
|
||||||
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||||
refreshStatus()
|
refreshSnapshot()
|
||||||
loadTransfers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
ticket: String,
|
||||||
outputDirectoryUrl: String,
|
outputDirectoryUrl: String,
|
||||||
receiverName: String,
|
receiverName: String,
|
||||||
) = runCore {
|
): Result<Unit> = runCore {
|
||||||
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
|
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
|
||||||
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
||||||
}
|
}
|
||||||
refreshStatus()
|
refreshSnapshot()
|
||||||
loadTransfers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cancel(transferId: ULong) = runCore {
|
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||||
requireCore().cancelTransfer(transferId)
|
requireCore().cancelTransfer(transferId)
|
||||||
refreshStatus()
|
refreshSnapshot()
|
||||||
loadTransfers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshReceiverRequests(transferId: ULong) = runCore {
|
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
|
||||||
val requests = requireCore().listReceiverRequests(transferId)
|
requireCore().deleteTransfer(transferId)
|
||||||
_state.update { it.copy(receiverRequests = requests, error = null) }
|
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)
|
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 {
|
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
|
||||||
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.")
|
|
||||||
|
|
||||||
private suspend fun shareSources(
|
private suspend fun shareSources(
|
||||||
sources: List<ShareSource>,
|
sources: List<ShareSource>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
) {
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
): Result<Share> = runCore {
|
||||||
withPlatformPathAccess(sources) {
|
withPlatformPathAccess(sources) {
|
||||||
val result = requireCore().shareFiles(
|
requireCore().shareFiles(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
metadata = ShareMetadataInput(
|
metadata = ShareMetadataInput(
|
||||||
transferId = nextTransferId(),
|
transferId = nextTransferId(),
|
||||||
transferName = transferName.ifBlank { null },
|
transferName = transferName.ifBlank { null },
|
||||||
senderName = senderName.ifBlank { null },
|
senderName = senderName.ifBlank { null },
|
||||||
|
accessMode = accessPolicy.toNative(),
|
||||||
),
|
),
|
||||||
)
|
).toModel()
|
||||||
_state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
|
}.also { share ->
|
||||||
|
refreshSnapshot()
|
||||||
|
_state.update { it.copy(lastShare = share) }
|
||||||
}
|
}
|
||||||
refreshStatus()
|
|
||||||
loadTransfers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> withPlatformPathAccess(
|
private suspend fun <T> withPlatformPathAccess(
|
||||||
@@ -207,36 +214,140 @@ class CoreRepository(
|
|||||||
index: Int = 0,
|
index: Int = 0,
|
||||||
block: suspend () -> T,
|
block: suspend () -> T,
|
||||||
): T {
|
): T {
|
||||||
if (index >= sources.size) {
|
if (index >= sources.size) return block()
|
||||||
return block()
|
|
||||||
}
|
|
||||||
val source = sources[index]
|
val source = sources[index]
|
||||||
return withPlatformPathAccess(source.kind, source.value) {
|
return withPlatformPathAccess(source.kind, source.value) {
|
||||||
withPlatformPathAccess(sources, index + 1, block)
|
withPlatformPathAccess(sources, index + 1, block)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshStatus() {
|
private fun refreshSnapshot() {
|
||||||
val status = core?.status()
|
val activeCore = requireCore()
|
||||||
|
val status = activeCore.status()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
status = status?.let { value ->
|
status = CoreStatus(status.endpointId, status.activeTransfers, status.activeShares),
|
||||||
"Endpoint ${value.endpointId.take(12)}... | active=${value.activeTransfers} shares=${value.activeShares}"
|
transfers = activeCore.listTransfers().map(StoredTransfer::toModel),
|
||||||
} ?: "Not initialized",
|
events = activeCore.listEvents(null).map(CoreEvent::toModel).take(MaxEvents),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTransfers() {
|
private suspend fun <T> runCore(block: suspend () -> T): Result<T> =
|
||||||
val transfers = core?.listTransfers().orEmpty()
|
withContext(dispatcher) {
|
||||||
_state.update { it.copy(transfers = transfers, error = null) }
|
try {
|
||||||
}
|
Result.success(block())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadEvents() {
|
private fun requireCore(): VnidropCore = core ?: error("Initialize the core first.")
|
||||||
val events = core?.listEvents(null).orEmpty()
|
|
||||||
_state.update { it.copy(events = events.take(200), error = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextTransferId(): ULong =
|
private fun nextTransferId(): ULong = Random.nextLong(1, Long.MAX_VALUE).toULong()
|
||||||
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,21 +5,26 @@ import androidx.compose.runtime.Composable
|
|||||||
data class PickedShareFile(
|
data class PickedShareFile(
|
||||||
val value: String,
|
val value: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
val sizeBytes: ULong? = null,
|
||||||
|
val thumbnailBytes: ByteArray? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface ShareFilePicker {
|
interface ShareFilePicker {
|
||||||
fun pickFile()
|
fun pickFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReceiveFolderPicker {
|
||||||
|
fun pickFolder()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun rememberShareFilePicker(
|
expect fun rememberShareFilePicker(
|
||||||
onFilePicked: (PickedShareFile) -> Unit,
|
onFilePicked: (PickedShareFile) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
): ShareFilePicker
|
): ShareFilePicker
|
||||||
|
|
||||||
expect suspend fun sharePickedFile(
|
@Composable
|
||||||
repository: CoreRepository,
|
expect fun rememberReceiveFolderPicker(
|
||||||
file: PickedShareFile,
|
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||||
transferName: String,
|
onError: (String) -> Unit,
|
||||||
senderName: String,
|
): ReceiveFolderPicker
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,7 +3,7 @@ package com.vnidrop.app.core
|
|||||||
import uniffi.vnidrop.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
|
|
||||||
// Platform file handles have different lifetime rules. Desktop paths need no
|
// 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
|
// security-scoped URLs must remain leased while Rust performs the blocking
|
||||||
// import/export call.
|
// import/export call.
|
||||||
internal expect suspend fun <T> withPlatformPathAccess(
|
internal expect suspend fun <T> withPlatformPathAccess(
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user