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);
}
#[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,

View File

@@ -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;

View File

@@ -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(
match &target {
ReceiveTarget::Directory(output_dir) => {
self.export_blob_to_directory(
transfer_id,
total_files,
i as u64,
&output_dir,
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 {

View File

@@ -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();

View File

@@ -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" }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,

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_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>

View File

@@ -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,
)
}
}

View File

@@ -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,6 +318,25 @@ 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 =

View File

@@ -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,

View File

@@ -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,

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 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)
}
}

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.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,11 +50,17 @@ 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)) {
if (!sendState.shouldShowEmptyState(coreState)) {
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
}
ErrorSection(coreState)
if (sendState.shouldShowEmptyState(coreState)) {
SendEmptyState(windowClass = windowClass)
} else {
SendSourceCard(
sendState = sendState,
onEvent = onEvent,
@@ -73,6 +80,7 @@ fun SendScreen(
ProgressSection(coreState)
}
}
}
@Composable
private fun SendSourceCard(
@@ -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)

View File

@@ -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)

View File

@@ -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() }

View File

@@ -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,
)
}

View File

@@ -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,

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.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))
openPicker(onError) {
pickShareFile()?.let(onFilePicked)
}
} 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)
}
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",
)
}
}