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.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -21,6 +23,13 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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" android-targetSdk = "36"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core = "1.19.0" androidx-core = "1.18.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0-beta01" androidx-lifecycle = "2.11.0-beta01"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
@@ -16,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
qrcode = "4.5.0"
jna = "5.17.0" jna = "5.17.0"
[libraries] [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-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-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" } 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" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
[plugins] [plugins]

View File

@@ -37,6 +37,7 @@ kotlin {
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }
commonMain.dependencies { commonMain.dependencies {
@@ -51,6 +52,7 @@ kotlin {
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutinesCore) implementation(libs.kotlinx.coroutinesCore)
implementation(libs.qrcode.kotlin)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) 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_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string> <string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</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_choose_file">Choose file</string>
<string name="button_change_file">Change file</string> <string name="button_change_file">Change file</string>
<string name="button_share_file">Share 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_choose_folder">Choose folder</string>
<string name="button_reset_default">Reset default</string> <string name="button_reset_default">Reset default</string>
<string name="button_back">Back</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_writable">Writable</string>
<string name="folder_status_permission_required">Permission required</string> <string name="folder_status_permission_required">Permission required</string>
<string name="folder_status_unavailable">Unavailable</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) AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
} }
val sendViewModel = viewModel { 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 { val receiveViewModel = viewModel {
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages) 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.CoreGateway
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.feature.approvals.ApprovalCoordinator 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.logging.AppLogger
import com.vnidrop.app.platform.AppVisibility import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults import com.vnidrop.app.preferences.AppPreferencesDefaults
@@ -22,6 +24,9 @@ class AppGraph(
) { ) {
val visibility = AppVisibility() val visibility = AppVisibility()
val messages = UiMessageController() val messages = UiMessageController()
val filePreviewRepository = AppFilePreviewRepository(
createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir),
)
val preferencesRepository = AppPreferencesRepository( val preferencesRepository = AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir), dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
defaults = AppPreferencesDefaults( defaults = AppPreferencesDefaults(

View File

@@ -89,12 +89,22 @@ data class ReceiverRequestModel(
val receiverName: String?, val receiverName: String?,
val receiverDeviceName: String?, val receiverDeviceName: String?,
val appVersion: String, val appVersion: String,
val status: String, val status: ReceiverDeliveryStatus,
val reason: String?, val reason: String?,
val requestedAt: Long, val requestedAt: Long,
val respondedAt: Long?, val respondedAt: Long?,
val completedAt: Long?,
) )
enum class ReceiverDeliveryStatus {
Requested,
Accepted,
Refused,
Expired,
Completed,
Unknown,
}
data class CoreState( data class CoreState(
val isInitialized: Boolean = false, val isInitialized: Boolean = false,
val status: CoreStatus? = null, val status: CoreStatus? = null,
@@ -106,6 +116,7 @@ data class CoreState(
sealed interface CoreSignal { sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
} }
interface CoreGateway { interface CoreGateway {
@@ -134,6 +145,7 @@ interface CoreGateway {
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit> suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit> suspend fun refresh(): Result<Unit>

View File

@@ -49,6 +49,9 @@ class CoreRepository(
if (model.phase == "approval" && model.transferId != null) { if (model.phase == "approval" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId)) _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() 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 { override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
} }
@@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
receiverName = receiverName, receiverName = receiverName,
receiverDeviceName = receiverDeviceName, receiverDeviceName = receiverDeviceName,
appVersion = appVersion, 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, reason = reason,
requestedAt = requestedAt, requestedAt = requestedAt,
respondedAt = respondedAt, 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.TransferDirection
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.notifications.LocalNotification import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.notifications.NotificationPermission
@@ -55,6 +56,7 @@ class ApprovalCoordinator(
repository.signals.collect { signal -> repository.signals.collect { signal ->
when (signal) { when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId) is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
is CoreSignal.ReceiverHistoryChanged -> Unit
} }
} }
} }
@@ -108,7 +110,7 @@ class ApprovalCoordinator(
private suspend fun refresh(transferId: ULong) { private suspend fun refresh(transferId: ULong) {
repository.receiverRequests(transferId).fold( repository.receiverRequests(transferId).fold(
onSuccess = { requests -> 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() val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
removed.forEach { id -> removed.forEach { id ->
notifications.cancel(notificationId(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)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title), transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.width(8.dp))
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
}
Text( Text(
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}", "${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
color = colors.foregroundLighter, color = colors.foregroundLighter,
@@ -185,7 +190,6 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.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(5f, 12f)
lineTo(12f, 5f) 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") { val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f) moveTo(9f, 18f)
lineTo(15f, 12f) lineTo(15f, 12f)

View File

@@ -18,6 +18,7 @@ fun SendRoute(
val coreState by viewModel.coreState.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed) val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect -> viewModel.effectFlow.collect { effect ->
@@ -32,6 +33,7 @@ fun SendRoute(
coreState = coreState, coreState = coreState,
state = state, state = state,
windowClass = windowClass, windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer, onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer, onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile, onSelectFile = viewModel::selectFile,
@@ -43,5 +45,13 @@ fun SendRoute(
onTransferSelected = viewModel::openTransfer, onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails, onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket, 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
@@ -15,6 +19,7 @@ fun SendScreen(
coreState: CoreState, coreState: CoreState,
state: SendState, state: SendState,
windowClass: WindowClass, windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit, onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit, onDismissComposer: () -> Unit,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
@@ -26,13 +31,34 @@ fun SendScreen(
onTransferSelected: (ULong) -> Unit, onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit, onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> 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 outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } 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()) { Box(Modifier.fillMaxSize()) {
if (selectedTransfer != null) { 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 { } else {
TransferCatalog( TransferCatalog(
transfers = outgoingTransfers, 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController 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.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created 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( data class SendState(
val isComposerOpen: Boolean = false, val isComposerOpen: Boolean = false,
@@ -30,11 +36,18 @@ data class SendState(
val isSharing: Boolean = false, val isSharing: Boolean = false,
val selectedTransferId: ULong? = null, val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(), 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 = fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
} }
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect { sealed interface SendEffect {
data object OpenFilePicker : SendEffect data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect data class CopyTicket(val ticket: String) : SendEffect
@@ -44,6 +57,7 @@ class SendViewModel(
private val repository: CoreGateway, private val repository: CoreGateway,
private val fileSystemService: FileSystemService, private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository, preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController, private val messages: UiMessageController,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SendState()) private val _state = MutableStateFlow(SendState())
@@ -54,6 +68,29 @@ class SendViewModel(
val effectFlow = effects.receiveAsFlow() val effectFlow = effects.receiveAsFlow()
init { 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 { viewModelScope.launch {
preferencesRepository.preferences.collect { preferences -> preferencesRepository.preferences.collect { preferences ->
_state.update { current -> _state.update { current ->
@@ -108,9 +145,70 @@ class SendViewModel(
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) } fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) } fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) } fun openTransfer(transferId: ULong) {
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) } _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 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() { fun createShare() {
val current = state.value val current = state.value
@@ -127,6 +225,7 @@ class SendViewModel(
) )
result.fold( result.fold(
onSuccess = { share -> onSuccess = { share ->
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = false, isComposerOpen = false,
@@ -134,9 +233,6 @@ class SendViewModel(
transferName = "", transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false, 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)) messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
@@ -152,4 +248,17 @@ class SendViewModel(
private fun sendEffect(effect: SendEffect) { private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) } 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_body
import vnidrop.shared.generated.resources.send_choose_file_title import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown 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 import vnidrop.shared.generated.resources.send_review_title
@Composable @Composable
@@ -73,7 +72,6 @@ internal fun TransferComposer(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp), modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.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 val file = state.selectedFile
if (file == null) { if (file == null) {
ChooseFileStep(onSelectFile) ChooseFileStep(onSelectFile)

View File

@@ -1,45 +1,79 @@
package com.vnidrop.app.feature.send 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.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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.ShareAccessPolicy
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard 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.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.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import qrcode.QRCode
import vnidrop.shared.generated.resources.button_back import kotlinx.coroutines.Dispatchers
import vnidrop.shared.generated.resources.button_copy_ticket import kotlinx.coroutines.withContext
import vnidrop.shared.generated.resources.metadata_files import vnidrop.shared.generated.resources.*
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_status enum class InvitationAction { Export, Share, Nfc }
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
@Composable @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( LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
@@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
) { ) {
item { item {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
}
Text( Text(
stringResource(Res.string.send_transfer_details_title), stringResource(Res.string.send_transfer_details_title),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
IconButton(onClick = onDelete) {
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
}
} }
} }
item { item {
@@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize)) DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) 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)) DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
} }
} }
transfer.ticket?.let { ticket ->
item { item {
AppCard(title = stringResource(Res.string.button_copy_ticket)) { Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
TicketText(ticket) Column {
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) }) 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 @Composable
private fun DetailValue(label: String, value: String) { 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(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) 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.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -31,9 +42,7 @@ fun AdaptiveDrawer(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = LocalVniDropColors.current.backgroundDialog, containerColor = LocalVniDropColors.current.backgroundDialog,
) { ) {
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) { ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content)
content()
}
} }
} else { } else {
Dialog( Dialog(
@@ -45,9 +54,31 @@ fun AdaptiveDrawer(
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = LocalVniDropColors.current.backgroundDialog, color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp, shadowElevation = 12.dp,
) { ClosableModalContent(onDismissRequest, content = content) }
}
}
}
@Composable
private fun ClosableModalContent(
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) { ) {
Box(modifier) {
content() 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) 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.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructiveDefault PillTone.Destructive -> colors.destructiveDefault
} }
val shape = RoundedCornerShape(7.dp)
Row( Row(
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f)) modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp), .border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(Modifier.size(7.dp).clip(CircleShape).background(color)) 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.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakeNotificationService import com.vnidrop.app.support.FakeNotificationService
import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
@@ -30,6 +31,7 @@ import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -141,7 +143,7 @@ class ViewModelsTest {
@Test @Test
fun sendViewModelOwnsSelectedFileState() = runTest { fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController()) val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer() viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
assertEquals("photo.jpg", viewModel.state.value.transferName) assertEquals("photo.jpg", viewModel.state.value.transferName)
@@ -157,10 +159,14 @@ class ViewModelsTest {
mutableState.value = CoreState(isInitialized = true) mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) 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() advanceUntilIdle()
viewModel.openComposer() 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.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare() viewModel.createShare()
advanceUntilIdle() advanceUntilIdle()
@@ -169,13 +175,14 @@ class ViewModelsTest {
assertEquals(null, viewModel.state.value.selectedFile) assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId) assertEquals(7UL, core.state.value.transfers.first().transferId)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
} }
@Test @Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest { fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) } 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() advanceUntilIdle()
viewModel.openComposer() viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
@@ -187,6 +194,36 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isSharing) 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 @Test
fun receiveViewModelBuildsStateFromPreferences() = runTest { fun receiveViewModelBuildsStateFromPreferences() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) 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.CoreSignal
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
@@ -88,10 +89,11 @@ class ApprovalCoordinatorTest {
receiverName = "Peer", receiverName = "Peer",
receiverDeviceName = "Phone", receiverDeviceName = "Phone",
appVersion = "1.0", appVersion = "1.0",
status = "requested", status = ReceiverDeliveryStatus.Requested,
reason = null, reason = null,
requestedAt = requestedAt, requestedAt = requestedAt,
respondedAt = null, respondedAt = null,
completedAt = null,
) )
private fun activeTransfer() = Transfer( 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.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.feature.send.FilePreviewRepository
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,6 +36,8 @@ class FakeCoreGateway : CoreGateway {
var responseResult: Result<Unit> = Result.success(Unit) var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>() val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException()) var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var deleteResult: Result<Unit> = Result.success(Unit)
val deletedTransfers = mutableListOf<ULong>()
var lastShareAccessPolicy: ShareAccessPolicy? = null var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result<Unit> { 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 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 receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
override suspend fun cancel(transferId: ULong) = Result.success(Unit) override suspend fun cancel(transferId: ULong) = Result.success(Unit)
override suspend fun delete(transferId: ULong): Result<Unit> {
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 receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> { override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
responses += Triple(requestId, accepted, reason) responses += Triple(requestId, accepted, reason)
@@ -138,3 +150,12 @@ class FakeFileSystemService(
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
) = repository.sharePath(file.value, transferName, senderName, accessPolicy) ) = 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.core.rememberFileSystemService
import com.vnidrop.app.notifications.IosLocalNotificationService import com.vnidrop.app.notifications.IosLocalNotificationService
import platform.Foundation.NSBundle import platform.Foundation.NSBundle
import platform.Foundation.NSTemporaryDirectory import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIDevice import platform.UIKit.UIDevice
@Composable @Composable
@@ -17,7 +19,7 @@ fun rememberIosAppDependencies(): AppDependencies {
environment = PlatformEnvironment( environment = PlatformEnvironment(
name = device.systemName() + " " + device.systemVersion, name = device.systemName() + " " + device.systemVersion,
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0", appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop", defaultCoreDataDir = iosApplicationDataDirectory(),
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver", defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
), ),
deviceInfoProvider = IosDeviceInfoProvider(device), 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 class IosDeviceInfoProvider(
private val device: UIDevice, private val device: UIDevice,
) : DeviceInfoProvider { ) : 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.assertCountEquals
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.getUnclippedBoundsInRoot 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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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() onNodeWithText("Photos").performClick()
runOnIdle { assertEquals(9UL, selectedId) } 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 @Test
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
val controller = UiMessageController() val controller = UiMessageController()