diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 5eda2d3..2f9818c 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -4,6 +4,8 @@
+
+
+
+
+
diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..db732ae
--- /dev/null
+++ b/androidApp/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7fb0a71..2039cee 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,7 +5,7 @@ android-minSdk = "24"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
-androidx-core = "1.19.0"
+androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0-beta01"
androidx-datastore = "1.2.1"
@@ -16,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0"
material3 = "1.11.0-alpha07"
+qrcode = "4.5.0"
jna = "5.17.0"
[libraries]
@@ -42,6 +43,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
+qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
[plugins]
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index ab3c94d..ddb9bf6 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -37,6 +37,7 @@ kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.ktx)
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {
@@ -51,6 +52,7 @@ kotlin {
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutinesCore)
+ implementation(libs.qrcode.kotlin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt
new file mode 100644
index 0000000..1b185eb
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt
@@ -0,0 +1,22 @@
+package com.vnidrop.app.feature.send
+
+import java.io.File
+
+actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmLikePreviewStore(File(appDataDir, "ui/previews"))
+
+private class JvmLikePreviewStore(private val directory: File) : PlatformPreviewStore {
+ override fun list(): List = directory.listFiles().orEmpty().mapNotNull { file ->
+ file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) }
+ }
+ override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull()
+ override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching {
+ directory.mkdirs()
+ val target = file(transferId)
+ if (target.isFile) return@runCatching true
+ val temporary = File(directory, ".${target.name}.tmp")
+ temporary.writeBytes(bytes)
+ temporary.renameTo(target).also { if (!it) temporary.delete() }
+ }.getOrDefault(false)
+ override fun delete(transferId: ULong) { file(transferId).delete() }
+ private fun file(transferId: ULong) = File(directory, "$transferId.preview")
+}
diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt
new file mode 100644
index 0000000..08bb9e0
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt
@@ -0,0 +1,105 @@
+package com.vnidrop.app.feature.send
+
+import android.content.ClipData
+import android.content.Intent
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.nfc.NfcAdapter
+import android.nfc.tech.Ndef
+import android.nfc.tech.NdefFormatable
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.FileProvider
+import java.io.File
+
+@Composable
+actual fun rememberTransferShareActions(): TransferShareActions {
+ val context = LocalContext.current
+ val activity = context as? ComponentActivity
+ val nfcEnabled = activity?.let { NfcAdapter.getDefaultAdapter(it)?.isEnabled == true } == true
+ var pendingExport by remember { mutableStateOf(null) }
+ val exporter = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument(InvitationMimeType),
+ ) { uri ->
+ val pending = pendingExport
+ pendingExport = null
+ if (pending != null && uri != null) {
+ pending.callback(runCatching {
+ context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.ticket.encodeToByteArray()) }
+ ?: error("The selected destination could not be opened")
+ })
+ }
+ }
+ return remember(activity, exporter, nfcEnabled) {
+ object : TransferShareActions {
+ override val canUseNativeShare = activity != null
+ override val nfcAvailability = when {
+ activity == null -> NfcShareAvailability.Unavailable
+ nfcEnabled -> NfcShareAvailability.Available
+ else -> NfcShareAvailability.Unavailable
+ }
+
+ override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) {
+ pendingExport = PendingExport(ticket, onResult)
+ exporter.launch(invitationFileName(transferName))
+ }
+
+ override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) {
+ onResult(runCatching {
+ val directory = File(context.cacheDir, "transfer-invitations").apply { mkdirs() }
+ val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = InvitationMimeType
+ putExtra(Intent.EXTRA_STREAM, uri)
+ clipData = ClipData.newRawUri(file.name, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ })
+ }
+
+ override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) {
+ val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
+ val adapter = NfcAdapter.getDefaultAdapter(host)
+ if (adapter?.isEnabled != true) return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
+ adapter.enableReaderMode(host, { tag ->
+ val result = runCatching {
+ val message = NdefMessage(arrayOf(NdefRecord.createMime(InvitationMimeType, ticket.encodeToByteArray())))
+ val ndef = Ndef.get(tag)
+ if (ndef != null) {
+ ndef.connect()
+ try {
+ require(ndef.isWritable) { "This NFC tag is read-only" }
+ require(ndef.maxSize >= message.toByteArray().size) { "This NFC tag is too small" }
+ ndef.writeNdefMessage(message)
+ } finally { ndef.close() }
+ } else {
+ val formatable = NdefFormatable.get(tag) ?: error("This NFC tag cannot store an invitation")
+ formatable.connect()
+ try { formatable.format(message) } finally { formatable.close() }
+ }
+ }
+ host.runOnUiThread {
+ adapter.disableReaderMode(host)
+ onResult(result)
+ }
+ }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null)
+ }
+
+ override fun cancelNfcWrite() {
+ activity?.let { host -> NfcAdapter.getDefaultAdapter(host)?.disableReaderMode(host) }
+ }
+ }
+ }
+}
+
+private data class PendingExport(val ticket: String, val callback: (Result) -> Unit)
+private const val InvitationMimeType = "application/vnd.vnidrop.transfer"
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 9318337..e96c089 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -20,6 +20,42 @@
Size unavailable
Transfer created.
Transfer details
+ Activity
+ See important updates for this transfer
+ Receivers
+ Requests, approvals, and completed deliveries
+ Share
+ QR code, invitation file, and nearby options
+ Delete transfer?
+ “%1$s” will stop being shared and its transfer history will be removed from this device.
+ Deleting…
+ Transfer deleted.
+ There is no activity to show yet.
+ Nobody has requested this transfer yet.
+ Waiting for your approval
+ Approved — waiting for completion
+ Request refused
+ Request expired
+ Received successfully
+ Status unavailable
+ Nearby device
+ Scan with VniDrop to receive this transfer
+ Write to NFC tag
+ Save .vnd file
+ Share invitation
+ NFC tag writing is not available on this device.
+ Hold your device near a writable NFC tag.
+ Invitation saved.
+ Invitation written to the NFC tag.
+ Preparing the selected files
+ Transfer ready to share
+ A receiver requested access
+ Receiver access approved
+ Receiver access refused
+ A receiver completed the transfer
+ Sharing stopped
+ The transfer encountered a problem
+ Transfer updated
Choose file
Change file
Share file
@@ -63,6 +99,9 @@
Choose folder
Reset default
Back
+ Close
+ Cancel
+ Delete transfer
Writable
Permission required
Unavailable
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
index e3f87a3..d0eaa69 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
@@ -45,7 +45,13 @@ fun App(dependencies: AppDependencies) {
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
}
val sendViewModel = viewModel {
- SendViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
+ SendViewModel(
+ graph.coreRepository,
+ dependencies.fileSystemService,
+ graph.preferencesRepository,
+ graph.filePreviewRepository,
+ graph.messages,
+ )
}
val receiveViewModel = viewModel {
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
index 52b03b9..1d3d6e6 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt
@@ -3,6 +3,8 @@ package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
+import com.vnidrop.app.feature.send.AppFilePreviewRepository
+import com.vnidrop.app.feature.send.createPlatformPreviewStore
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults
@@ -22,6 +24,9 @@ class AppGraph(
) {
val visibility = AppVisibility()
val messages = UiMessageController()
+ val filePreviewRepository = AppFilePreviewRepository(
+ createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir),
+ )
val preferencesRepository = AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
defaults = AppPreferencesDefaults(
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
index b117248..2e62e26 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
@@ -89,12 +89,22 @@ data class ReceiverRequestModel(
val receiverName: String?,
val receiverDeviceName: String?,
val appVersion: String,
- val status: String,
+ val status: ReceiverDeliveryStatus,
val reason: String?,
val requestedAt: Long,
val respondedAt: Long?,
+ val completedAt: Long?,
)
+enum class ReceiverDeliveryStatus {
+ Requested,
+ Accepted,
+ Refused,
+ Expired,
+ Completed,
+ Unknown,
+}
+
data class CoreState(
val isInitialized: Boolean = false,
val status: CoreStatus? = null,
@@ -106,6 +116,7 @@ data class CoreState(
sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal
+ data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
}
interface CoreGateway {
@@ -134,6 +145,7 @@ interface CoreGateway {
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result
suspend fun cancel(transferId: ULong): Result
+ suspend fun delete(transferId: ULong): Result
suspend fun receiverRequests(transferId: ULong): Result>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result
suspend fun refresh(): Result
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
index 76f7aa7..4b0867f 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
@@ -49,6 +49,9 @@ class CoreRepository(
if (model.phase == "approval" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
}
+ if (model.phase == "delivery" && model.transferId != null) {
+ _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
+ }
}
}
@@ -163,6 +166,13 @@ class CoreRepository(
refreshSnapshot()
}
+ override suspend fun delete(transferId: ULong): Result = runCore {
+ requireCore().deleteTransfer(transferId)
+ refreshSnapshot()
+ _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
+ _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
+ }
+
override suspend fun receiverRequests(transferId: ULong): Result> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
}
@@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
receiverName = receiverName,
receiverDeviceName = receiverDeviceName,
appVersion = appVersion,
- status = status,
+ status = when (status) {
+ "requested" -> ReceiverDeliveryStatus.Requested
+ "accepted" -> ReceiverDeliveryStatus.Accepted
+ "refused" -> ReceiverDeliveryStatus.Refused
+ "expired" -> ReceiverDeliveryStatus.Expired
+ "completed" -> ReceiverDeliveryStatus.Completed
+ else -> ReceiverDeliveryStatus.Unknown
+ },
reason = reason,
requestedAt = requestedAt,
respondedAt = respondedAt,
+ completedAt = completedAt,
)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
index b05a9ca..54c0871 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
@@ -5,6 +5,7 @@ import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.ReceiverRequestModel
+import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
@@ -55,6 +56,7 @@ class ApprovalCoordinator(
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
+ is CoreSignal.ReceiverHistoryChanged -> Unit
}
}
}
@@ -108,7 +110,7 @@ class ApprovalCoordinator(
private suspend fun refresh(transferId: ULong) {
repository.receiverRequests(transferId).fold(
onSuccess = { requests ->
- val refreshed = requests.filter { it.status == "requested" }.map(ReceiverRequestModel::toPending)
+ val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending)
val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
removed.forEach { id ->
notifications.cancel(notificationId(id))
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt
new file mode 100644
index 0000000..61c3459
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt
@@ -0,0 +1,111 @@
+package com.vnidrop.app.feature.send
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+data class PreviewFileInfo(
+ val transferId: ULong,
+ val byteSize: Long,
+ val modifiedAtMillis: Long,
+)
+
+interface PlatformPreviewStore {
+ fun list(): List
+ fun read(transferId: ULong): ByteArray?
+ fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean
+ fun delete(transferId: ULong)
+}
+
+expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore
+
+interface FilePreviewRepository {
+ val previews: StateFlow