From 3da91be7715dfbae19fd5a8cae01c3a1b08ec8db Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sat, 11 Jul 2026 09:31:19 +0200 Subject: [PATCH] feat(send): build durable transfer details and sharing flow --- androidApp/src/main/AndroidManifest.xml | 9 + androidApp/src/main/res/xml/file_paths.xml | 4 + gradle/libs.versions.toml | 4 +- shared/build.gradle.kts | 2 + .../send/PlatformPreviewStore.android.kt | 22 ++ .../send/TransferShareActions.android.kt | 105 ++++++ .../composeResources/values/strings.xml | 39 +++ .../commonMain/kotlin/com/vnidrop/app/App.kt | 8 +- .../kotlin/com/vnidrop/app/AppGraph.kt | 5 + .../kotlin/com/vnidrop/app/core/CoreModels.kt | 14 +- .../com/vnidrop/app/core/CoreRepository.kt | 20 +- .../feature/approvals/ApprovalCoordinator.kt | 4 +- .../app/feature/send/FilePreviewRepository.kt | 111 +++++++ .../vnidrop/app/feature/send/SendCatalog.kt | 20 +- .../com/vnidrop/app/feature/send/SendIcons.kt | 7 + .../com/vnidrop/app/feature/send/SendRoute.kt | 10 + .../vnidrop/app/feature/send/SendScreen.kt | 55 +++- .../vnidrop/app/feature/send/SendViewModel.kt | 119 ++++++- .../app/feature/send/TransferComposer.kt | 2 - .../app/feature/send/TransferDetails.kt | 301 ++++++++++++++++-- .../app/feature/send/TransferShareActions.kt | 38 +++ .../app/ui/components/AdaptiveDrawer.kt | 43 ++- .../com/vnidrop/app/ui/components/Buttons.kt | 16 + .../com/vnidrop/app/ui/components/Status.kt | 5 +- .../com/vnidrop/app/feature/ViewModelsTest.kt | 45 ++- .../approvals/ApprovalCoordinatorTest.kt | 4 +- .../feature/send/FilePreviewRepositoryTest.kt | 68 ++++ .../feature/send/TransferShareActionsTest.kt | 19 ++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 21 ++ .../kotlin/com/vnidrop/app/Platform.ios.kt | 11 +- .../feature/send/PlatformPreviewStore.ios.kt | 62 ++++ .../feature/send/TransferShareActions.ios.kt | 66 ++++ .../feature/send/PlatformPreviewStore.jvm.kt | 22 ++ .../feature/send/TransferShareActions.jvm.kt | 62 ++++ .../feature/send/PlatformPreviewStoreTest.kt | 26 ++ .../vnidrop/app/ui/FoundationComposeTest.kt | 31 ++ 36 files changed, 1340 insertions(+), 60 deletions(-) create mode 100644 androidApp/src/main/res/xml/file_paths.xml create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 5eda2d3..2f9818c 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..db732ae --- /dev/null +++ b/androidApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fb0a71..2039cee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" -androidx-core = "1.19.0" +androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.11.0-beta01" androidx-datastore = "1.2.1" @@ -16,6 +16,7 @@ junit = "4.13.2" kotlin = "2.4.0" kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" +qrcode = "4.5.0" jna = "5.17.0" [libraries] @@ -42,6 +43,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } [plugins] diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ab3c94d..ddb9bf6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { sourceSets { androidMain.dependencies { implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) implementation(libs.compose.uiToolingPreview) } commonMain.dependencies { @@ -51,6 +52,7 @@ kotlin { implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutinesCore) + implementation(libs.qrcode.kotlin) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt new file mode 100644 index 0000000..1b185eb --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.android.kt @@ -0,0 +1,22 @@ +package com.vnidrop.app.feature.send + +import java.io.File + +actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmLikePreviewStore(File(appDataDir, "ui/previews")) + +private class JvmLikePreviewStore(private val directory: File) : PlatformPreviewStore { + override fun list(): List = directory.listFiles().orEmpty().mapNotNull { file -> + file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) } + } + override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull() + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching { + directory.mkdirs() + val target = file(transferId) + if (target.isFile) return@runCatching true + val temporary = File(directory, ".${target.name}.tmp") + temporary.writeBytes(bytes) + temporary.renameTo(target).also { if (!it) temporary.delete() } + }.getOrDefault(false) + override fun delete(transferId: ULong) { file(transferId).delete() } + private fun file(transferId: ULong) = File(directory, "$transferId.preview") +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt new file mode 100644 index 0000000..08bb9e0 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.android.kt @@ -0,0 +1,105 @@ +package com.vnidrop.app.feature.send + +import android.content.ClipData +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.tech.Ndef +import android.nfc.tech.NdefFormatable +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import java.io.File + +@Composable +actual fun rememberTransferShareActions(): TransferShareActions { + val context = LocalContext.current + val activity = context as? ComponentActivity + val nfcEnabled = activity?.let { NfcAdapter.getDefaultAdapter(it)?.isEnabled == true } == true + var pendingExport by remember { mutableStateOf(null) } + val exporter = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument(InvitationMimeType), + ) { uri -> + val pending = pendingExport + pendingExport = null + if (pending != null && uri != null) { + pending.callback(runCatching { + context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.ticket.encodeToByteArray()) } + ?: error("The selected destination could not be opened") + }) + } + } + return remember(activity, exporter, nfcEnabled) { + object : TransferShareActions { + override val canUseNativeShare = activity != null + override val nfcAvailability = when { + activity == null -> NfcShareAvailability.Unavailable + nfcEnabled -> NfcShareAvailability.Available + else -> NfcShareAvailability.Unavailable + } + + override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + pendingExport = PendingExport(ticket, onResult) + exporter.launch(invitationFileName(transferName)) + } + + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { + onResult(runCatching { + val directory = File(context.cacheDir, "transfer-invitations").apply { mkdirs() } + val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = InvitationMimeType + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newRawUri(file.name, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }) + } + + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { + val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + val adapter = NfcAdapter.getDefaultAdapter(host) + if (adapter?.isEnabled != true) return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable"))) + adapter.enableReaderMode(host, { tag -> + val result = runCatching { + val message = NdefMessage(arrayOf(NdefRecord.createMime(InvitationMimeType, ticket.encodeToByteArray()))) + val ndef = Ndef.get(tag) + if (ndef != null) { + ndef.connect() + try { + require(ndef.isWritable) { "This NFC tag is read-only" } + require(ndef.maxSize >= message.toByteArray().size) { "This NFC tag is too small" } + ndef.writeNdefMessage(message) + } finally { ndef.close() } + } else { + val formatable = NdefFormatable.get(tag) ?: error("This NFC tag cannot store an invitation") + formatable.connect() + try { formatable.format(message) } finally { formatable.close() } + } + } + host.runOnUiThread { + adapter.disableReaderMode(host) + onResult(result) + } + }, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null) + } + + override fun cancelNfcWrite() { + activity?.let { host -> NfcAdapter.getDefaultAdapter(host)?.disableReaderMode(host) } + } + } + } +} + +private data class PendingExport(val ticket: String, val callback: (Result) -> Unit) +private const val InvitationMimeType = "application/vnd.vnidrop.transfer" diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 9318337..e96c089 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -20,6 +20,42 @@ Size unavailable Transfer created. Transfer details + Activity + See important updates for this transfer + Receivers + Requests, approvals, and completed deliveries + Share + QR code, invitation file, and nearby options + Delete transfer? + “%1$s” will stop being shared and its transfer history will be removed from this device. + Deleting… + Transfer deleted. + There is no activity to show yet. + Nobody has requested this transfer yet. + Waiting for your approval + Approved — waiting for completion + Request refused + Request expired + Received successfully + Status unavailable + Nearby device + Scan with VniDrop to receive this transfer + Write to NFC tag + Save .vnd file + Share invitation + NFC tag writing is not available on this device. + Hold your device near a writable NFC tag. + Invitation saved. + Invitation written to the NFC tag. + Preparing the selected files + Transfer ready to share + A receiver requested access + Receiver access approved + Receiver access refused + A receiver completed the transfer + Sharing stopped + The transfer encountered a problem + Transfer updated Choose file Change file Share file @@ -63,6 +99,9 @@ Choose folder Reset default Back + Close + Cancel + Delete transfer Writable Permission required Unavailable diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index e3f87a3..d0eaa69 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -45,7 +45,13 @@ fun App(dependencies: AppDependencies) { AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages) } val sendViewModel = viewModel { - SendViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages) + SendViewModel( + graph.coreRepository, + dependencies.fileSystemService, + graph.preferencesRepository, + graph.filePreviewRepository, + graph.messages, + ) } val receiveViewModel = viewModel { ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index 52b03b9..1d3d6e6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -3,6 +3,8 @@ package com.vnidrop.app import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.feature.approvals.ApprovalCoordinator +import com.vnidrop.app.feature.send.AppFilePreviewRepository +import com.vnidrop.app.feature.send.createPlatformPreviewStore import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.platform.AppVisibility import com.vnidrop.app.preferences.AppPreferencesDefaults @@ -22,6 +24,9 @@ class AppGraph( ) { val visibility = AppVisibility() val messages = UiMessageController() + val filePreviewRepository = AppFilePreviewRepository( + createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir), + ) val preferencesRepository = AppPreferencesRepository( dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir), defaults = AppPreferencesDefaults( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index b117248..2e62e26 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -89,12 +89,22 @@ data class ReceiverRequestModel( val receiverName: String?, val receiverDeviceName: String?, val appVersion: String, - val status: String, + val status: ReceiverDeliveryStatus, val reason: String?, val requestedAt: Long, val respondedAt: Long?, + val completedAt: Long?, ) +enum class ReceiverDeliveryStatus { + Requested, + Accepted, + Refused, + Expired, + Completed, + Unknown, +} + data class CoreState( val isInitialized: Boolean = false, val status: CoreStatus? = null, @@ -106,6 +116,7 @@ data class CoreState( sealed interface CoreSignal { data class ApprovalChanged(val transferId: ULong) : CoreSignal + data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal } interface CoreGateway { @@ -134,6 +145,7 @@ interface CoreGateway { suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result suspend fun cancel(transferId: ULong): Result + suspend fun delete(transferId: ULong): Result suspend fun receiverRequests(transferId: ULong): Result> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result suspend fun refresh(): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 76f7aa7..4b0867f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -49,6 +49,9 @@ class CoreRepository( if (model.phase == "approval" && model.transferId != null) { _signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId)) } + if (model.phase == "delivery" && model.transferId != null) { + _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId)) + } } } @@ -163,6 +166,13 @@ class CoreRepository( refreshSnapshot() } + override suspend fun delete(transferId: ULong): Result = runCore { + requireCore().deleteTransfer(transferId) + refreshSnapshot() + _signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) + _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) + } + override suspend fun receiverRequests(transferId: ULong): Result> = runCore { requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) } @@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod receiverName = receiverName, receiverDeviceName = receiverDeviceName, appVersion = appVersion, - status = status, + status = when (status) { + "requested" -> ReceiverDeliveryStatus.Requested + "accepted" -> ReceiverDeliveryStatus.Accepted + "refused" -> ReceiverDeliveryStatus.Refused + "expired" -> ReceiverDeliveryStatus.Expired + "completed" -> ReceiverDeliveryStatus.Completed + else -> ReceiverDeliveryStatus.Unknown + }, reason = reason, requestedAt = requestedAt, respondedAt = respondedAt, + completedAt = completedAt, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt index b05a9ca..54c0871 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt @@ -5,6 +5,7 @@ import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.notifications.LocalNotification import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.NotificationPermission @@ -55,6 +56,7 @@ class ApprovalCoordinator( repository.signals.collect { signal -> when (signal) { is CoreSignal.ApprovalChanged -> refresh(signal.transferId) + is CoreSignal.ReceiverHistoryChanged -> Unit } } } @@ -108,7 +110,7 @@ class ApprovalCoordinator( private suspend fun refresh(transferId: ULong) { repository.receiverRequests(transferId).fold( onSuccess = { requests -> - val refreshed = requests.filter { it.status == "requested" }.map(ReceiverRequestModel::toPending) + val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending) val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet() removed.forEach { id -> notifications.cancel(notificationId(id)) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt new file mode 100644 index 0000000..61c3459 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/FilePreviewRepository.kt @@ -0,0 +1,111 @@ +package com.vnidrop.app.feature.send + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class PreviewFileInfo( + val transferId: ULong, + val byteSize: Long, + val modifiedAtMillis: Long, +) + +interface PlatformPreviewStore { + fun list(): List + fun read(transferId: ULong): ByteArray? + fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean + fun delete(transferId: ULong) +} + +expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore + +interface FilePreviewRepository { + val previews: StateFlow> + suspend fun restore(activeTransferIds: Set) + 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>(emptyMap()) + override val previews: StateFlow> = _previews.asStateFlow() + + override suspend fun restore(activeTransferIds: Set) = 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 +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index 72c191d..ad7083d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -170,13 +170,18 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC } Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - transfer.transferName ?: stringResource(Res.string.send_new_transfer_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + transfer.transferName ?: stringResource(Res.string.send_new_transfer_title), + modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone()) + } Text( "${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}", color = colors.foregroundLighter, @@ -185,7 +190,6 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC overflow = TextOverflow.Ellipsis, ) } - StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone()) Spacer(Modifier.width(8.dp)) Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp)) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt index ce88401..89089a3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt @@ -35,6 +35,13 @@ internal object SendIcons { lineTo(5f, 12f) lineTo(12f, 5f) } + val Delete = lineIcon("Delete") { + moveTo(4f, 7f); lineTo(20f, 7f) + moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f) + moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f) + moveTo(10f, 11f); lineTo(10f, 17f) + moveTo(14f, 11f); lineTo(14f, 17f) + } val ChevronRight = lineIcon("ChevronRight") { moveTo(9f, 18f) lineTo(15f, 12f) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt index 6fdb0d4..2312276 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -18,6 +18,7 @@ fun SendRoute( val coreState by viewModel.coreState.collectAsStateWithLifecycle() val clipboard = LocalClipboardManager.current val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed) + val shareActions = rememberTransferShareActions() LaunchedEffect(viewModel) { viewModel.effectFlow.collect { effect -> @@ -32,6 +33,7 @@ fun SendRoute( coreState = coreState, state = state, windowClass = windowClass, + shareActions = shareActions, onOpenComposer = viewModel::openComposer, onDismissComposer = viewModel::dismissComposer, onSelectFile = viewModel::selectFile, @@ -43,5 +45,13 @@ fun SendRoute( onTransferSelected = viewModel::openTransfer, onCloseTransferDetails = viewModel::closeTransferDetails, onCopyTicket = viewModel::copyTicket, + onActivity = viewModel::openActivity, + onReceivers = viewModel::openReceivers, + onShare = viewModel::openShare, + onCloseDetailPanel = viewModel::closeDetailPanel, + onInvitationResult = viewModel::onInvitationResult, + onRequestDelete = viewModel::requestDeleteTransfer, + onDismissDelete = viewModel::dismissDeleteTransfer, + onConfirmDelete = viewModel::confirmDeleteTransfer, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index d6bfdc5..e13347f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -3,7 +3,11 @@ package com.vnidrop.app.feature.send import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.TransferDirection @@ -15,6 +19,7 @@ fun SendScreen( coreState: CoreState, state: SendState, windowClass: WindowClass, + shareActions: TransferShareActions = UnavailableTransferShareActions, onOpenComposer: () -> Unit, onDismissComposer: () -> Unit, onSelectFile: () -> Unit, @@ -26,13 +31,34 @@ fun SendScreen( onTransferSelected: (ULong) -> Unit, onCloseTransferDetails: () -> Unit, onCopyTicket: (String) -> Unit, + onActivity: () -> Unit = {}, + onReceivers: () -> Unit = {}, + onShare: () -> Unit = {}, + onCloseDetailPanel: () -> Unit = {}, + onInvitationResult: (InvitationAction, Result) -> Unit = { _, _ -> }, + onRequestDelete: () -> Unit = {}, + onDismissDelete: () -> Unit = {}, + onConfirmDelete: () -> Unit = {}, ) { val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } + val qrCache = remember { mutableStateMapOf() } + LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) { + qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet()) + } Box(Modifier.fillMaxSize()) { if (selectedTransfer != null) { - TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket) + TransferDetails( + transfer = selectedTransfer, + events = coreState.events, + completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed }, + onBack = onCloseTransferDetails, + onActivity = onActivity, + onReceivers = onReceivers, + onShare = onShare, + onDelete = onRequestDelete, + ) } else { TransferCatalog( transfers = outgoingTransfers, @@ -59,4 +85,31 @@ fun SendScreen( ) } } + + if (selectedTransfer != null && state.detailPanel != null) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { + when (state.detailPanel) { + TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) + TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers) + TransferDetailPanel.Share -> TransferSharePanel( + selectedTransfer, + shareActions, + qrBitmap = selectedTransfer.ticket?.let(qrCache::get), + onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap }, + onResult = onInvitationResult, + ) + } + } + } + + if (selectedTransfer != null && state.isDeleteConfirmationOpen) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) { + DeleteTransferPanel( + transferName = selectedTransfer.transferName, + isDeleting = state.isDeleting, + onCancel = onDismissDelete, + onConfirm = onConfirmDelete, + ) + } + } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 065ac15..3f2e824 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -3,9 +3,11 @@ package com.vnidrop.app.feature.send import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController @@ -17,9 +19,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.send_transfer_created +import vnidrop.shared.generated.resources.transfer_nfc_written +import vnidrop.shared.generated.resources.transfer_deleted data class SendState( val isComposerOpen: Boolean = false, @@ -30,11 +36,18 @@ data class SendState( val isSharing: Boolean = false, val selectedTransferId: ULong? = null, val transferThumbnails: Map = emptyMap(), + val detailPanel: TransferDetailPanel? = null, + val receiverHistory: List = emptyList(), + val isLoadingReceivers: Boolean = false, + val isDeleteConfirmationOpen: Boolean = false, + val isDeleting: Boolean = false, ) { fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing } +enum class TransferDetailPanel { Activity, Receivers, Share } + sealed interface SendEffect { data object OpenFilePicker : SendEffect data class CopyTicket(val ticket: String) : SendEffect @@ -44,6 +57,7 @@ class SendViewModel( private val repository: CoreGateway, private val fileSystemService: FileSystemService, preferencesRepository: PreferencesRepository, + private val filePreviewRepository: FilePreviewRepository, private val messages: UiMessageController, ) : ViewModel() { private val _state = MutableStateFlow(SendState()) @@ -54,6 +68,29 @@ class SendViewModel( val effectFlow = effects.receiveAsFlow() init { + viewModelScope.launch { + repository.signals.collect { signal -> + val transferId = when (signal) { + is CoreSignal.ReceiverHistoryChanged -> signal.transferId + is CoreSignal.ApprovalChanged -> signal.transferId + } + if (transferId == _state.value.selectedTransferId && + _state.value.detailPanel == TransferDetailPanel.Receivers + ) refreshReceivers(transferId) + } + } + viewModelScope.launch { + filePreviewRepository.previews.collect { previews -> + _state.update { it.copy(transferThumbnails = previews) } + } + } + viewModelScope.launch { + coreState.map { core -> + core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet() + }.distinctUntilChanged().collect { activeIds -> + if (activeIds != null) filePreviewRepository.restore(activeIds) + } + } viewModelScope.launch { preferencesRepository.preferences.collect { preferences -> _state.update { current -> @@ -108,9 +145,70 @@ class SendViewModel( fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } fun setSenderName(value: String) = _state.update { it.copy(senderName = value) } fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) } - fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) } - fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) } + fun openTransfer(transferId: ULong) { + _state.update { it.copy(selectedTransferId = transferId, detailPanel = null) } + refreshReceivers(transferId) + } + fun closeTransferDetails() = _state.update { + it.copy( + selectedTransferId = null, + detailPanel = null, + receiverHistory = emptyList(), + isDeleteConfirmationOpen = false, + ) + } + fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) } + fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) } + fun openReceivers() { + val transferId = _state.value.selectedTransferId ?: return + _state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) } + refreshReceivers(transferId) + } + fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) } + fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) } + fun dismissDeleteTransfer() { + if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) } + } + fun confirmDeleteTransfer() { + val transferId = _state.value.selectedTransferId ?: return + if (_state.value.isDeleting) return + viewModelScope.launch { + _state.update { it.copy(isDeleting = true) } + repository.delete(transferId).fold( + onSuccess = { + filePreviewRepository.remove(transferId) + _state.update { + it.copy( + selectedTransferId = null, + detailPanel = null, + receiverHistory = emptyList(), + isDeleteConfirmationOpen = false, + isDeleting = false, + ) + } + messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success)) + }, + onFailure = { error -> + _state.update { it.copy(isDeleting = false) } + messages.error(error) + }, + ) + } + } fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket)) + fun onInvitationResult(action: InvitationAction, result: Result) { + result.fold( + onSuccess = { + val message = when (action) { + InvitationAction.Export -> null + InvitationAction.Nfc -> Res.string.transfer_nfc_written + InvitationAction.Share -> null + } + message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) } + }, + onFailure = messages::error, + ) + } fun createShare() { val current = state.value @@ -127,6 +225,7 @@ class SendViewModel( ) result.fold( onSuccess = { share -> + file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) } _state.update { it.copy( isComposerOpen = false, @@ -134,9 +233,6 @@ class SendViewModel( transferName = "", accessPolicy = ShareAccessPolicy.RequireApproval, isSharing = false, - transferThumbnails = file.thumbnailBytes?.let { bytes -> - it.transferThumbnails + (share.transferId to bytes) - } ?: it.transferThumbnails, ) } messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success)) @@ -152,4 +248,17 @@ class SendViewModel( private fun sendEffect(effect: SendEffect) { viewModelScope.launch { effects.send(effect) } } + + private fun refreshReceivers(transferId: ULong) { + viewModelScope.launch { + _state.update { it.copy(isLoadingReceivers = true) } + repository.receiverRequests(transferId).fold( + onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } }, + onFailure = { error -> + _state.update { it.copy(isLoadingReceivers = false) } + messages.error(error) + }, + ) + } + } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt index 8ad4281..3639126 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -54,7 +54,6 @@ import vnidrop.shared.generated.resources.send_access_title import vnidrop.shared.generated.resources.send_choose_file_body import vnidrop.shared.generated.resources.send_choose_file_title import vnidrop.shared.generated.resources.send_file_size_unknown -import vnidrop.shared.generated.resources.send_new_transfer_title import vnidrop.shared.generated.resources.send_review_title @Composable @@ -73,7 +72,6 @@ internal fun TransferComposer( modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(stringResource(Res.string.send_new_transfer_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) val file = state.selectedFile if (file == null) { ChooseFileStep(onSelectFile) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index a2b6ca0..61af8ad 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -1,45 +1,79 @@ package com.vnidrop.app.feature.send +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.CoreEventModel +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.Transfer import com.vnidrop.app.ui.components.AppCard +import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.screens.TicketText +import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_back -import vnidrop.shared.generated.resources.button_copy_ticket -import vnidrop.shared.generated.resources.metadata_files -import vnidrop.shared.generated.resources.metadata_size -import vnidrop.shared.generated.resources.metadata_status -import vnidrop.shared.generated.resources.send_access_anyone -import vnidrop.shared.generated.resources.send_access_approval -import vnidrop.shared.generated.resources.send_access_title -import vnidrop.shared.generated.resources.send_new_transfer_title -import vnidrop.shared.generated.resources.send_transfer_details_title +import qrcode.QRCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import vnidrop.shared.generated.resources.* + +enum class InvitationAction { Export, Share, Nfc } @Composable -internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) { +internal fun TransferDetails( + transfer: Transfer, + events: List, + completedReceivers: Int, + onBack: () -> Unit, + onActivity: () -> Unit, + onReceivers: () -> Unit, + onShare: () -> Unit, + onDelete: () -> Unit, +) { LazyColumn( modifier = Modifier.fillMaxSize().statusBarsPadding(), contentPadding = PaddingValues(16.dp), @@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke ) { item { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back)) - } + IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) } Text( stringResource(Res.string.send_transfer_details_title), + modifier = Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) + IconButton(onClick = onDelete) { + Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault) + } } } item { @@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke HorizontalDivider(color = LocalVniDropColors.current.borderDefault) DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize)) HorizontalDivider(color = LocalVniDropColors.current.borderDefault) - DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString()) - HorizontalDivider(color = LocalVniDropColors.current.borderDefault) DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy)) } } - transfer.ticket?.let { ticket -> - item { - AppCard(title = stringResource(Res.string.button_copy_ticket)) { - TicketText(ticket) - PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) }) + item { + Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Column { + DetailDestination( + title = stringResource(Res.string.transfer_activity_title), + description = stringResource(Res.string.transfer_activity_description), + count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() }, + onClick = onActivity, + ) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_receivers_title), + description = stringResource(Res.string.transfer_receivers_description), + count = completedReceivers, + onClick = onReceivers, + ) + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_share_title), + description = stringResource(Res.string.transfer_share_description), + onClick = onShare, + ) } } } } } +@Composable +private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + if (count != null && count > 0) { + Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp)) + Spacer(Modifier.width(8.dp)) + } + Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp)) + } +} + +@Composable +internal fun ReceiverHistoryPanel(receivers: List, 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, 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, +) { + DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) } + val ticket = transfer.ticket + PanelContainer(stringResource(Res.string.transfer_share_title)) { + if (ticket == null) { + Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter) + return@PanelContainer + } + val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) { + if (value == null) { + value = withContext(Dispatchers.Default) { + runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull() + } + value?.let { onQrRendered(ticket, it) } + } + } + val renderedQr = renderedBitmap + Surface( + modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White, + ) { + if (renderedQr != null) { + Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp))) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } + } + Text( + stringResource(Res.string.transfer_scan_qr), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + if (actions.nfcAvailability != NfcShareAvailability.Hidden) { + var writingNfc by remember(ticket) { mutableStateOf(false) } + SecondaryButton( + if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc), + onClick = { + writingNfc = true + actions.writeInvitationToNfc(ticket) { + writingNfc = false + onResult(InvitationAction.Nfc, it) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc, + ) + if (actions.nfcAvailability == NfcShareAvailability.Unavailable) { + Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + } + SecondaryButton( + stringResource(Res.string.button_download_invitation), + onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } }, + modifier = Modifier.fillMaxWidth(), + ) + PrimaryButton( + stringResource(Res.string.button_native_share), + onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } }, + modifier = Modifier.fillMaxWidth(), + enabled = actions.canUseNativeShare, + ) + } +} + +@Composable +internal fun DeleteTransferPanel( + transferName: String?, + isDeleting: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + PanelContainer(stringResource(Res.string.transfer_delete_title)) { + Text( + stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting) + DestructiveButton( + if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer), + onClick = onConfirm, + enabled = !isDeleting, + ) + } + } +} + +@Composable +private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + content() + } +} + +@Composable +private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) { + ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested + ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted + ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused + ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired + ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed + ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown +}) + +@Composable +private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) { + ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault + ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault + else -> LocalVniDropColors.current.foregroundLighter +} + +private fun CoreEventModel.isMeaningfulActivity() = + (phase == "import" && kind == "started") || + (phase == "ticket" && kind == "created") || + kind in setOf( + "receiver-requested", "receiver-accepted", "receiver-auto-approved", + "receiver-refused", "receiver-completed", "share-stopped", "failed", + ) + +@Composable +private fun eventTitle(event: CoreEventModel) = stringResource(when { + event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing + event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready + event.kind == "receiver-requested" -> Res.string.transfer_event_requested + event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved + event.kind == "receiver-refused" -> Res.string.transfer_event_refused + event.kind == "receiver-completed" -> Res.string.transfer_event_completed + event.kind == "share-stopped" -> Res.string.transfer_event_stopped + event.kind == "failed" -> Res.string.transfer_event_failed + else -> Res.string.transfer_event_updated +}) + @Composable private fun DetailValue(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt new file mode 100644 index 0000000..df22a95 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.kt @@ -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) + fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) + fun writeInvitationToNfc(ticket: String, onResult: (Result) -> 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) = + onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable"))) + override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) = + onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable"))) + override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> 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" +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt index a62f6f9..4750994 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt @@ -4,19 +4,30 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_close @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,9 +42,7 @@ fun AdaptiveDrawer( sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = LocalVniDropColors.current.backgroundDialog, ) { - Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) { - content() - } + ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content) } } else { Dialog( @@ -45,9 +54,31 @@ fun AdaptiveDrawer( shape = RoundedCornerShape(20.dp), color = LocalVniDropColors.current.backgroundDialog, shadowElevation = 12.dp, - ) { - content() - } + ) { ClosableModalContent(onDismissRequest, content = content) } } } } + +@Composable +private fun ClosableModalContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box(modifier) { + content() + IconButton( + onClick = onClose, + modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp), + ) { + Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight) + } + } +} + +private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply { + path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) { + moveTo(6f, 6f); lineTo(18f, 18f) + moveTo(18f, 6f); lineTo(6f, 18f) + } +}.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt index ac68baa..730f9f9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -40,3 +40,19 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) } } + +@Composable +fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = LocalVniDropColors.current.destructiveDefault, + contentColor = Color.White, + ), + ) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt index 3bfbee9..1d352c8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Status.kt @@ -28,9 +28,10 @@ fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = Pi PillTone.Warning -> colors.warningDefault PillTone.Destructive -> colors.destructiveDefault } + val shape = RoundedCornerShape(7.dp) Row( - modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f)) - .border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp), + modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(7.dp).clip(CircleShape).background(color)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index cefa3cb..2755739 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -15,6 +15,7 @@ import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeFileSystemService +import com.vnidrop.app.support.FakeFilePreviewRepository import com.vnidrop.app.support.FakeNotificationService import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.ui.feedback.UiMessageController @@ -30,6 +31,7 @@ import kotlinx.coroutines.test.setMain import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertContentEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -141,7 +143,7 @@ class ViewModelsTest { @Test fun sendViewModelOwnsSelectedFileState() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController()) + val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) viewModel.openComposer() viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) assertEquals("photo.jpg", viewModel.state.value.transferName) @@ -157,10 +159,14 @@ class ViewModelsTest { mutableState.value = CoreState(isInitialized = true) shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) } - val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + val previews = FakeFilePreviewRepository() + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) advanceUntilIdle() viewModel.openComposer() - viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) + val thumbnail = ByteArray(12).also { + it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() + } + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail)) viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) viewModel.createShare() advanceUntilIdle() @@ -169,13 +175,14 @@ class ViewModelsTest { assertEquals(null, viewModel.state.value.selectedFile) assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(7UL, core.state.value.transfers.first().transferId) + assertContentEquals(thumbnail, previews.previews.value.getValue(7UL)) } @Test fun sendComposerStaysOpenWhenShareCreationFails() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) } - val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) advanceUntilIdle() viewModel.openComposer() viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) @@ -187,6 +194,36 @@ class ViewModelsTest { assertFalse(viewModel.state.value.isSharing) } + @Test + fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true, transfers = listOf( + com.vnidrop.app.core.Transfer( + localId = "send-7", transferId = 7UL, + direction = com.vnidrop.app.core.TransferDirection.Send, + status = com.vnidrop.app.core.TransferStatus.Sharing, + peerId = null, transferName = "Photo", contentHash = "hash", + fileCount = 1UL, totalSize = 42UL, ticket = "ticket", + accessPolicy = ShareAccessPolicy.RequireApproval, createdAt = 1, updatedAt = 1, + ), + )) + } + val previews = FakeFilePreviewRepository() + previews.save(7UL, byteArrayOf(1, 2, 3)) + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) + advanceUntilIdle() + viewModel.openTransfer(7UL) + viewModel.requestDeleteTransfer() + viewModel.confirmDeleteTransfer() + advanceUntilIdle() + + assertEquals(listOf(7UL), core.deletedTransfers) + assertFalse(7UL in previews.previews.value) + assertEquals(null, viewModel.state.value.selectedTransferId) + assertFalse(viewModel.state.value.isDeleteConfirmationOpen) + } + @Test fun receiveViewModelBuildsStateFromPreferences() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt index 763a54f..4983f7d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt @@ -3,6 +3,7 @@ package com.vnidrop.app.feature.approvals import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind @@ -88,10 +89,11 @@ class ApprovalCoordinatorTest { receiverName = "Peer", receiverDeviceName = "Phone", appVersion = "1.0", - status = "requested", + status = ReceiverDeliveryStatus.Requested, reason = null, requestedAt = requestedAt, respondedAt = null, + completedAt = null, ) private fun activeTransfer() = Transfer( diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt new file mode 100644 index 0000000..b89042f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/FilePreviewRepositoryTest.kt @@ -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() + 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) } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt new file mode 100644 index 0000000..328d98d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferShareActionsTest.kt @@ -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")) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index f0d9bec..e8d8c74 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -19,6 +19,7 @@ import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.feature.send.FilePreviewRepository import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -35,6 +36,8 @@ class FakeCoreGateway : CoreGateway { var responseResult: Result = Result.success(Unit) val responses = mutableListOf>() var shareResult: Result = Result.failure(UnsupportedOperationException()) + var deleteResult: Result = Result.success(Unit) + val deletedTransfers = mutableListOf() var lastShareAccessPolicy: ShareAccessPolicy? = null override suspend fun initialize(appDataDir: String): Result { @@ -86,6 +89,15 @@ class FakeCoreGateway : CoreGateway { override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit) override suspend fun cancel(transferId: ULong) = Result.success(Unit) + override suspend fun delete(transferId: ULong): Result { + if (deleteResult.isSuccess) { + deletedTransfers += transferId + mutableState.value = mutableState.value.copy( + transfers = mutableState.value.transfers.filterNot { it.transferId == transferId }, + ) + } + return deleteResult + } override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty()) override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result { responses += Triple(requestId, accepted, reason) @@ -138,3 +150,12 @@ class FakeFileSystemService( accessPolicy: ShareAccessPolicy, ) = repository.sharePath(file.value, transferName, senderName, accessPolicy) } + +class FakeFilePreviewRepository : FilePreviewRepository { + private val state = MutableStateFlow>(emptyMap()) + override val previews: StateFlow> = state + val restored = mutableListOf>() + override suspend fun restore(activeTransferIds: Set) { 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 } +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt index cebe5b7..2178715 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt @@ -5,7 +5,9 @@ import androidx.compose.runtime.remember import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.IosLocalNotificationService import platform.Foundation.NSBundle -import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask import platform.UIKit.UIDevice @Composable @@ -17,7 +19,7 @@ fun rememberIosAppDependencies(): AppDependencies { environment = PlatformEnvironment( name = device.systemName() + " " + device.systemVersion, appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0", - defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop", + defaultCoreDataDir = iosApplicationDataDirectory(), defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver", ), deviceInfoProvider = IosDeviceInfoProvider(device), @@ -27,6 +29,11 @@ fun rememberIosAppDependencies(): AppDependencies { } } +private fun iosApplicationDataDirectory(): String = + (NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String) + ?.trimEnd('/')?.plus("/VniDrop") + ?: error("iOS Application Support directory is unavailable") + private class IosDeviceInfoProvider( private val device: UIDevice, ) : DeviceInfoProvider { diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt new file mode 100644 index 0000000..deffba1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt @@ -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 { + ensureDirectory() + return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance().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" +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt new file mode 100644 index 0000000..ca9be15 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt @@ -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) { + 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) { + 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) { + 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) +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt new file mode 100644 index 0000000..9d8fb51 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.jvm.kt @@ -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 = directory.listFiles().orEmpty().mapNotNull { file -> + file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) } + } + override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull() + override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching { + directory.mkdirs() + val target = file(transferId) + if (target.isFile) return@runCatching true + val temporary = File(directory, ".${target.name}.tmp") + temporary.writeBytes(bytes) + temporary.renameTo(target).also { if (!it) temporary.delete() } + }.getOrDefault(false) + override fun delete(transferId: ULong) { file(transferId).delete() } + private fun file(transferId: ULong) = File(directory, "$transferId.preview") +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt new file mode 100644 index 0000000..11029f4 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt @@ -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) { + 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) { + 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) { + 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)? = null +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt new file mode 100644 index 0000000..2dd75c1 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStoreTest.kt @@ -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() + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 031fac5..58d1ea8 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -281,10 +283,39 @@ class FoundationComposeTest { } } + val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot() + val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot() + assertTrue(statusBounds.left - titleBounds.right <= 12.dp) onNodeWithText("Photos").performClick() runOnIdle { assertEquals(9UL, selectedId) } } + @Test + fun transferDetailsRevealSharingOnlyAfterSelection() = runComposeUiTest { + val state = mutableStateOf(SendState(selectedTransferId = 9UL)) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())), + state = state.value, + windowClass = WindowClass.Desktop, + onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {}, + onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {}, + onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {}, + onShare = { state.value = state.value.copy(detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share) }, + ) + } + } + + onNodeWithText("Share").assertIsDisplayed() + onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0) + onNode(hasText("Share") and hasClickAction()).performClick() + runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) } + onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed() + onNodeWithText("Save .vnd file").assertIsDisplayed() + onNodeWithContentDescription("Close").assertIsDisplayed() + } + @Test fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { val controller = UiMessageController()