Merge pull request #4 from vnidrop/feat/receive-flow

feat(receive): adaptive receive flow and Android Downloads
This commit is contained in:
Hammed Abass
2026-07-12 08:51:47 +02:00
committed by GitHub
44 changed files with 2126 additions and 132 deletions

View File

@@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NFC"/> <uses-permission android:name="android.permission.NFC"/>
<!-- Pre-Android 10 public Downloads writes. Android 10+ uses MediaStore. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/> <uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application <application
@@ -14,14 +18,36 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<meta-data android:name="com.google.mlkit.vision.DEPENDENCIES" android:value="barcode_ui"/>
<activity <activity
android:exported="true" android:exported="true"
android:launchMode="singleTask"
android:name=".MainActivity"> android:name=".MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Preferred: exact VniDrop invitation MIME type. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="*/*"/>
<data android:pathPattern=".*\\.vnd"/>
<data android:pathPattern=".*\\..*\\.vnd"/>
<data android:pathPattern=".*\\..*\\..*\\.vnd"/>
<data android:pathPattern=".*\\..*\\..*\\..*\\.vnd"/>
</intent-filter>
</activity> </activity>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View File

@@ -1,16 +1,64 @@
package com.vnidrop.app package com.vnidrop.app
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.VniDropInvitationMimeType
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val externalInvitations = ExternalInvitationController()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
App(rememberAndroidAppDependencies(this)) App(rememberAndroidAppDependencies(this, externalInvitations))
} }
handleInvitationIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleInvitationIntent(intent)
}
private fun handleInvitationIntent(source: Intent?) {
if (source?.action != Intent.ACTION_VIEW) return
val uri = source.data
setIntent(Intent(this, MainActivity::class.java).setAction(Intent.ACTION_MAIN))
if (uri == null) {
externalInvitations.reportOpenFailure("The invitation could not be opened")
return
}
lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { readInvitation(uri, source.type) }
result.fold(externalInvitations::openInvitation) { error ->
externalInvitations.reportOpenFailure(error.message ?: "The invitation could not be opened")
}
}
}
private fun readInvitation(uri: Uri, declaredType: String?): Result<String> = runCatching {
val resolvedType = declaredType ?: contentResolver.getType(uri)
val path = uri.path.orEmpty()
val lastSegment = uri.lastPathSegment.orEmpty()
val hasExpectedName = lastSegment.endsWith(".$VniDropInvitationExtension", ignoreCase = true) ||
path.endsWith(".$VniDropInvitationExtension", ignoreCase = true)
require(resolvedType == VniDropInvitationMimeType || hasExpectedName) { "This is not a VniDrop invitation" }
val bytes = contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxVniDropInvitationBytes + 1) }
?: error("The invitation could not be opened")
decodeInvitationBytes(bytes)
} }
} }

View File

@@ -59,9 +59,18 @@ bytes through Kotlin memory.
- Persisted shares are restored only when their root collection is complete and - Persisted shares are restored only when their root collection is complete and
readable. Missing or corrupt roots fail closed and emit a recovery event. readable. Missing or corrupt roots fail closed and emit a recovery event.
- Receive destinations use a no-overwrite policy. Rust writes a uniquely named - Receive destinations use a no-overwrite policy. Rust writes a uniquely named
temporary file in the destination directory, syncs it, and atomically temporary file in the destination directory, syncs it, and publishes it with
publishes it with a no-clobber hard link. Failure or cancellation removes the a no-clobber hard link when the filesystem supports it. On platforms that
temporary file. Stale VniDrop temporary files are cleaned on later writes. reject hard links (notably Android emulated external storage), publication
falls back to an exclusive rename (`renameat2(RENAME_NOREPLACE)` /
`renamex_np(RENAME_EXCL)`). Failure or cancellation removes the temporary
file. Stale VniDrop temporary files are cleaned on later writes.
- Android defaults to the shared system Downloads collection via MediaStore
(`ReceiveFolderKind.AndroidPublicDownloads` on API 29+). Files show up in the
user's Downloads UI like a browser download. Custom folders still use a SAF
tree URI from the folder picker. Both Android sinks stream through
`ReceiveOutputSink` instead of raw filesystem paths. Pre-Android 10 falls
back to the public Downloads path with legacy storage permission.
- Foreign output sinks receive exactly one terminal callback after a successful - Foreign output sinks receive exactly one terminal callback after a successful
`start_file`: `finish_file` or `abort_file`. `start_file`: `finish_file` or `abort_file`.

View File

@@ -101,16 +101,132 @@ impl AtomicOutputFile {
} }
pub(crate) fn commit(mut self) -> Result<()> { pub(crate) fn commit(mut self) -> Result<()> {
// Creating a hard link is an atomic no-clobber publication on the same publish_temp_as_final(&self.temporary, &self.target)
// filesystem. It fails if another writer created the destination.
std::fs::hard_link(&self.temporary, &self.target)
.with_context(|| format!("failed to commit {}", self.target.display()))?; .with_context(|| format!("failed to commit {}", self.target.display()))?;
self.committed = true; self.committed = true;
if let Err(error) = std::fs::remove_file(&self.temporary) { Ok(())
tracing::warn!(%error, path = %self.temporary.display(), "failed to remove committed temporary file"); }
}
/// Publish a fully written temporary file as the final destination without
/// clobbering an existing peer.
///
/// Prefer a same-directory hard link (atomic no-clobber on most Unix volumes).
/// Android's emulated external storage and some FUSE mounts reject hard links
/// even when ordinary create/write/rename work, so fall back to an exclusive
/// rename when the link is unsupported.
fn publish_temp_as_final(temporary: &Path, target: &Path) -> io::Result<()> {
match std::fs::hard_link(temporary, target) {
Ok(()) => {
if let Err(error) = std::fs::remove_file(temporary) {
tracing::warn!(
%error,
path = %temporary.display(),
"failed to remove committed temporary file"
);
} }
Ok(()) Ok(())
} }
Err(error) if is_hard_link_unsupported(&error) => rename_no_replace(temporary, target),
Err(error) => Err(error),
}
}
fn is_hard_link_unsupported(error: &io::Error) -> bool {
match error.raw_os_error() {
Some(code)
if code == libc::EPERM
|| code == libc::EACCES
|| code == libc::EOPNOTSUPP
|| code == libc::ENOTSUP
|| code == libc::EXDEV
|| code == libc::EINVAL
|| code == libc::ENOSYS =>
{
true
}
_ => matches!(
error.kind(),
io::ErrorKind::Unsupported | io::ErrorKind::PermissionDenied
),
}
}
fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
match renameat2_noreplace(from, to) {
Ok(()) => return Ok(()),
Err(error)
if error.raw_os_error() == Some(libc::ENOSYS)
|| error.raw_os_error() == Some(libc::EINVAL) => {}
Err(error) => return Err(error),
}
}
#[cfg(target_os = "macos")]
{
match renamex_np_excl(from, to) {
Ok(()) => return Ok(()),
Err(error) if error.raw_os_error() == Some(libc::ENOTSUP) => {}
Err(error) => return Err(error),
}
}
// Last resort: refuse an existing destination, then rename. There is a
// small race versus concurrent writers, but this path only runs when the
// platform lacks both hard links and exclusive rename.
if std::fs::symlink_metadata(to).is_ok() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("destination already exists: {}", to.display()),
));
}
std::fs::rename(from, to)
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn renameat2_noreplace(from: &Path, to: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let from_c = CString::new(from.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let to_c = CString::new(to.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
// Android defines RENAME_NOREPLACE as c_int while renameat2 takes c_uint.
let flags = libc::RENAME_NOREPLACE as libc::c_uint;
let rc = unsafe {
libc::renameat2(
libc::AT_FDCWD,
from_c.as_ptr(),
libc::AT_FDCWD,
to_c.as_ptr(),
flags,
)
};
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(target_os = "macos")]
fn renamex_np_excl(from: &Path, to: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let from_c = CString::new(from.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let to_c = CString::new(to.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let rc = unsafe { libc::renamex_np(from_c.as_ptr(), to_c.as_ptr(), libc::RENAME_EXCL) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
} }
pub(crate) fn cleanup_stale_temporary_files( pub(crate) fn cleanup_stale_temporary_files(

View File

@@ -27,6 +27,8 @@ pub(crate) struct Repository {
pool: SqlitePool, pool: SqlitePool,
#[cfg(test)] #[cfg(test)]
fail_next_write: Arc<AtomicBool>, fail_next_write: Arc<AtomicBool>,
#[cfg(test)]
fail_receive_history_after_dependants: Arc<AtomicBool>,
} }
pub(crate) struct TransferUpsert<'a> { pub(crate) struct TransferUpsert<'a> {
@@ -80,6 +82,8 @@ impl Repository {
pool, pool,
#[cfg(test)] #[cfg(test)]
fail_next_write: Arc::new(AtomicBool::new(false)), fail_next_write: Arc::new(AtomicBool::new(false)),
#[cfg(test)]
fail_receive_history_after_dependants: Arc::new(AtomicBool::new(false)),
}; };
repository.ensure_schema().await?; repository.ensure_schema().await?;
Ok(repository) Ok(repository)
@@ -482,6 +486,12 @@ impl Repository {
self.fail_next_write.store(true, Ordering::SeqCst); self.fail_next_write.store(true, Ordering::SeqCst);
} }
#[cfg(test)]
pub(crate) fn fail_receive_history_after_dependants(&self) {
self.fail_receive_history_after_dependants
.store(true, Ordering::SeqCst);
}
#[cfg(test)] #[cfg(test)]
fn maybe_fail_write(&self) -> Result<()> { fn maybe_fail_write(&self) -> Result<()> {
if self.fail_next_write.swap(false, Ordering::SeqCst) { if self.fail_next_write.swap(false, Ordering::SeqCst) {
@@ -759,6 +769,60 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn delete_receive_history(&self) -> Result<u64> {
self.maybe_fail_write()?;
let mut transaction = self.pool.begin().await?;
// Delete dependants before their transfer rows. Keep the terminal-state
// predicate on every statement so receive work that is still active and
// every send record remain outside this transaction's scope.
sqlx::query(
r#"
DELETE FROM receiver_requests
WHERE transfer_id IN (
SELECT transfer_id
FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
)
"#,
)
.execute(&mut *transaction)
.await?;
sqlx::query(
r#"
DELETE FROM transfer_events
WHERE transfer_id IN (
SELECT transfer_id
FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
)
"#,
)
.execute(&mut *transaction)
.await?;
#[cfg(test)]
if self
.fail_receive_history_after_dependants
.swap(false, Ordering::SeqCst)
{
anyhow::bail!("injected receive history failure after dependant deletion");
}
let deleted = sqlx::query(
r#"
DELETE FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
"#,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(deleted.rows_affected())
}
pub(crate) async fn list_events( pub(crate) async fn list_events(
&self, &self,
transfer_id: Option<u64>, transfer_id: Option<u64>,

View File

@@ -250,6 +250,12 @@ impl VnidropCore {
.map_err(VnidropError::transfer) .map_err(VnidropError::transfer)
} }
pub fn delete_receive_history(&self) -> Result<u64, VnidropError> {
self.runtime
.block_on(self.inner.delete_receive_history())
.map_err(VnidropError::repository)
}
pub fn set_transfer_access_mode( pub fn set_transfer_access_mode(
&self, &self,
transfer_id: u64, transfer_id: u64,
@@ -992,9 +998,20 @@ impl CoreInner {
.await .await
.retain(|_, id| *id != transfer_id); .retain(|_, id| *id != transfer_id);
self.access_policy.remove_transfer(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await;
// Events are persisted asynchronously. Drain events emitted before this
// request so none can be written back after the transfer is deleted.
self.event_hub.flush().await;
self.repository.delete_transfer(transfer_id).await self.repository.delete_transfer(transfer_id).await
} }
async fn delete_receive_history(&self) -> Result<u64> {
// Transfer events are persisted on a background task. Drain everything
// emitted before this request so cleared history cannot be reinserted
// after the repository transaction commits.
self.event_hub.flush().await;
self.repository.delete_receive_history().await
}
async fn set_transfer_access_mode( async fn set_transfer_access_mode(
&self, &self,
transfer_id: u64, transfer_id: u64,

View File

@@ -117,6 +117,25 @@ fn atomic_output_commits_without_overwriting() {
); );
} }
#[test]
fn atomic_output_commit_does_not_clobber_existing_peer() {
let output = tempfile::tempdir().unwrap();
let target = output.path().join("peer.txt");
std::fs::write(&target, b"original").unwrap();
let (pending, mut file) = AtomicOutputFile::create(output.path(), "other.txt").unwrap();
std::io::Write::write_all(&mut file, b"new").unwrap();
drop(file);
pending.commit().unwrap();
// Existing peer must stay intact while a different file commits.
assert_eq!(std::fs::read(&target).unwrap(), b"original");
assert_eq!(
std::fs::read(output.path().join("other.txt")).unwrap(),
b"new"
);
}
#[test] #[test]
fn dropped_atomic_output_removes_partial_file() { fn dropped_atomic_output_removes_partial_file() {
let output = tempfile::tempdir().unwrap(); let output = tempfile::tempdir().unwrap();

View File

@@ -585,6 +585,149 @@ async fn deleting_transfer_removes_related_history_transactionally() {
.is_empty()); .is_empty());
assert!(repository.delete_transfer(88).await.is_err()); assert!(repository.delete_transfer(88).await.is_err());
} }
#[tokio::test]
async fn deleting_receive_history_only_removes_terminal_receives_and_dependants() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
let records = [
(100, TransferDirection::Receive, TransferStatus::Done),
(101, TransferDirection::Receive, TransferStatus::Failed),
(102, TransferDirection::Receive, TransferStatus::Cancelled),
(103, TransferDirection::Receive, TransferStatus::Receiving),
(104, TransferDirection::Send, TransferStatus::Done),
(105, TransferDirection::Send, TransferStatus::Sharing),
];
for (transfer_id, direction, status) in records {
repository
.insert_transfer(transfer(transfer_id, direction, status))
.await
.unwrap();
let request_id = format!("request-{transfer_id}");
repository
.insert_receiver_request(ReceiverRequestInsert {
id: &request_id,
transfer_id,
remote_endpoint_id: "receiver",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "1.0",
})
.await
.unwrap();
repository
.insert_event(
&CoreEvent {
id: format!("event-{transfer_id}"),
timestamp: transfer_id as i64,
scope: "transfer".to_string(),
transfer_id: Some(transfer_id),
direction: Some(direction.as_str().to_string()),
phase: "test".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
}
assert_eq!(repository.delete_receive_history().await.unwrap(), 3);
let remaining = repository.list_transfers().await.unwrap();
assert_eq!(remaining.len(), 3);
for transfer_id in [103, 104, 105] {
assert!(remaining
.iter()
.any(|transfer| transfer.transfer_id == transfer_id));
assert_eq!(
repository
.list_receiver_requests(transfer_id)
.await
.unwrap()
.len(),
1
);
assert_eq!(
repository
.list_events(Some(transfer_id), 500)
.await
.unwrap()
.len(),
1
);
}
for transfer_id in [100, 101, 102] {
assert!(repository
.list_receiver_requests(transfer_id)
.await
.unwrap()
.is_empty());
assert!(repository
.list_events(Some(transfer_id), 500)
.await
.unwrap()
.is_empty());
}
assert_eq!(repository.delete_receive_history().await.unwrap(), 0);
}
#[tokio::test]
async fn receive_history_mid_transaction_failure_preserves_all_related_rows() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
106,
TransferDirection::Receive,
TransferStatus::Done,
))
.await
.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-preserved",
transfer_id: 106,
remote_endpoint_id: "receiver",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "1.0",
})
.await
.unwrap();
repository
.insert_event(
&CoreEvent {
id: "event-preserved".to_string(),
timestamp: 1,
scope: "transfer".to_string(),
transfer_id: Some(106),
direction: Some("receive".to_string()),
phase: "test".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
repository.fail_receive_history_after_dependants();
assert!(repository.delete_receive_history().await.is_err());
assert_eq!(repository.list_transfers().await.unwrap().len(), 1);
assert_eq!(
repository.list_receiver_requests(106).await.unwrap().len(),
1
);
assert_eq!(
repository.list_events(Some(106), 500).await.unwrap().len(),
1
);
}
use std::str::FromStr; use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};

View File

@@ -25,6 +25,11 @@ compose.desktop {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.vnidrop.app" packageName = "com.vnidrop.app"
packageVersion = "1.0.0" packageVersion = "1.0.0"
fileAssociation(
mimeType = "application/vnd.vnidrop.transfer",
extension = "vnd",
description = "VniDrop Invitation",
)
} }
} }
} }

View File

@@ -4,9 +4,21 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.send.DesktopShareBridge
import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import java.awt.Desktop
import java.io.File
fun main() { fun main(args: Array<String>) {
val externalInvitations = ExternalInvitationController()
configureMacOsNativeAppearance() configureMacOsNativeAppearance()
configureInvitationOpenHandler(externalInvitations)
args.asSequence()
.map(::File)
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
.forEach { externalInvitations.openFile(it) }
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) { if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
DesktopShareBridge.shareFile = MacOsShareSheet::share DesktopShareBridge.shareFile = MacOsShareSheet::share
@@ -16,11 +28,29 @@ fun main() {
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "vnidrop", title = "vnidrop",
) { ) {
App(rememberJvmAppDependencies()) App(rememberJvmAppDependencies(externalInvitations))
} }
} }
} }
private fun configureInvitationOpenHandler(controller: ExternalInvitationController) {
if (!Desktop.isDesktopSupported()) return
val desktop = Desktop.getDesktop()
if (!desktop.isSupported(Desktop.Action.APP_OPEN_FILE)) return
desktop.setOpenFileHandler { event -> event.files.forEach(controller::openFile) }
}
private fun ExternalInvitationController.openFile(file: File) {
val result = runCatching {
require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" }
val bytes = file.inputStream().use { it.readNBytes(MaxVniDropInvitationBytes + 1) }
decodeInvitationBytes(bytes)
}
result.fold(::openInvitation) { error ->
reportOpenFailure(error.message ?: "The invitation could not be opened")
}
}
private fun configureMacOsNativeAppearance() { private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
// AWT reads this before creating the first native window. Runtime theme // AWT reads this before creating the first native window. Runtime theme

View File

@@ -18,6 +18,7 @@ kotlinx-coroutines = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
qrcode = "4.5.0" qrcode = "4.5.0"
jna = "5.17.0" jna = "5.17.0"
google-code-scanner = "16.1.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -45,6 +46,7 @@ kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-te
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -58,16 +58,68 @@ final class VniDropHostViewController: UIViewController {
} }
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
let externalInvitations: ExternalInvitationController
func makeUIViewController(context: Self.Context) -> UIViewController { func makeUIViewController(context: Self.Context) -> UIViewController {
VniDropHostViewController(composeController: MainViewControllerKt.MainViewController()) VniDropHostViewController(
composeController: MainViewControllerKt.MainViewController(
externalInvitations: externalInvitations
)
)
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
} }
struct ContentView: View { struct ContentView: View {
let externalInvitations: ExternalInvitationController
var body: some View { var body: some View {
ComposeView() ComposeView(externalInvitations: externalInvitations)
.ignoresSafeArea() .ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
private func openInvitation(_ url: URL) {
guard url.pathExtension.caseInsensitiveCompare("vnd") == .orderedSame else {
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
return
}
let hasSecurityAccess = url.startAccessingSecurityScopedResource()
defer {
if hasSecurityAccess {
url.stopAccessingSecurityScopedResource()
}
}
do {
let values = try url.resourceValues(forKeys: [.fileSizeKey])
if let fileSize = values.fileSize, fileSize > 65_536 {
throw InvitationOpenError.tooLarge
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
guard data.count <= 65_536 else { throw InvitationOpenError.tooLarge }
guard let raw = String(data: data, encoding: .utf8) else {
throw InvitationOpenError.invalidEncoding
}
externalInvitations.openInvitation(raw: raw)
} catch {
externalInvitations.reportOpenFailure(
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
)
}
}
}
private enum InvitationOpenError: LocalizedError {
case tooLarge
case invalidEncoding
var errorDescription: String? {
switch self {
case .tooLarge: "The invitation is too large"
case .invalidEncoding: "The invitation is not valid text"
}
} }
} }

View File

@@ -4,6 +4,45 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>VniDrop Invitation</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.vnidrop.app.invitation</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>VniDrop Invitation</string>
<key>UTTypeIdentifier</key>
<string>com.vnidrop.app.invitation</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>vnd</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.vnidrop.transfer</string>
</dict>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
</dict> </dict>

View File

@@ -1,10 +1,13 @@
import Shared
import SwiftUI import SwiftUI
@main @main
struct iOSApp: App { struct iOSApp: App {
private let externalInvitations = ExternalInvitationController()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView(externalInvitations: externalInvitations)
} }
} }
} }

View File

@@ -1,6 +1,8 @@
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) @file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.cargo.dsl.appleMobile
import gobley.gradle.cargo.dsl.jvm
import gobley.gradle.GobleyHost
import gobley.gradle.rust.targets.RustAndroidTarget import gobley.gradle.rust.targets.RustAndroidTarget
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -38,6 +40,7 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.google.code.scanner)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }
commonMain.dependencies { commonMain.dependencies {
@@ -87,6 +90,13 @@ cargo {
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
publishJvmArtifacts = true publishJvmArtifacts = true
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64)) androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
builds.jvm {
variants {
// Desktop distributions are built per host. Do not publish disabled
// cross-platform native jars into the app runtime classpath.
embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget)
}
}
builds.appleMobile { builds.appleMobile {
variants { variants {
buildTaskProvider.configure { buildTaskProvider.configure {

View File

@@ -2,4 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
</manifest> </manifest>

View File

@@ -10,10 +10,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import java.net.NetworkInterface import java.net.NetworkInterface
@Composable @Composable
fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies { fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitations: ExternalInvitationController): AppDependencies {
val context = activity.applicationContext val context = activity.applicationContext
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
val notificationService = rememberAndroidLocalNotificationService(activity) val notificationService = rememberAndroidLocalNotificationService(activity)
@@ -28,6 +29,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity): AppDependencies
deviceInfoProvider = AndroidDeviceInfoProvider(context), deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = notificationService, localNotificationService = notificationService,
externalInvitations = externalInvitations,
) )
} }
} }

View File

@@ -1,15 +1,21 @@
package com.vnidrop.app.core package com.vnidrop.app.core
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.core.net.toUri import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import java.io.File
import java.io.OutputStream import java.io.OutputStream
import java.net.URLConnection
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -22,27 +28,39 @@ private class AndroidFileSystemService(
private val context: Context, private val context: Context,
) : FileSystemService { ) : FileSystemService {
override fun defaultReceiveFolder(): ReceiveFolder { override fun defaultReceiveFolder(): ReceiveFolder {
val path = context // Match desktop: shared system Downloads. On Android 10+ this is MediaStore,
.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) // not a raw filesystem path (scoped storage). Older APIs fall back to the
?.absolutePath // public Downloads directory when legacy storage writes are allowed.
?: (System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return ReceiveFolder( ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath, kind = ReceiveFolderKind.AndroidPublicDownloads,
value = path, value = AndroidPublicDownloadsToken,
displayName = "Downloads", displayName = "Downloads",
) )
} else {
val publicDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = publicDownloads.absolutePath,
displayName = "Downloads",
)
}
} }
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) { when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? { override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
if (folder.kind != ReceiveFolderKind.AndroidTreeUri) return null when (folder.kind) {
return AndroidTreeReceiveOutputSink(context, folder.value.toUri()) ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
ReceiveFolderKind.FileSystemPath,
ReceiveFolderKind.IosSecurityScopedUrl -> null
} }
override suspend fun sharePickedFile( override suspend fun sharePickedFile(
@@ -64,11 +82,37 @@ private class AndroidFileSystemService(
} }
} }
/**
* Probe a real create/write/delete instead of [File.canWrite].
*
* Scoped storage often reports public directories as writable even when
* the process cannot create files there. A probe matches what receive needs.
*/
private fun validatePath(path: String): FolderAccessStatus = private fun validatePath(path: String): FolderAccessStatus =
runCatching { runCatching {
val directory = java.io.File(path) val directory = File(path)
if (!directory.exists()) directory.mkdirs() if (!directory.exists() && !directory.mkdirs()) {
if (directory.isDirectory && directory.canWrite()) FolderAccessStatus.Writable else FolderAccessStatus.Unavailable return FolderAccessStatus.Unavailable
}
if (!directory.isDirectory) return FolderAccessStatus.Unavailable
val probe = File(directory, ".vnidrop-write-test-${UUID.randomUUID()}")
try {
probe.outputStream().use { stream -> stream.write(1) }
if (!probe.exists()) return FolderAccessStatus.Unavailable
FolderAccessStatus.Writable
} finally {
probe.delete()
}
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validatePublicDownloads(): FolderAccessStatus =
runCatching {
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
val sink = AndroidMediaStoreDownloadsSink(context)
sink.startFile(probeName)
sink.writeChunk(probeName, byteArrayOf(1))
sink.abortFile(probeName, "write probe complete")
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable) }.getOrDefault(FolderAccessStatus.Unavailable)
private fun validateTreeUri(value: String): FolderAccessStatus { private fun validateTreeUri(value: String): FolderAccessStatus {
@@ -88,6 +132,134 @@ private class AndroidFileSystemService(
} }
} }
/**
* Writes into the shared system Downloads collection via MediaStore.
*
* Files appear in the user's Downloads app / Files UI the same way a browser
* download would. Nested relative paths become subfolders under Download/.
*/
private class AndroidMediaStoreDownloadsSink(
private val context: Context,
) : ReceiveOutputSink {
private data class PendingDocument(
val stream: OutputStream,
val uri: Uri,
)
private val pending = mutableMapOf<String, PendingDocument>()
private val resolver = context.contentResolver
override fun startFile(relativePath: String) {
check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"MediaStore Downloads requires Android 10 or newer"
}
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
val parts = relativePath.split('/').filter { it.isNotBlank() }
require(parts.isNotEmpty()) { "relative path must not be empty" }
val finalName = parts.last()
val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
check(!mediaStoreItemExists(finalName, relativeDir)) {
"Destination already exists: $relativePath"
}
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, finalName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeFor(finalName))
put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(downloadsCollection(), values)
?: error("Could not create Downloads entry for $relativePath")
val stream = runCatching { resolver.openOutputStream(uri, "w") }
.getOrElse { error ->
resolver.delete(uri, null, null)
throw error
} ?: run {
resolver.delete(uri, null, null)
error("Could not open output stream for $relativePath")
}
pending[relativePath] = PendingDocument(stream, uri)
}
override fun writeChunk(relativePath: String, bytes: ByteArray) {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes)
}
override fun finishFile(relativePath: String) {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try {
document.stream.flush()
document.stream.close()
val published = ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}
val updated = resolver.update(document.uri, published, null, null)
check(updated == 1) { "Could not publish received file $relativePath" }
} catch (error: Throwable) {
runCatching { document.stream.close() }
resolver.delete(document.uri, null, null)
throw error
}
}
override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return
runCatching { document.stream.close() }
resolver.delete(document.uri, null, null)
}
private fun downloadsCollection(): Uri =
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
private fun mediaStoreRelativePath(subdirs: List<String>): String {
val base = Environment.DIRECTORY_DOWNLOADS
return if (subdirs.isEmpty()) {
"$base/"
} else {
"$base/${subdirs.joinToString("/")}/"
}
}
private fun mediaStoreItemExists(displayName: String, relativePath: String): Boolean {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection =
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?"
resolver.query(
downloadsCollection(),
projection,
selection,
arrayOf(displayName, relativePath),
null,
)?.use { cursor ->
return cursor.moveToFirst()
}
// Some providers omit the trailing slash; check the alternate form.
val altPath = relativePath.trimEnd('/')
if (altPath != relativePath) {
resolver.query(
downloadsCollection(),
projection,
selection,
arrayOf(displayName, altPath),
null,
)?.use { cursor ->
return cursor.moveToFirst()
}
}
return false
}
private fun mimeTypeFor(fileName: String): String {
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
if (extension.isNotEmpty()) {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let { return it }
URLConnection.guessContentTypeFromName(fileName)?.let { return it }
}
return "application/octet-stream"
}
}
private class AndroidTreeReceiveOutputSink( private class AndroidTreeReceiveOutputSink(
private val context: Context, private val context: Context,
private val treeUri: Uri, private val treeUri: Uri,

View File

@@ -0,0 +1,92 @@
package com.vnidrop.app.feature.receive
import android.nfc.NfcAdapter
import android.nfc.tech.Ndef
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
@Composable
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions {
val context = LocalContext.current
val activity = context as? ComponentActivity
var fileResult by remember { mutableStateOf<((Result<String>) -> Unit)?>(null) }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val callback = fileResult.also { fileResult = null } ?: return@rememberLauncherForActivityResult
if (uri == null) return@rememberLauncherForActivityResult
callback(runCatching {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readNBytes(MaxInvitationBytes + 1) }
?: error("The invitation could not be opened")
decodeInvitationBytes(bytes)
})
}
val nfcAdapter = remember(activity) { activity?.let(NfcAdapter::getDefaultAdapter) }
return remember(activity, filePicker, nfcAdapter) {
object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = if (activity != null) ReceiveMethodAvailability.Available else ReceiveMethodAvailability.Unavailable
override val nfcAvailability = if (nfcAdapter?.isEnabled == true) ReceiveMethodAvailability.Available else ReceiveMethodAvailability.Unavailable
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
// Stop NFC reader before another acquisition path so only one method is active.
cancel()
fileResult = onResult
filePicker.launch(arrayOf(InvitationMimeType, "application/octet-stream", "text/plain", "*/*"))
}
override fun scanQrCode(onResult: (Result<String>) -> Unit) {
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable")))
cancel()
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.enableAutoZoom()
.build()
GmsBarcodeScanning.getClient(host, options).startScan()
.addOnSuccessListener { barcode ->
val value = barcode.rawValue
onResult(if (value.isNullOrBlank()) Result.failure(IllegalArgumentException("The QR code is empty")) else Result.success(value))
}
.addOnFailureListener { onResult(Result.failure(it)) }
.addOnCanceledListener {
onResult(Result.failure(IllegalStateException("QR scanning was cancelled")))
}
}
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) {
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
val adapter = nfcAdapter?.takeIf { it.isEnabled }
?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
cancel()
adapter.enableReaderMode(host, { tag ->
val result = runCatching {
val ndef = Ndef.get(tag) ?: error("This NFC tag does not contain an invitation")
ndef.connect()
try {
val record = ndef.ndefMessage?.records?.firstOrNull { record ->
record.tnf == android.nfc.NdefRecord.TNF_MIME_MEDIA && record.type.decodeToString() == InvitationMimeType
} ?: error("This NFC tag does not contain a VniDrop invitation")
decodeInvitationBytes(record.payload)
} finally { ndef.close() }
}
host.runOnUiThread {
adapter.disableReaderMode(host)
onResult(result)
}
}, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null)
}
override fun cancel() {
activity?.let { nfcAdapter?.disableReaderMode(it) }
}
}
}
}

View File

@@ -79,6 +79,30 @@
<string name="button_refuse">Refuse</string> <string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string> <string name="button_approve">Approve</string>
<string name="receive_title">Receive</string> <string name="receive_title">Receive</string>
<string name="receive_new_subtitle">Files received directly on this device.</string>
<string name="receive_empty_title">Receive your first file</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.</string>
<string name="button_receive_files">Receive files</string>
<string name="receive_history_title">Received files</string>
<string name="receive_clear_history">Clear history</string>
<string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string>
<string name="receive_delete_history_description">“%1$s” will be removed from VniDrops history. The downloaded file will remain on this device.</string>
<string name="receive_clear_history_title">Clear receive history?</string>
<string name="receive_clear_history_description">All completed, failed, and cancelled receives will be removed from VniDrops history. Downloaded files will remain on this device.</string>
<string name="receive_history_cleared">Receive history cleared.</string>
<string name="receive_choose_method_title">How would you like to connect?</string>
<string name="receive_choose_method_body">Choose the invitation method available to you.</string>
<string name="receive_method_file">Open a .vnd invitation</string>
<string name="receive_method_file_description">Choose an invitation saved or shared to this device.</string>
<string name="receive_method_scan">Scan QR code</string>
<string name="receive_method_scan_description">Use the camera to scan the senders VniDrop code.</string>
<string name="receive_method_nfc">Read NFC tag</string>
<string name="receive_method_nfc_description">Hold this device near the senders invitation tag.</string>
<string name="receive_nfc_waiting">Hold near the NFC tag…</string>
<string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string>
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string> <string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string> <string name="ticket_card_title">Ticket</string>
<string name="field_ticket">Ticket</string> <string name="field_ticket">Ticket</string>

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -19,7 +20,9 @@ import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.app.AppGraphViewModel import com.vnidrop.app.feature.app.AppGraphViewModel
import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.receive.ReceiveRoute import com.vnidrop.app.feature.receive.ReceiveRoute
import com.vnidrop.app.feature.receive.ReceiveFloatingAction
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.receive.ReceiveMethod
import com.vnidrop.app.feature.send.SendRoute import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.send.SendViewModel
@@ -35,6 +38,9 @@ import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@Composable @Composable
fun App(dependencies: AppDependencies) { fun App(dependencies: AppDependencies) {
@@ -69,8 +75,35 @@ fun App(dependencies: AppDependencies) {
val appState by appViewModel.state.collectAsStateWithLifecycle() val appState by appViewModel.state.collectAsStateWithLifecycle()
val sendState by sendViewModel.state.collectAsStateWithLifecycle() val sendState by sendViewModel.state.collectAsStateWithLifecycle()
val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle() val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle()
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle()
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) {
dependencies.externalInvitations.invitations.collect { invitation ->
appViewModel.selectDestination(AppDestination.Receive)
if (invitation.isSuccess) {
// Cold-open can race app startup. Wait for core before inspecting so
// the ticket is not dropped as "not initialized", but do not block
// forever if initialization failed.
val ready = withTimeoutOrNull(30_000) {
receiveViewModel.coreState.filter { it.isInitialized }.first()
}
if (ready == null) {
receiveViewModel.onInvitationResult(
ReceiveMethod.InvitationFile,
Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")),
)
return@collect
}
// Avoid clobbering an in-flight inspection or receive.
receiveViewModel.state.filter { state ->
!state.isInspecting && !state.isReceiving && state.ticket.isBlank()
}.first()
}
receiveViewModel.onInvitationResult(ReceiveMethod.InvitationFile, invitation)
}
}
DisposableEffect(lifecycleOwner, graph, settingsViewModel) { DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -97,6 +130,10 @@ fun App(dependencies: AppDependencies) {
sendCoreState.transfers.any { it.transferId == selectedId } sendCoreState.transfers.any { it.transferId == selectedId }
} != true && } != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send } sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive &&
windowClass == WindowClass.Phone &&
!receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell( AppShell(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination, selectedDestination = appState.destination,
@@ -112,13 +149,20 @@ fun App(dependencies: AppDependencies) {
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) )
} }
} else if (showReceiveAction) {
{
ReceiveFloatingAction(
onClick = receiveViewModel::openAcquisition,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else { } else {
null null
}, },
) { ) {
when (appState.destination) { when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass) AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) } AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
} }
} }

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
data class PlatformEnvironment( data class PlatformEnvironment(
val name: String, val name: String,
@@ -27,4 +28,5 @@ data class AppDependencies(
val deviceInfoProvider: DeviceInfoProvider, val deviceInfoProvider: DeviceInfoProvider,
val fileSystemService: FileSystemService, val fileSystemService: FileSystemService,
val localNotificationService: LocalNotificationService, val localNotificationService: LocalNotificationService,
val externalInvitations: ExternalInvitationController,
) )

View File

@@ -146,6 +146,7 @@ interface CoreGateway {
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit> suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit> suspend fun delete(transferId: ULong): Result<Unit>
suspend fun clearReceiveHistory(): Result<ULong>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit> suspend fun refresh(): Result<Unit>

View File

@@ -173,6 +173,12 @@ class CoreRepository(
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
} }
override suspend fun clearReceiveHistory(): Result<ULong> = runCore {
val deleted = requireCore().deleteReceiveHistory()
refreshSnapshot()
deleted
}
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
} }

View File

@@ -5,10 +5,15 @@ import uniffi.vnidrop.ReceiveOutputSink
enum class ReceiveFolderKind { enum class ReceiveFolderKind {
FileSystemPath, FileSystemPath,
/** Shared system Downloads via MediaStore (Android 10+). */
AndroidPublicDownloads,
AndroidTreeUri, AndroidTreeUri,
IosSecurityScopedUrl, IosSecurityScopedUrl,
} }
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
const val AndroidPublicDownloadsToken = "media-store:downloads"
data class ReceiveFolder( data class ReceiveFolder(
val kind: ReceiveFolderKind, val kind: ReceiveFolderKind,
val value: String, val value: String,

View File

@@ -0,0 +1,52 @@
package com.vnidrop.app.feature.receive
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
const val VniDropInvitationMimeType = "application/vnd.vnidrop.transfer"
const val VniDropInvitationExtension = "vnd"
const val MaxVniDropInvitationBytes = 64 * 1024
/**
* Buffered ingress for invitation documents opened by a platform host.
*
* Hosts can submit before Compose is attached during a cold launch. Each
* document is then consumed exactly once by the app-level receive workflow.
*/
class ExternalInvitationController {
// OS document-open dispatch can be triggered by another process. Keep the
// cold-launch queue bounded so repeated intents cannot grow memory forever.
private val pending = Channel<Result<String>>(capacity = 16)
val invitations: Flow<Result<String>> = pending.receiveAsFlow()
fun openInvitation(raw: String) {
pending.trySend(validateInvitation(raw))
}
fun reportOpenFailure(message: String) {
pending.trySend(Result.failure(IllegalArgumentException(message)))
}
}
internal fun validateInvitation(raw: String): Result<String> = runCatching {
require(raw.isNotBlank()) { "The invitation is empty" }
require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
raw
}
/**
* Decode invitation document bytes as strict UTF-8 text.
*
* Hosts often receive invitation files as opaque binary streams. Reject payloads
* that are not valid UTF-8 so binary junk never reaches ticket inspection.
*/
fun decodeInvitationBytes(bytes: ByteArray): String {
require(bytes.isNotEmpty()) { "The invitation is empty" }
require(bytes.size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
val text = bytes.decodeToString()
// decodeToString() replaces malformed sequences; require a lossless round-trip.
require(text.encodeToByteArray().contentEquals(bytes)) { "The invitation is not valid text" }
require(text.isNotBlank()) { "The invitation is empty" }
return text
}

View File

@@ -0,0 +1,24 @@
package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable
enum class ReceiveMethod { InvitationFile, QrCode, Nfc }
enum class ReceiveMethodAvailability { Available, Unavailable, Hidden }
interface ReceiveInvitationActions {
val fileAvailability: ReceiveMethodAvailability
val qrAvailability: ReceiveMethodAvailability
val nfcAvailability: ReceiveMethodAvailability
fun pickInvitation(onResult: (Result<String>) -> Unit)
fun scanQrCode(onResult: (Result<String>) -> Unit)
fun readNfcInvitation(onResult: (Result<String>) -> Unit)
fun cancel()
}
@Composable
expect fun rememberReceiveInvitationActions(): ReceiveInvitationActions
internal const val InvitationMimeType = VniDropInvitationMimeType
internal const val MaxInvitationBytes = MaxVniDropInvitationBytes

View File

@@ -1,19 +1,35 @@
package com.vnidrop.app.feature.receive package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.ui.state.WindowClass
@Composable @Composable
fun ReceiveRoute(viewModel: ReceiveViewModel) { fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val actions = rememberReceiveInvitationActions()
DisposableEffect(actions) { onDispose(actions::cancel) }
ReceiveScreen( ReceiveScreen(
coreState = coreState, coreState = coreState,
state = state, state = state,
onTicketChanged = viewModel::setTicket, windowClass = windowClass,
actions = actions,
onOpenAcquisition = viewModel::openAcquisition,
onDismissAcquisition = {
actions.cancel()
viewModel.dismissAcquisition()
},
onReceiverNameChanged = viewModel::setReceiverName, onReceiverNameChanged = viewModel::setReceiverName,
onInspectTicket = viewModel::inspectTicket, onInvitationResult = viewModel::onInvitationResult,
onWaitingForNfc = viewModel::setWaitingForNfc,
onReceive = viewModel::receive, onReceive = viewModel::receive,
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
onRequestClearHistory = viewModel::requestClearHistory,
onDismissHistoryDelete = viewModel::dismissHistoryDelete,
onConfirmHistoryDelete = viewModel::confirmHistoryDelete,
) )
} }

View File

@@ -1,76 +1,323 @@
package com.vnidrop.app.feature.receive package com.vnidrop.app.feature.receive
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.screens.ProgressSection import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.screens.ScreenHeader import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.screens.TicketInspectionCard import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.*
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive @Composable
import vnidrop.shared.generated.resources.button_receiving fun ReceiveFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
import vnidrop.shared.generated.resources.field_output_directory FloatingActionButton(
import vnidrop.shared.generated.resources.field_receiver_name onClick = onClick,
import vnidrop.shared.generated.resources.field_ticket modifier = modifier,
import vnidrop.shared.generated.resources.folder_status_permission_required containerColor = LocalVniDropColors.current.brandButton,
import vnidrop.shared.generated.resources.folder_status_unavailable contentColor = Color.White,
import vnidrop.shared.generated.resources.folder_status_writable ) { Icon(ReceiveIcons.Download, stringResource(Res.string.button_receive_files)) }
import vnidrop.shared.generated.resources.metadata_status }
import vnidrop.shared.generated.resources.receive_subtitle
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title
@Composable @Composable
fun ReceiveScreen( fun ReceiveScreen(
coreState: CoreState, coreState: CoreState,
state: ReceiveState, state: ReceiveState,
onTicketChanged: (String) -> Unit, windowClass: WindowClass,
actions: ReceiveInvitationActions,
onOpenAcquisition: () -> Unit,
onDismissAcquisition: () -> Unit,
onReceiverNameChanged: (String) -> Unit, onReceiverNameChanged: (String) -> Unit,
onInspectTicket: () -> Unit, onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
onReceive: () -> Unit, onReceive: () -> Unit,
onRequestDeleteHistoryItem: (ULong) -> Unit,
onRequestClearHistory: () -> Unit,
onDismissHistoryDelete: () -> Unit,
onConfirmHistoryDelete: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
AppCard(title = stringResource(Res.string.ticket_card_title)) { LazyColumn(
Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4) modifier = Modifier.fillMaxSize().statusBarsPadding(),
MetadataRow( contentPadding = PaddingValues(16.dp),
stringResource(Res.string.field_output_directory), verticalArrangement = Arrangement.spacedBy(14.dp),
state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory, ) {
item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) }
if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
else {
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(Res.string.receive_history_title), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (deletableTransfers.isNotEmpty()) DestructiveQuietButton(stringResource(Res.string.receive_clear_history), onClick = onRequestClearHistory)
}
}
items(transfers, key = Transfer::localId) { transfer ->
ReceiveTransferRow(transfer, onDelete = { onRequestDeleteHistoryItem(transfer.transferId) })
}
}
}
if (state.isAcquisitionOpen) {
AdaptiveDrawer(windowClass, onDismissAcquisition) {
if (state.ticket.isBlank()) {
ReceiveMethodPanel(
actions = actions,
isWaitingForNfc = state.isWaitingForNfc,
onResult = onInvitationResult,
onWaitingForNfc = onWaitingForNfc,
) )
MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName()) } else {
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name)) InvitationReviewPanel(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { state = state,
SecondaryButton( coreInitialized = coreState.isInitialized,
stringResource(Res.string.button_inspect_ticket), onReceiverNameChanged = onReceiverNameChanged,
onClick = onInspectTicket, onReceive = onReceive,
enabled = state.canInspect(coreState.isInitialized),
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
enabled = state.canReceive(coreState.isInitialized),
) )
} }
} }
coreState.lastInspection?.let { TicketInspectionCard(it) } }
ProgressSection(coreState)
state.historyDeleteTarget?.let { target ->
val transferName = (target as? ReceiveHistoryDeleteTarget.Transfer)?.let { selected ->
transfers.firstOrNull { it.transferId == selected.transferId }?.transferName
}
AdaptiveDrawer(windowClass, onDismissHistoryDelete) {
ReceiveHistoryDeletePanel(
clearAll = target == ReceiveHistoryDeleteTarget.All,
transferName = transferName,
isDeleting = state.isDeletingHistory,
onCancel = onDismissHistoryDelete,
onConfirm = onConfirmHistoryDelete,
)
}
} }
} }
@Composable @Composable
private fun FolderAccessStatus.displayName(): String = when (this) { private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable) Text(stringResource(Res.string.receive_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter)
}
if (showAction && windowClass != WindowClass.Phone) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen)
}
}
} }
@Composable
private fun ReceiveEmptyState(onOpen: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(68.dp).background(colors.brandLink.copy(alpha = 0.12f), RoundedCornerShape(20.dp)), contentAlignment = Alignment.Center) {
Icon(ReceiveIcons.Download, null, tint = colors.brandLink, modifier = Modifier.size(30.dp))
}
Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 22.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
stringResource(Res.string.receive_empty_body),
modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp),
color = colors.foregroundLighter,
textAlign = TextAlign.Center,
)
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen, modifier = Modifier.padding(top = 22.dp))
}
}
@Composable
private fun ReceiveMethodPanel(
actions: ReceiveInvitationActions,
isWaitingForNfc: Boolean,
onResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(Res.string.receive_choose_method_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_choose_method_body), color = LocalVniDropColors.current.foregroundLighter)
ReceiveMethodRow(
ReceiveIcons.File, stringResource(Res.string.receive_method_file), stringResource(Res.string.receive_method_file_description),
actions.fileAvailability,
) { actions.pickInvitation { onResult(ReceiveMethod.InvitationFile, it) } }
if (actions.qrAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow(
ReceiveIcons.Scan, stringResource(Res.string.receive_method_scan), stringResource(Res.string.receive_method_scan_description),
actions.qrAvailability,
) { actions.scanQrCode { onResult(ReceiveMethod.QrCode, it) } }
if (actions.nfcAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow(
ReceiveIcons.Nfc,
if (isWaitingForNfc) stringResource(Res.string.receive_nfc_waiting) else stringResource(Res.string.receive_method_nfc),
stringResource(Res.string.receive_method_nfc_description),
if (isWaitingForNfc) ReceiveMethodAvailability.Unavailable else actions.nfcAvailability,
) {
onWaitingForNfc(true)
actions.readNfcInvitation { onResult(ReceiveMethod.Nfc, it) }
}
}
}
@Composable
private fun ReceiveMethodRow(icon: ImageVector, title: String, description: String, availability: ReceiveMethodAvailability, onClick: () -> Unit) {
val enabled = availability == ReceiveMethodAvailability.Available
Surface(
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(14.dp),
color = LocalVniDropColors.current.backgroundSurface200,
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = if (enabled) LocalVniDropColors.current.brandLink else LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (availability == ReceiveMethodAvailability.Unavailable) Text(stringResource(Res.string.value_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean, onReceiverNameChanged: (String) -> Unit, onReceive: () -> Unit) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(Res.string.receive_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (state.isInspecting) Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
state.inspection?.let { inspection ->
val metadata = inspection.metadata
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(metadata?.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (metadata != null) Text("${metadata.fileCount} ${stringResource(Res.string.metadata_files).lowercase()} · ${formatBytes(metadata.totalSize)}", color = LocalVniDropColors.current.foregroundLighter)
}
}
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name))
Text(
state.receiveFolder?.displayName ?: stringResource(Res.string.value_unavailable),
color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
modifier = Modifier.fillMaxWidth(),
enabled = state.canReceive(coreInitialized),
)
}
}
}
@Composable
private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
Icon(ReceiveIcons.File, null, tint = LocalVniDropColors.current.foregroundLighter)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(transfer.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${formatBytes(transfer.totalSize)} · ${displayNameForStatus(transfer.status)}", color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (transfer.status.isTerminalReceiveHistory()) {
IconButton(onClick = onDelete) {
Icon(ReceiveIcons.Trash, stringResource(Res.string.receive_delete_history_item), tint = LocalVniDropColors.current.destructiveDefault)
}
}
}
}
}
@Composable
private fun ReceiveHistoryDeletePanel(
clearAll: Boolean,
transferName: String?,
isDeleting: Boolean,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
stringResource(if (clearAll) Res.string.receive_clear_history_title else Res.string.receive_delete_history_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(
if (clearAll) stringResource(Res.string.receive_clear_history_description)
else stringResource(Res.string.receive_delete_history_description, transferName ?: stringResource(Res.string.receive_unknown_transfer)),
color = LocalVniDropColors.current.foregroundLighter,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
DestructiveButton(
if (isDeleting) stringResource(Res.string.transfer_deleting)
else stringResource(if (clearAll) Res.string.receive_clear_history else Res.string.button_delete_transfer),
onClick = onConfirm,
enabled = !isDeleting,
)
}
}
}
private object ReceiveIcons {
val Download = lineIcon("Download") { moveTo(12f, 3f); lineTo(12f, 15f); moveTo(7f, 10f); lineTo(12f, 15f); lineTo(17f, 10f); moveTo(4f, 20f); lineTo(20f, 20f) }
val File = lineIcon("File") { moveTo(14f, 2f); lineTo(6f, 2f); lineTo(6f, 22f); lineTo(18f, 22f); lineTo(18f, 6f); close(); moveTo(14f, 2f); lineTo(14f, 6f); lineTo(18f, 6f) }
val Scan = lineIcon("Scan") { moveTo(3f, 8f); lineTo(3f, 3f); lineTo(8f, 3f); moveTo(16f, 3f); lineTo(21f, 3f); lineTo(21f, 8f); moveTo(21f, 16f); lineTo(21f, 21f); lineTo(16f, 21f); moveTo(8f, 21f); lineTo(3f, 21f); lineTo(3f, 16f); moveTo(7f, 12f); lineTo(17f, 12f) }
val Nfc = lineIcon("Nfc") { moveTo(6f, 8f); curveTo(10f, 12f, 10f, 12f, 6f, 16f); moveTo(10f, 5f); curveTo(17f, 12f, 17f, 12f, 10f, 19f); moveTo(14f, 2f); curveTo(24f, 12f, 24f, 12f, 14f, 22f) }
val Trash = lineIcon("Delete") { moveTo(4f, 7f); lineTo(20f, 7f); moveTo(9f, 3f); lineTo(15f, 3f); lineTo(16f, 7f); moveTo(7f, 7f); lineTo(8f, 21f); lineTo(16f, 21f); lineTo(17f, 7f); moveTo(10f, 11f); lineTo(10f, 17f); moveTo(14f, 11f); lineTo(14f, 17f) }
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit) = ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, pathFillType = PathFillType.NonZero, pathBuilder = block)
}.build()

View File

@@ -7,26 +7,46 @@ import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.TicketInspectionModel
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted
sealed interface ReceiveHistoryDeleteTarget {
data class Transfer(val transferId: ULong) : ReceiveHistoryDeleteTarget
data object All : ReceiveHistoryDeleteTarget
}
data class ReceiveState( data class ReceiveState(
val isAcquisitionOpen: Boolean = false,
val ticket: String = "", val ticket: String = "",
val outputDirectory: String = "", val method: ReceiveMethod? = null,
val inspection: TicketInspectionModel? = null,
val receiverName: String = "", val receiverName: String = "",
val receiveFolder: ReceiveFolder? = null, val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isInspecting: Boolean = false,
val isReceiving: Boolean = false, val isReceiving: Boolean = false,
val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false,
) { ) {
fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank()
fun canReceive(coreInitialized: Boolean): Boolean = fun canReceive(coreInitialized: Boolean): Boolean =
coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && coreInitialized && ticket.isNotBlank() && inspection != null &&
folderAccessStatus == FolderAccessStatus.Writable && !isReceiving folderAccessStatus == FolderAccessStatus.Writable && !isReceiving && !isInspecting
} }
class ReceiveViewModel( class ReceiveViewModel(
@@ -47,7 +67,6 @@ class ReceiveViewModel(
current.copy( current.copy(
receiverName = current.receiverName.ifBlank { preferences.username }, receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder, receiveFolder = preferences.receiveFolder,
outputDirectory = preferences.receiveFolder.value,
folderAccessStatus = status, folderAccessStatus = status,
) )
} }
@@ -55,14 +74,58 @@ class ReceiveViewModel(
} }
} }
fun setTicket(value: String) = _state.update { it.copy(ticket = value) } fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) } fun dismissAcquisition() {
if (!_state.value.isReceiving && !_state.value.isInspecting) resetAcquisition()
}
fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) } fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) }
fun setWaitingForNfc(waiting: Boolean) = _state.update { it.copy(isWaitingForNfc = waiting) }
fun requestDeleteHistoryItem(transferId: ULong) {
val canDelete = coreState.value.transfers.any { transfer ->
transfer.transferId == transferId && transfer.direction == TransferDirection.Receive && transfer.status.isTerminalReceiveHistory()
}
if (canDelete) _state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.Transfer(transferId)) }
}
fun requestClearHistory() {
if (coreState.value.transfers.any { it.direction == TransferDirection.Receive && it.status.isTerminalReceiveHistory() }) {
_state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.All) }
}
}
fun dismissHistoryDelete() {
if (!_state.value.isDeletingHistory) _state.update { it.copy(historyDeleteTarget = null) }
}
fun confirmHistoryDelete() {
val target = _state.value.historyDeleteTarget ?: return
if (_state.value.isDeletingHistory) return
viewModelScope.launch {
_state.update { it.copy(isDeletingHistory = true) }
val result = when (target) {
is ReceiveHistoryDeleteTarget.Transfer -> repository.delete(target.transferId).map { Unit }
ReceiveHistoryDeleteTarget.All -> repository.clearReceiveHistory().map { Unit }
}
result.fold(
onSuccess = {
_state.update { it.copy(historyDeleteTarget = null, isDeletingHistory = false) }
val message = when (target) {
is ReceiveHistoryDeleteTarget.Transfer -> Res.string.transfer_deleted
ReceiveHistoryDeleteTarget.All -> Res.string.receive_history_cleared
}
messages.tryShow(UiMessage(UiText.Resource(message), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isDeletingHistory = false) }
messages.error(error)
},
)
}
}
fun inspectTicket() { fun onInvitationResult(method: ReceiveMethod, result: Result<String>) {
val current = state.value _state.update { it.copy(isWaitingForNfc = false) }
if (!current.canInspect(coreState.value.isInitialized)) return result.fold(
viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) } onSuccess = { raw -> inspectInvitation(method, raw) },
onFailure = messages::error,
)
} }
fun receive() { fun receive() {
@@ -71,7 +134,6 @@ class ReceiveViewModel(
if (!current.canReceive(coreState.value.isInitialized)) return if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isReceiving = true) } _state.update { it.copy(isReceiving = true) }
try {
val outputSink = fileSystemService.createReceiveOutputSink(folder) val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when { val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
@@ -80,12 +142,56 @@ class ReceiveViewModel(
folder.value, folder.value,
current.receiverName, current.receiverName,
) )
else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName) else -> repository.receive(current.ticket, folder.value, current.receiverName)
} }
result.onFailure(messages::error) result.fold(
} finally { onSuccess = {
resetAcquisition()
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isReceiving = false) } _state.update { it.copy(isReceiving = false) }
messages.error(error)
},
)
} }
} }
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
viewModelScope.launch {
_state.update {
it.copy(
isAcquisitionOpen = true,
ticket = ticket,
method = method,
inspection = null,
isInspecting = true,
)
}
repository.inspectTicket(ticket).fold(
onSuccess = { inspection -> _state.update { it.copy(inspection = inspection, isInspecting = false) } },
onFailure = { error ->
_state.update { it.copy(ticket = "", method = null, inspection = null, isInspecting = false) }
messages.error(error)
},
)
}
}
private fun resetAcquisition() = _state.update {
it.copy(
isAcquisitionOpen = false,
ticket = "",
method = null,
inspection = null,
isInspecting = false,
isReceiving = false,
isWaitingForNfc = false,
)
} }
} }
internal fun TransferStatus.isTerminalReceiveHistory(): Boolean =
this == TransferStatus.Done || this == TransferStatus.Failed || this == TransferStatus.Cancelled

View File

@@ -47,14 +47,7 @@ class AppPreferencesRepository(
.map { prefs -> .map { prefs ->
AppPreferences( AppPreferences(
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
receiveFolder = ReceiveFolder( receiveFolder = resolveReceiveFolder(prefs, defaults.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, themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
) )
@@ -105,6 +98,32 @@ private object PreferenceKeys {
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
} }
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
val kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) }
?: defaults.kind
val value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() }
?: defaults.value
val displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() }
?: defaults.displayName
// Older Android builds defaulted to app-private "Downloads" paths that are
// invisible in the system Downloads UI. Promote those back to the shared
// public Downloads default so receive matches desktop expectations.
if (kind == ReceiveFolderKind.FileSystemPath && isLegacyAndroidAppDownloadsPath(value)) {
return defaults
}
return ReceiveFolder(kind = kind, value = value, displayName = displayName)
}
private fun isLegacyAndroidAppDownloadsPath(path: String): Boolean {
// Typical: /storage/emulated/0/Android/data/<pkg>/files/Download[s]
val normalized = path.replace('\\', '/')
return normalized.contains("/Android/data/") &&
(normalized.endsWith("/files/Download") ||
normalized.endsWith("/files/Downloads") ||
normalized.contains("/files/Download/") ||
normalized.contains("/files/Downloads/"))
}
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull() runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()

View File

@@ -41,6 +41,18 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier
} }
} }
@Composable
fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
TextButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 40.dp),
colors = ButtonDefaults.textButtonColors(contentColor = LocalVniDropColors.current.destructiveDefault),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable @Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button( Button(

View File

@@ -7,7 +7,11 @@ import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsViewModel import com.vnidrop.app.feature.settings.SettingsViewModel
@@ -227,20 +231,236 @@ class ViewModelsTest {
@Test @Test
fun receiveViewModelBuildsStateFromPreferences() = runTest { fun receiveViewModelBuildsStateFromPreferences() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = mutableState.value.copy(isInitialized = true) } val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle() advanceUntilIdle()
viewModel.setTicket("ticket") viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
assertTrue(viewModel.state.value.canReceive(coreInitialized = true)) assertTrue(viewModel.state.value.canReceive(coreInitialized = true))
assertEquals("Receiver", viewModel.state.value.receiverName) assertEquals("Receiver", viewModel.state.value.receiverName)
} }
@Test
fun receiveViewModelDeletesOneTerminalHistoryItem() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(receivedTransfer(21UL, TransferStatus.Done)))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.requestDeleteHistoryItem(21UL)
viewModel.confirmHistoryDelete()
advanceUntilIdle()
assertEquals(listOf(21UL), core.deletedTransfers)
assertEquals(null, viewModel.state.value.historyDeleteTarget)
}
@Test
fun receiveViewModelClearHistoryUsesAtomicCoreOperationAndKeepsActiveReceive() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
clearReceiveHistoryResult = Result.success(2UL)
mutableState.value = CoreState(
isInitialized = true,
transfers = listOf(
receivedTransfer(21UL, TransferStatus.Done),
receivedTransfer(22UL, TransferStatus.Failed),
receivedTransfer(23UL, TransferStatus.Receiving),
),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.requestClearHistory()
assertEquals(ReceiveHistoryDeleteTarget.All, viewModel.state.value.historyDeleteTarget)
viewModel.confirmHistoryDelete()
advanceUntilIdle()
assertEquals(1, core.clearReceiveHistoryCount)
assertEquals(listOf(23UL), core.state.value.transfers.map(Transfer::transferId))
}
@Test
fun receiveViewModelKeepsDeleteConfirmationAfterFailure() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
deleteResult = Result.failure(IllegalStateException("database busy"))
mutableState.value = CoreState(isInitialized = true, transfers = listOf(receivedTransfer(21UL, TransferStatus.Done)))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.requestDeleteHistoryItem(21UL)
viewModel.confirmHistoryDelete()
advanceUntilIdle()
assertEquals(ReceiveHistoryDeleteTarget.Transfer(21UL), viewModel.state.value.historyDeleteTarget)
assertFalse(viewModel.state.value.isDeletingHistory)
}
@Test
fun receiveViewModelCompletesSuccessfulReceiveAndResetsAcquisition() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
assertEquals(1, core.receiveCount)
assertEquals("ticket-abc", core.lastReceiveTicket)
assertEquals("Receiver", core.lastReceiveReceiverName)
assertFalse(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
}
@Test
fun receiveViewModelKeepsReviewStateWhenReceiveFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
receiveResult = Result.failure(IllegalStateException("sender refused"))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.QrCode, Result.success("ticket-xyz"))
advanceUntilIdle()
viewModel.receive()
advanceUntilIdle()
assertEquals(1, core.receiveCount)
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null)
}
@Test
fun receiveViewModelClearsTicketWhenInspectionFailsButKeepsAcquisitionOpen() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.failure(IllegalArgumentException("invalid ticket"))
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("bad-ticket"))
advanceUntilIdle()
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("", viewModel.state.value.ticket)
assertEquals(null, viewModel.state.value.inspection)
assertFalse(viewModel.state.value.isInspecting)
}
@Test
fun receiveViewModelIgnoresDeleteForActiveReceive() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(
isInitialized = true,
transfers = listOf(receivedTransfer(21UL, TransferStatus.Receiving)),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.requestDeleteHistoryItem(21UL)
assertEquals(null, viewModel.state.value.historyDeleteTarget)
}
@Test
fun receiveViewModelDismissResetsIdleAcquisitionButNotWhileReceiving() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
inspectionResult = Result.success(
com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop",
blobTicket = "blob",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
),
)
// Keep receive suspended so dismiss can be asserted mid-transfer.
receiveResult = Result.success(Unit)
receiveSuspend = true
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.openAcquisition()
viewModel.dismissAcquisition()
assertFalse(viewModel.state.value.isAcquisitionOpen)
viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket"))
advanceUntilIdle()
viewModel.receive()
// Start receive but do not finish the suspended core call yet.
testScheduler.runCurrent()
assertTrue(viewModel.state.value.isReceiving)
viewModel.dismissAcquisition()
assertTrue(viewModel.state.value.isAcquisitionOpen)
assertEquals("ticket", viewModel.state.value.ticket)
core.completeSuspendedReceive()
advanceUntilIdle()
assertFalse(viewModel.state.value.isAcquisitionOpen)
}
private fun preferences() = FakePreferencesRepository( private fun preferences() = FakePreferencesRepository(
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false), AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
) )
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop") private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop")
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
localId = "receive-$id",
transferId = id,
direction = TransferDirection.Receive,
status = status,
peerId = null,
transferName = "Received $id",
contentHash = "hash-$id",
fileCount = 1UL,
totalSize = 42UL,
ticket = null,
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 1L,
)
private companion object { private companion object {
val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads") val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads")
} }

View File

@@ -0,0 +1,53 @@
package com.vnidrop.app.feature.receive
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class ExternalInvitationControllerTest {
@Test
fun buffersColdLaunchInvitationsAndDeliversThemInOrder() = runTest {
val controller = ExternalInvitationController()
controller.openInvitation("first")
controller.openInvitation("second")
val received = async { controller.invitations.take(2).toList() }.await()
assertEquals(listOf("first", "second"), received.map { it.getOrThrow() })
}
@Test
fun rejectsEmptyAndOversizedDocumentsBeforeInspection() = runTest {
val controller = ExternalInvitationController()
controller.openInvitation(" ")
controller.openInvitation("x".repeat(MaxVniDropInvitationBytes + 1))
val received = async { controller.invitations.take(2).toList() }.await()
assertTrue(received.all { it.isFailure })
}
@Test
fun decodeInvitationBytesAcceptsValidUtf8WithinLimit() {
val ticket = "vnd1:example-ticket"
assertEquals(ticket, decodeInvitationBytes(ticket.encodeToByteArray()))
}
@Test
fun decodeInvitationBytesRejectsBinaryAndOversizePayloads() {
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()))
}
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(ByteArray(MaxVniDropInvitationBytes + 1) { 'a'.code.toByte() })
}
assertFailsWith<IllegalArgumentException> {
decodeInvitationBytes(byteArrayOf())
}
}
}

View File

@@ -21,6 +21,7 @@ import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.feature.send.FilePreviewRepository import com.vnidrop.app.feature.send.FilePreviewRepository
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -36,10 +37,30 @@ class FakeCoreGateway : CoreGateway {
var responseResult: Result<Unit> = Result.success(Unit) var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>() val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException()) var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var inspectionResult: Result<TicketInspectionModel> = Result.failure(UnsupportedOperationException())
var receiveResult: Result<Unit> = Result.success(Unit)
var receiveSuspend: Boolean = false
private var receiveGate: CompletableDeferred<Unit>? = null
var deleteResult: Result<Unit> = Result.success(Unit) var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0
var receiveCount = 0
var lastReceiveTicket: String? = null
var lastReceiveReceiverName: String? = null
var lastShareAccessPolicy: ShareAccessPolicy? = null var lastShareAccessPolicy: ShareAccessPolicy? = null
fun completeSuspendedReceive() {
receiveGate?.complete(Unit)
}
private suspend fun awaitReceiveIfNeeded() {
if (!receiveSuspend) return
val gate = CompletableDeferred<Unit>()
receiveGate = gate
gate.await()
}
override suspend fun initialize(appDataDir: String): Result<Unit> { override suspend fun initialize(appDataDir: String): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true) mutableState.value = mutableState.value.copy(isInitialized = true)
return Result.success(Unit) return Result.success(Unit)
@@ -84,10 +105,28 @@ class FakeCoreGateway : CoreGateway {
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException()) ) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException()) override suspend fun inspectTicket(ticket: String) = inspectionResult
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) receiveCount += 1
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun cancel(transferId: ULong) = Result.success(Unit) override suspend fun cancel(transferId: ULong) = Result.success(Unit)
override suspend fun delete(transferId: ULong): Result<Unit> { override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) { if (deleteResult.isSuccess) {
@@ -98,6 +137,21 @@ class FakeCoreGateway : CoreGateway {
} }
return deleteResult return deleteResult
} }
override suspend fun clearReceiveHistory(): Result<ULong> {
clearReceiveHistoryCount += 1
clearReceiveHistoryResult.onSuccess {
mutableState.value = mutableState.value.copy(
transfers = mutableState.value.transfers.filterNot { transfer ->
transfer.direction == TransferDirection.Receive && transfer.status in setOf(
TransferStatus.Done,
TransferStatus.Failed,
TransferStatus.Cancelled,
)
},
)
}
return clearReceiveHistoryResult
}
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty()) override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> { override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
responses += Triple(requestId, accepted, reason) responses += Triple(requestId, accepted, reason)

View File

@@ -60,15 +60,14 @@ class AppUiModelsTest {
fun receiveStateExposesInspectAndReceiveEligibility() { fun receiveStateExposesInspectAndReceiveEligibility() {
val ready = ReceiveState( val ready = ReceiveState(
ticket = "ticket", ticket = "ticket",
outputDirectory = "/tmp/out", inspection = com.vnidrop.app.core.TicketInspectionModel("vnidrop", "blob", null),
folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable, folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable,
) )
assertTrue(ready.canInspect(coreInitialized = true))
assertTrue(ready.canReceive(coreInitialized = true)) assertTrue(ready.canReceive(coreInitialized = true))
assertFalse(ready.canInspect(coreInitialized = false)) assertFalse(ready.canReceive(coreInitialized = false))
assertFalse(ready.copy(ticket = "").canReceive(coreInitialized = true)) assertFalse(ready.copy(ticket = "").canReceive(coreInitialized = true))
assertFalse(ready.copy(outputDirectory = "").canReceive(coreInitialized = true)) assertFalse(ready.copy(inspection = null).canReceive(coreInitialized = true))
assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true)) assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true))
} }

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
import com.vnidrop.app.feature.receive.ExternalInvitationController
fun MainViewController() = ComposeUIViewController { App(rememberIosAppDependencies()) } fun MainViewController(externalInvitations: ExternalInvitationController) =
ComposeUIViewController { App(rememberIosAppDependencies(externalInvitations)) }

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.IosLocalNotificationService import com.vnidrop.app.notifications.IosLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import platform.Foundation.NSBundle import platform.Foundation.NSBundle
import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSSearchPathForDirectoriesInDomains
@@ -11,7 +12,7 @@ import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIDevice import platform.UIKit.UIDevice
@Composable @Composable
fun rememberIosAppDependencies(): AppDependencies { fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
return remember(fileSystemService) { return remember(fileSystemService) {
val device = UIDevice.currentDevice val device = UIDevice.currentDevice
@@ -25,6 +26,7 @@ fun rememberIosAppDependencies(): AppDependencies {
deviceInfoProvider = IosDeviceInfoProvider(device), deviceInfoProvider = IosDeviceInfoProvider(device),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = IosLocalNotificationService(), localNotificationService = IosLocalNotificationService(),
externalInvitations = externalInvitations,
) )
} }
} }

View File

@@ -37,7 +37,8 @@ private class IosFileSystemService : FileSystemService {
} }
} }
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
ReceiveFolderKind.AndroidTreeUri -> FolderAccessStatus.Unavailable ReceiveFolderKind.AndroidTreeUri,
ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null

View File

@@ -0,0 +1,68 @@
package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readBytes
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIModalPresentationFormSheet
import platform.UniformTypeIdentifiers.UTTypeData
import platform.darwin.NSObject
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
@Composable
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Unavailable
override val nfcAvailability = ReceiveMethodAvailability.Unavailable
@OptIn(ExperimentalForeignApi::class)
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
val delegate = InvitationDocumentDelegate(onResult)
retainedInvitationDelegate = delegate
picker.delegate = delegate
picker.modalPresentationStyle = UIModalPresentationFormSheet
presenter.presentViewController(picker, animated = true, completion = null)
}
override fun scanQrCode(onResult: (Result<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is not enabled for this iOS build")))
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC reading is not enabled for this iOS build")))
override fun cancel() = Unit
}
}
private class InvitationDocumentDelegate(
private val onResult: (Result<String>) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol {
@OptIn(ExperimentalForeignApi::class)
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
onResult(runCatching {
requireNotNull(url) { "The selected invitation URL was invalid" }
val path = url.path ?: error("The invitation path was invalid")
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
val length = data.length.toInt()
require(length <= MaxInvitationBytes) { "The invitation is too large" }
val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty")
decodeInvitationBytes(bytes)
})
retainedInvitationDelegate = null
}
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
retainedInvitationDelegate = null
}
}

View File

@@ -4,10 +4,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.JvmLocalNotificationService import com.vnidrop.app.notifications.JvmLocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
import java.net.NetworkInterface import java.net.NetworkInterface
@Composable @Composable
fun rememberJvmAppDependencies(): AppDependencies { fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
val fileSystemService = rememberFileSystemService() val fileSystemService = rememberFileSystemService()
return remember(fileSystemService) { return remember(fileSystemService) {
AppDependencies( AppDependencies(
@@ -20,6 +21,7 @@ fun rememberJvmAppDependencies(): AppDependencies {
deviceInfoProvider = JvmDeviceInfoProvider, deviceInfoProvider = JvmDeviceInfoProvider,
fileSystemService = fileSystemService, fileSystemService = fileSystemService,
localNotificationService = JvmLocalNotificationService(), localNotificationService = JvmLocalNotificationService(),
externalInvitations = externalInvitations,
) )
} }
} }

View File

@@ -0,0 +1,50 @@
package com.vnidrop.app.feature.receive
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
@Composable
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Hidden
override val nfcAvailability = ReceiveMethodAvailability.Hidden
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
EventQueue.invokeLater {
val dialog = FileDialog(activeFrame(), "Open VniDrop invitation", FileDialog.LOAD).apply {
setFilenameFilter { _, name -> name.endsWith(".$VniDropInvitationExtension", ignoreCase = true) }
}
try {
dialog.isVisible = true
val directory = dialog.directory
val name = dialog.file
if (directory != null && name != null) onResult(readInvitation(File(directory, name)))
} finally { dialog.dispose() }
}
}
override fun scanQrCode(onResult: (Result<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("QR scanning is unavailable on desktop")))
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop")))
override fun cancel() = Unit
}
}
private fun readInvitation(file: File): Result<String> = runCatching {
require(file.extension.equals(VniDropInvitationExtension, ignoreCase = true)) { "This is not a VniDrop invitation" }
val bytes = file.inputStream().use { it.readNBytes(MaxInvitationBytes + 1) }
decodeInvitationBytes(bytes)
}
private fun activeFrame(): Frame? =
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }

View File

@@ -39,13 +39,46 @@ class AppPreferencesRepositoryTest {
assertEquals(true, repository.preferences.first().notificationsEnabled) assertEquals(true, repository.preferences.first().notificationsEnabled)
} }
private fun repositoryForTest(): AppPreferencesRepository { @Test
fun legacyAndroidAppDownloadsPathIsPromotedToDefault() = runBlocking {
val publicDefault = ReceiveFolder(
kind = ReceiveFolderKind.AndroidPublicDownloads,
value = "media-store:downloads",
displayName = "Downloads",
)
val repository = repositoryForTest(default = publicDefault)
repository.setReceiveFolder(
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/storage/emulated/0/Android/data/com.vnidrop.app/files/Downloads",
displayName = "App downloads",
),
)
assertEquals(publicDefault, repository.preferences.first().receiveFolder)
}
@Test
fun customFileSystemFolderIsNotPromotedAway() = runBlocking {
val repository = repositoryForTest()
val custom = ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = "/tmp/custom-receive",
displayName = "Custom",
)
repository.setReceiveFolder(custom)
assertEquals(custom, repository.preferences.first().receiveFolder)
}
private fun repositoryForTest(
default: ReceiveFolder = defaultFolder,
): AppPreferencesRepository {
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
return AppPreferencesRepository( return AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(directory), dataStore = createAppPreferencesDataStore(directory),
defaults = AppPreferencesDefaults( defaults = AppPreferencesDefaults(
username = "Device Name", username = "Device Name",
receiveFolder = defaultFolder, receiveFolder = default,
themeMode = ThemeMode.System, themeMode = ThemeMode.System,
), ),
) )

View File

@@ -24,6 +24,11 @@ import androidx.compose.runtime.mutableStateOf
import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.approvals.ApprovalState import com.vnidrop.app.feature.approvals.ApprovalState
import com.vnidrop.app.feature.approvals.PendingApproval import com.vnidrop.app.feature.approvals.PendingApproval
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveInvitationActions
import com.vnidrop.app.feature.receive.ReceiveMethodAvailability
import com.vnidrop.app.feature.receive.ReceiveScreen
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsScreen
import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.SettingsState import com.vnidrop.app.feature.settings.SettingsState
@@ -316,6 +321,85 @@ class FoundationComposeTest {
onNodeWithContentDescription("Close").assertIsDisplayed() onNodeWithContentDescription("Close").assertIsDisplayed()
} }
@Test
fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest {
val state = mutableStateOf(ReceiveState())
val actions = object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Hidden
override val nfcAvailability = ReceiveMethodAvailability.Hidden
override fun pickInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun scanQrCode(onResult: (Result<String>) -> Unit) = Unit
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun cancel() = Unit
}
setContent {
VniDropTheme(isDarkTheme = false) {
ReceiveScreen(
coreState = CoreState(isInitialized = true),
state = state.value,
windowClass = WindowClass.Phone,
actions = actions,
onOpenAcquisition = { state.value = state.value.copy(isAcquisitionOpen = true) },
onDismissAcquisition = {},
onReceiverNameChanged = {},
onInvitationResult = { _, _ -> },
onWaitingForNfc = {},
onReceive = {},
onRequestDeleteHistoryItem = {},
onRequestClearHistory = {},
onDismissHistoryDelete = {},
onConfirmHistoryDelete = {},
)
}
}
onNodeWithText("Receive your first file").assertIsDisplayed()
onNodeWithText("Receive files").performClick()
onNodeWithText("How would you like to connect?").assertIsDisplayed()
onNodeWithText("Open a .vnd invitation").assertIsDisplayed()
}
@Test
fun receiveHistoryOffersPerItemDeleteAndConfirmedClearAll() = runComposeUiTest {
val state = mutableStateOf(ReceiveState())
val actions = object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Hidden
override val nfcAvailability = ReceiveMethodAvailability.Hidden
override fun pickInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun scanQrCode(onResult: (Result<String>) -> Unit) = Unit
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) = Unit
override fun cancel() = Unit
}
setContent {
VniDropTheme(isDarkTheme = false) {
ReceiveScreen(
coreState = CoreState(isInitialized = true, transfers = listOf(receivedTransfer())),
state = state.value,
windowClass = WindowClass.Phone,
actions = actions,
onOpenAcquisition = {},
onDismissAcquisition = {},
onReceiverNameChanged = {},
onInvitationResult = { _, _ -> },
onWaitingForNfc = {},
onReceive = {},
onRequestDeleteHistoryItem = { state.value = state.value.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.Transfer(it)) },
onRequestClearHistory = { state.value = state.value.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.All) },
onDismissHistoryDelete = { state.value = state.value.copy(historyDeleteTarget = null) },
onConfirmHistoryDelete = {},
)
}
}
onNodeWithContentDescription("Delete from receive history").assertIsDisplayed()
onNodeWithText("Clear history").performClick()
onNodeWithText("Clear receive history?").assertIsDisplayed()
onNodeWithText("Downloaded files will remain on this device.", substring = true).assertIsDisplayed()
onNodeWithContentDescription("Close").assertIsDisplayed()
}
@Test @Test
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
val controller = UiMessageController() val controller = UiMessageController()
@@ -361,4 +445,20 @@ class FoundationComposeTest {
createdAt = 1L, createdAt = 1L,
updatedAt = 1L, updatedAt = 1L,
) )
private fun receivedTransfer() = Transfer(
localId = "receive-10",
transferId = 10UL,
direction = TransferDirection.Receive,
status = TransferStatus.Done,
peerId = "sender",
transferName = "Holiday photos",
contentHash = "received-hash",
fileCount = 3UL,
totalSize = 4096UL,
ticket = null,
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 2L,
)
} }