mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(send): build durable transfer details and sharing flow
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import java.io.File
|
||||
|
||||
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmLikePreviewStore(File(appDataDir, "ui/previews"))
|
||||
|
||||
private class JvmLikePreviewStore(private val directory: File) : PlatformPreviewStore {
|
||||
override fun list(): List<PreviewFileInfo> = directory.listFiles().orEmpty().mapNotNull { file ->
|
||||
file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) }
|
||||
}
|
||||
override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull()
|
||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching {
|
||||
directory.mkdirs()
|
||||
val target = file(transferId)
|
||||
if (target.isFile) return@runCatching true
|
||||
val temporary = File(directory, ".${target.name}.tmp")
|
||||
temporary.writeBytes(bytes)
|
||||
temporary.renameTo(target).also { if (!it) temporary.delete() }
|
||||
}.getOrDefault(false)
|
||||
override fun delete(transferId: ULong) { file(transferId).delete() }
|
||||
private fun file(transferId: ULong) = File(directory, "$transferId.preview")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.nfc.NdefMessage
|
||||
import android.nfc.NdefRecord
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.tech.Ndef
|
||||
import android.nfc.tech.NdefFormatable
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
actual fun rememberTransferShareActions(): TransferShareActions {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val nfcEnabled = activity?.let { NfcAdapter.getDefaultAdapter(it)?.isEnabled == true } == true
|
||||
var pendingExport by remember { mutableStateOf<PendingExport?>(null) }
|
||||
val exporter = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument(InvitationMimeType),
|
||||
) { uri ->
|
||||
val pending = pendingExport
|
||||
pendingExport = null
|
||||
if (pending != null && uri != null) {
|
||||
pending.callback(runCatching {
|
||||
context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.ticket.encodeToByteArray()) }
|
||||
?: error("The selected destination could not be opened")
|
||||
})
|
||||
}
|
||||
}
|
||||
return remember(activity, exporter, nfcEnabled) {
|
||||
object : TransferShareActions {
|
||||
override val canUseNativeShare = activity != null
|
||||
override val nfcAvailability = when {
|
||||
activity == null -> NfcShareAvailability.Unavailable
|
||||
nfcEnabled -> NfcShareAvailability.Available
|
||||
else -> NfcShareAvailability.Unavailable
|
||||
}
|
||||
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
pendingExport = PendingExport(ticket, onResult)
|
||||
exporter.launch(invitationFileName(transferName))
|
||||
}
|
||||
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(runCatching {
|
||||
val directory = File(context.cacheDir, "transfer-invitations").apply { mkdirs() }
|
||||
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = InvitationMimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
clipData = ClipData.newRawUri(file.name, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
})
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
val host = activity ?: return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
|
||||
val adapter = NfcAdapter.getDefaultAdapter(host)
|
||||
if (adapter?.isEnabled != true) return onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
|
||||
adapter.enableReaderMode(host, { tag ->
|
||||
val result = runCatching {
|
||||
val message = NdefMessage(arrayOf(NdefRecord.createMime(InvitationMimeType, ticket.encodeToByteArray())))
|
||||
val ndef = Ndef.get(tag)
|
||||
if (ndef != null) {
|
||||
ndef.connect()
|
||||
try {
|
||||
require(ndef.isWritable) { "This NFC tag is read-only" }
|
||||
require(ndef.maxSize >= message.toByteArray().size) { "This NFC tag is too small" }
|
||||
ndef.writeNdefMessage(message)
|
||||
} finally { ndef.close() }
|
||||
} else {
|
||||
val formatable = NdefFormatable.get(tag) ?: error("This NFC tag cannot store an invitation")
|
||||
formatable.connect()
|
||||
try { formatable.format(message) } finally { formatable.close() }
|
||||
}
|
||||
}
|
||||
host.runOnUiThread {
|
||||
adapter.disableReaderMode(host)
|
||||
onResult(result)
|
||||
}
|
||||
}, NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V, null)
|
||||
}
|
||||
|
||||
override fun cancelNfcWrite() {
|
||||
activity?.let { host -> NfcAdapter.getDefaultAdapter(host)?.disableReaderMode(host) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingExport(val ticket: String, val callback: (Result<Unit>) -> Unit)
|
||||
private const val InvitationMimeType = "application/vnd.vnidrop.transfer"
|
||||
@@ -20,6 +20,42 @@
|
||||
<string name="send_file_size_unknown">Size unavailable</string>
|
||||
<string name="send_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</string>
|
||||
<string name="transfer_activity_title">Activity</string>
|
||||
<string name="transfer_activity_description">See important updates for this transfer</string>
|
||||
<string name="transfer_receivers_title">Receivers</string>
|
||||
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
|
||||
<string name="transfer_share_title">Share</string>
|
||||
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
|
||||
<string name="transfer_delete_title">Delete transfer?</string>
|
||||
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
|
||||
<string name="transfer_deleting">Deleting…</string>
|
||||
<string name="transfer_deleted">Transfer deleted.</string>
|
||||
<string name="transfer_no_activity">There is no activity to show yet.</string>
|
||||
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
|
||||
<string name="transfer_receiver_requested">Waiting for your approval</string>
|
||||
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
|
||||
<string name="transfer_receiver_refused">Request refused</string>
|
||||
<string name="transfer_receiver_expired">Request expired</string>
|
||||
<string name="transfer_receiver_completed">Received successfully</string>
|
||||
<string name="transfer_receiver_unknown">Status unavailable</string>
|
||||
<string name="transfer_nearby_device">Nearby device</string>
|
||||
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
|
||||
<string name="button_write_nfc">Write to NFC tag</string>
|
||||
<string name="button_download_invitation">Save .vnd file</string>
|
||||
<string name="button_native_share">Share invitation</string>
|
||||
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
|
||||
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
|
||||
<string name="transfer_invitation_saved">Invitation saved.</string>
|
||||
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
|
||||
<string name="transfer_event_preparing">Preparing the selected files</string>
|
||||
<string name="transfer_event_ready">Transfer ready to share</string>
|
||||
<string name="transfer_event_requested">A receiver requested access</string>
|
||||
<string name="transfer_event_approved">Receiver access approved</string>
|
||||
<string name="transfer_event_refused">Receiver access refused</string>
|
||||
<string name="transfer_event_completed">A receiver completed the transfer</string>
|
||||
<string name="transfer_event_stopped">Sharing stopped</string>
|
||||
<string name="transfer_event_failed">The transfer encountered a problem</string>
|
||||
<string name="transfer_event_updated">Transfer updated</string>
|
||||
<string name="button_choose_file">Choose file</string>
|
||||
<string name="button_change_file">Change file</string>
|
||||
<string name="button_share_file">Share file</string>
|
||||
@@ -63,6 +99,9 @@
|
||||
<string name="button_choose_folder">Choose folder</string>
|
||||
<string name="button_reset_default">Reset default</string>
|
||||
<string name="button_back">Back</string>
|
||||
<string name="button_close">Close</string>
|
||||
<string name="button_cancel">Cancel</string>
|
||||
<string name="button_delete_transfer">Delete transfer</string>
|
||||
<string name="folder_status_writable">Writable</string>
|
||||
<string name="folder_status_permission_required">Permission required</string>
|
||||
<string name="folder_status_unavailable">Unavailable</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -89,12 +89,22 @@ data class ReceiverRequestModel(
|
||||
val receiverName: String?,
|
||||
val receiverDeviceName: String?,
|
||||
val appVersion: String,
|
||||
val status: String,
|
||||
val status: ReceiverDeliveryStatus,
|
||||
val reason: String?,
|
||||
val requestedAt: Long,
|
||||
val respondedAt: Long?,
|
||||
val completedAt: Long?,
|
||||
)
|
||||
|
||||
enum class ReceiverDeliveryStatus {
|
||||
Requested,
|
||||
Accepted,
|
||||
Refused,
|
||||
Expired,
|
||||
Completed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
data class CoreState(
|
||||
val isInitialized: Boolean = false,
|
||||
val status: CoreStatus? = null,
|
||||
@@ -106,6 +116,7 @@ data class CoreState(
|
||||
|
||||
sealed interface CoreSignal {
|
||||
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
||||
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
|
||||
}
|
||||
|
||||
interface CoreGateway {
|
||||
@@ -134,6 +145,7 @@ interface CoreGateway {
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||
suspend fun delete(transferId: ULong): Result<Unit>
|
||||
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
|
||||
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
|
||||
suspend fun refresh(): Result<Unit>
|
||||
|
||||
@@ -49,6 +49,9 @@ class CoreRepository(
|
||||
if (model.phase == "approval" && model.transferId != null) {
|
||||
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
|
||||
}
|
||||
if (model.phase == "delivery" && model.transferId != null) {
|
||||
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +166,13 @@ class CoreRepository(
|
||||
refreshSnapshot()
|
||||
}
|
||||
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().deleteTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||
}
|
||||
|
||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
|
||||
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||
}
|
||||
@@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
|
||||
receiverName = receiverName,
|
||||
receiverDeviceName = receiverDeviceName,
|
||||
appVersion = appVersion,
|
||||
status = status,
|
||||
status = when (status) {
|
||||
"requested" -> ReceiverDeliveryStatus.Requested
|
||||
"accepted" -> ReceiverDeliveryStatus.Accepted
|
||||
"refused" -> ReceiverDeliveryStatus.Refused
|
||||
"expired" -> ReceiverDeliveryStatus.Expired
|
||||
"completed" -> ReceiverDeliveryStatus.Completed
|
||||
else -> ReceiverDeliveryStatus.Unknown
|
||||
},
|
||||
reason = reason,
|
||||
requestedAt = requestedAt,
|
||||
respondedAt = respondedAt,
|
||||
completedAt = completedAt,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
data class PreviewFileInfo(
|
||||
val transferId: ULong,
|
||||
val byteSize: Long,
|
||||
val modifiedAtMillis: Long,
|
||||
)
|
||||
|
||||
interface PlatformPreviewStore {
|
||||
fun list(): List<PreviewFileInfo>
|
||||
fun read(transferId: ULong): ByteArray?
|
||||
fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean
|
||||
fun delete(transferId: ULong)
|
||||
}
|
||||
|
||||
expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore
|
||||
|
||||
interface FilePreviewRepository {
|
||||
val previews: StateFlow<Map<ULong, ByteArray>>
|
||||
suspend fun restore(activeTransferIds: Set<ULong>)
|
||||
suspend fun save(transferId: ULong, bytes: ByteArray)
|
||||
suspend fun remove(transferId: ULong)
|
||||
}
|
||||
|
||||
data class PreviewStoragePolicy(
|
||||
val maxEntryBytes: Int = 512 * 1024,
|
||||
val maxTotalBytes: Long = 20L * 1024L * 1024L,
|
||||
) {
|
||||
init {
|
||||
require(maxEntryBytes > 0)
|
||||
require(maxTotalBytes >= maxEntryBytes)
|
||||
}
|
||||
}
|
||||
|
||||
class AppFilePreviewRepository(
|
||||
private val store: PlatformPreviewStore,
|
||||
private val policy: PreviewStoragePolicy = PreviewStoragePolicy(),
|
||||
) : FilePreviewRepository {
|
||||
private val mutex = Mutex()
|
||||
private val _previews = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
|
||||
override val previews: StateFlow<Map<ULong, ByteArray>> = _previews.asStateFlow()
|
||||
|
||||
override suspend fun restore(activeTransferIds: Set<ULong>) = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
val files = store.list()
|
||||
for (file in files) {
|
||||
if (file.transferId !in activeTransferIds || file.byteSize !in 1..policy.maxEntryBytes.toLong()) {
|
||||
store.delete(file.transferId)
|
||||
}
|
||||
}
|
||||
enforceQuota()
|
||||
_previews.value = store.list()
|
||||
.filter { it.transferId in activeTransferIds }
|
||||
.mapNotNull { file ->
|
||||
store.read(file.transferId)
|
||||
?.takeIf { it.isSupportedPreview() && it.size <= policy.maxEntryBytes }
|
||||
?.let { file.transferId to it }
|
||||
?: run {
|
||||
store.delete(file.transferId)
|
||||
null
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun save(transferId: ULong, bytes: ByteArray) = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
if (bytes.size !in 1..policy.maxEntryBytes || !bytes.isSupportedPreview()) return@withLock
|
||||
if (!store.writeAtomically(transferId, bytes)) return@withLock
|
||||
enforceQuota(protectedTransferId = transferId)
|
||||
if (store.read(transferId) != null) {
|
||||
_previews.value = _previews.value + (transferId to bytes.copyOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(transferId: ULong) = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
store.delete(transferId)
|
||||
_previews.value = _previews.value - transferId
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceQuota(protectedTransferId: ULong? = null) {
|
||||
val files = store.list().sortedBy { it.modifiedAtMillis }
|
||||
var total = files.sumOf(PreviewFileInfo::byteSize)
|
||||
for (file in files) {
|
||||
if (total <= policy.maxTotalBytes) break
|
||||
if (file.transferId == protectedTransferId) continue
|
||||
store.delete(file.transferId)
|
||||
total -= file.byteSize
|
||||
_previews.value = _previews.value - file.transferId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.isSupportedPreview(): Boolean {
|
||||
val png = size >= 8 && this[0] == 0x89.toByte() && decodeToString(1, 4) == "PNG"
|
||||
val jpeg = size >= 3 && this[0] == 0xff.toByte() && this[1] == 0xd8.toByte() && this[2] == 0xff.toByte()
|
||||
val webp = size >= 12 && decodeToString(0, 4) == "RIFF" && decodeToString(8, 12) == "WEBP"
|
||||
return png || jpeg || webp
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package com.vnidrop.app.feature.send
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
@@ -15,6 +19,7 @@ fun SendScreen(
|
||||
coreState: CoreState,
|
||||
state: SendState,
|
||||
windowClass: WindowClass,
|
||||
shareActions: TransferShareActions = UnavailableTransferShareActions,
|
||||
onOpenComposer: () -> Unit,
|
||||
onDismissComposer: () -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
@@ -26,13 +31,34 @@ fun SendScreen(
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onCloseTransferDetails: () -> Unit,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onActivity: () -> Unit = {},
|
||||
onReceivers: () -> Unit = {},
|
||||
onShare: () -> Unit = {},
|
||||
onCloseDetailPanel: () -> Unit = {},
|
||||
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
|
||||
onRequestDelete: () -> Unit = {},
|
||||
onDismissDelete: () -> Unit = {},
|
||||
onConfirmDelete: () -> Unit = {},
|
||||
) {
|
||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
|
||||
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
|
||||
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (selectedTransfer != null) {
|
||||
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
|
||||
TransferDetails(
|
||||
transfer = selectedTransfer,
|
||||
events = coreState.events,
|
||||
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
|
||||
onBack = onCloseTransferDetails,
|
||||
onActivity = onActivity,
|
||||
onReceivers = onReceivers,
|
||||
onShare = onShare,
|
||||
onDelete = onRequestDelete,
|
||||
)
|
||||
} else {
|
||||
TransferCatalog(
|
||||
transfers = outgoingTransfers,
|
||||
@@ -59,4 +85,31 @@ fun SendScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.detailPanel != null) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
||||
when (state.detailPanel) {
|
||||
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
||||
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
|
||||
TransferDetailPanel.Share -> TransferSharePanel(
|
||||
selectedTransfer,
|
||||
shareActions,
|
||||
qrBitmap = selectedTransfer.ticket?.let(qrCache::get),
|
||||
onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap },
|
||||
onResult = onInvitationResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
|
||||
DeleteTransferPanel(
|
||||
transferName = selectedTransfer.transferName,
|
||||
isDeleting = state.isDeleting,
|
||||
onCancel = onDismissDelete,
|
||||
onConfirm = onConfirmDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.vnidrop.app.feature.send
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -17,9 +19,13 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.send_transfer_created
|
||||
import vnidrop.shared.generated.resources.transfer_nfc_written
|
||||
import vnidrop.shared.generated.resources.transfer_deleted
|
||||
|
||||
data class SendState(
|
||||
val isComposerOpen: Boolean = false,
|
||||
@@ -30,11 +36,18 @@ data class SendState(
|
||||
val isSharing: Boolean = false,
|
||||
val selectedTransferId: ULong? = null,
|
||||
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||
val detailPanel: TransferDetailPanel? = null,
|
||||
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
|
||||
val isLoadingReceivers: Boolean = false,
|
||||
val isDeleteConfirmationOpen: Boolean = false,
|
||||
val isDeleting: Boolean = false,
|
||||
) {
|
||||
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
||||
}
|
||||
|
||||
enum class TransferDetailPanel { Activity, Receivers, Share }
|
||||
|
||||
sealed interface SendEffect {
|
||||
data object OpenFilePicker : SendEffect
|
||||
data class CopyTicket(val ticket: String) : SendEffect
|
||||
@@ -44,6 +57,7 @@ class SendViewModel(
|
||||
private val repository: CoreGateway,
|
||||
private val fileSystemService: FileSystemService,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
private val filePreviewRepository: FilePreviewRepository,
|
||||
private val messages: UiMessageController,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SendState())
|
||||
@@ -54,6 +68,29 @@ class SendViewModel(
|
||||
val effectFlow = effects.receiveAsFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.signals.collect { signal ->
|
||||
val transferId = when (signal) {
|
||||
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
|
||||
is CoreSignal.ApprovalChanged -> signal.transferId
|
||||
}
|
||||
if (transferId == _state.value.selectedTransferId &&
|
||||
_state.value.detailPanel == TransferDetailPanel.Receivers
|
||||
) refreshReceivers(transferId)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
filePreviewRepository.previews.collect { previews ->
|
||||
_state.update { it.copy(transferThumbnails = previews) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
coreState.map { core ->
|
||||
core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet()
|
||||
}.distinctUntilChanged().collect { activeIds ->
|
||||
if (activeIds != null) filePreviewRepository.restore(activeIds)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collect { preferences ->
|
||||
_state.update { current ->
|
||||
@@ -108,9 +145,70 @@ class SendViewModel(
|
||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
||||
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
|
||||
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
|
||||
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
|
||||
fun openTransfer(transferId: ULong) {
|
||||
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
|
||||
refreshReceivers(transferId)
|
||||
}
|
||||
fun closeTransferDetails() = _state.update {
|
||||
it.copy(
|
||||
selectedTransferId = null,
|
||||
detailPanel = null,
|
||||
receiverHistory = emptyList(),
|
||||
isDeleteConfirmationOpen = false,
|
||||
)
|
||||
}
|
||||
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
|
||||
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||
fun openReceivers() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
|
||||
refreshReceivers(transferId)
|
||||
}
|
||||
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
|
||||
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
|
||||
fun dismissDeleteTransfer() {
|
||||
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
|
||||
}
|
||||
fun confirmDeleteTransfer() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
if (_state.value.isDeleting) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isDeleting = true) }
|
||||
repository.delete(transferId).fold(
|
||||
onSuccess = {
|
||||
filePreviewRepository.remove(transferId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTransferId = null,
|
||||
detailPanel = null,
|
||||
receiverHistory = emptyList(),
|
||||
isDeleteConfirmationOpen = false,
|
||||
isDeleting = false,
|
||||
)
|
||||
}
|
||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success))
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.update { it.copy(isDeleting = false) }
|
||||
messages.error(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
||||
fun onInvitationResult(action: InvitationAction, result: Result<Unit>) {
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
val message = when (action) {
|
||||
InvitationAction.Export -> null
|
||||
InvitationAction.Nfc -> Res.string.transfer_nfc_written
|
||||
InvitationAction.Share -> null
|
||||
}
|
||||
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
|
||||
},
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
|
||||
fun createShare() {
|
||||
val current = state.value
|
||||
@@ -127,6 +225,7 @@ class SendViewModel(
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { share ->
|
||||
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
@@ -134,9 +233,6 @@ class SendViewModel(
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
isSharing = false,
|
||||
transferThumbnails = file.thumbnailBytes?.let { bytes ->
|
||||
it.transferThumbnails + (share.transferId to bytes)
|
||||
} ?: it.transferThumbnails,
|
||||
)
|
||||
}
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||
@@ -152,4 +248,17 @@ class SendViewModel(
|
||||
private fun sendEffect(effect: SendEffect) {
|
||||
viewModelScope.launch { effects.send(effect) }
|
||||
}
|
||||
|
||||
private fun refreshReceivers(transferId: ULong) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoadingReceivers = true) }
|
||||
repository.receiverRequests(transferId).fold(
|
||||
onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } },
|
||||
onFailure = { error ->
|
||||
_state.update { it.copy(isLoadingReceivers = false) }
|
||||
messages.error(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,45 +1,79 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreEventModel
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.DestructiveButton
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.screens.TicketText
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_back
|
||||
import vnidrop.shared.generated.resources.button_copy_ticket
|
||||
import vnidrop.shared.generated.resources.metadata_files
|
||||
import vnidrop.shared.generated.resources.metadata_size
|
||||
import vnidrop.shared.generated.resources.metadata_status
|
||||
import vnidrop.shared.generated.resources.send_access_anyone
|
||||
import vnidrop.shared.generated.resources.send_access_approval
|
||||
import vnidrop.shared.generated.resources.send_access_title
|
||||
import vnidrop.shared.generated.resources.send_new_transfer_title
|
||||
import vnidrop.shared.generated.resources.send_transfer_details_title
|
||||
import qrcode.QRCode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import vnidrop.shared.generated.resources.*
|
||||
|
||||
enum class InvitationAction { Export, Share, Nfc }
|
||||
|
||||
@Composable
|
||||
internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) {
|
||||
internal fun TransferDetails(
|
||||
transfer: Transfer,
|
||||
events: List<CoreEventModel>,
|
||||
completedReceivers: Int,
|
||||
onBack: () -> Unit,
|
||||
onActivity: () -> Unit,
|
||||
onReceivers: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
@@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
|
||||
) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
|
||||
}
|
||||
IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
|
||||
Text(
|
||||
stringResource(Res.string.send_transfer_details_title),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
@@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString())
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
|
||||
}
|
||||
}
|
||||
transfer.ticket?.let { ticket ->
|
||||
item {
|
||||
AppCard(title = stringResource(Res.string.button_copy_ticket)) {
|
||||
TicketText(ticket)
|
||||
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) })
|
||||
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<ReceiverRequestModel>, loading: Boolean) {
|
||||
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
|
||||
else -> receivers.forEachIndexed { index, receiver ->
|
||||
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
ReceiverRow(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiverRow(receiver: ReceiverRequestModel) {
|
||||
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
receiver.receiverDeviceName?.takeIf { it != name }?.let {
|
||||
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TransferActivityPanel(events: List<CoreEventModel>, transferId: ULong) {
|
||||
val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp)
|
||||
PanelContainer(stringResource(Res.string.transfer_activity_title)) {
|
||||
if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter)
|
||||
else visible.forEachIndexed { index, event ->
|
||||
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TransferSharePanel(
|
||||
transfer: Transfer,
|
||||
actions: TransferShareActions,
|
||||
qrBitmap: androidx.compose.ui.graphics.ImageBitmap?,
|
||||
onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit,
|
||||
onResult: (InvitationAction, Result<Unit>) -> Unit,
|
||||
) {
|
||||
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
|
||||
val ticket = transfer.ticket
|
||||
PanelContainer(stringResource(Res.string.transfer_share_title)) {
|
||||
if (ticket == null) {
|
||||
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||
return@PanelContainer
|
||||
}
|
||||
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
|
||||
if (value == null) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||
}
|
||||
value?.let { onQrRendered(ticket, it) }
|
||||
}
|
||||
}
|
||||
val renderedQr = renderedBitmap
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
if (renderedQr != null) {
|
||||
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.transfer_scan_qr),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
|
||||
var writingNfc by remember(ticket) { mutableStateOf(false) }
|
||||
SecondaryButton(
|
||||
if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc),
|
||||
onClick = {
|
||||
writingNfc = true
|
||||
actions.writeInvitationToNfc(ticket) {
|
||||
writingNfc = false
|
||||
onResult(InvitationAction.Nfc, it)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc,
|
||||
)
|
||||
if (actions.nfcAvailability == NfcShareAvailability.Unavailable) {
|
||||
Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
SecondaryButton(
|
||||
stringResource(Res.string.button_download_invitation),
|
||||
onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
PrimaryButton(
|
||||
stringResource(Res.string.button_native_share),
|
||||
onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = actions.canUseNativeShare,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeleteTransferPanel(
|
||||
transferName: String?,
|
||||
isDeleting: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
PanelContainer(stringResource(Res.string.transfer_delete_title)) {
|
||||
Text(
|
||||
stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
|
||||
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
|
||||
DestructiveButton(
|
||||
if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer),
|
||||
onClick = onConfirm,
|
||||
enabled = !isDeleting,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) {
|
||||
ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested
|
||||
ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted
|
||||
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
|
||||
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
|
||||
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
|
||||
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
|
||||
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
|
||||
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
|
||||
else -> LocalVniDropColors.current.foregroundLighter
|
||||
}
|
||||
|
||||
private fun CoreEventModel.isMeaningfulActivity() =
|
||||
(phase == "import" && kind == "started") ||
|
||||
(phase == "ticket" && kind == "created") ||
|
||||
kind in setOf(
|
||||
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
||||
"receiver-refused", "receiver-completed", "share-stopped", "failed",
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun eventTitle(event: CoreEventModel) = stringResource(when {
|
||||
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
|
||||
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
|
||||
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
|
||||
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
|
||||
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
|
||||
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
|
||||
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
|
||||
event.kind == "failed" -> Res.string.transfer_event_failed
|
||||
else -> Res.string.transfer_event_updated
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun DetailValue(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
enum class NfcShareAvailability { Available, Unavailable, Hidden }
|
||||
|
||||
interface TransferShareActions {
|
||||
val canUseNativeShare: Boolean
|
||||
val nfcAvailability: NfcShareAvailability
|
||||
fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
|
||||
fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
|
||||
fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit)
|
||||
fun cancelNfcWrite()
|
||||
}
|
||||
|
||||
object UnavailableTransferShareActions : TransferShareActions {
|
||||
override val canUseNativeShare = false
|
||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
|
||||
onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable")))
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
|
||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable")))
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) =
|
||||
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
|
||||
override fun cancelNfcWrite() = Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberTransferShareActions(): TransferShareActions
|
||||
|
||||
internal fun invitationFileName(transferName: String): String {
|
||||
val safe = transferName.trim()
|
||||
.map { character -> if (character.isLetterOrDigit() || character in "-_. ") character else '_' }
|
||||
.joinToString("")
|
||||
.trim('.', ' ')
|
||||
.ifBlank { "VniDrop transfer" }
|
||||
return if (safe.endsWith(".vnd", ignoreCase = true)) safe else "$safe.vnd"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FilePreviewRepositoryTest {
|
||||
@Test
|
||||
fun preview_survives_repository_recreation() = runTest {
|
||||
val store = MemoryPreviewStore()
|
||||
AppFilePreviewRepository(store).save(42UL, png(12))
|
||||
|
||||
val restored = AppFilePreviewRepository(store)
|
||||
restored.restore(setOf(42UL))
|
||||
|
||||
assertContentEquals(png(12), restored.previews.value.getValue(42UL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restore_removes_orphans_and_corrupt_entries() = runTest {
|
||||
val store = MemoryPreviewStore()
|
||||
store.writeAtomically(1UL, png(12))
|
||||
store.writeAtomically(2UL, "not an image".encodeToByteArray())
|
||||
store.writeAtomically(3UL, png(12))
|
||||
|
||||
val repository = AppFilePreviewRepository(store)
|
||||
repository.restore(setOf(1UL, 2UL))
|
||||
|
||||
assertEquals(setOf(1UL), repository.previews.value.keys)
|
||||
assertFalse(store.entries.containsKey(2UL))
|
||||
assertFalse(store.entries.containsKey(3UL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entry_and_total_limits_are_enforced() = runTest {
|
||||
val store = MemoryPreviewStore()
|
||||
val repository = AppFilePreviewRepository(store, PreviewStoragePolicy(maxEntryBytes = 20, maxTotalBytes = 32))
|
||||
|
||||
repository.save(1UL, png(17))
|
||||
store.clock += 1
|
||||
repository.save(2UL, png(17))
|
||||
store.clock += 1
|
||||
repository.save(3UL, png(21))
|
||||
|
||||
assertEquals(setOf(2UL), repository.previews.value.keys)
|
||||
assertTrue(3UL !in store.entries)
|
||||
}
|
||||
|
||||
private fun png(size: Int): ByteArray = ByteArray(size.coerceAtLeast(8)).also {
|
||||
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryPreviewStore : PlatformPreviewStore {
|
||||
data class Entry(val bytes: ByteArray, val modified: Long)
|
||||
val entries = mutableMapOf<ULong, Entry>()
|
||||
var clock = 1L
|
||||
override fun list() = entries.map { (id, entry) -> PreviewFileInfo(id, entry.bytes.size.toLong(), entry.modified) }
|
||||
override fun read(transferId: ULong) = entries[transferId]?.bytes?.copyOf()
|
||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
|
||||
if (transferId !in entries) entries[transferId] = Entry(bytes.copyOf(), clock)
|
||||
return true
|
||||
}
|
||||
override fun delete(transferId: ULong) { entries.remove(transferId) }
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.feature.send.FilePreviewRepository
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -35,6 +36,8 @@ class FakeCoreGateway : CoreGateway {
|
||||
var responseResult: Result<Unit> = Result.success(Unit)
|
||||
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
||||
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
|
||||
var deleteResult: Result<Unit> = Result.success(Unit)
|
||||
val deletedTransfers = mutableListOf<ULong>()
|
||||
var lastShareAccessPolicy: ShareAccessPolicy? = null
|
||||
|
||||
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
||||
@@ -86,6 +89,15 @@ class FakeCoreGateway : CoreGateway {
|
||||
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
||||
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
|
||||
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> {
|
||||
if (deleteResult.isSuccess) {
|
||||
deletedTransfers += transferId
|
||||
mutableState.value = mutableState.value.copy(
|
||||
transfers = mutableState.value.transfers.filterNot { it.transferId == transferId },
|
||||
)
|
||||
}
|
||||
return deleteResult
|
||||
}
|
||||
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
|
||||
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
|
||||
responses += Triple(requestId, accepted, reason)
|
||||
@@ -138,3 +150,12 @@ class FakeFileSystemService(
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
||||
}
|
||||
|
||||
class FakeFilePreviewRepository : FilePreviewRepository {
|
||||
private val state = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
|
||||
override val previews: StateFlow<Map<ULong, ByteArray>> = state
|
||||
val restored = mutableListOf<Set<ULong>>()
|
||||
override suspend fun restore(activeTransferIds: Set<ULong>) { restored += activeTransferIds }
|
||||
override suspend fun save(transferId: ULong, bytes: ByteArray) { state.value = state.value + (transferId to bytes) }
|
||||
override suspend fun remove(transferId: ULong) { state.value = state.value - transferId }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.readBytes
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileModificationDate
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.Foundation.writeToFile
|
||||
|
||||
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore =
|
||||
IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews")
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private class IosPreviewStore(private val directory: String) : PlatformPreviewStore {
|
||||
private val files = NSFileManager.defaultManager
|
||||
|
||||
override fun list(): List<PreviewFileInfo> {
|
||||
ensureDirectory()
|
||||
return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance<String>().mapNotNull { name ->
|
||||
val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null
|
||||
val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null
|
||||
val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
|
||||
val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0
|
||||
PreviewFileInfo(id, size, modified.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(transferId: ULong): ByteArray? {
|
||||
val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null
|
||||
return data.bytes?.readBytes(data.length.toInt())
|
||||
}
|
||||
|
||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
|
||||
ensureDirectory()
|
||||
if (files.fileExistsAtPath(path(transferId))) return true
|
||||
val temporary = "$directory/.$transferId.tmp"
|
||||
val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) }
|
||||
if (!data.writeToFile(temporary, atomically = true)) return false
|
||||
val moved = files.moveItemAtPath(temporary, path(transferId), null)
|
||||
if (!moved) files.removeItemAtPath(temporary, null)
|
||||
return moved
|
||||
}
|
||||
|
||||
override fun delete(transferId: ULong) {
|
||||
files.removeItemAtPath(path(transferId), null)
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
|
||||
private fun path(transferId: ULong) = "$directory/$transferId.preview"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSTemporaryDirectory
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.writeToFile
|
||||
import platform.UIKit.UIActivityViewController
|
||||
import platform.UIKit.UIApplication
|
||||
import platform.UIKit.UIDocumentPickerViewController
|
||||
import platform.UIKit.UIModalPresentationFormSheet
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
@Composable
|
||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
object : TransferShareActions {
|
||||
override val canUseNativeShare = true
|
||||
// Core NFC tag writing requires the NFC entitlement. Keep the action
|
||||
// visible but disabled until that capability is provisioned for the app.
|
||||
override val nfcAvailability = NfcShareAvailability.Unavailable
|
||||
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(runCatching {
|
||||
val url = createInvitation(ticket, transferName)
|
||||
val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true)
|
||||
present(picker)
|
||||
})
|
||||
}
|
||||
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(runCatching {
|
||||
val url = createInvitation(ticket, transferName)
|
||||
val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null)
|
||||
controller.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
presenter().presentViewController(controller, animated = true, completion = null)
|
||||
})
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(Result.failure(UnsupportedOperationException("NFC tag writing is not enabled for this build")))
|
||||
}
|
||||
override fun cancelNfcWrite() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun createInvitation(ticket: String, transferName: String): NSURL {
|
||||
val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName)
|
||||
val text = NSString.create(string = ticket)
|
||||
require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) {
|
||||
"The invitation file could not be created"
|
||||
}
|
||||
return NSURL.fileURLWithPath(path)
|
||||
}
|
||||
|
||||
private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController
|
||||
?: error("Could not find an iOS view controller")
|
||||
|
||||
private fun present(controller: platform.UIKit.UIViewController) {
|
||||
presenter().presentViewController(controller, animated = true, completion = null)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import java.io.File
|
||||
|
||||
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = JvmPreviewStore(File(appDataDir, "ui/previews"))
|
||||
|
||||
private class JvmPreviewStore(private val directory: File) : PlatformPreviewStore {
|
||||
override fun list(): List<PreviewFileInfo> = directory.listFiles().orEmpty().mapNotNull { file ->
|
||||
file.name.removeSuffix(".preview").toULongOrNull()?.let { PreviewFileInfo(it, file.length(), file.lastModified()) }
|
||||
}
|
||||
override fun read(transferId: ULong): ByteArray? = runCatching { file(transferId).takeIf(File::isFile)?.readBytes() }.getOrNull()
|
||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean = runCatching {
|
||||
directory.mkdirs()
|
||||
val target = file(transferId)
|
||||
if (target.isFile) return@runCatching true
|
||||
val temporary = File(directory, ".${target.name}.tmp")
|
||||
temporary.writeBytes(bytes)
|
||||
temporary.renameTo(target).also { if (!it) temporary.delete() }
|
||||
}.getOrDefault(false)
|
||||
override fun delete(transferId: ULong) { file(transferId).delete() }
|
||||
private fun file(transferId: ULong) = File(directory, "$transferId.preview")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import java.awt.EventQueue
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
object : TransferShareActions {
|
||||
override val canUseNativeShare = DesktopShareBridge.shareFile != null
|
||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||
|
||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
EventQueue.invokeLater {
|
||||
onResult(runCatching {
|
||||
val dialog = FileDialog(activeFrame(), "Save VniDrop invitation", FileDialog.SAVE).apply {
|
||||
file = invitationFileName(transferName)
|
||||
}
|
||||
try {
|
||||
dialog.isVisible = true
|
||||
val directory = dialog.directory
|
||||
val name = dialog.file
|
||||
if (directory != null && name != null) File(directory, name).writeText(ticket)
|
||||
} finally { dialog.dispose() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||
EventQueue.invokeLater {
|
||||
val share = DesktopShareBridge.shareFile
|
||||
if (share == null) {
|
||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||
return@invokeLater
|
||||
}
|
||||
onResult(runCatching {
|
||||
val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() }
|
||||
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
|
||||
share(file).getOrThrow()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on desktop")))
|
||||
}
|
||||
override fun cancelNfcWrite() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeFrame(): Frame? =
|
||||
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
||||
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
|
||||
|
||||
object DesktopShareBridge {
|
||||
@Volatile
|
||||
var shareFile: ((File) -> Result<Unit>)? = null
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user