mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(send): build durable transfer details and sharing flow
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.NFC"/>
|
||||||
|
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -21,6 +23,13 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths"/>
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
4
androidApp/src/main/res/xml/file_paths.xml
Normal file
4
androidApp/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="transfer_invitations" path="transfer-invitations/"/>
|
||||||
|
</paths>
|
||||||
@@ -5,7 +5,7 @@ android-minSdk = "24"
|
|||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.19.0"
|
androidx-core = "1.18.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.11.0-beta01"
|
androidx-lifecycle = "2.11.0-beta01"
|
||||||
androidx-datastore = "1.2.1"
|
androidx-datastore = "1.2.1"
|
||||||
@@ -16,6 +16,7 @@ junit = "4.13.2"
|
|||||||
kotlin = "2.4.0"
|
kotlin = "2.4.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
|
qrcode = "4.5.0"
|
||||||
jna = "5.17.0"
|
jna = "5.17.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -42,6 +43,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
|
|||||||
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
|
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
|
||||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
@@ -51,6 +52,7 @@ kotlin {
|
|||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.kotlinx.coroutinesCore)
|
implementation(libs.kotlinx.coroutinesCore)
|
||||||
|
implementation(libs.qrcode.kotlin)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
@@ -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_file_size_unknown">Size unavailable</string>
|
||||||
<string name="send_transfer_created">Transfer created.</string>
|
<string name="send_transfer_created">Transfer created.</string>
|
||||||
<string name="send_transfer_details_title">Transfer details</string>
|
<string name="send_transfer_details_title">Transfer details</string>
|
||||||
|
<string name="transfer_activity_title">Activity</string>
|
||||||
|
<string name="transfer_activity_description">See important updates for this transfer</string>
|
||||||
|
<string name="transfer_receivers_title">Receivers</string>
|
||||||
|
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
|
||||||
|
<string name="transfer_share_title">Share</string>
|
||||||
|
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
|
||||||
|
<string name="transfer_delete_title">Delete transfer?</string>
|
||||||
|
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
|
||||||
|
<string name="transfer_deleting">Deleting…</string>
|
||||||
|
<string name="transfer_deleted">Transfer deleted.</string>
|
||||||
|
<string name="transfer_no_activity">There is no activity to show yet.</string>
|
||||||
|
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
|
||||||
|
<string name="transfer_receiver_requested">Waiting for your approval</string>
|
||||||
|
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
|
||||||
|
<string name="transfer_receiver_refused">Request refused</string>
|
||||||
|
<string name="transfer_receiver_expired">Request expired</string>
|
||||||
|
<string name="transfer_receiver_completed">Received successfully</string>
|
||||||
|
<string name="transfer_receiver_unknown">Status unavailable</string>
|
||||||
|
<string name="transfer_nearby_device">Nearby device</string>
|
||||||
|
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
|
||||||
|
<string name="button_write_nfc">Write to NFC tag</string>
|
||||||
|
<string name="button_download_invitation">Save .vnd file</string>
|
||||||
|
<string name="button_native_share">Share invitation</string>
|
||||||
|
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
|
||||||
|
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
|
||||||
|
<string name="transfer_invitation_saved">Invitation saved.</string>
|
||||||
|
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
|
||||||
|
<string name="transfer_event_preparing">Preparing the selected files</string>
|
||||||
|
<string name="transfer_event_ready">Transfer ready to share</string>
|
||||||
|
<string name="transfer_event_requested">A receiver requested access</string>
|
||||||
|
<string name="transfer_event_approved">Receiver access approved</string>
|
||||||
|
<string name="transfer_event_refused">Receiver access refused</string>
|
||||||
|
<string name="transfer_event_completed">A receiver completed the transfer</string>
|
||||||
|
<string name="transfer_event_stopped">Sharing stopped</string>
|
||||||
|
<string name="transfer_event_failed">The transfer encountered a problem</string>
|
||||||
|
<string name="transfer_event_updated">Transfer updated</string>
|
||||||
<string name="button_choose_file">Choose file</string>
|
<string name="button_choose_file">Choose file</string>
|
||||||
<string name="button_change_file">Change file</string>
|
<string name="button_change_file">Change file</string>
|
||||||
<string name="button_share_file">Share file</string>
|
<string name="button_share_file">Share file</string>
|
||||||
@@ -63,6 +99,9 @@
|
|||||||
<string name="button_choose_folder">Choose folder</string>
|
<string name="button_choose_folder">Choose folder</string>
|
||||||
<string name="button_reset_default">Reset default</string>
|
<string name="button_reset_default">Reset default</string>
|
||||||
<string name="button_back">Back</string>
|
<string name="button_back">Back</string>
|
||||||
|
<string name="button_close">Close</string>
|
||||||
|
<string name="button_cancel">Cancel</string>
|
||||||
|
<string name="button_delete_transfer">Delete transfer</string>
|
||||||
<string name="folder_status_writable">Writable</string>
|
<string name="folder_status_writable">Writable</string>
|
||||||
<string name="folder_status_permission_required">Permission required</string>
|
<string name="folder_status_permission_required">Permission required</string>
|
||||||
<string name="folder_status_unavailable">Unavailable</string>
|
<string name="folder_status_unavailable">Unavailable</string>
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ fun App(dependencies: AppDependencies) {
|
|||||||
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
||||||
}
|
}
|
||||||
val sendViewModel = viewModel {
|
val sendViewModel = viewModel {
|
||||||
SendViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
|
SendViewModel(
|
||||||
|
graph.coreRepository,
|
||||||
|
dependencies.fileSystemService,
|
||||||
|
graph.preferencesRepository,
|
||||||
|
graph.filePreviewRepository,
|
||||||
|
graph.messages,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val receiveViewModel = viewModel {
|
val receiveViewModel = viewModel {
|
||||||
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
|
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.vnidrop.app
|
|||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
import com.vnidrop.app.core.CoreRepository
|
import com.vnidrop.app.core.CoreRepository
|
||||||
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||||
|
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||||
|
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||||
import com.vnidrop.app.logging.AppLogger
|
import com.vnidrop.app.logging.AppLogger
|
||||||
import com.vnidrop.app.platform.AppVisibility
|
import com.vnidrop.app.platform.AppVisibility
|
||||||
import com.vnidrop.app.preferences.AppPreferencesDefaults
|
import com.vnidrop.app.preferences.AppPreferencesDefaults
|
||||||
@@ -22,6 +24,9 @@ class AppGraph(
|
|||||||
) {
|
) {
|
||||||
val visibility = AppVisibility()
|
val visibility = AppVisibility()
|
||||||
val messages = UiMessageController()
|
val messages = UiMessageController()
|
||||||
|
val filePreviewRepository = AppFilePreviewRepository(
|
||||||
|
createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir),
|
||||||
|
)
|
||||||
val preferencesRepository = AppPreferencesRepository(
|
val preferencesRepository = AppPreferencesRepository(
|
||||||
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
|
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
|
||||||
defaults = AppPreferencesDefaults(
|
defaults = AppPreferencesDefaults(
|
||||||
|
|||||||
@@ -89,12 +89,22 @@ data class ReceiverRequestModel(
|
|||||||
val receiverName: String?,
|
val receiverName: String?,
|
||||||
val receiverDeviceName: String?,
|
val receiverDeviceName: String?,
|
||||||
val appVersion: String,
|
val appVersion: String,
|
||||||
val status: String,
|
val status: ReceiverDeliveryStatus,
|
||||||
val reason: String?,
|
val reason: String?,
|
||||||
val requestedAt: Long,
|
val requestedAt: Long,
|
||||||
val respondedAt: Long?,
|
val respondedAt: Long?,
|
||||||
|
val completedAt: Long?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ReceiverDeliveryStatus {
|
||||||
|
Requested,
|
||||||
|
Accepted,
|
||||||
|
Refused,
|
||||||
|
Expired,
|
||||||
|
Completed,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
data class CoreState(
|
data class CoreState(
|
||||||
val isInitialized: Boolean = false,
|
val isInitialized: Boolean = false,
|
||||||
val status: CoreStatus? = null,
|
val status: CoreStatus? = null,
|
||||||
@@ -106,6 +116,7 @@ data class CoreState(
|
|||||||
|
|
||||||
sealed interface CoreSignal {
|
sealed interface CoreSignal {
|
||||||
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
||||||
|
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoreGateway {
|
interface CoreGateway {
|
||||||
@@ -134,6 +145,7 @@ interface CoreGateway {
|
|||||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||||
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
||||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||||
|
suspend fun delete(transferId: ULong): Result<Unit>
|
||||||
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
|
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
|
||||||
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
|
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
|
||||||
suspend fun refresh(): Result<Unit>
|
suspend fun refresh(): Result<Unit>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class CoreRepository(
|
|||||||
if (model.phase == "approval" && model.transferId != null) {
|
if (model.phase == "approval" && model.transferId != null) {
|
||||||
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
|
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
|
||||||
}
|
}
|
||||||
|
if (model.phase == "delivery" && model.transferId != null) {
|
||||||
|
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,13 @@ class CoreRepository(
|
|||||||
refreshSnapshot()
|
refreshSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
|
||||||
|
requireCore().deleteTransfer(transferId)
|
||||||
|
refreshSnapshot()
|
||||||
|
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||||
|
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
|
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
|
||||||
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||||
}
|
}
|
||||||
@@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
|
|||||||
receiverName = receiverName,
|
receiverName = receiverName,
|
||||||
receiverDeviceName = receiverDeviceName,
|
receiverDeviceName = receiverDeviceName,
|
||||||
appVersion = appVersion,
|
appVersion = appVersion,
|
||||||
status = status,
|
status = when (status) {
|
||||||
|
"requested" -> ReceiverDeliveryStatus.Requested
|
||||||
|
"accepted" -> ReceiverDeliveryStatus.Accepted
|
||||||
|
"refused" -> ReceiverDeliveryStatus.Refused
|
||||||
|
"expired" -> ReceiverDeliveryStatus.Expired
|
||||||
|
"completed" -> ReceiverDeliveryStatus.Completed
|
||||||
|
else -> ReceiverDeliveryStatus.Unknown
|
||||||
|
},
|
||||||
reason = reason,
|
reason = reason,
|
||||||
requestedAt = requestedAt,
|
requestedAt = requestedAt,
|
||||||
respondedAt = respondedAt,
|
respondedAt = respondedAt,
|
||||||
|
completedAt = completedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.vnidrop.app.core.CoreSignal
|
|||||||
import com.vnidrop.app.core.TransferDirection
|
import com.vnidrop.app.core.TransferDirection
|
||||||
import com.vnidrop.app.core.TransferStatus
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.core.ReceiverRequestModel
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
import com.vnidrop.app.notifications.LocalNotification
|
import com.vnidrop.app.notifications.LocalNotification
|
||||||
import com.vnidrop.app.notifications.LocalNotificationService
|
import com.vnidrop.app.notifications.LocalNotificationService
|
||||||
import com.vnidrop.app.notifications.NotificationPermission
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
@@ -55,6 +56,7 @@ class ApprovalCoordinator(
|
|||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
when (signal) {
|
when (signal) {
|
||||||
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
|
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
|
||||||
|
is CoreSignal.ReceiverHistoryChanged -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ class ApprovalCoordinator(
|
|||||||
private suspend fun refresh(transferId: ULong) {
|
private suspend fun refresh(transferId: ULong) {
|
||||||
repository.receiverRequests(transferId).fold(
|
repository.receiverRequests(transferId).fold(
|
||||||
onSuccess = { requests ->
|
onSuccess = { requests ->
|
||||||
val refreshed = requests.filter { it.status == "requested" }.map(ReceiverRequestModel::toPending)
|
val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending)
|
||||||
val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
|
val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
|
||||||
removed.forEach { id ->
|
removed.forEach { id ->
|
||||||
notifications.cancel(notificationId(id))
|
notifications.cancel(notificationId(id))
|
||||||
|
|||||||
@@ -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))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
|
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
|
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
|
||||||
color = colors.foregroundLighter,
|
color = colors.foregroundLighter,
|
||||||
@@ -185,7 +190,6 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ internal object SendIcons {
|
|||||||
lineTo(5f, 12f)
|
lineTo(5f, 12f)
|
||||||
lineTo(12f, 5f)
|
lineTo(12f, 5f)
|
||||||
}
|
}
|
||||||
|
val Delete = lineIcon("Delete") {
|
||||||
|
moveTo(4f, 7f); lineTo(20f, 7f)
|
||||||
|
moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f)
|
||||||
|
moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f)
|
||||||
|
moveTo(10f, 11f); lineTo(10f, 17f)
|
||||||
|
moveTo(14f, 11f); lineTo(14f, 17f)
|
||||||
|
}
|
||||||
val ChevronRight = lineIcon("ChevronRight") {
|
val ChevronRight = lineIcon("ChevronRight") {
|
||||||
moveTo(9f, 18f)
|
moveTo(9f, 18f)
|
||||||
lineTo(15f, 12f)
|
lineTo(15f, 12f)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ fun SendRoute(
|
|||||||
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
|
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
|
||||||
|
val shareActions = rememberTransferShareActions()
|
||||||
|
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.effectFlow.collect { effect ->
|
viewModel.effectFlow.collect { effect ->
|
||||||
@@ -32,6 +33,7 @@ fun SendRoute(
|
|||||||
coreState = coreState,
|
coreState = coreState,
|
||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
|
shareActions = shareActions,
|
||||||
onOpenComposer = viewModel::openComposer,
|
onOpenComposer = viewModel::openComposer,
|
||||||
onDismissComposer = viewModel::dismissComposer,
|
onDismissComposer = viewModel::dismissComposer,
|
||||||
onSelectFile = viewModel::selectFile,
|
onSelectFile = viewModel::selectFile,
|
||||||
@@ -43,5 +45,13 @@ fun SendRoute(
|
|||||||
onTransferSelected = viewModel::openTransfer,
|
onTransferSelected = viewModel::openTransfer,
|
||||||
onCloseTransferDetails = viewModel::closeTransferDetails,
|
onCloseTransferDetails = viewModel::closeTransferDetails,
|
||||||
onCopyTicket = viewModel::copyTicket,
|
onCopyTicket = viewModel::copyTicket,
|
||||||
|
onActivity = viewModel::openActivity,
|
||||||
|
onReceivers = viewModel::openReceivers,
|
||||||
|
onShare = viewModel::openShare,
|
||||||
|
onCloseDetailPanel = viewModel::closeDetailPanel,
|
||||||
|
onInvitationResult = viewModel::onInvitationResult,
|
||||||
|
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||||
|
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||||
|
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package com.vnidrop.app.feature.send
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import com.vnidrop.app.core.CoreState
|
import com.vnidrop.app.core.CoreState
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.TransferDirection
|
import com.vnidrop.app.core.TransferDirection
|
||||||
@@ -15,6 +19,7 @@ fun SendScreen(
|
|||||||
coreState: CoreState,
|
coreState: CoreState,
|
||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
|
shareActions: TransferShareActions = UnavailableTransferShareActions,
|
||||||
onOpenComposer: () -> Unit,
|
onOpenComposer: () -> Unit,
|
||||||
onDismissComposer: () -> Unit,
|
onDismissComposer: () -> Unit,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
@@ -26,13 +31,34 @@ fun SendScreen(
|
|||||||
onTransferSelected: (ULong) -> Unit,
|
onTransferSelected: (ULong) -> Unit,
|
||||||
onCloseTransferDetails: () -> Unit,
|
onCloseTransferDetails: () -> Unit,
|
||||||
onCopyTicket: (String) -> Unit,
|
onCopyTicket: (String) -> Unit,
|
||||||
|
onActivity: () -> Unit = {},
|
||||||
|
onReceivers: () -> Unit = {},
|
||||||
|
onShare: () -> Unit = {},
|
||||||
|
onCloseDetailPanel: () -> Unit = {},
|
||||||
|
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
|
||||||
|
onRequestDelete: () -> Unit = {},
|
||||||
|
onDismissDelete: () -> Unit = {},
|
||||||
|
onConfirmDelete: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||||
|
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
|
||||||
|
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
|
||||||
|
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (selectedTransfer != null) {
|
if (selectedTransfer != null) {
|
||||||
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
|
TransferDetails(
|
||||||
|
transfer = selectedTransfer,
|
||||||
|
events = coreState.events,
|
||||||
|
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
|
||||||
|
onBack = onCloseTransferDetails,
|
||||||
|
onActivity = onActivity,
|
||||||
|
onReceivers = onReceivers,
|
||||||
|
onShare = onShare,
|
||||||
|
onDelete = onRequestDelete,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
TransferCatalog(
|
TransferCatalog(
|
||||||
transfers = outgoingTransfers,
|
transfers = outgoingTransfers,
|
||||||
@@ -59,4 +85,31 @@ fun SendScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedTransfer != null && state.detailPanel != null) {
|
||||||
|
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
||||||
|
when (state.detailPanel) {
|
||||||
|
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
||||||
|
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
|
||||||
|
TransferDetailPanel.Share -> TransferSharePanel(
|
||||||
|
selectedTransfer,
|
||||||
|
shareActions,
|
||||||
|
qrBitmap = selectedTransfer.ticket?.let(qrCache::get),
|
||||||
|
onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap },
|
||||||
|
onResult = onInvitationResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
|
||||||
|
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
|
||||||
|
DeleteTransferPanel(
|
||||||
|
transferName = selectedTransfer.transferName,
|
||||||
|
isDeleting = state.isDeleting,
|
||||||
|
onCancel = onDismissDelete,
|
||||||
|
onConfirm = onConfirmDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.vnidrop.app.feature.send
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
|
import com.vnidrop.app.core.CoreSignal
|
||||||
import com.vnidrop.app.core.FileSystemService
|
import com.vnidrop.app.core.FileSystemService
|
||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessage
|
import com.vnidrop.app.ui.feedback.UiMessage
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
@@ -17,9 +19,13 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.send_transfer_created
|
import vnidrop.shared.generated.resources.send_transfer_created
|
||||||
|
import vnidrop.shared.generated.resources.transfer_nfc_written
|
||||||
|
import vnidrop.shared.generated.resources.transfer_deleted
|
||||||
|
|
||||||
data class SendState(
|
data class SendState(
|
||||||
val isComposerOpen: Boolean = false,
|
val isComposerOpen: Boolean = false,
|
||||||
@@ -30,11 +36,18 @@ data class SendState(
|
|||||||
val isSharing: Boolean = false,
|
val isSharing: Boolean = false,
|
||||||
val selectedTransferId: ULong? = null,
|
val selectedTransferId: ULong? = null,
|
||||||
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||||
|
val detailPanel: TransferDetailPanel? = null,
|
||||||
|
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
|
||||||
|
val isLoadingReceivers: Boolean = false,
|
||||||
|
val isDeleteConfirmationOpen: Boolean = false,
|
||||||
|
val isDeleting: Boolean = false,
|
||||||
) {
|
) {
|
||||||
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||||
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class TransferDetailPanel { Activity, Receivers, Share }
|
||||||
|
|
||||||
sealed interface SendEffect {
|
sealed interface SendEffect {
|
||||||
data object OpenFilePicker : SendEffect
|
data object OpenFilePicker : SendEffect
|
||||||
data class CopyTicket(val ticket: String) : SendEffect
|
data class CopyTicket(val ticket: String) : SendEffect
|
||||||
@@ -44,6 +57,7 @@ class SendViewModel(
|
|||||||
private val repository: CoreGateway,
|
private val repository: CoreGateway,
|
||||||
private val fileSystemService: FileSystemService,
|
private val fileSystemService: FileSystemService,
|
||||||
preferencesRepository: PreferencesRepository,
|
preferencesRepository: PreferencesRepository,
|
||||||
|
private val filePreviewRepository: FilePreviewRepository,
|
||||||
private val messages: UiMessageController,
|
private val messages: UiMessageController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(SendState())
|
private val _state = MutableStateFlow(SendState())
|
||||||
@@ -54,6 +68,29 @@ class SendViewModel(
|
|||||||
val effectFlow = effects.receiveAsFlow()
|
val effectFlow = effects.receiveAsFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.signals.collect { signal ->
|
||||||
|
val transferId = when (signal) {
|
||||||
|
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
|
||||||
|
is CoreSignal.ApprovalChanged -> signal.transferId
|
||||||
|
}
|
||||||
|
if (transferId == _state.value.selectedTransferId &&
|
||||||
|
_state.value.detailPanel == TransferDetailPanel.Receivers
|
||||||
|
) refreshReceivers(transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
filePreviewRepository.previews.collect { previews ->
|
||||||
|
_state.update { it.copy(transferThumbnails = previews) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
coreState.map { core ->
|
||||||
|
core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet()
|
||||||
|
}.distinctUntilChanged().collect { activeIds ->
|
||||||
|
if (activeIds != null) filePreviewRepository.restore(activeIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.preferences.collect { preferences ->
|
preferencesRepository.preferences.collect { preferences ->
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
@@ -108,9 +145,70 @@ class SendViewModel(
|
|||||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||||
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
||||||
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
|
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
|
||||||
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
|
fun openTransfer(transferId: ULong) {
|
||||||
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
|
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
|
||||||
|
refreshReceivers(transferId)
|
||||||
|
}
|
||||||
|
fun closeTransferDetails() = _state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedTransferId = null,
|
||||||
|
detailPanel = null,
|
||||||
|
receiverHistory = emptyList(),
|
||||||
|
isDeleteConfirmationOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
|
||||||
|
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||||
|
fun openReceivers() {
|
||||||
|
val transferId = _state.value.selectedTransferId ?: return
|
||||||
|
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
|
||||||
|
refreshReceivers(transferId)
|
||||||
|
}
|
||||||
|
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
|
||||||
|
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
|
||||||
|
fun dismissDeleteTransfer() {
|
||||||
|
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
|
||||||
|
}
|
||||||
|
fun confirmDeleteTransfer() {
|
||||||
|
val transferId = _state.value.selectedTransferId ?: return
|
||||||
|
if (_state.value.isDeleting) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isDeleting = true) }
|
||||||
|
repository.delete(transferId).fold(
|
||||||
|
onSuccess = {
|
||||||
|
filePreviewRepository.remove(transferId)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedTransferId = null,
|
||||||
|
detailPanel = null,
|
||||||
|
receiverHistory = emptyList(),
|
||||||
|
isDeleteConfirmationOpen = false,
|
||||||
|
isDeleting = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success))
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_state.update { it.copy(isDeleting = false) }
|
||||||
|
messages.error(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
||||||
|
fun onInvitationResult(action: InvitationAction, result: Result<Unit>) {
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
val message = when (action) {
|
||||||
|
InvitationAction.Export -> null
|
||||||
|
InvitationAction.Nfc -> Res.string.transfer_nfc_written
|
||||||
|
InvitationAction.Share -> null
|
||||||
|
}
|
||||||
|
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
|
||||||
|
},
|
||||||
|
onFailure = messages::error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun createShare() {
|
fun createShare() {
|
||||||
val current = state.value
|
val current = state.value
|
||||||
@@ -127,6 +225,7 @@ class SendViewModel(
|
|||||||
)
|
)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { share ->
|
onSuccess = { share ->
|
||||||
|
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isComposerOpen = false,
|
isComposerOpen = false,
|
||||||
@@ -134,9 +233,6 @@ class SendViewModel(
|
|||||||
transferName = "",
|
transferName = "",
|
||||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
isSharing = false,
|
isSharing = false,
|
||||||
transferThumbnails = file.thumbnailBytes?.let { bytes ->
|
|
||||||
it.transferThumbnails + (share.transferId to bytes)
|
|
||||||
} ?: it.transferThumbnails,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||||
@@ -152,4 +248,17 @@ class SendViewModel(
|
|||||||
private fun sendEffect(effect: SendEffect) {
|
private fun sendEffect(effect: SendEffect) {
|
||||||
viewModelScope.launch { effects.send(effect) }
|
viewModelScope.launch { effects.send(effect) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshReceivers(transferId: ULong) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoadingReceivers = true) }
|
||||||
|
repository.receiverRequests(transferId).fold(
|
||||||
|
onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } },
|
||||||
|
onFailure = { error ->
|
||||||
|
_state.update { it.copy(isLoadingReceivers = false) }
|
||||||
|
messages.error(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import vnidrop.shared.generated.resources.send_access_title
|
|||||||
import vnidrop.shared.generated.resources.send_choose_file_body
|
import vnidrop.shared.generated.resources.send_choose_file_body
|
||||||
import vnidrop.shared.generated.resources.send_choose_file_title
|
import vnidrop.shared.generated.resources.send_choose_file_title
|
||||||
import vnidrop.shared.generated.resources.send_file_size_unknown
|
import vnidrop.shared.generated.resources.send_file_size_unknown
|
||||||
import vnidrop.shared.generated.resources.send_new_transfer_title
|
|
||||||
import vnidrop.shared.generated.resources.send_review_title
|
import vnidrop.shared.generated.resources.send_review_title
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -73,7 +72,6 @@ internal fun TransferComposer(
|
|||||||
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(Res.string.send_new_transfer_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
|
||||||
val file = state.selectedFile
|
val file = state.selectedFile
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
ChooseFileStep(onSelectFile)
|
ChooseFileStep(onSelectFile)
|
||||||
|
|||||||
@@ -1,45 +1,79 @@
|
|||||||
package com.vnidrop.app.feature.send
|
package com.vnidrop.app.feature.send
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.core.CoreEventModel
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.ui.components.AppCard
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
|
import com.vnidrop.app.ui.components.DestructiveButton
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
import com.vnidrop.app.ui.screens.TicketText
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import vnidrop.shared.generated.resources.Res
|
import qrcode.QRCode
|
||||||
import vnidrop.shared.generated.resources.button_back
|
import kotlinx.coroutines.Dispatchers
|
||||||
import vnidrop.shared.generated.resources.button_copy_ticket
|
import kotlinx.coroutines.withContext
|
||||||
import vnidrop.shared.generated.resources.metadata_files
|
import vnidrop.shared.generated.resources.*
|
||||||
import vnidrop.shared.generated.resources.metadata_size
|
|
||||||
import vnidrop.shared.generated.resources.metadata_status
|
enum class InvitationAction { Export, Share, Nfc }
|
||||||
import vnidrop.shared.generated.resources.send_access_anyone
|
|
||||||
import vnidrop.shared.generated.resources.send_access_approval
|
|
||||||
import vnidrop.shared.generated.resources.send_access_title
|
|
||||||
import vnidrop.shared.generated.resources.send_new_transfer_title
|
|
||||||
import vnidrop.shared.generated.resources.send_transfer_details_title
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) {
|
internal fun TransferDetails(
|
||||||
|
transfer: Transfer,
|
||||||
|
events: List<CoreEventModel>,
|
||||||
|
completedReceivers: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onActivity: () -> Unit,
|
||||||
|
onReceivers: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
@@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
|
||||||
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.send_transfer_details_title),
|
stringResource(Res.string.send_transfer_details_title),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
|
|||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
|
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
|
||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString())
|
|
||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
|
||||||
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
|
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transfer.ticket?.let { ticket ->
|
|
||||||
item {
|
item {
|
||||||
AppCard(title = stringResource(Res.string.button_copy_ticket)) {
|
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
||||||
TicketText(ticket)
|
Column {
|
||||||
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) })
|
DetailDestination(
|
||||||
|
title = stringResource(Res.string.transfer_activity_title),
|
||||||
|
description = stringResource(Res.string.transfer_activity_description),
|
||||||
|
count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() },
|
||||||
|
onClick = onActivity,
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
DetailDestination(
|
||||||
|
title = stringResource(Res.string.transfer_receivers_title),
|
||||||
|
description = stringResource(Res.string.transfer_receivers_description),
|
||||||
|
count = completedReceivers,
|
||||||
|
onClick = onReceivers,
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
DetailDestination(
|
||||||
|
title = stringResource(Res.string.transfer_share_title),
|
||||||
|
description = stringResource(Res.string.transfer_share_description),
|
||||||
|
onClick = onShare,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ReceiverHistoryPanel(receivers: List<ReceiverRequestModel>, loading: Boolean) {
|
||||||
|
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
|
||||||
|
when {
|
||||||
|
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
|
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
|
||||||
|
else -> receivers.forEachIndexed { index, receiver ->
|
||||||
|
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
ReceiverRow(receiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReceiverRow(receiver: ReceiverRequestModel) {
|
||||||
|
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
receiver.receiverDeviceName?.takeIf { it != name }?.let {
|
||||||
|
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TransferActivityPanel(events: List<CoreEventModel>, transferId: ULong) {
|
||||||
|
val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp)
|
||||||
|
PanelContainer(stringResource(Res.string.transfer_activity_title)) {
|
||||||
|
if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter)
|
||||||
|
else visible.forEachIndexed { index, event ->
|
||||||
|
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TransferSharePanel(
|
||||||
|
transfer: Transfer,
|
||||||
|
actions: TransferShareActions,
|
||||||
|
qrBitmap: androidx.compose.ui.graphics.ImageBitmap?,
|
||||||
|
onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit,
|
||||||
|
onResult: (InvitationAction, Result<Unit>) -> Unit,
|
||||||
|
) {
|
||||||
|
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
|
||||||
|
val ticket = transfer.ticket
|
||||||
|
PanelContainer(stringResource(Res.string.transfer_share_title)) {
|
||||||
|
if (ticket == null) {
|
||||||
|
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||||
|
return@PanelContainer
|
||||||
|
}
|
||||||
|
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
|
||||||
|
if (value == null) {
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
value?.let { onQrRendered(ticket, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val renderedQr = renderedBitmap
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = Color.White,
|
||||||
|
) {
|
||||||
|
if (renderedQr != null) {
|
||||||
|
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.transfer_scan_qr),
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
|
||||||
|
var writingNfc by remember(ticket) { mutableStateOf(false) }
|
||||||
|
SecondaryButton(
|
||||||
|
if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc),
|
||||||
|
onClick = {
|
||||||
|
writingNfc = true
|
||||||
|
actions.writeInvitationToNfc(ticket) {
|
||||||
|
writingNfc = false
|
||||||
|
onResult(InvitationAction.Nfc, it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc,
|
||||||
|
)
|
||||||
|
if (actions.nfcAvailability == NfcShareAvailability.Unavailable) {
|
||||||
|
Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecondaryButton(
|
||||||
|
stringResource(Res.string.button_download_invitation),
|
||||||
|
onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
PrimaryButton(
|
||||||
|
stringResource(Res.string.button_native_share),
|
||||||
|
onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = actions.canUseNativeShare,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DeleteTransferPanel(
|
||||||
|
transferName: String?,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
) {
|
||||||
|
PanelContainer(stringResource(Res.string.transfer_delete_title)) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)),
|
||||||
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
|
||||||
|
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
|
||||||
|
DestructiveButton(
|
||||||
|
if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer),
|
||||||
|
onClick = onConfirm,
|
||||||
|
enabled = !isDeleting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) {
|
||||||
|
ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested
|
||||||
|
ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted
|
||||||
|
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
|
||||||
|
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
|
||||||
|
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
|
||||||
|
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
|
||||||
|
})
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
|
||||||
|
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
|
||||||
|
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
|
||||||
|
else -> LocalVniDropColors.current.foregroundLighter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoreEventModel.isMeaningfulActivity() =
|
||||||
|
(phase == "import" && kind == "started") ||
|
||||||
|
(phase == "ticket" && kind == "created") ||
|
||||||
|
kind in setOf(
|
||||||
|
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
||||||
|
"receiver-refused", "receiver-completed", "share-stopped", "failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun eventTitle(event: CoreEventModel) = stringResource(when {
|
||||||
|
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
|
||||||
|
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
|
||||||
|
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
|
||||||
|
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
|
||||||
|
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
|
||||||
|
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
|
||||||
|
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
|
||||||
|
event.kind == "failed" -> Res.string.transfer_event_failed
|
||||||
|
else -> Res.string.transfer_event_updated
|
||||||
|
})
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailValue(label: String, value: String) {
|
private fun DetailValue(label: String, value: String) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
|
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.button_close
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -31,9 +42,7 @@ fun AdaptiveDrawer(
|
|||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
containerColor = LocalVniDropColors.current.backgroundDialog,
|
containerColor = LocalVniDropColors.current.backgroundDialog,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
|
ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content)
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -45,9 +54,31 @@ fun AdaptiveDrawer(
|
|||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
color = LocalVniDropColors.current.backgroundDialog,
|
color = LocalVniDropColors.current.backgroundDialog,
|
||||||
shadowElevation = 12.dp,
|
shadowElevation = 12.dp,
|
||||||
|
) { ClosableModalContent(onDismissRequest, content = content) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClosableModalContent(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
Box(modifier) {
|
||||||
content()
|
content()
|
||||||
|
IconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply {
|
||||||
|
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) {
|
||||||
|
moveTo(6f, 6f); lineTo(18f, 18f)
|
||||||
|
moveTo(18f, 6f); lineTo(6f, 18f)
|
||||||
}
|
}
|
||||||
|
}.build()
|
||||||
|
|||||||
@@ -40,3 +40,19 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier
|
|||||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.heightIn(min = 44.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = LocalVniDropColors.current.destructiveDefault,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = Pi
|
|||||||
PillTone.Warning -> colors.warningDefault
|
PillTone.Warning -> colors.warningDefault
|
||||||
PillTone.Destructive -> colors.destructiveDefault
|
PillTone.Destructive -> colors.destructiveDefault
|
||||||
}
|
}
|
||||||
|
val shape = RoundedCornerShape(7.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f))
|
modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f))
|
||||||
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp),
|
.border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(7.dp).clip(CircleShape).background(color))
|
Box(Modifier.size(7.dp).clip(CircleShape).background(color))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.vnidrop.app.notifications.NotificationPermission
|
|||||||
import com.vnidrop.app.preferences.AppPreferences
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
import com.vnidrop.app.support.FakeCoreGateway
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
import com.vnidrop.app.support.FakeFileSystemService
|
import com.vnidrop.app.support.FakeFileSystemService
|
||||||
|
import com.vnidrop.app.support.FakeFilePreviewRepository
|
||||||
import com.vnidrop.app.support.FakeNotificationService
|
import com.vnidrop.app.support.FakeNotificationService
|
||||||
import com.vnidrop.app.support.FakePreferencesRepository
|
import com.vnidrop.app.support.FakePreferencesRepository
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
@@ -30,6 +31,7 @@ import kotlinx.coroutines.test.setMain
|
|||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ class ViewModelsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun sendViewModelOwnsSelectedFileState() = runTest {
|
fun sendViewModelOwnsSelectedFileState() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
viewModel.openComposer()
|
viewModel.openComposer()
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
||||||
assertEquals("photo.jpg", viewModel.state.value.transferName)
|
assertEquals("photo.jpg", viewModel.state.value.transferName)
|
||||||
@@ -157,10 +159,14 @@ class ViewModelsTest {
|
|||||||
mutableState.value = CoreState(isInitialized = true)
|
mutableState.value = CoreState(isInitialized = true)
|
||||||
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
|
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
|
||||||
}
|
}
|
||||||
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
val previews = FakeFilePreviewRepository()
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.openComposer()
|
viewModel.openComposer()
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
val thumbnail = ByteArray(12).also {
|
||||||
|
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
|
||||||
|
}
|
||||||
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail))
|
||||||
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
|
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
|
||||||
viewModel.createShare()
|
viewModel.createShare()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -169,13 +175,14 @@ class ViewModelsTest {
|
|||||||
assertEquals(null, viewModel.state.value.selectedFile)
|
assertEquals(null, viewModel.state.value.selectedFile)
|
||||||
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
|
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
|
||||||
assertEquals(7UL, core.state.value.transfers.first().transferId)
|
assertEquals(7UL, core.state.value.transfers.first().transferId)
|
||||||
|
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
|
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
|
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
|
||||||
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.openComposer()
|
viewModel.openComposer()
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
||||||
@@ -187,6 +194,36 @@ class ViewModelsTest {
|
|||||||
assertFalse(viewModel.state.value.isSharing)
|
assertFalse(viewModel.state.value.isSharing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = CoreState(isInitialized = true, transfers = listOf(
|
||||||
|
com.vnidrop.app.core.Transfer(
|
||||||
|
localId = "send-7", transferId = 7UL,
|
||||||
|
direction = com.vnidrop.app.core.TransferDirection.Send,
|
||||||
|
status = com.vnidrop.app.core.TransferStatus.Sharing,
|
||||||
|
peerId = null, transferName = "Photo", contentHash = "hash",
|
||||||
|
fileCount = 1UL, totalSize = 42UL, ticket = "ticket",
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval, createdAt = 1, updatedAt = 1,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
val previews = FakeFilePreviewRepository()
|
||||||
|
previews.save(7UL, byteArrayOf(1, 2, 3))
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.openTransfer(7UL)
|
||||||
|
viewModel.requestDeleteTransfer()
|
||||||
|
viewModel.confirmDeleteTransfer()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf(7UL), core.deletedTransfers)
|
||||||
|
assertFalse(7UL in previews.previews.value)
|
||||||
|
assertEquals(null, viewModel.state.value.selectedTransferId)
|
||||||
|
assertFalse(viewModel.state.value.isDeleteConfirmationOpen)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun receiveViewModelBuildsStateFromPreferences() = runTest {
|
fun receiveViewModelBuildsStateFromPreferences() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.approvals
|
|||||||
import com.vnidrop.app.core.CoreSignal
|
import com.vnidrop.app.core.CoreSignal
|
||||||
import com.vnidrop.app.core.CoreState
|
import com.vnidrop.app.core.CoreState
|
||||||
import com.vnidrop.app.core.ReceiverRequestModel
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
import com.vnidrop.app.core.ReceiveFolderKind
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
@@ -88,10 +89,11 @@ class ApprovalCoordinatorTest {
|
|||||||
receiverName = "Peer",
|
receiverName = "Peer",
|
||||||
receiverDeviceName = "Phone",
|
receiverDeviceName = "Phone",
|
||||||
appVersion = "1.0",
|
appVersion = "1.0",
|
||||||
status = "requested",
|
status = ReceiverDeliveryStatus.Requested,
|
||||||
reason = null,
|
reason = null,
|
||||||
requestedAt = requestedAt,
|
requestedAt = requestedAt,
|
||||||
respondedAt = null,
|
respondedAt = null,
|
||||||
|
completedAt = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun activeTransfer() = Transfer(
|
private fun activeTransfer() = Transfer(
|
||||||
|
|||||||
@@ -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.notifications.NotificationPermission
|
||||||
import com.vnidrop.app.preferences.AppPreferences
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
|
import com.vnidrop.app.feature.send.FilePreviewRepository
|
||||||
import com.vnidrop.app.ui.theme.ThemeMode
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -35,6 +36,8 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
var responseResult: Result<Unit> = Result.success(Unit)
|
var responseResult: Result<Unit> = Result.success(Unit)
|
||||||
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
||||||
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
|
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
|
||||||
|
var deleteResult: Result<Unit> = Result.success(Unit)
|
||||||
|
val deletedTransfers = mutableListOf<ULong>()
|
||||||
var lastShareAccessPolicy: ShareAccessPolicy? = null
|
var lastShareAccessPolicy: ShareAccessPolicy? = null
|
||||||
|
|
||||||
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
||||||
@@ -86,6 +89,15 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
||||||
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
|
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
|
||||||
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
|
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
|
||||||
|
override suspend fun delete(transferId: ULong): Result<Unit> {
|
||||||
|
if (deleteResult.isSuccess) {
|
||||||
|
deletedTransfers += transferId
|
||||||
|
mutableState.value = mutableState.value.copy(
|
||||||
|
transfers = mutableState.value.transfers.filterNot { it.transferId == transferId },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return deleteResult
|
||||||
|
}
|
||||||
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
|
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
|
||||||
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
|
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
|
||||||
responses += Triple(requestId, accepted, reason)
|
responses += Triple(requestId, accepted, reason)
|
||||||
@@ -138,3 +150,12 @@ class FakeFileSystemService(
|
|||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeFilePreviewRepository : FilePreviewRepository {
|
||||||
|
private val state = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
|
||||||
|
override val previews: StateFlow<Map<ULong, ByteArray>> = state
|
||||||
|
val restored = mutableListOf<Set<ULong>>()
|
||||||
|
override suspend fun restore(activeTransferIds: Set<ULong>) { restored += activeTransferIds }
|
||||||
|
override suspend fun save(transferId: ULong, bytes: ByteArray) { state.value = state.value + (transferId to bytes) }
|
||||||
|
override suspend fun remove(transferId: ULong) { state.value = state.value - transferId }
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import androidx.compose.runtime.remember
|
|||||||
import com.vnidrop.app.core.rememberFileSystemService
|
import com.vnidrop.app.core.rememberFileSystemService
|
||||||
import com.vnidrop.app.notifications.IosLocalNotificationService
|
import com.vnidrop.app.notifications.IosLocalNotificationService
|
||||||
import platform.Foundation.NSBundle
|
import platform.Foundation.NSBundle
|
||||||
import platform.Foundation.NSTemporaryDirectory
|
import platform.Foundation.NSApplicationSupportDirectory
|
||||||
|
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
import platform.UIKit.UIDevice
|
import platform.UIKit.UIDevice
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -17,7 +19,7 @@ fun rememberIosAppDependencies(): AppDependencies {
|
|||||||
environment = PlatformEnvironment(
|
environment = PlatformEnvironment(
|
||||||
name = device.systemName() + " " + device.systemVersion,
|
name = device.systemName() + " " + device.systemVersion,
|
||||||
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
|
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
|
||||||
defaultCoreDataDir = NSTemporaryDirectory() + "vnidrop",
|
defaultCoreDataDir = iosApplicationDataDirectory(),
|
||||||
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
|
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
|
||||||
),
|
),
|
||||||
deviceInfoProvider = IosDeviceInfoProvider(device),
|
deviceInfoProvider = IosDeviceInfoProvider(device),
|
||||||
@@ -27,6 +29,11 @@ fun rememberIosAppDependencies(): AppDependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun iosApplicationDataDirectory(): String =
|
||||||
|
(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String)
|
||||||
|
?.trimEnd('/')?.plus("/VniDrop")
|
||||||
|
?: error("iOS Application Support directory is unavailable")
|
||||||
|
|
||||||
private class IosDeviceInfoProvider(
|
private class IosDeviceInfoProvider(
|
||||||
private val device: UIDevice,
|
private val device: UIDevice,
|
||||||
) : DeviceInfoProvider {
|
) : DeviceInfoProvider {
|
||||||
|
|||||||
@@ -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.assertCountEquals
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
@@ -281,10 +283,39 @@ class FoundationComposeTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot()
|
||||||
|
val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot()
|
||||||
|
assertTrue(statusBounds.left - titleBounds.right <= 12.dp)
|
||||||
onNodeWithText("Photos").performClick()
|
onNodeWithText("Photos").performClick()
|
||||||
runOnIdle { assertEquals(9UL, selectedId) }
|
runOnIdle { assertEquals(9UL, selectedId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transferDetailsRevealSharingOnlyAfterSelection() = runComposeUiTest {
|
||||||
|
val state = mutableStateOf(SendState(selectedTransferId = 9UL))
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
SendScreen(
|
||||||
|
coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())),
|
||||||
|
state = state.value,
|
||||||
|
windowClass = WindowClass.Desktop,
|
||||||
|
onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {},
|
||||||
|
onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {},
|
||||||
|
onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {},
|
||||||
|
onShare = { state.value = state.value.copy(detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Share").assertIsDisplayed()
|
||||||
|
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0)
|
||||||
|
onNode(hasText("Share") and hasClickAction()).performClick()
|
||||||
|
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
|
||||||
|
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed()
|
||||||
|
onNodeWithText("Save .vnd file").assertIsDisplayed()
|
||||||
|
onNodeWithContentDescription("Close").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
|
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
|
||||||
val controller = UiMessageController()
|
val controller = UiMessageController()
|
||||||
|
|||||||
Reference in New Issue
Block a user