feat(send): build durable transfer details and sharing flow

This commit is contained in:
2026-07-11 09:31:19 +02:00
parent dc862bc01c
commit 3da91be771
36 changed files with 1340 additions and 60 deletions

View File

@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application
android:allowBackup="true"
@@ -21,6 +23,13 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="transfer_invitations" path="transfer-invitations/"/>
</paths>

View File

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

View File

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

View File

@@ -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<PreviewFileInfo> = 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")
}

View File

@@ -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<PendingExport?>(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>) -> Unit) {
pendingExport = PendingExport(ticket, onResult)
exporter.launch(invitationFileName(transferName))
}
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> 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>) -> 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>) -> Unit)
private const val InvitationMimeType = "application/vnd.vnidrop.transfer"

View File

@@ -20,6 +20,42 @@
<string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string>
<string name="transfer_activity_title">Activity</string>
<string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_receivers_title">Receivers</string>
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
<string name="transfer_share_title">Share</string>
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
<string name="transfer_delete_title">Delete transfer?</string>
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
<string name="transfer_deleting">Deleting…</string>
<string name="transfer_deleted">Transfer deleted.</string>
<string name="transfer_no_activity">There is no activity to show yet.</string>
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_requested">Waiting for your approval</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_unknown">Status unavailable</string>
<string name="transfer_nearby_device">Nearby device</string>
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
<string name="button_write_nfc">Write to NFC tag</string>
<string name="button_download_invitation">Save .vnd file</string>
<string name="button_native_share">Share invitation</string>
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
<string name="transfer_invitation_saved">Invitation saved.</string>
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
<string name="transfer_event_preparing">Preparing the selected files</string>
<string name="transfer_event_ready">Transfer ready to share</string>
<string name="transfer_event_requested">A receiver requested access</string>
<string name="transfer_event_approved">Receiver access approved</string>
<string name="transfer_event_refused">Receiver access refused</string>
<string name="transfer_event_completed">A receiver completed the transfer</string>
<string name="transfer_event_stopped">Sharing stopped</string>
<string name="transfer_event_failed">The transfer encountered a problem</string>
<string name="transfer_event_updated">Transfer updated</string>
<string name="button_choose_file">Choose file</string>
<string name="button_change_file">Change file</string>
<string name="button_share_file">Share file</string>
@@ -63,6 +99,9 @@
<string name="button_choose_folder">Choose folder</string>
<string name="button_reset_default">Reset default</string>
<string name="button_back">Back</string>
<string name="button_close">Close</string>
<string name="button_cancel">Cancel</string>
<string name="button_delete_transfer">Delete transfer</string>
<string name="folder_status_writable">Writable</string>
<string name="folder_status_permission_required">Permission required</string>
<string name="folder_status_unavailable">Unavailable</string>

View File

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

View File

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

View File

@@ -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<Unit>
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>

View File

@@ -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<Unit> = runCore {
requireCore().deleteTransfer(transferId)
refreshSnapshot()
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
}
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = 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,
)

View File

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

View File

@@ -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<PreviewFileInfo>
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<Map<ULong, ByteArray>>
suspend fun restore(activeTransferIds: Set<ULong>)
suspend fun save(transferId: ULong, bytes: ByteArray)
suspend fun remove(transferId: ULong)
}
data class PreviewStoragePolicy(
val maxEntryBytes: Int = 512 * 1024,
val maxTotalBytes: Long = 20L * 1024L * 1024L,
) {
init {
require(maxEntryBytes > 0)
require(maxTotalBytes >= maxEntryBytes)
}
}
class AppFilePreviewRepository(
private val store: PlatformPreviewStore,
private val policy: PreviewStoragePolicy = PreviewStoragePolicy(),
) : FilePreviewRepository {
private val mutex = Mutex()
private val _previews = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
override val previews: StateFlow<Map<ULong, ByteArray>> = _previews.asStateFlow()
override suspend fun restore(activeTransferIds: Set<ULong>) = withContext(Dispatchers.Default) {
mutex.withLock {
val files = store.list()
for (file in files) {
if (file.transferId !in activeTransferIds || file.byteSize !in 1..policy.maxEntryBytes.toLong()) {
store.delete(file.transferId)
}
}
enforceQuota()
_previews.value = store.list()
.filter { it.transferId in activeTransferIds }
.mapNotNull { file ->
store.read(file.transferId)
?.takeIf { it.isSupportedPreview() && it.size <= policy.maxEntryBytes }
?.let { file.transferId to it }
?: run {
store.delete(file.transferId)
null
}
}
.toMap()
}
}
override suspend fun save(transferId: ULong, bytes: ByteArray) = withContext(Dispatchers.Default) {
mutex.withLock {
if (bytes.size !in 1..policy.maxEntryBytes || !bytes.isSupportedPreview()) return@withLock
if (!store.writeAtomically(transferId, bytes)) return@withLock
enforceQuota(protectedTransferId = transferId)
if (store.read(transferId) != null) {
_previews.value = _previews.value + (transferId to bytes.copyOf())
}
}
}
override suspend fun remove(transferId: ULong) = withContext(Dispatchers.Default) {
mutex.withLock {
store.delete(transferId)
_previews.value = _previews.value - transferId
}
}
private fun enforceQuota(protectedTransferId: ULong? = null) {
val files = store.list().sortedBy { it.modifiedAtMillis }
var total = files.sumOf(PreviewFileInfo::byteSize)
for (file in files) {
if (total <= policy.maxTotalBytes) break
if (file.transferId == protectedTransferId) continue
store.delete(file.transferId)
total -= file.byteSize
_previews.value = _previews.value - file.transferId
}
}
}
private fun ByteArray.isSupportedPreview(): Boolean {
val png = size >= 8 && this[0] == 0x89.toByte() && decodeToString(1, 4) == "PNG"
val jpeg = size >= 3 && this[0] == 0xff.toByte() && this[1] == 0xd8.toByte() && this[2] == 0xff.toByte()
val webp = size >= 12 && decodeToString(0, 4) == "RIFF" && decodeToString(8, 12) == "WEBP"
return png || jpeg || webp
}

View File

@@ -170,13 +170,18 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
}
Text(
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
color = colors.foregroundLighter,
@@ -185,7 +190,6 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
overflow = TextOverflow.Ellipsis,
)
}
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
}

View File

@@ -35,6 +35,13 @@ internal object SendIcons {
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val Delete = lineIcon("Delete") {
moveTo(4f, 7f); lineTo(20f, 7f)
moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f)
moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f)
moveTo(10f, 11f); lineTo(10f, 17f)
moveTo(14f, 11f); lineTo(14f, 17f)
}
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)

View File

@@ -18,6 +18,7 @@ fun SendRoute(
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
@@ -32,6 +33,7 @@ fun SendRoute(
coreState = coreState,
state = state,
windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
@@ -43,5 +45,13 @@ fun SendRoute(
onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket,
onActivity = viewModel::openActivity,
onReceivers = viewModel::openReceivers,
onShare = viewModel::openShare,
onCloseDetailPanel = viewModel::closeDetailPanel,
onInvitationResult = viewModel::onInvitationResult,
onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)
}

View File

@@ -3,7 +3,11 @@ package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
@@ -15,6 +19,7 @@ fun SendScreen(
coreState: CoreState,
state: SendState,
windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
@@ -26,13 +31,34 @@ fun SendScreen(
onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit,
onActivity: () -> Unit = {},
onReceivers: () -> Unit = {},
onShare: () -> Unit = {},
onCloseDetailPanel: () -> Unit = {},
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
}
Box(Modifier.fillMaxSize()) {
if (selectedTransfer != null) {
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
TransferDetails(
transfer = selectedTransfer,
events = coreState.events,
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
onBack = onCloseTransferDetails,
onActivity = onActivity,
onReceivers = onReceivers,
onShare = onShare,
onDelete = onRequestDelete,
)
} else {
TransferCatalog(
transfers = outgoingTransfers,
@@ -59,4 +85,31 @@ fun SendScreen(
)
}
}
if (selectedTransfer != null && state.detailPanel != null) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
when (state.detailPanel) {
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
TransferDetailPanel.Share -> TransferSharePanel(
selectedTransfer,
shareActions,
qrBitmap = selectedTransfer.ticket?.let(qrCache::get),
onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap },
onResult = onInvitationResult,
)
}
}
}
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
DeleteTransferPanel(
transferName = selectedTransfer.transferName,
isDeleting = state.isDeleting,
onCancel = onDismissDelete,
onConfirm = onConfirmDelete,
)
}
}
}

View File

@@ -3,9 +3,11 @@ package com.vnidrop.app.feature.send
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
@@ -17,9 +19,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_nfc_written
import vnidrop.shared.generated.resources.transfer_deleted
data class SendState(
val isComposerOpen: Boolean = false,
@@ -30,11 +36,18 @@ data class SendState(
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false,
) {
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
}
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
@@ -44,6 +57,7 @@ class SendViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SendState())
@@ -54,6 +68,29 @@ class SendViewModel(
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
repository.signals.collect { signal ->
val transferId = when (signal) {
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
is CoreSignal.ApprovalChanged -> signal.transferId
}
if (transferId == _state.value.selectedTransferId &&
_state.value.detailPanel == TransferDetailPanel.Receivers
) refreshReceivers(transferId)
}
}
viewModelScope.launch {
filePreviewRepository.previews.collect { previews ->
_state.update { it.copy(transferThumbnails = previews) }
}
}
viewModelScope.launch {
coreState.map { core ->
core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet()
}.distinctUntilChanged().collect { activeIds ->
if (activeIds != null) filePreviewRepository.restore(activeIds)
}
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { current ->
@@ -108,9 +145,70 @@ class SendViewModel(
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
fun openTransfer(transferId: ULong) {
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
refreshReceivers(transferId)
}
fun closeTransferDetails() = _state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
)
}
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
fun openReceivers() {
val transferId = _state.value.selectedTransferId ?: return
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
refreshReceivers(transferId)
}
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
fun dismissDeleteTransfer() {
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
}
fun confirmDeleteTransfer() {
val transferId = _state.value.selectedTransferId ?: return
if (_state.value.isDeleting) return
viewModelScope.launch {
_state.update { it.copy(isDeleting = true) }
repository.delete(transferId).fold(
onSuccess = {
filePreviewRepository.remove(transferId)
_state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
isDeleting = false,
)
}
messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isDeleting = false) }
messages.error(error)
},
)
}
}
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
fun onInvitationResult(action: InvitationAction, result: Result<Unit>) {
result.fold(
onSuccess = {
val message = when (action) {
InvitationAction.Export -> null
InvitationAction.Nfc -> Res.string.transfer_nfc_written
InvitationAction.Share -> null
}
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
},
onFailure = messages::error,
)
}
fun createShare() {
val current = state.value
@@ -127,6 +225,7 @@ class SendViewModel(
)
result.fold(
onSuccess = { share ->
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
_state.update {
it.copy(
isComposerOpen = false,
@@ -134,9 +233,6 @@ class SendViewModel(
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
transferThumbnails = file.thumbnailBytes?.let { bytes ->
it.transferThumbnails + (share.transferId to bytes)
} ?: it.transferThumbnails,
)
}
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
@@ -152,4 +248,17 @@ class SendViewModel(
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) }
repository.receiverRequests(transferId).fold(
onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } },
onFailure = { error ->
_state.update { it.copy(isLoadingReceivers = false) }
messages.error(error)
},
)
}
}
}

View File

@@ -54,7 +54,6 @@ import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_choose_file_body
import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_review_title
@Composable
@@ -73,7 +72,6 @@ internal fun TransferComposer(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(Res.string.send_new_transfer_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
val file = state.selectedFile
if (file == null) {
ChooseFileStep(onSelectFile)

View File

@@ -1,45 +1,79 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
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.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.screens.TicketText
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_back
import vnidrop.shared.generated.resources.button_copy_ticket
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_status
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_transfer_details_title
import qrcode.QRCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import vnidrop.shared.generated.resources.*
enum class InvitationAction { Export, Share, Nfc }
@Composable
internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) {
internal fun TransferDetails(
transfer: Transfer,
events: List<CoreEventModel>,
completedReceivers: Int,
onBack: () -> Unit,
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
@@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
}
IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
Text(
stringResource(Res.string.send_transfer_details_title),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onDelete) {
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
}
}
}
item {
@@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString())
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
}
}
transfer.ticket?.let { ticket ->
item {
AppCard(title = stringResource(Res.string.button_copy_ticket)) {
TicketText(ticket)
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) })
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column {
DetailDestination(
title = stringResource(Res.string.transfer_activity_title),
description = stringResource(Res.string.transfer_activity_description),
count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() },
onClick = onActivity,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_receivers_title),
description = stringResource(Res.string.transfer_receivers_description),
count = completedReceivers,
onClick = onReceivers,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_share_title),
description = stringResource(Res.string.transfer_share_description),
onClick = onShare,
)
}
}
}
}
}
@Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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 (count != null && count > 0) {
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
Spacer(Modifier.width(8.dp))
}
Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
@Composable
internal fun ReceiverHistoryPanel(receivers: List<ReceiverRequestModel>, loading: Boolean) {
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
when {
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
else -> receivers.forEachIndexed { index, receiver ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
ReceiverRow(receiver)
}
}
}
}
@Composable
private fun ReceiverRow(receiver: ReceiverRequestModel) {
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
receiver.receiverDeviceName?.takeIf { it != name }?.let {
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
@Composable
internal fun TransferActivityPanel(events: List<CoreEventModel>, transferId: ULong) {
val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp)
PanelContainer(stringResource(Res.string.transfer_activity_title)) {
if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter)
else visible.forEachIndexed { index, event ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium)
}
}
}
@Composable
internal fun TransferSharePanel(
transfer: Transfer,
actions: TransferShareActions,
qrBitmap: androidx.compose.ui.graphics.ImageBitmap?,
onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit,
onResult: (InvitationAction, Result<Unit>) -> Unit,
) {
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
val ticket = transfer.ticket
PanelContainer(stringResource(Res.string.transfer_share_title)) {
if (ticket == null) {
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
return@PanelContainer
}
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
if (value == null) {
value = withContext(Dispatchers.Default) {
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
}
value?.let { onQrRendered(ticket, it) }
}
}
val renderedQr = renderedBitmap
Surface(
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White,
) {
if (renderedQr != null) {
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
}
}
Text(
stringResource(Res.string.transfer_scan_qr),
modifier = Modifier.align(Alignment.CenterHorizontally),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
var writingNfc by remember(ticket) { mutableStateOf(false) }
SecondaryButton(
if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc),
onClick = {
writingNfc = true
actions.writeInvitationToNfc(ticket) {
writingNfc = false
onResult(InvitationAction.Nfc, it)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc,
)
if (actions.nfcAvailability == NfcShareAvailability.Unavailable) {
Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
}
SecondaryButton(
stringResource(Res.string.button_download_invitation),
onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } },
modifier = Modifier.fillMaxWidth(),
)
PrimaryButton(
stringResource(Res.string.button_native_share),
onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } },
modifier = Modifier.fillMaxWidth(),
enabled = actions.canUseNativeShare,
)
}
}
@Composable
internal fun DeleteTransferPanel(
transferName: String?,
isDeleting: Boolean,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
PanelContainer(stringResource(Res.string.transfer_delete_title)) {
Text(
stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
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(Res.string.button_delete_transfer),
onClick = onConfirm,
enabled = !isDeleting,
)
}
}
}
@Composable
private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
content()
}
}
@Composable
private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) {
ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested
ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
})
@Composable
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
else -> LocalVniDropColors.current.foregroundLighter
}
private fun CoreEventModel.isMeaningfulActivity() =
(phase == "import" && kind == "started") ||
(phase == "ticket" && kind == "created") ||
kind in setOf(
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed",
)
@Composable
private fun eventTitle(event: CoreEventModel) = stringResource(when {
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
event.kind == "failed" -> Res.string.transfer_event_failed
else -> Res.string.transfer_event_updated
})
@Composable
private fun DetailValue(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
}

View File

@@ -0,0 +1,38 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
enum class NfcShareAvailability { Available, Unavailable, Hidden }
interface TransferShareActions {
val canUseNativeShare: Boolean
val nfcAvailability: NfcShareAvailability
fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit)
fun cancelNfcWrite()
}
object UnavailableTransferShareActions : TransferShareActions {
override val canUseNativeShare = false
override val nfcAvailability = NfcShareAvailability.Hidden
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable")))
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable")))
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
override fun cancelNfcWrite() = Unit
}
@Composable
expect fun rememberTransferShareActions(): TransferShareActions
internal fun invitationFileName(transferName: String): String {
val safe = transferName.trim()
.map { character -> if (character.isLetterOrDigit() || character in "-_. ") character else '_' }
.joinToString("")
.trim('.', ' ')
.ifBlank { "VniDrop transfer" }
return if (safe.endsWith(".vnd", ignoreCase = true)) safe else "$safe.vnd"
}

View File

@@ -4,19 +4,30 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_close
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -31,9 +42,7 @@ fun AdaptiveDrawer(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = LocalVniDropColors.current.backgroundDialog,
) {
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
content()
}
ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content)
}
} else {
Dialog(
@@ -45,9 +54,31 @@ fun AdaptiveDrawer(
shape = RoundedCornerShape(20.dp),
color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp,
) {
content()
}
) { ClosableModalContent(onDismissRequest, content = content) }
}
}
}
@Composable
private fun ClosableModalContent(
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(modifier) {
content()
IconButton(
onClick = onClose,
modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp),
) {
Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight)
}
}
}
private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) {
moveTo(6f, 6f); lineTo(18f, 18f)
moveTo(18f, 6f); lineTo(6f, 18f)
}
}.build()

View File

@@ -40,3 +40,19 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = LocalVniDropColors.current.destructiveDefault,
contentColor = Color.White,
),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View File

@@ -28,9 +28,10 @@ fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = Pi
PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructiveDefault
}
val shape = RoundedCornerShape(7.dp)
Row(
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp),
modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(color))

View File

@@ -15,6 +15,7 @@ import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakeNotificationService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
@@ -30,6 +31,7 @@ import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -141,7 +143,7 @@ class ViewModelsTest {
@Test
fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
assertEquals("photo.jpg", viewModel.state.value.transferName)
@@ -157,10 +159,14 @@ class ViewModelsTest {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
val previews = FakeFilePreviewRepository()
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
val thumbnail = ByteArray(12).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
}
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare()
advanceUntilIdle()
@@ -169,13 +175,14 @@ class ViewModelsTest {
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
}
@Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
@@ -187,6 +194,36 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isSharing)
}
@Test
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(
com.vnidrop.app.core.Transfer(
localId = "send-7", transferId = 7UL,
direction = com.vnidrop.app.core.TransferDirection.Send,
status = com.vnidrop.app.core.TransferStatus.Sharing,
peerId = null, transferName = "Photo", contentHash = "hash",
fileCount = 1UL, totalSize = 42UL, ticket = "ticket",
accessPolicy = ShareAccessPolicy.RequireApproval, createdAt = 1, updatedAt = 1,
),
))
}
val previews = FakeFilePreviewRepository()
previews.save(7UL, byteArrayOf(1, 2, 3))
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
advanceUntilIdle()
viewModel.openTransfer(7UL)
viewModel.requestDeleteTransfer()
viewModel.confirmDeleteTransfer()
advanceUntilIdle()
assertEquals(listOf(7UL), core.deletedTransfers)
assertFalse(7UL in previews.previews.value)
assertEquals(null, viewModel.state.value.selectedTransferId)
assertFalse(viewModel.state.value.isDeleteConfirmationOpen)
}
@Test
fun receiveViewModelBuildsStateFromPreferences() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.approvals
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
@@ -88,10 +89,11 @@ class ApprovalCoordinatorTest {
receiverName = "Peer",
receiverDeviceName = "Phone",
appVersion = "1.0",
status = "requested",
status = ReceiverDeliveryStatus.Requested,
reason = null,
requestedAt = requestedAt,
respondedAt = null,
completedAt = null,
)
private fun activeTransfer() = Transfer(

View File

@@ -0,0 +1,68 @@
package com.vnidrop.app.feature.send
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilePreviewRepositoryTest {
@Test
fun preview_survives_repository_recreation() = runTest {
val store = MemoryPreviewStore()
AppFilePreviewRepository(store).save(42UL, png(12))
val restored = AppFilePreviewRepository(store)
restored.restore(setOf(42UL))
assertContentEquals(png(12), restored.previews.value.getValue(42UL))
}
@Test
fun restore_removes_orphans_and_corrupt_entries() = runTest {
val store = MemoryPreviewStore()
store.writeAtomically(1UL, png(12))
store.writeAtomically(2UL, "not an image".encodeToByteArray())
store.writeAtomically(3UL, png(12))
val repository = AppFilePreviewRepository(store)
repository.restore(setOf(1UL, 2UL))
assertEquals(setOf(1UL), repository.previews.value.keys)
assertFalse(store.entries.containsKey(2UL))
assertFalse(store.entries.containsKey(3UL))
}
@Test
fun entry_and_total_limits_are_enforced() = runTest {
val store = MemoryPreviewStore()
val repository = AppFilePreviewRepository(store, PreviewStoragePolicy(maxEntryBytes = 20, maxTotalBytes = 32))
repository.save(1UL, png(17))
store.clock += 1
repository.save(2UL, png(17))
store.clock += 1
repository.save(3UL, png(21))
assertEquals(setOf(2UL), repository.previews.value.keys)
assertTrue(3UL !in store.entries)
}
private fun png(size: Int): ByteArray = ByteArray(size.coerceAtLeast(8)).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
}
}
private class MemoryPreviewStore : PlatformPreviewStore {
data class Entry(val bytes: ByteArray, val modified: Long)
val entries = mutableMapOf<ULong, Entry>()
var clock = 1L
override fun list() = entries.map { (id, entry) -> PreviewFileInfo(id, entry.bytes.size.toLong(), entry.modified) }
override fun read(transferId: ULong) = entries[transferId]?.bytes?.copyOf()
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
if (transferId !in entries) entries[transferId] = Entry(bytes.copyOf(), clock)
return true
}
override fun delete(transferId: ULong) { entries.remove(transferId) }
}

View File

@@ -0,0 +1,19 @@
package com.vnidrop.app.feature.send
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class TransferShareActionsTest {
@Test
fun invitation_names_are_safe_and_have_the_vnd_extension() {
val name = invitationFileName("../Summer/photos: 2026")
assertEquals("_Summer_photos_ 2026.vnd", name)
assertFalse('/' in name)
}
@Test
fun an_existing_extension_is_not_duplicated() {
assertEquals("Transfer.VND", invitationFileName("Transfer.VND"))
}
}

View File

@@ -19,6 +19,7 @@ import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.feature.send.FilePreviewRepository
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,6 +36,8 @@ class FakeCoreGateway : CoreGateway {
var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var deleteResult: Result<Unit> = Result.success(Unit)
val deletedTransfers = mutableListOf<ULong>()
var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result<Unit> {
@@ -86,6 +89,15 @@ class FakeCoreGateway : CoreGateway {
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) {
deletedTransfers += transferId
mutableState.value = mutableState.value.copy(
transfers = mutableState.value.transfers.filterNot { it.transferId == transferId },
)
}
return deleteResult
}
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
responses += Triple(requestId, accepted, reason)
@@ -138,3 +150,12 @@ class FakeFileSystemService(
accessPolicy: ShareAccessPolicy,
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
}
class FakeFilePreviewRepository : FilePreviewRepository {
private val state = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
override val previews: StateFlow<Map<ULong, ByteArray>> = state
val restored = mutableListOf<Set<ULong>>()
override suspend fun restore(activeTransferIds: Set<ULong>) { restored += activeTransferIds }
override suspend fun save(transferId: ULong, bytes: ByteArray) { state.value = state.value + (transferId to bytes) }
override suspend fun remove(transferId: ULong) { state.value = state.value - transferId }
}

View File

@@ -5,7 +5,9 @@ import androidx.compose.runtime.remember
import com.vnidrop.app.core.rememberFileSystemService
import com.vnidrop.app.notifications.IosLocalNotificationService
import platform.Foundation.NSBundle
import platform.Foundation.NSTemporaryDirectory
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIDevice
@Composable
@@ -17,7 +19,7 @@ fun rememberIosAppDependencies(): AppDependencies {
environment = PlatformEnvironment(
name = device.systemName() + " " + device.systemVersion,
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop",
defaultCoreDataDir = iosApplicationDataDirectory(),
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
),
deviceInfoProvider = IosDeviceInfoProvider(device),
@@ -27,6 +29,11 @@ fun rememberIosAppDependencies(): AppDependencies {
}
}
private fun iosApplicationDataDirectory(): String =
(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String)
?.trimEnd('/')?.plus("/VniDrop")
?: error("iOS Application Support directory is unavailable")
private class IosDeviceInfoProvider(
private val device: UIDevice,
) : DeviceInfoProvider {

View File

@@ -0,0 +1,62 @@
package com.vnidrop.app.feature.send
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.readBytes
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileModificationDate
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.timeIntervalSince1970
import platform.Foundation.writeToFile
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore =
IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews")
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private class IosPreviewStore(private val directory: String) : PlatformPreviewStore {
private val files = NSFileManager.defaultManager
override fun list(): List<PreviewFileInfo> {
ensureDirectory()
return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance<String>().mapNotNull { name ->
val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null
val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null
val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0
PreviewFileInfo(id, size, modified.toLong())
}
}
override fun read(transferId: ULong): ByteArray? {
val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null
return data.bytes?.readBytes(data.length.toInt())
}
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
ensureDirectory()
if (files.fileExistsAtPath(path(transferId))) return true
val temporary = "$directory/.$transferId.tmp"
val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) }
if (!data.writeToFile(temporary, atomically = true)) return false
val moved = files.moveItemAtPath(temporary, path(transferId), null)
if (!moved) files.removeItemAtPath(temporary, null)
return moved
}
override fun delete(transferId: ULong) {
files.removeItemAtPath(path(transferId), null)
}
private fun ensureDirectory() {
files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
}
private fun path(transferId: ULong) = "$directory/$transferId.preview"
}

View File

@@ -0,0 +1,66 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.BetaInteropApi
import platform.Foundation.NSString
import platform.Foundation.NSTemporaryDirectory
import platform.Foundation.NSURL
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.writeToFile
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIModalPresentationFormSheet
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun rememberTransferShareActions(): TransferShareActions = remember {
object : TransferShareActions {
override val canUseNativeShare = true
// Core NFC tag writing requires the NFC entitlement. Keep the action
// visible but disabled until that capability is provisioned for the app.
override val nfcAvailability = NfcShareAvailability.Unavailable
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
onResult(runCatching {
val url = createInvitation(ticket, transferName)
val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true)
present(picker)
})
}
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
onResult(runCatching {
val url = createInvitation(ticket, transferName)
val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null)
controller.modalPresentationStyle = UIModalPresentationFormSheet
presenter().presentViewController(controller, animated = true, completion = null)
})
}
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build")))
}
override fun cancelNfcWrite() = Unit
}
}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun createInvitation(ticket: String, transferName: String): NSURL {
val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName)
val text = NSString.create(string = ticket)
require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) {
"The invitation file could not be created"
}
return NSURL.fileURLWithPath(path)
}
private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController
?: error("Could not find an iOS view controller")
private fun present(controller: platform.UIKit.UIViewController) {
presenter().presentViewController(controller, animated = true, completion = null)
}

View File

@@ -0,0 +1,22 @@
package com.vnidrop.app.feature.send
import java.io.File
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmPreviewStore(File(appDataDir, "ui/previews"))
private class JvmPreviewStore(private val directory: File) : PlatformPreviewStore {
override fun list(): List<PreviewFileInfo> = 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")
}

View File

@@ -0,0 +1,62 @@
package com.vnidrop.app.feature.send
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 rememberTransferShareActions(): TransferShareActions = remember {
object : TransferShareActions {
override val canUseNativeShare = DesktopShareBridge.shareFile != null
override val nfcAvailability = NfcShareAvailability.Hidden
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
EventQueue.invokeLater {
onResult(runCatching {
val dialog = FileDialog(activeFrame(), "Save VniDrop invitation", FileDialog.SAVE).apply {
file = invitationFileName(transferName)
}
try {
dialog.isVisible = true
val directory = dialog.directory
val name = dialog.file
if (directory != null && name != null) File(directory, name).writeText(ticket)
} finally { dialog.dispose() }
})
}
}
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
EventQueue.invokeLater {
val share = DesktopShareBridge.shareFile
if (share == null) {
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
return@invokeLater
}
onResult(runCatching {
val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() }
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
share(file).getOrThrow()
})
}
}
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop")))
}
override fun cancelNfcWrite() = Unit
}
}
private fun activeFrame(): Frame? =
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
object DesktopShareBridge {
@Volatile
var shareFile: ((File) -> Result<Unit>)? = null
}

View File

@@ -0,0 +1,26 @@
package com.vnidrop.app.feature.send
import kotlinx.coroutines.test.runTest
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertContentEquals
class PlatformPreviewStoreTest {
@Test
fun preview_is_restored_from_private_application_storage() = runTest {
val directory = Files.createTempDirectory("vnidrop-preview-test")
try {
val bytes = ByteArray(16).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
}
AppFilePreviewRepository(createPlatformPreviewStore(directory.toString())).save(91UL, bytes)
val restarted = AppFilePreviewRepository(createPlatformPreviewStore(directory.toString()))
restarted.restore(setOf(91UL))
assertContentEquals(bytes, restarted.previews.value.getValue(91UL))
} finally {
directory.toFile().deleteRecursively()
}
}
}

View File

@@ -10,6 +10,8 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -281,10 +283,39 @@ class FoundationComposeTest {
}
}
val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot()
val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot()
assertTrue(statusBounds.left - titleBounds.right <= 12.dp)
onNodeWithText("Photos").performClick()
runOnIdle { assertEquals(9UL, selectedId) }
}
@Test
fun transferDetailsRevealSharingOnlyAfterSelection() = runComposeUiTest {
val state = mutableStateOf(SendState(selectedTransferId = 9UL))
setContent {
VniDropTheme(isDarkTheme = false) {
SendScreen(
coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())),
state = state.value,
windowClass = WindowClass.Desktop,
onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {},
onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {},
onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
onShare = { state.value = state.value.copy(detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share) },
)
}
}
onNodeWithText("Share").assertIsDisplayed()
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0)
onNode(hasText("Share") and hasClickAction()).performClick()
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed()
onNodeWithText("Save .vnd file").assertIsDisplayed()
onNodeWithContentDescription("Close").assertIsDisplayed()
}
@Test
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
val controller = UiMessageController()