mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat: add preferences and send empty state
This commit is contained in:
@@ -20,6 +20,17 @@ pub trait CoreEventSink: Send + Sync {
|
||||
fn on_event(&self, event: CoreEvent);
|
||||
}
|
||||
|
||||
#[uniffi::export(with_foreign)]
|
||||
pub trait ReceiveOutputSink: Send + Sync {
|
||||
fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>;
|
||||
fn write_chunk(
|
||||
&self,
|
||||
relative_path: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<(), crate::error::VnidropError>;
|
||||
fn finish_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct RuntimeStatus {
|
||||
pub endpoint_id: String,
|
||||
|
||||
@@ -13,9 +13,9 @@ mod ticket;
|
||||
mod util;
|
||||
|
||||
pub use api::{
|
||||
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
TransferMetadata,
|
||||
CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode, TransferMetadata,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
pub use runtime::VnidropCore;
|
||||
|
||||
@@ -32,16 +32,17 @@ use tokio::{
|
||||
use crate::{
|
||||
access_policy::{AccessDecision, AccessPolicy},
|
||||
api::{
|
||||
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode, TransferMetadata,
|
||||
},
|
||||
approval::ApprovalService,
|
||||
error::VnidropError,
|
||||
event_hub::EventHub,
|
||||
filesystem::{
|
||||
collect_import_files, default_collection_name, platform_path,
|
||||
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
|
||||
write_stream_to_blocking_writer, TransferImport,
|
||||
read_stream_from_blocking_reader, safe_output_path, validated_relative_string,
|
||||
wait_for_writer, write_stream_to_blocking_writer, TransferImport,
|
||||
},
|
||||
handshake::{HandshakeResponse, HandshakeService},
|
||||
logging::init_logging,
|
||||
@@ -82,6 +83,11 @@ struct CoreInner {
|
||||
shutdown_started: AtomicBool,
|
||||
}
|
||||
|
||||
enum ReceiveTarget {
|
||||
Directory(PathBuf),
|
||||
OutputSink(Arc<dyn ReceiveOutputSink>),
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl VnidropCore {
|
||||
#[uniffi::constructor]
|
||||
@@ -139,6 +145,33 @@ impl VnidropCore {
|
||||
.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> {
|
||||
self.runtime
|
||||
.block_on(self.inner.cancel_transfer(transfer_id))
|
||||
@@ -397,6 +430,30 @@ impl CoreInner {
|
||||
ticket: String,
|
||||
output_dir: PathBuf,
|
||||
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<()> {
|
||||
let parsed = match parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")
|
||||
{
|
||||
@@ -424,7 +481,7 @@ impl CoreInner {
|
||||
.insert(transfer_id, shutdown_tx);
|
||||
|
||||
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")),
|
||||
};
|
||||
|
||||
@@ -449,7 +506,7 @@ impl CoreInner {
|
||||
self: &Arc<Self>,
|
||||
transfer_id: u64,
|
||||
parsed: ParsedTransferTicket,
|
||||
output_dir: PathBuf,
|
||||
target: ReceiveTarget,
|
||||
receiver_name: Option<String>,
|
||||
) -> Result<()> {
|
||||
let metadata_json =
|
||||
@@ -490,7 +547,9 @@ impl CoreInner {
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.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!({}));
|
||||
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?;
|
||||
self.export_collection(transfer_id, total_files, output_dir, collection)
|
||||
self.export_collection(transfer_id, total_files, target, collection)
|
||||
.await?;
|
||||
self.repository
|
||||
.update_transfer_status(transfer_id, STATUS_DONE)
|
||||
@@ -795,24 +854,39 @@ impl CoreInner {
|
||||
&self,
|
||||
transfer_id: u64,
|
||||
total_files: u64,
|
||||
output_dir: PathBuf,
|
||||
target: ReceiveTarget,
|
||||
collection: Collection,
|
||||
) -> Result<()> {
|
||||
for (i, (name, hash)) in collection.iter().enumerate() {
|
||||
self.export_blob(
|
||||
transfer_id,
|
||||
total_files,
|
||||
i as u64,
|
||||
&output_dir,
|
||||
name.as_ref(),
|
||||
*hash,
|
||||
)
|
||||
.await?;
|
||||
match &target {
|
||||
ReceiveTarget::Directory(output_dir) => {
|
||||
self.export_blob_to_directory(
|
||||
transfer_id,
|
||||
total_files,
|
||||
i as u64,
|
||||
output_dir,
|
||||
name.as_ref(),
|
||||
*hash,
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
|
||||
async fn export_blob(
|
||||
async fn export_blob_to_directory(
|
||||
&self,
|
||||
transfer_id: u64,
|
||||
total_files: u64,
|
||||
@@ -873,6 +947,62 @@ impl CoreInner {
|
||||
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>) {
|
||||
let core = self.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vnidrop::{
|
||||
CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind,
|
||||
VnidropCore,
|
||||
CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, ShareSource,
|
||||
SourceKind, VnidropCore, VnidropError,
|
||||
};
|
||||
|
||||
#[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 {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
@@ -68,6 +107,31 @@ fn receive_with_response(
|
||||
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]
|
||||
fn two_local_cores_transfer_file() {
|
||||
let sender_dir = tempfile::tempdir().unwrap();
|
||||
@@ -201,6 +265,124 @@ fn two_local_cores_transfer_directory() {
|
||||
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]
|
||||
fn approval_required_denies_then_allows_receiver() {
|
||||
let sender_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -8,6 +8,7 @@ androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.11.0-beta01"
|
||||
androidx-datastore = "1.2.1"
|
||||
androidx-testExt = "1.3.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
gobley = "0.3.7"
|
||||
@@ -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" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
|
||||
@@ -47,6 +47,8 @@ kotlin {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.coroutinesCore)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
|
||||
@@ -5,14 +5,19 @@ import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class AndroidPlatform : Platform {
|
||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||
override val defaultCoreDataDir: String =
|
||||
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
|
||||
AndroidPlatformContextHolder.context?.filesDir?.resolve("vnidrop")?.absolutePath
|
||||
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop")
|
||||
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(
|
||||
deviceName = Build.DEVICE,
|
||||
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
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(
|
||||
repository: CoreRepository,
|
||||
file: PickedShareFile,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
<string name="nav_receive">Receive</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="send_title">Send</string>
|
||||
<string name="send_subtitle">Create a VniDrop ticket and approve receivers when required.</string>
|
||||
<string name="send_subtitle">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="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
||||
<string name="button_select_file">Select file</string>
|
||||
@@ -11,9 +14,9 @@
|
||||
<string name="transfer_details_title">Transfer details</string>
|
||||
<string name="field_transfer_name">Transfer name</string>
|
||||
<string name="field_sender_name">Sender name</string>
|
||||
<string name="button_create_share_ticket">Create share ticket</string>
|
||||
<string name="button_creating_ticket">Creating ticket...</string>
|
||||
<string name="share_ticket_title">Share ticket</string>
|
||||
<string name="button_create_share">Create share</string>
|
||||
<string name="button_creating_share">Creating share...</string>
|
||||
<string name="share_details_title">Share details</string>
|
||||
<string name="receiver_requests_title">Receiver requests</string>
|
||||
<string name="button_copy">Copy</string>
|
||||
<string name="button_use_locally">Use locally</string>
|
||||
@@ -35,6 +38,15 @@
|
||||
<string name="settings_subtitle">Configure the local node and app appearance.</string>
|
||||
<string name="node_title">Node</string>
|
||||
<string name="appearance_title">Appearance</string>
|
||||
<string name="preferences_title">Preferences</string>
|
||||
<string name="field_username">Username</string>
|
||||
<string name="preferences_receive_folder_title">Receive folder</string>
|
||||
<string name="button_choose_folder">Choose folder</string>
|
||||
<string name="button_reset_default">Reset default</string>
|
||||
<string name="folder_status_writable">Writable</string>
|
||||
<string name="folder_status_permission_required">Permission required</string>
|
||||
<string name="folder_status_unavailable">Unavailable</string>
|
||||
<string name="folder_status_validating">Checking folder...</string>
|
||||
<string name="appearance_mode_title">Display mode</string>
|
||||
<string name="appearance_system_mode">System</string>
|
||||
<string name="appearance_dark_mode">Dark mode</string>
|
||||
|
||||
@@ -10,8 +10,13 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.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.ui.navigation.AppDestination
|
||||
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.shell.AppShell
|
||||
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.rememberResolvedDarkTheme
|
||||
|
||||
@@ -26,11 +32,23 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
@Preview
|
||||
fun App() {
|
||||
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 {
|
||||
VniDropAppViewModel(
|
||||
appDataDir = platform.defaultCoreDataDir,
|
||||
defaultReceiveDir = platform.defaultReceiveDir,
|
||||
platformName = platform.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
fileSystemService = fileSystemService,
|
||||
)
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -44,6 +62,14 @@ fun App() {
|
||||
viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
|
||||
},
|
||||
)
|
||||
val receiveFolderPicker = rememberReceiveFolderPicker(
|
||||
onFolderPicked = { folder ->
|
||||
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPicked(folder))
|
||||
},
|
||||
onError = { error ->
|
||||
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPickFailed(error))
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.effectFlow.collect { effect ->
|
||||
@@ -52,6 +78,10 @@ fun App() {
|
||||
AppLogger.info("file-picker", "open share file picker")
|
||||
picker.pickFile()
|
||||
}
|
||||
VniDropAppEffect.OpenReceiveFolderPicker -> {
|
||||
AppLogger.info("file-picker", "open receive folder picker")
|
||||
receiveFolderPicker.pickFolder()
|
||||
}
|
||||
is VniDropAppEffect.CopyTicket -> {
|
||||
AppLogger.info("send", "ticket copied")
|
||||
clipboard.setText(AnnotatedString(effect.ticket))
|
||||
@@ -78,19 +108,22 @@ fun App() {
|
||||
AppDestination.Send -> SendScreen(
|
||||
coreState = coreState,
|
||||
sendState = state.send,
|
||||
windowClass = windowClass,
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
AppDestination.Receive -> ReceiveScreen(
|
||||
coreState = coreState,
|
||||
receiveState = state.receive,
|
||||
preferencesState = state.preferences,
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
AppDestination.Settings -> SettingsScreen(
|
||||
deviceInfo = platform.deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = state.app.themeMode,
|
||||
preferencesState = state.preferences,
|
||||
windowClass = windowClass,
|
||||
onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@ 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.FileSystemService
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
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.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.state.AppUiState
|
||||
import com.vnidrop.app.ui.state.PreferencesUiState
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
@@ -21,6 +28,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
data class VniDropAppState(
|
||||
val app: AppUiState = AppUiState(),
|
||||
val preferences: PreferencesUiState = PreferencesUiState(),
|
||||
val send: SendUiState = SendUiState(),
|
||||
val receive: ReceiveUiState = ReceiveUiState(),
|
||||
)
|
||||
@@ -28,6 +36,11 @@ data class VniDropAppState(
|
||||
sealed interface VniDropAppEvent {
|
||||
data class DestinationSelected(val destination: AppDestination) : 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 class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent
|
||||
data class ShareFilePickFailed(val reason: String) : VniDropAppEvent
|
||||
@@ -48,16 +61,18 @@ sealed interface VniDropAppEvent {
|
||||
|
||||
sealed interface VniDropAppEffect {
|
||||
data object OpenShareFilePicker : VniDropAppEffect
|
||||
data object OpenReceiveFolderPicker : VniDropAppEffect
|
||||
data class CopyTicket(val ticket: String) : VniDropAppEffect
|
||||
}
|
||||
|
||||
class VniDropAppViewModel(
|
||||
appDataDir: String,
|
||||
defaultReceiveDir: String,
|
||||
platformName: String,
|
||||
private val preferencesRepository: AppPreferencesRepository,
|
||||
private val fileSystemService: FileSystemService,
|
||||
private val repository: CoreRepository = CoreRepository(),
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir)))
|
||||
private val _state = MutableStateFlow(VniDropAppState())
|
||||
val state: StateFlow<VniDropAppState> = _state
|
||||
val coreState: StateFlow<CoreUiState> = repository.state
|
||||
|
||||
@@ -73,12 +88,23 @@ class VniDropAppViewModel(
|
||||
viewModelScope.launch {
|
||||
repository.initialize(appDataDir)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collect { preferences ->
|
||||
applyPreferences(preferences)
|
||||
validateReceiveFolder(preferences.receiveFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: VniDropAppEvent) {
|
||||
when (event) {
|
||||
is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) }
|
||||
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)
|
||||
is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file)
|
||||
is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason)
|
||||
@@ -101,6 +127,27 @@ class VniDropAppViewModel(
|
||||
private fun setThemeMode(mode: ThemeMode) {
|
||||
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name))
|
||||
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) {
|
||||
@@ -179,19 +226,72 @@ class VniDropAppViewModel(
|
||||
|
||||
private fun 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 {
|
||||
AppLogger.info("receive", "receive requested")
|
||||
updateReceiveState { copy(isReceiving = true) }
|
||||
try {
|
||||
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
|
||||
receiveIntoFolder(receiveState, preferences.receiveFolder)
|
||||
} finally {
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
effects.send(effect)
|
||||
@@ -218,8 +318,27 @@ class VniDropAppViewModel(
|
||||
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 =
|
||||
copy(
|
||||
selectedSource = file.value,
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlin.random.Random
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import uniffi.vnidrop.ShareMetadataInput
|
||||
import uniffi.vnidrop.ShareResult
|
||||
import uniffi.vnidrop.ShareSource
|
||||
@@ -126,6 +127,12 @@ class CoreRepository(
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = runCore {
|
||||
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun receiveIntoSecurityScopedDirectory(
|
||||
ticket: String,
|
||||
outputDirectoryUrl: String,
|
||||
|
||||
@@ -11,12 +11,22 @@ interface ShareFilePicker {
|
||||
fun pickFile()
|
||||
}
|
||||
|
||||
interface ReceiveFolderPicker {
|
||||
fun pickFolder()
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberShareFilePicker(
|
||||
onFilePicked: (PickedShareFile) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ShareFilePicker
|
||||
|
||||
@Composable
|
||||
expect fun rememberReceiveFolderPicker(
|
||||
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ReceiveFolderPicker
|
||||
|
||||
expect suspend fun sharePickedFile(
|
||||
repository: CoreRepository,
|
||||
file: PickedShareFile,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -7,10 +7,13 @@ 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.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.state.PreferencesUiState
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
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_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_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_title
|
||||
import vnidrop.shared.generated.resources.ticket_card_title
|
||||
@@ -28,6 +36,7 @@ import vnidrop.shared.generated.resources.ticket_card_title
|
||||
fun ReceiveScreen(
|
||||
coreState: CoreUiState,
|
||||
receiveState: ReceiveUiState,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
@@ -40,10 +49,13 @@ fun ReceiveScreen(
|
||||
label = stringResource(Res.string.field_ticket),
|
||||
minLines = 4,
|
||||
)
|
||||
Field(
|
||||
value = receiveState.outputDirectory,
|
||||
onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) },
|
||||
MetadataRow(
|
||||
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(
|
||||
value = receiveState.receiverName,
|
||||
@@ -59,7 +71,7 @@ fun ReceiveScreen(
|
||||
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),
|
||||
enabled = receiveState.canReceive(coreState.isInitialized) && preferencesState.canReceiveIntoFolder,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -67,3 +79,15 @@ fun ReceiveScreen(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
@@ -17,6 +17,7 @@ import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
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_clear
|
||||
import vnidrop.shared.generated.resources.button_copy
|
||||
import vnidrop.shared.generated.resources.button_create_share_ticket
|
||||
import vnidrop.shared.generated.resources.button_creating_ticket
|
||||
import vnidrop.shared.generated.resources.button_create_share
|
||||
import vnidrop.shared.generated.resources.button_creating_share
|
||||
import vnidrop.shared.generated.resources.button_refresh
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
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_subtitle
|
||||
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.transfer_details_title
|
||||
|
||||
@@ -49,28 +50,35 @@ import vnidrop.shared.generated.resources.transfer_details_title
|
||||
fun SendScreen(
|
||||
coreState: CoreUiState,
|
||||
sendState: SendUiState,
|
||||
windowClass: WindowClass,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
if (!sendState.shouldShowEmptyState(coreState)) {
|
||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
}
|
||||
ErrorSection(coreState)
|
||||
SendSourceCard(
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
SendDetailsCard(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
if (sendState.shouldShowEmptyState(coreState)) {
|
||||
SendEmptyState(windowClass = windowClass)
|
||||
} else {
|
||||
SendSourceCard(
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
SendDetailsCard(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +126,7 @@ private fun SendDetailsCard(
|
||||
label = stringResource(Res.string.field_sender_name),
|
||||
)
|
||||
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) },
|
||||
enabled = sendState.canCreateShare(coreState.isInitialized),
|
||||
)
|
||||
@@ -131,7 +139,7 @@ private fun ShareResultCard(
|
||||
requests: List<ReceiverRequest>,
|
||||
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)
|
||||
}) {
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||
|
||||
@@ -39,7 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.VniDropAppEvent
|
||||
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.theme.LocalVniDropColors
|
||||
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_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.device_model_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.node_title
|
||||
import vnidrop.shared.generated.resources.not_initialized
|
||||
@@ -68,6 +83,7 @@ import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
private enum class SettingsPane {
|
||||
Overview,
|
||||
Preferences,
|
||||
Appearance,
|
||||
About,
|
||||
}
|
||||
@@ -77,8 +93,9 @@ fun SettingsScreen(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
preferencesState: PreferencesUiState,
|
||||
windowClass: WindowClass,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
var pane by remember { mutableStateOf(SettingsPane.Overview) }
|
||||
|
||||
@@ -89,7 +106,8 @@ fun SettingsScreen(
|
||||
deviceInfo = deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
else -> MobileSettings(
|
||||
pane = pane,
|
||||
@@ -97,7 +115,8 @@ fun SettingsScreen(
|
||||
deviceInfo = deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +128,8 @@ private fun MobileSettings(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ErrorSection(coreState)
|
||||
@@ -117,13 +137,21 @@ private fun MobileSettings(
|
||||
SettingsPane.Overview -> SettingsOverview(
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
preferencesState = preferencesState,
|
||||
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
|
||||
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
|
||||
onOpenAbout = { onPaneSelected(SettingsPane.About) },
|
||||
largeTitle = true,
|
||||
)
|
||||
SettingsPane.Preferences -> PreferencesSettings(
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
onBack = { onPaneSelected(SettingsPane.Overview) },
|
||||
showBack = true,
|
||||
)
|
||||
SettingsPane.Appearance -> AppearanceSettings(
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onBack = { onPaneSelected(SettingsPane.Overview) },
|
||||
showBack = true,
|
||||
)
|
||||
@@ -144,7 +172,8 @@ private fun DesktopSettings(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -158,6 +187,8 @@ private fun DesktopSettings(
|
||||
SettingsOverview(
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
preferencesState = preferencesState,
|
||||
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
|
||||
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
|
||||
onOpenAbout = { onPaneSelected(SettingsPane.About) },
|
||||
largeTitle = false,
|
||||
@@ -170,9 +201,15 @@ private fun DesktopSettings(
|
||||
) {
|
||||
when (selectedPane) {
|
||||
SettingsPane.Overview,
|
||||
SettingsPane.Preferences -> PreferencesSettings(
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
)
|
||||
SettingsPane.Appearance -> AppearanceSettings(
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
)
|
||||
@@ -191,6 +228,8 @@ private fun DesktopSettings(
|
||||
private fun SettingsOverview(
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
preferencesState: PreferencesUiState,
|
||||
onOpenPreferences: () -> Unit,
|
||||
onOpenAppearance: () -> Unit,
|
||||
onOpenAbout: () -> Unit,
|
||||
largeTitle: Boolean,
|
||||
@@ -203,6 +242,13 @@ private fun SettingsOverview(
|
||||
Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Device,
|
||||
title = stringResource(Res.string.preferences_title),
|
||||
value = preferencesState.username,
|
||||
selected = selectedPane == SettingsPane.Preferences,
|
||||
onClick = onOpenPreferences,
|
||||
)
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Sun,
|
||||
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
|
||||
private fun AppearanceSettings(
|
||||
themeMode: ThemeMode,
|
||||
@@ -479,6 +565,18 @@ private fun ThemeMode.displayName(): String =
|
||||
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 {
|
||||
Brand,
|
||||
Neutral,
|
||||
@@ -533,6 +631,15 @@ private object SettingsIcons {
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
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.StoredTransfer
|
||||
import kotlin.math.roundToInt
|
||||
@@ -27,6 +30,20 @@ data class AppUiState(
|
||||
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(
|
||||
val selectedSource: String = "",
|
||||
val selectedDisplayName: String = "",
|
||||
@@ -72,6 +89,9 @@ fun displayNameForStatus(status: String): String =
|
||||
else -> status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
fun StoredTransfer.isActiveTransfer(): Boolean =
|
||||
status.lowercase() in activeTransferStatuses
|
||||
|
||||
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
|
||||
events
|
||||
.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 activeTransferStatuses = setOf("sharing", "receiving")
|
||||
|
||||
private fun eventLabel(event: CoreEvent): String {
|
||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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.resolveDarkTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import uniffi.vnidrop.StoredTransfer
|
||||
|
||||
class AppUiModelsTest {
|
||||
@Test
|
||||
@@ -69,4 +73,40 @@ class AppUiModelsTest {
|
||||
assertFalse(ready.copy(outputDirectory = "").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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import platform.UIKit.UIApplication
|
||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||
import platform.UIKit.UIDocumentPickerViewController
|
||||
import platform.UIKit.UIModalPresentationFormSheet
|
||||
import platform.UniformTypeIdentifiers.UTTypeFolder
|
||||
import platform.UniformTypeIdentifiers.UTTypeItem
|
||||
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(
|
||||
repository: CoreRepository,
|
||||
file: PickedShareFile,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.vnidrop.app.core
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import java.awt.EventQueue
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareFilePicker(
|
||||
@@ -13,17 +16,29 @@ actual fun rememberShareFilePicker(
|
||||
): ShareFilePicker = remember(onFilePicked, onError) {
|
||||
object : ShareFilePicker {
|
||||
override fun pickFile() {
|
||||
try {
|
||||
val dialog = FileDialog(null as Frame?, "Select file to share", FileDialog.LOAD)
|
||||
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())
|
||||
openPicker(onError) {
|
||||
pickShareFile()?.let(onFilePicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user