feat: add preferences and send empty state

This commit is contained in:
2026-07-08 00:06:00 +02:00
parent 9a7abaca00
commit 72340c6c84
27 changed files with 1436 additions and 78 deletions

View File

@@ -20,6 +20,17 @@ 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>;
}
#[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,

View File

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

View File

@@ -32,16 +32,17 @@ use tokio::{
use crate::{ use crate::{
access_policy::{AccessDecision, AccessPolicy}, access_policy::{AccessDecision, AccessPolicy},
api::{ api::{
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, RuntimeStatus,
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
TransferAccessMode, TransferMetadata,
}, },
approval::ApprovalService, approval::ApprovalService,
error::VnidropError, error::VnidropError,
event_hub::EventHub, event_hub::EventHub,
filesystem::{ filesystem::{
collect_import_files, default_collection_name, platform_path, collect_import_files, default_collection_name, platform_path,
read_stream_from_blocking_reader, safe_output_path, wait_for_writer, read_stream_from_blocking_reader, safe_output_path, validated_relative_string,
write_stream_to_blocking_writer, TransferImport, wait_for_writer, write_stream_to_blocking_writer, TransferImport,
}, },
handshake::{HandshakeResponse, HandshakeService}, handshake::{HandshakeResponse, HandshakeService},
logging::init_logging, logging::init_logging,
@@ -82,6 +83,11 @@ struct CoreInner {
shutdown_started: AtomicBool, shutdown_started: AtomicBool,
} }
enum ReceiveTarget {
Directory(PathBuf),
OutputSink(Arc<dyn ReceiveOutputSink>),
}
#[uniffi::export] #[uniffi::export]
impl VnidropCore { impl VnidropCore {
#[uniffi::constructor] #[uniffi::constructor]
@@ -139,6 +145,33 @@ impl VnidropCore {
.map_err(VnidropError::transfer) .map_err(VnidropError::transfer)
} }
pub fn receive_with_output_sink(
&self,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
receiver_name: Option<String>,
) -> Result<(), VnidropError> {
if let Err(error) =
parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")
{
self.runtime.block_on(async {
self.inner.emit_endpoint(
"error",
"invalid-ticket",
json!({ "reason": error.to_string() }),
);
self.inner.event_hub.flush().await;
});
return Err(VnidropError::ticket(error));
}
self.runtime
.block_on(
self.inner
.receive_with_output_sink(ticket, output_sink, receiver_name),
)
.map_err(VnidropError::transfer)
}
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
self.runtime self.runtime
.block_on(self.inner.cancel_transfer(transfer_id)) .block_on(self.inner.cancel_transfer(transfer_id))
@@ -397,6 +430,30 @@ impl CoreInner {
ticket: String, ticket: String,
output_dir: PathBuf, output_dir: PathBuf,
receiver_name: Option<String>, receiver_name: Option<String>,
) -> Result<()> {
self.receive_to_target(ticket, ReceiveTarget::Directory(output_dir), receiver_name)
.await
}
async fn receive_with_output_sink(
self: &Arc<Self>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
receiver_name: Option<String>,
) -> Result<()> {
self.receive_to_target(
ticket,
ReceiveTarget::OutputSink(output_sink),
receiver_name,
)
.await
}
async fn receive_to_target(
self: &Arc<Self>,
ticket: String,
target: ReceiveTarget,
receiver_name: Option<String>,
) -> Result<()> { ) -> Result<()> {
let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket") let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")
{ {
@@ -424,7 +481,7 @@ impl CoreInner {
.insert(transfer_id, shutdown_tx); .insert(transfer_id, shutdown_tx);
let result = tokio::select! { let result = tokio::select! {
result = self.receive_inner(transfer_id, parsed, output_dir, receiver_name) => result, result = self.receive_inner(transfer_id, parsed, target, receiver_name) => result,
_ = &mut shutdown_rx => Err(anyhow::anyhow!("transfer cancelled")), _ = &mut shutdown_rx => Err(anyhow::anyhow!("transfer cancelled")),
}; };
@@ -449,7 +506,7 @@ impl CoreInner {
self: &Arc<Self>, self: &Arc<Self>,
transfer_id: u64, transfer_id: u64,
parsed: ParsedTransferTicket, parsed: ParsedTransferTicket,
output_dir: PathBuf, target: ReceiveTarget,
receiver_name: Option<String>, receiver_name: Option<String>,
) -> Result<()> { ) -> Result<()> {
let metadata_json = let metadata_json =
@@ -490,7 +547,9 @@ impl CoreInner {
.unwrap_or_default(), .unwrap_or_default(),
}) })
.await?; .await?;
tokio::fs::create_dir_all(&output_dir).await?; if let ReceiveTarget::Directory(output_dir) = &target {
tokio::fs::create_dir_all(output_dir).await?;
}
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
if let Some(metadata) = &parsed.metadata { if let Some(metadata) = &parsed.metadata {
@@ -542,7 +601,7 @@ impl CoreInner {
} }
let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?;
self.export_collection(transfer_id, total_files, output_dir, collection) self.export_collection(transfer_id, total_files, target, collection)
.await?; .await?;
self.repository self.repository
.update_transfer_status(transfer_id, STATUS_DONE) .update_transfer_status(transfer_id, STATUS_DONE)
@@ -795,24 +854,39 @@ impl CoreInner {
&self, &self,
transfer_id: u64, transfer_id: u64,
total_files: u64, total_files: u64,
output_dir: PathBuf, target: ReceiveTarget,
collection: Collection, collection: Collection,
) -> Result<()> { ) -> Result<()> {
for (i, (name, hash)) in collection.iter().enumerate() { for (i, (name, hash)) in collection.iter().enumerate() {
self.export_blob( match &target {
ReceiveTarget::Directory(output_dir) => {
self.export_blob_to_directory(
transfer_id, transfer_id,
total_files, total_files,
i as u64, i as u64,
&output_dir, output_dir,
name.as_ref(), name.as_ref(),
*hash, *hash,
) )
.await?; .await?;
} }
ReceiveTarget::OutputSink(output_sink) => {
self.export_blob_to_sink(
transfer_id,
total_files,
i as u64,
output_sink.as_ref(),
name.as_ref(),
*hash,
)
.await?;
}
}
}
Ok(()) Ok(())
} }
async fn export_blob( async fn export_blob_to_directory(
&self, &self,
transfer_id: u64, transfer_id: u64,
total_files: u64, total_files: u64,
@@ -873,6 +947,62 @@ impl CoreInner {
Ok(()) Ok(())
} }
async fn export_blob_to_sink(
&self,
transfer_id: u64,
total_files: u64,
current_file_index: u64,
output_sink: &dyn ReceiveOutputSink,
relative_path: &str,
hash: Hash,
) -> Result<()> {
let relative_path = validated_relative_string(relative_path)?;
output_sink
.start_file(relative_path.clone())
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream();
let mut file_size = 0;
let mut exported = 0;
while let Some(item) = stream.next().await {
match item {
ExportRangesItem::Size(size) => file_size = size,
ExportRangesItem::Data(leaf) => {
if leaf.offset != exported {
anyhow::bail!(
"export stream for {relative_path} yielded out-of-order data"
);
}
exported += leaf.data.len() as u64;
output_sink
.write_chunk(relative_path.clone(), leaf.data.to_vec())
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
self.emit_transfer(
transfer_id,
"receive",
"export",
"progress",
json!({
"total_files": total_files,
"current_file_index": current_file_index,
"file_name": relative_path,
"file_size": file_size,
"exported": exported,
}),
);
}
ExportRangesItem::Error(error) => {
anyhow::bail!("export failed for {relative_path}: {error}");
}
}
}
output_sink
.finish_file(relative_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
Ok(())
}
async fn spawn_provider_event_task(self: &Arc<Self>, mut rx: mpsc::Receiver<ProviderMessage>) { async fn spawn_provider_event_task(self: &Arc<Self>, mut rx: mpsc::Receiver<ProviderMessage>) {
let core = self.clone(); let core = self.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {

View File

@@ -1,11 +1,12 @@
use std::{ use std::{
collections::HashMap,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, ShareSource,
VnidropCore, SourceKind, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[derive(Default)]
@@ -25,6 +26,44 @@ impl RecordingSink {
} }
} }
#[derive(Default)]
struct MemoryOutputSink {
files: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
}
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.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> {
Ok(())
}
}
impl MemoryOutputSink {
fn file(&self, relative_path: &str) -> Vec<u8> {
self.files.lock().unwrap()[relative_path].clone()
}
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest { fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest {
let started = Instant::now(); let started = Instant::now();
loop { loop {
@@ -68,6 +107,31 @@ fn receive_with_response(
handle.join().unwrap() handle.join().unwrap()
} }
fn receive_with_sink_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>,
receiver_name: Option<String>,
accepted: bool,
) -> Result<(), String> {
let handle = std::thread::spawn(move || {
receiver
.receive_with_output_sink(ticket, output_sink, 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] #[test]
fn two_local_cores_transfer_file() { fn two_local_cores_transfer_file() {
let sender_dir = tempfile::tempdir().unwrap(); let sender_dir = tempfile::tempdir().unwrap();
@@ -201,6 +265,124 @@ fn two_local_cores_transfer_directory() {
receiver.shutdown(); receiver.shutdown();
} }
#[test]
fn output_sink_receive_exports_nested_files() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_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: 18,
transfer_name: Some("photos".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_sink.clone(),
Some("receiver".to_string()),
true,
)
.unwrap();
assert_eq!(output_sink.file("photos/cover.txt"), b"cover");
assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside");
sender.shutdown();
receiver.shutdown();
}
#[test]
fn output_sink_receive_fails_when_sink_write_fails() {
let sender_dir = tempfile::tempdir().unwrap();
let receiver_dir = tempfile::tempdir().unwrap();
let source_path = sender_dir.path().join("hello.txt");
std::fs::write(&source_path, b"hello").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_path.to_string_lossy().to_string(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 19,
transfer_name: Some("hello".to_string()),
sender_name: Some("sender".to_string()),
},
)
.unwrap();
let output_sink = Arc::new(MemoryOutputSink {
files: Mutex::new(HashMap::new()),
fail_writes: true,
});
let error = receive_with_sink_response(
&sender,
share.transfer_id,
receiver.clone(),
share.ticket,
output_sink,
Some("receiver".to_string()),
true,
)
.unwrap_err();
assert!(error.contains("sink write failed"));
sender.shutdown();
receiver.shutdown();
}
#[test] #[test]
fn approval_required_denies_then_allows_receiver() { fn approval_required_denies_then_allows_receiver() {
let sender_dir = tempfile::tempdir().unwrap(); let sender_dir = tempfile::tempdir().unwrap();

View File

@@ -8,6 +8,7 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.19.0" androidx-core = "1.19.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"
@@ -29,6 +30,8 @@ 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" }

View File

@@ -47,6 +47,8 @@ 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)
} }
commonTest.dependencies { commonTest.dependencies {

View File

@@ -5,14 +5,19 @@ 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 android.os.Environment
import java.net.NetworkInterface import java.net.NetworkInterface
class AndroidPlatform : Platform { class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" override val name: String = "Android ${Build.VERSION.SDK_INT}"
override val defaultCoreDataDir: String = override val defaultCoreDataDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop" AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop")
override val defaultReceiveDir: String = override val defaultReceiveDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive" AndroidPlatformContextHolder.context
?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.absolutePath
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive")
override val deviceInfo: DeviceInfo = DeviceInfo( override val deviceInfo: DeviceInfo = DeviceInfo(
deviceName = Build.DEVICE, deviceName = Build.DEVICE,
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL) deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)

View File

@@ -1,6 +1,7 @@
package com.vnidrop.app.core package com.vnidrop.app.core
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -29,6 +30,38 @@ actual fun rememberShareFilePicker(
} }
} }
@Composable
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.AndroidTreeUri,
value = uri.toString(),
displayName = uri.lastPathSegment ?: "Downloads",
),
)
}
}
return remember(launcher) {
object : ReceiveFolderPicker {
override fun pickFolder() {
launcher.launch(null)
}
}
}
}
actual suspend fun sharePickedFile( actual suspend fun sharePickedFile(
repository: CoreRepository, repository: CoreRepository,
file: PickedShareFile, file: PickedShareFile,

View File

@@ -0,0 +1,132 @@
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
@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())
}
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"
probe.startFile(probeName)
probe.writeChunk(probeName, byteArrayOf())
probe.finishFile(probeName)
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
}
}
private class AndroidTreeReceiveOutputSink(
private val context: Context,
private val treeUri: Uri,
) : ReceiveOutputSink {
private val streams = mutableMapOf<String, OutputStream>()
override fun startFile(relativePath: String) {
streams[relativePath]?.close()
val documentUri = createDocument(relativePath)
val stream = context.contentResolver.openOutputStream(documentUri, "w")
?: error("Could not open output stream for $relativePath")
streams[relativePath] = stream
}
override fun writeChunk(relativePath: String, bytes: ByteArray) {
val stream = streams[relativePath] ?: error("Output stream is not open for $relativePath")
stream.write(bytes)
}
override fun finishFile(relativePath: String) {
streams.remove(relativePath)?.close()
}
private fun createDocument(relativePath: String): Uri {
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 DocumentsContract.createDocument(context.contentResolver, parent, "application/octet-stream", parts.last())
?: error("Could not create file ${parts.last()}")
}
private fun findChild(parent: Uri, name: String): Uri? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri,
DocumentsContract.getDocumentId(parent),
)
context.contentResolver.query(
childrenUri,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
while (cursor.moveToNext()) {
if (cursor.getString(nameIndex) == name) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex))
}
}
}
return null
}
}

View File

@@ -3,7 +3,10 @@
<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">Send files to another device when you are ready.</string>
<string name="send_empty_title">No active transfers</string>
<string name="send_empty_body">Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device.</string>
<string name="button_create_new_transfer">Create 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 +14,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 +38,15 @@
<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="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>

View File

@@ -10,8 +10,13 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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.rememberFileSystemService
import com.vnidrop.app.core.rememberReceiveFolderPicker
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.preferences.AppPreferencesDefaults
import com.vnidrop.app.preferences.AppPreferencesRepository
import com.vnidrop.app.preferences.createAppPreferencesDataStore
import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.platform.PlatformSystemAppearance
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.ReceiveScreen
@@ -19,6 +24,7 @@ import com.vnidrop.app.ui.screens.SendScreen
import com.vnidrop.app.ui.screens.SettingsScreen 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.state.windowClassFor import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.ThemeMode
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
@@ -26,11 +32,23 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
@Preview @Preview
fun App() { fun App() {
val platform = remember { getPlatform() } val platform = remember { getPlatform() }
val fileSystemService = rememberFileSystemService()
val preferencesRepository = remember(platform.defaultCoreDataDir) {
AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(platform.defaultCoreDataDir),
defaults = AppPreferencesDefaults(
username = platform.deviceInfo.deviceName?.takeIf { it.isNotBlank() } ?: "Receiver",
receiveFolder = fileSystemService.defaultReceiveFolder(),
themeMode = ThemeMode.System,
),
)
}
val viewModel = viewModel { val viewModel = viewModel {
VniDropAppViewModel( VniDropAppViewModel(
appDataDir = platform.defaultCoreDataDir, appDataDir = platform.defaultCoreDataDir,
defaultReceiveDir = platform.defaultReceiveDir,
platformName = platform.name, platformName = platform.name,
preferencesRepository = preferencesRepository,
fileSystemService = fileSystemService,
) )
} }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -44,6 +62,14 @@ fun App() {
viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error)) viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
}, },
) )
val receiveFolderPicker = rememberReceiveFolderPicker(
onFolderPicked = { folder ->
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPicked(folder))
},
onError = { error ->
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPickFailed(error))
},
)
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect -> viewModel.effectFlow.collect { effect ->
@@ -52,6 +78,10 @@ fun App() {
AppLogger.info("file-picker", "open share file picker") AppLogger.info("file-picker", "open share file picker")
picker.pickFile() picker.pickFile()
} }
VniDropAppEffect.OpenReceiveFolderPicker -> {
AppLogger.info("file-picker", "open receive folder picker")
receiveFolderPicker.pickFolder()
}
is VniDropAppEffect.CopyTicket -> { is VniDropAppEffect.CopyTicket -> {
AppLogger.info("send", "ticket copied") AppLogger.info("send", "ticket copied")
clipboard.setText(AnnotatedString(effect.ticket)) clipboard.setText(AnnotatedString(effect.ticket))
@@ -78,19 +108,22 @@ fun App() {
AppDestination.Send -> SendScreen( AppDestination.Send -> SendScreen(
coreState = coreState, coreState = coreState,
sendState = state.send, sendState = state.send,
windowClass = windowClass,
onEvent = viewModel::onEvent, onEvent = viewModel::onEvent,
) )
AppDestination.Receive -> ReceiveScreen( AppDestination.Receive -> ReceiveScreen(
coreState = coreState, coreState = coreState,
receiveState = state.receive, receiveState = state.receive,
preferencesState = state.preferences,
onEvent = viewModel::onEvent, onEvent = viewModel::onEvent,
) )
AppDestination.Settings -> SettingsScreen( AppDestination.Settings -> SettingsScreen(
deviceInfo = platform.deviceInfo, deviceInfo = platform.deviceInfo,
coreState = coreState, coreState = coreState,
themeMode = state.app.themeMode, themeMode = state.app.themeMode,
preferencesState = state.preferences,
windowClass = windowClass, windowClass = windowClass,
onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, onEvent = viewModel::onEvent,
) )
} }
} }

View File

@@ -4,11 +4,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.AppPreferencesRepository
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.state.AppUiState import com.vnidrop.app.ui.state.AppUiState
import com.vnidrop.app.ui.state.PreferencesUiState
import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.ReceiveUiState
import com.vnidrop.app.ui.state.SendUiState import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
@@ -21,6 +28,7 @@ import kotlinx.coroutines.launch
data class VniDropAppState( data class VniDropAppState(
val app: AppUiState = AppUiState(), val app: AppUiState = AppUiState(),
val preferences: PreferencesUiState = PreferencesUiState(),
val send: SendUiState = SendUiState(), val send: SendUiState = SendUiState(),
val receive: ReceiveUiState = ReceiveUiState(), val receive: ReceiveUiState = ReceiveUiState(),
) )
@@ -28,6 +36,11 @@ data class VniDropAppState(
sealed interface VniDropAppEvent { sealed interface VniDropAppEvent {
data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent
data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent
data class UsernameChanged(val value: String) : VniDropAppEvent
data object ChooseReceiveFolderClicked : VniDropAppEvent
data class ReceiveFolderPicked(val folder: ReceiveFolder) : VniDropAppEvent
data class ReceiveFolderPickFailed(val reason: String) : VniDropAppEvent
data object ResetReceiveFolderClicked : VniDropAppEvent
data object SelectFileClicked : VniDropAppEvent data object SelectFileClicked : VniDropAppEvent
data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent
data class ShareFilePickFailed(val reason: String) : VniDropAppEvent data class ShareFilePickFailed(val reason: String) : VniDropAppEvent
@@ -48,16 +61,18 @@ sealed interface VniDropAppEvent {
sealed interface VniDropAppEffect { sealed interface VniDropAppEffect {
data object OpenShareFilePicker : VniDropAppEffect data object OpenShareFilePicker : VniDropAppEffect
data object OpenReceiveFolderPicker : VniDropAppEffect
data class CopyTicket(val ticket: String) : VniDropAppEffect data class CopyTicket(val ticket: String) : VniDropAppEffect
} }
class VniDropAppViewModel( class VniDropAppViewModel(
appDataDir: String, appDataDir: String,
defaultReceiveDir: String,
platformName: String, platformName: String,
private val preferencesRepository: AppPreferencesRepository,
private val fileSystemService: FileSystemService,
private val repository: CoreRepository = CoreRepository(), private val repository: CoreRepository = CoreRepository(),
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir))) private val _state = MutableStateFlow(VniDropAppState())
val state: StateFlow<VniDropAppState> = _state val state: StateFlow<VniDropAppState> = _state
val coreState: StateFlow<CoreUiState> = repository.state val coreState: StateFlow<CoreUiState> = repository.state
@@ -73,12 +88,23 @@ class VniDropAppViewModel(
viewModelScope.launch { viewModelScope.launch {
repository.initialize(appDataDir) repository.initialize(appDataDir)
} }
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
applyPreferences(preferences)
validateReceiveFolder(preferences.receiveFolder)
}
}
} }
fun onEvent(event: VniDropAppEvent) { fun onEvent(event: VniDropAppEvent) {
when (event) { when (event) {
is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) } is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) }
is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode) is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode)
is VniDropAppEvent.UsernameChanged -> setUsername(event.value)
VniDropAppEvent.ChooseReceiveFolderClicked -> sendEffect(VniDropAppEffect.OpenReceiveFolderPicker)
is VniDropAppEvent.ReceiveFolderPicked -> setReceiveFolder(event.folder)
is VniDropAppEvent.ReceiveFolderPickFailed -> setFilePickerError(event.reason)
VniDropAppEvent.ResetReceiveFolderClicked -> resetReceiveFolder()
VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker) VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker)
is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file) is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file)
is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason) is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason)
@@ -101,6 +127,27 @@ class VniDropAppViewModel(
private fun setThemeMode(mode: ThemeMode) { private fun setThemeMode(mode: ThemeMode) {
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name)) AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name))
updateAppState { copy(themeMode = mode) } updateAppState { copy(themeMode = mode) }
viewModelScope.launch {
preferencesRepository.setThemeMode(mode)
}
}
private fun setUsername(username: String) {
viewModelScope.launch {
preferencesRepository.setUsername(username)
}
}
private fun setReceiveFolder(folder: ReceiveFolder) {
viewModelScope.launch {
preferencesRepository.setReceiveFolder(folder)
}
}
private fun resetReceiveFolder() {
viewModelScope.launch {
preferencesRepository.resetReceiveFolder()
}
} }
private fun setSelectedFile(file: PickedShareFile) { private fun setSelectedFile(file: PickedShareFile) {
@@ -179,19 +226,72 @@ class VniDropAppViewModel(
private fun receive() { private fun receive() {
val receiveState = state.value.receive val receiveState = state.value.receive
if (!receiveState.canReceive(coreState.value.isInitialized)) return val preferences = state.value.preferences
if (!receiveState.canReceive(coreState.value.isInitialized) || !preferences.canReceiveIntoFolder) return
viewModelScope.launch { viewModelScope.launch {
AppLogger.info("receive", "receive requested") AppLogger.info("receive", "receive requested")
updateReceiveState { copy(isReceiving = true) } updateReceiveState { copy(isReceiving = true) }
try { try {
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) receiveIntoFolder(receiveState, preferences.receiveFolder)
} finally { } finally {
updateReceiveState { copy(isReceiving = false) } updateReceiveState { copy(isReceiving = false) }
} }
} }
} }
private suspend fun receiveIntoFolder(receiveState: ReceiveUiState, folder: ReceiveFolder) {
val outputSink = fileSystemService.createReceiveOutputSink(folder)
when {
outputSink != null -> repository.receiveWithOutputSink(receiveState.ticket, outputSink, receiveState.receiverName)
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> {
repository.receiveIntoSecurityScopedDirectory(receiveState.ticket, folder.value, receiveState.receiverName)
}
else -> repository.receive(receiveState.ticket, folder.value, receiveState.receiverName)
}
}
private fun applyPreferences(preferences: AppPreferences) {
_state.update { current ->
val previousUsername = current.preferences.username
current.copy(
app = current.app.copy(themeMode = preferences.themeMode),
preferences = current.preferences.copy(
username = preferences.username,
receiveFolder = preferences.receiveFolder,
),
send = current.send.withDefaultName(
currentValue = current.send.senderName,
previousDefault = previousUsername,
nextDefault = preferences.username,
update = { copy(senderName = it) },
),
receive = current.receive
.withDefaultName(
currentValue = current.receive.receiverName,
previousDefault = previousUsername,
nextDefault = preferences.username,
update = { copy(receiverName = it) },
)
.copy(outputDirectory = preferences.receiveFolder.value),
)
}
}
private suspend fun validateReceiveFolder(folder: ReceiveFolder) {
updatePreferencesState { copy(isValidatingFolder = true) }
val status = fileSystemService.validateReceiveFolder(folder)
updatePreferencesState {
copy(
folderAccessStatus = status,
isValidatingFolder = false,
)
}
if (status != FolderAccessStatus.Writable) {
repository.setError("Receive folder is not writable.")
}
}
private fun sendEffect(effect: VniDropAppEffect) { private fun sendEffect(effect: VniDropAppEffect) {
viewModelScope.launch { viewModelScope.launch {
effects.send(effect) effects.send(effect)
@@ -218,6 +318,25 @@ class VniDropAppViewModel(
if (next == current.receive) current else current.copy(receive = next) if (next == current.receive) current else current.copy(receive = next)
} }
} }
private fun updatePreferencesState(reducer: PreferencesUiState.() -> PreferencesUiState) {
_state.update { current ->
val next = current.preferences.reducer()
if (next == current.preferences) current else current.copy(preferences = next)
}
}
}
private fun <T> T.withDefaultName(
currentValue: String,
previousDefault: String,
nextDefault: String,
update: T.(String) -> T,
): T =
if (currentValue.isBlank() || currentValue == previousDefault) {
update(nextDefault)
} else {
this
} }
private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =

View File

@@ -11,6 +11,7 @@ import kotlin.random.Random
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareMetadataInput
import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.ShareSource import uniffi.vnidrop.ShareSource
@@ -126,6 +127,12 @@ class CoreRepository(
loadTransfers() loadTransfers()
} }
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = runCore {
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
refreshStatus()
loadTransfers()
}
suspend fun receiveIntoSecurityScopedDirectory( suspend fun receiveIntoSecurityScopedDirectory(
ticket: String, ticket: String,
outputDirectoryUrl: String, outputDirectoryUrl: String,

View File

@@ -11,12 +11,22 @@ 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
@Composable
expect fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker
expect suspend fun sharePickedFile( expect suspend fun sharePickedFile(
repository: CoreRepository, repository: CoreRepository,
file: PickedShareFile, file: PickedShareFile,

View File

@@ -0,0 +1,34 @@
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?
}
@Composable
expect fun rememberFileSystemService(): FileSystemService
fun ReceiveFolder.isFileSystemPath(): Boolean =
kind == ReceiveFolderKind.FileSystemPath

View File

@@ -0,0 +1,94 @@
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 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,
)
class AppPreferencesDefaults(
val username: String,
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
)
class AppPreferencesRepository(
private val dataStore: DataStore<Preferences>,
private val defaults: AppPreferencesDefaults,
) {
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,
)
}
suspend fun setUsername(username: String) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.Username] = username.trim()
}
}
suspend fun setReceiveFolder(folder: ReceiveFolder) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name
prefs[PreferenceKeys.ReceiveFolderValue] = folder.value
prefs[PreferenceKeys.ReceiveFolderDisplayName] = folder.displayName
}
}
suspend fun resetReceiveFolder() {
setReceiveFolder(defaults.receiveFolder)
}
suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ThemeMode] = mode.name
}
}
}
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")
}
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()
private fun themeModeOrNull(raw: String): ThemeMode? =
runCatching { ThemeMode.valueOf(raw) }.getOrNull()
private const val AppPreferencesFileName = "app_preferences.preferences_pb"

View File

@@ -7,10 +7,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.VniDropAppEvent import com.vnidrop.app.VniDropAppEvent
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field 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.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.PreferencesUiState
import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.ReceiveUiState
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
@@ -20,6 +23,11 @@ import vnidrop.shared.generated.resources.button_receiving
import vnidrop.shared.generated.resources.field_output_directory import vnidrop.shared.generated.resources.field_output_directory
import vnidrop.shared.generated.resources.field_receiver_name import vnidrop.shared.generated.resources.field_receiver_name
import vnidrop.shared.generated.resources.field_ticket 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_validating
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_subtitle
import vnidrop.shared.generated.resources.receive_title import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title import vnidrop.shared.generated.resources.ticket_card_title
@@ -28,6 +36,7 @@ import vnidrop.shared.generated.resources.ticket_card_title
fun ReceiveScreen( fun ReceiveScreen(
coreState: CoreUiState, coreState: CoreUiState,
receiveState: ReceiveUiState, receiveState: ReceiveUiState,
preferencesState: PreferencesUiState,
onEvent: (VniDropAppEvent) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -40,10 +49,13 @@ fun ReceiveScreen(
label = stringResource(Res.string.field_ticket), label = stringResource(Res.string.field_ticket),
minLines = 4, minLines = 4,
) )
Field( MetadataRow(
value = receiveState.outputDirectory,
onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) },
label = stringResource(Res.string.field_output_directory), label = stringResource(Res.string.field_output_directory),
value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
)
MetadataRow(
label = stringResource(Res.string.metadata_status),
value = preferencesState.folderAccessStatus.displayName(preferencesState.isValidatingFolder),
) )
Field( Field(
value = receiveState.receiverName, value = receiveState.receiverName,
@@ -59,7 +71,7 @@ fun ReceiveScreen(
PrimaryButton( PrimaryButton(
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = { onEvent(VniDropAppEvent.ReceiveClicked) }, onClick = { onEvent(VniDropAppEvent.ReceiveClicked) },
enabled = receiveState.canReceive(coreState.isInitialized), enabled = receiveState.canReceive(coreState.isInitialized) && preferencesState.canReceiveIntoFolder,
) )
} }
} }
@@ -67,3 +79,15 @@ fun ReceiveScreen(
ProgressSection(coreState) ProgressSection(coreState)
} }
} }
@Composable
private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
if (isValidating) {
stringResource(Res.string.folder_status_validating)
} else {
when (this) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
}
}

View File

@@ -0,0 +1,77 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material3.Icon
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.navigation.VniDropIcons
import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.isActiveTransfer
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_create_new_transfer
import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
@Composable
internal fun SendEmptyState(windowClass: WindowClass) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = if (windowClass == WindowClass.Phone) 540.dp else 420.dp)
.padding(horizontal = 24.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = VniDropIcons.Send,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = colors.brandDefault,
)
Text(
text = stringResource(Res.string.send_empty_title),
modifier = Modifier.padding(top = 24.dp),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.send_empty_body),
modifier = Modifier
.padding(top = 10.dp)
.widthIn(max = 520.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
PrimaryButton(
text = stringResource(Res.string.button_create_new_transfer),
onClick = {},
modifier = Modifier.padding(top = 24.dp),
)
}
}
internal fun SendUiState.shouldShowEmptyState(coreState: CoreUiState): Boolean =
!hasSelectedSource &&
!isSharing &&
coreState.lastShare == null &&
coreState.transfers.none { it.direction == "send" && it.isActiveTransfer() }

View File

@@ -17,6 +17,7 @@ import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.SendUiState import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ReceiverRequest
@@ -25,8 +26,8 @@ import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_approve import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_clear import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_copy import vnidrop.shared.generated.resources.button_copy
import vnidrop.shared.generated.resources.button_create_share_ticket import vnidrop.shared.generated.resources.button_create_share
import vnidrop.shared.generated.resources.button_creating_ticket import vnidrop.shared.generated.resources.button_creating_share
import vnidrop.shared.generated.resources.button_refresh import vnidrop.shared.generated.resources.button_refresh
import vnidrop.shared.generated.resources.button_refuse import vnidrop.shared.generated.resources.button_refuse
import vnidrop.shared.generated.resources.button_select_file import vnidrop.shared.generated.resources.button_select_file
@@ -41,7 +42,7 @@ import vnidrop.shared.generated.resources.receiver_requests_title
import vnidrop.shared.generated.resources.send_source_empty import vnidrop.shared.generated.resources.send_source_empty
import vnidrop.shared.generated.resources.send_subtitle import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.share_ticket_title import vnidrop.shared.generated.resources.share_details_title
import vnidrop.shared.generated.resources.source_title import vnidrop.shared.generated.resources.source_title
import vnidrop.shared.generated.resources.transfer_details_title import vnidrop.shared.generated.resources.transfer_details_title
@@ -49,11 +50,17 @@ import vnidrop.shared.generated.resources.transfer_details_title
fun SendScreen( fun SendScreen(
coreState: CoreUiState, coreState: CoreUiState,
sendState: SendUiState, sendState: SendUiState,
windowClass: WindowClass,
onEvent: (VniDropAppEvent) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
if (!sendState.shouldShowEmptyState(coreState)) {
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
}
ErrorSection(coreState) ErrorSection(coreState)
if (sendState.shouldShowEmptyState(coreState)) {
SendEmptyState(windowClass = windowClass)
} else {
SendSourceCard( SendSourceCard(
sendState = sendState, sendState = sendState,
onEvent = onEvent, onEvent = onEvent,
@@ -73,6 +80,7 @@ fun SendScreen(
ProgressSection(coreState) ProgressSection(coreState)
} }
} }
}
@Composable @Composable
private fun SendSourceCard( private fun SendSourceCard(
@@ -118,7 +126,7 @@ private fun SendDetailsCard(
label = stringResource(Res.string.field_sender_name), label = stringResource(Res.string.field_sender_name),
) )
PrimaryButton( PrimaryButton(
text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket), text = if (sendState.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
onClick = { onEvent(VniDropAppEvent.CreateShareClicked) }, onClick = { onEvent(VniDropAppEvent.CreateShareClicked) },
enabled = sendState.canCreateShare(coreState.isInitialized), enabled = sendState.canCreateShare(coreState.isInitialized),
) )
@@ -131,7 +139,7 @@ private fun ShareResultCard(
requests: List<ReceiverRequest>, requests: List<ReceiverRequest>,
onEvent: (VniDropAppEvent) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
) { ) {
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = { AppCard(title = stringResource(Res.string.share_details_title), trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) { }) {
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName) MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)

View File

@@ -39,7 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.DeviceInfo import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.VniDropAppEvent
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
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 com.vnidrop.app.ui.state.PreferencesUiState
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
@@ -55,9 +61,18 @@ import vnidrop.shared.generated.resources.appearance_mode_title
import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.battery_level_title import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.button_choose_folder
import vnidrop.shared.generated.resources.core_status_ready import vnidrop.shared.generated.resources.core_status_ready
import vnidrop.shared.generated.resources.device_model_title import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title import vnidrop.shared.generated.resources.device_name_title
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
import vnidrop.shared.generated.resources.button_reset_default
import vnidrop.shared.generated.resources.field_username
import vnidrop.shared.generated.resources.network_title import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.node_title import vnidrop.shared.generated.resources.node_title
import vnidrop.shared.generated.resources.not_initialized import vnidrop.shared.generated.resources.not_initialized
@@ -68,6 +83,7 @@ import vnidrop.shared.generated.resources.version_title
private enum class SettingsPane { private enum class SettingsPane {
Overview, Overview,
Preferences,
Appearance, Appearance,
About, About,
} }
@@ -77,8 +93,9 @@ fun SettingsScreen(
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,
coreState: CoreUiState, coreState: CoreUiState,
themeMode: ThemeMode, themeMode: ThemeMode,
preferencesState: PreferencesUiState,
windowClass: WindowClass, windowClass: WindowClass,
onThemeModeChange: (ThemeMode) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
) { ) {
var pane by remember { mutableStateOf(SettingsPane.Overview) } var pane by remember { mutableStateOf(SettingsPane.Overview) }
@@ -89,7 +106,8 @@ fun SettingsScreen(
deviceInfo = deviceInfo, deviceInfo = deviceInfo,
coreState = coreState, coreState = coreState,
themeMode = themeMode, themeMode = themeMode,
onThemeModeChange = onThemeModeChange, preferencesState = preferencesState,
onEvent = onEvent,
) )
else -> MobileSettings( else -> MobileSettings(
pane = pane, pane = pane,
@@ -97,7 +115,8 @@ fun SettingsScreen(
deviceInfo = deviceInfo, deviceInfo = deviceInfo,
coreState = coreState, coreState = coreState,
themeMode = themeMode, themeMode = themeMode,
onThemeModeChange = onThemeModeChange, preferencesState = preferencesState,
onEvent = onEvent,
) )
} }
} }
@@ -109,7 +128,8 @@ private fun MobileSettings(
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,
coreState: CoreUiState, coreState: CoreUiState,
themeMode: ThemeMode, themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit, preferencesState: PreferencesUiState,
onEvent: (VniDropAppEvent) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ErrorSection(coreState) ErrorSection(coreState)
@@ -117,13 +137,21 @@ private fun MobileSettings(
SettingsPane.Overview -> SettingsOverview( SettingsPane.Overview -> SettingsOverview(
coreState = coreState, coreState = coreState,
themeMode = themeMode, themeMode = themeMode,
preferencesState = preferencesState,
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
onOpenAbout = { onPaneSelected(SettingsPane.About) }, onOpenAbout = { onPaneSelected(SettingsPane.About) },
largeTitle = true, largeTitle = true,
) )
SettingsPane.Preferences -> PreferencesSettings(
preferencesState = preferencesState,
onEvent = onEvent,
onBack = { onPaneSelected(SettingsPane.Overview) },
showBack = true,
)
SettingsPane.Appearance -> AppearanceSettings( SettingsPane.Appearance -> AppearanceSettings(
themeMode = themeMode, themeMode = themeMode,
onThemeModeChange = onThemeModeChange, onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
onBack = { onPaneSelected(SettingsPane.Overview) }, onBack = { onPaneSelected(SettingsPane.Overview) },
showBack = true, showBack = true,
) )
@@ -144,7 +172,8 @@ private fun DesktopSettings(
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,
coreState: CoreUiState, coreState: CoreUiState,
themeMode: ThemeMode, themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit, preferencesState: PreferencesUiState,
onEvent: (VniDropAppEvent) -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -158,6 +187,8 @@ private fun DesktopSettings(
SettingsOverview( SettingsOverview(
coreState = coreState, coreState = coreState,
themeMode = themeMode, themeMode = themeMode,
preferencesState = preferencesState,
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
onOpenAbout = { onPaneSelected(SettingsPane.About) }, onOpenAbout = { onPaneSelected(SettingsPane.About) },
largeTitle = false, largeTitle = false,
@@ -170,9 +201,15 @@ private fun DesktopSettings(
) { ) {
when (selectedPane) { when (selectedPane) {
SettingsPane.Overview, SettingsPane.Overview,
SettingsPane.Preferences -> PreferencesSettings(
preferencesState = preferencesState,
onEvent = onEvent,
onBack = {},
showBack = false,
)
SettingsPane.Appearance -> AppearanceSettings( SettingsPane.Appearance -> AppearanceSettings(
themeMode = themeMode, themeMode = themeMode,
onThemeModeChange = onThemeModeChange, onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
onBack = {}, onBack = {},
showBack = false, showBack = false,
) )
@@ -191,6 +228,8 @@ private fun DesktopSettings(
private fun SettingsOverview( private fun SettingsOverview(
coreState: CoreUiState, coreState: CoreUiState,
themeMode: ThemeMode, themeMode: ThemeMode,
preferencesState: PreferencesUiState,
onOpenPreferences: () -> Unit,
onOpenAppearance: () -> Unit, onOpenAppearance: () -> Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
largeTitle: Boolean, largeTitle: Boolean,
@@ -203,6 +242,13 @@ private fun SettingsOverview(
Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
} }
SettingsGroup { SettingsGroup {
SettingsRow(
icon = SettingsIcons.Device,
title = stringResource(Res.string.preferences_title),
value = preferencesState.username,
selected = selectedPane == SettingsPane.Preferences,
onClick = onOpenPreferences,
)
SettingsRow( SettingsRow(
icon = SettingsIcons.Sun, icon = SettingsIcons.Sun,
title = stringResource(Res.string.appearance_title), title = stringResource(Res.string.appearance_title),
@@ -228,6 +274,46 @@ private fun SettingsOverview(
} }
} }
@Composable
private fun PreferencesSettings(
preferencesState: PreferencesUiState,
onEvent: (VniDropAppEvent) -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(title = stringResource(Res.string.preferences_title), onBack = onBack, showBack = showBack)
Field(
value = preferencesState.username,
onValueChange = { onEvent(VniDropAppEvent.UsernameChanged(it)) },
label = stringResource(Res.string.field_username),
)
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Folder,
title = stringResource(Res.string.preferences_receive_folder_title),
value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
iconTone = IconTone.Neutral,
)
SettingsRow(
icon = SettingsIcons.Check,
title = preferencesState.folderAccessStatus.displayName(isValidating = preferencesState.isValidatingFolder),
iconTone = IconTone.Neutral,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(
text = stringResource(Res.string.button_choose_folder),
onClick = { onEvent(VniDropAppEvent.ChooseReceiveFolderClicked) },
)
SecondaryButton(
text = stringResource(Res.string.button_reset_default),
onClick = { onEvent(VniDropAppEvent.ResetReceiveFolderClicked) },
)
}
}
}
@Composable @Composable
private fun AppearanceSettings( private fun AppearanceSettings(
themeMode: ThemeMode, themeMode: ThemeMode,
@@ -479,6 +565,18 @@ private fun ThemeMode.displayName(): String =
ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode) ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
} }
@Composable
private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
if (isValidating) {
stringResource(Res.string.folder_status_validating)
} else {
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)
}
}
private enum class IconTone { private enum class IconTone {
Brand, Brand,
Neutral, Neutral,
@@ -533,6 +631,15 @@ private object SettingsIcons {
moveTo(11f, 18f) moveTo(11f, 18f)
lineTo(13f, 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") { val Info = lineIcon("Info") {
moveTo(12f, 16f) moveTo(12f, 16f)
lineTo(12f, 12f) lineTo(12f, 12f)

View File

@@ -2,6 +2,9 @@ package com.vnidrop.app.ui.state
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.StoredTransfer
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -27,6 +30,20 @@ data class AppUiState(
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
) )
data class PreferencesUiState(
val username: String = "",
val receiveFolder: ReceiveFolder = ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "",
displayName = "",
),
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isValidatingFolder: Boolean = false,
) {
val canReceiveIntoFolder: Boolean
get() = folderAccessStatus == FolderAccessStatus.Writable
}
data class SendUiState( data class SendUiState(
val selectedSource: String = "", val selectedSource: String = "",
val selectedDisplayName: String = "", val selectedDisplayName: String = "",
@@ -72,6 +89,9 @@ fun displayNameForStatus(status: String): String =
else -> status.replaceFirstChar { it.uppercase() } else -> status.replaceFirstChar { it.uppercase() }
} }
fun StoredTransfer.isActiveTransfer(): Boolean =
status.lowercase() in activeTransferStatuses
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> = fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
events events
.filter { event -> event.transferId != null && event.phase in progressPhases } .filter { event -> event.transferId != null && event.phase in progressPhases }
@@ -123,6 +143,7 @@ fun friendlyCoreError(raw: String?): String? {
} }
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
private val activeTransferStatuses = setOf("sharing", "receiving")
private fun eventLabel(event: CoreEvent): String { private fun eventLabel(event: CoreEvent): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() } val direction = event.direction?.replaceFirstChar { it.uppercase() }

View File

@@ -1,11 +1,15 @@
package com.vnidrop.app.ui.state package com.vnidrop.app.ui.state
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.resolveDarkTheme import com.vnidrop.app.ui.theme.resolveDarkTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import uniffi.vnidrop.StoredTransfer
class AppUiModelsTest { class AppUiModelsTest {
@Test @Test
@@ -69,4 +73,40 @@ class AppUiModelsTest {
assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true)) assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true))
assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true)) assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true))
} }
@Test
fun preferencesStateExposesReceiveFolderEligibility() {
val folder = ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/tmp/downloads",
displayName = "Downloads",
)
assertTrue(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Writable).canReceiveIntoFolder)
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.PermissionRequired).canReceiveIntoFolder)
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Unavailable).canReceiveIntoFolder)
}
@Test
fun transferActivityOnlyIncludesRunningStatuses() {
assertTrue(storedTransfer(status = "sharing").isActiveTransfer())
assertTrue(storedTransfer(status = "receiving").isActiveTransfer())
assertFalse(storedTransfer(status = "done").isActiveTransfer())
assertFalse(storedTransfer(status = "failed").isActiveTransfer())
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
}
private fun storedTransfer(status: String): StoredTransfer =
StoredTransfer(
transferId = 1UL,
direction = "send",
status = status,
transferName = "Demo",
contentHash = null,
ticket = null,
fileCount = 1UL,
totalSize = 128UL,
createdAt = 1L,
updatedAt = 1L,
)
} }

View File

@@ -8,6 +8,7 @@ import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIModalPresentationFormSheet import platform.UIKit.UIModalPresentationFormSheet
import platform.UniformTypeIdentifiers.UTTypeFolder
import platform.UniformTypeIdentifiers.UTTypeItem import platform.UniformTypeIdentifiers.UTTypeItem
import platform.darwin.NSObject import platform.darwin.NSObject
@@ -37,6 +38,41 @@ actual fun rememberShareFilePicker(
} }
} }
@Composable
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
object : ReceiveFolderPicker {
@OptIn(ExperimentalForeignApi::class)
override fun pickFolder() {
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
if (presenter == null) {
onError("Could not find an iOS view controller for the folder picker")
return
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
val delegate = DocumentPickerDelegate(
onFilePicked = { folder ->
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.IosSecurityScopedUrl,
value = folder.value,
displayName = folder.displayName,
),
)
},
onError = onError,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
picker.modalPresentationStyle = UIModalPresentationFormSheet
presenter.presentViewController(picker, animated = true, completion = null)
}
}
}
actual suspend fun sharePickedFile( actual suspend fun sharePickedFile(
repository: CoreRepository, repository: CoreRepository,
file: PickedShareFile, file: PickedShareFile,

View File

@@ -0,0 +1,59 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import platform.Foundation.NSFileManager
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
import uniffi.vnidrop.ReceiveOutputSink
@Composable
actual fun rememberFileSystemService(): FileSystemService =
remember { IosFileSystemService() }
private class IosFileSystemService : FileSystemService {
override fun defaultReceiveFolder(): ReceiveFolder {
val path = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
NSUserDomainMask,
true,
).firstOrNull() as? String ?: ""
return ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = path,
displayName = "Documents",
)
}
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> {
if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) {
FolderAccessStatus.Writable
} else {
FolderAccessStatus.Unavailable
}
}
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
val didStartAccess = url.startAccessingSecurityScopedResource()
return try {
val path = url.path
if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) {
FolderAccessStatus.Writable
} else {
FolderAccessStatus.PermissionRequired
}
} finally {
if (didStartAccess) url.stopAccessingSecurityScopedResource()
}
}
}

View File

@@ -2,9 +2,12 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import java.awt.EventQueue
import java.awt.FileDialog import java.awt.FileDialog
import java.awt.Frame import java.awt.Frame
import java.awt.KeyboardFocusManager
import java.io.File import java.io.File
import javax.swing.JFileChooser
@Composable @Composable
actual fun rememberShareFilePicker( actual fun rememberShareFilePicker(
@@ -13,17 +16,29 @@ actual fun rememberShareFilePicker(
): ShareFilePicker = remember(onFilePicked, onError) { ): ShareFilePicker = remember(onFilePicked, onError) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFile() { override fun pickFile() {
try { openPicker(onError) {
val dialog = FileDialog(null as Frame?, "Select file to share", FileDialog.LOAD) pickShareFile()?.let(onFilePicked)
dialog.isVisible = true
val directory = dialog.directory
val file = dialog.file
if (directory != null && file != null) {
val selected = File(directory, file)
onFilePicked(PickedShareFile(selected.absolutePath, selected.name))
} }
} catch (error: Throwable) { }
onError(error.message ?: error.toString()) }
}
@Composable
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
object : ReceiveFolderPicker {
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory() ?: return@openPicker
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
),
)
} }
} }
} }
@@ -37,3 +52,85 @@ actual suspend fun sharePickedFile(
) { ) {
repository.sharePath(file.value, transferName, senderName) repository.sharePath(file.value, transferName, senderName)
} }
private fun openPicker(
onError: (String) -> Unit,
block: () -> Unit,
) {
EventQueue.invokeLater {
try {
block()
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
}
private fun nativeFileDialog(title: String): FileDialog =
FileDialog(activeFrame(), title, FileDialog.LOAD)
private fun activeFrame(): Frame? {
val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
return activeWindow as? Frame
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
?: Frame.getFrames().firstOrNull { it.isVisible }
}
private fun pickShareFile(): PickedShareFile? {
val dialog = nativeFileDialog("Select file to share")
return try {
dialog.isVisible = true
val directory = dialog.directory
val file = dialog.file
if (directory != null && file != null) {
val selected = File(directory, file)
PickedShareFile(selected.absolutePath, selected.name)
} else {
null
}
} finally {
dialog.dispose()
}
}
private fun pickDirectory(): File? =
if (isMacOs()) {
val dialog = withMacDirectoryDialog {
nativeFileDialog("Select receive folder").apply { isVisible = true }
}
try {
val directory = dialog.directory ?: return null
dialog.file
?.let { File(directory, it) }
?: File(directory)
} finally {
dialog.dispose()
}
} else {
val chooser = JFileChooser().apply {
dialogTitle = "Select receive folder"
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
}
private fun <T> withMacDirectoryDialog(block: () -> T): T {
if (!isMacOs()) return block()
val key = "apple.awt.fileDialogForDirectories"
val previous = System.getProperty(key)
System.setProperty(key, "true")
return try {
block()
} finally {
if (previous == null) {
System.clearProperty(key)
} else {
System.setProperty(key, previous)
}
}
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)

View File

@@ -0,0 +1,30 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import uniffi.vnidrop.ReceiveOutputSink
import java.io.File
@Composable
actual fun rememberFileSystemService(): FileSystemService =
remember { JvmFileSystemService() }
private class JvmFileSystemService : FileSystemService {
override fun defaultReceiveFolder(): ReceiveFolder =
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = File(System.getProperty("user.home"), "Downloads").absolutePath,
displayName = "Downloads",
)
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus {
if (folder.kind != ReceiveFolderKind.FileSystemPath) return FolderAccessStatus.Unavailable
return runCatching {
val directory = File(folder.value)
if (!directory.exists()) directory.mkdirs()
if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable
}.getOrDefault(FolderAccessStatus.Unavailable)
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
}

View File

@@ -0,0 +1,52 @@
package com.vnidrop.app.preferences
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.ui.theme.ThemeMode
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class AppPreferencesRepositoryTest {
@Test
fun preferencesUseDefaultsWhenNothingIsStored() = runBlocking {
val repository = repositoryForTest()
val preferences = repository.preferences.first()
assertEquals("Device Name", preferences.username)
assertEquals(defaultFolder, preferences.receiveFolder)
assertEquals(ThemeMode.System, preferences.themeMode)
}
@Test
fun themeModeIsPersisted() = runBlocking {
val repository = repositoryForTest()
repository.setThemeMode(ThemeMode.Dark)
assertEquals(ThemeMode.Dark, repository.preferences.first().themeMode)
}
private fun repositoryForTest(): AppPreferencesRepository {
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
return AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(directory),
defaults = AppPreferencesDefaults(
username = "Device Name",
receiveFolder = defaultFolder,
themeMode = ThemeMode.System,
),
)
}
private companion object {
val defaultFolder = ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/tmp/Downloads",
displayName = "Downloads",
)
}
}