diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt
index 8cc39ca..b7b8b1f 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt
@@ -55,8 +55,9 @@ private class AndroidLocalNotificationService(
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
+ // Shown under system notification settings for this app.
manager.createNotificationChannel(
- NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH),
+ NotificationChannel(ChannelId, "Transfer requests", NotificationManager.IMPORTANCE_HIGH),
)
}
}
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 707d235..325aef4 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -3,9 +3,9 @@
Receive
Settings
Send
- Files you have made available from this device.
- Share your first file
- Choose a file, decide who can receive it, then share it directly from this device.
+ Transfers you’re sharing from this device.
+ Nothing shared yet
+ Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file.
New transfer
Your transfers
New transfer
@@ -21,8 +21,8 @@
Ask before each download
You approve or refuse every new receiver.
Anyone with this transfer
- No approval is required. Only use this for files you are comfortable sharing.
- Anyone who has the ticket can download until you stop the share. Do not use this for private or sensitive files.
+ No approval is required. Only use this for items you are comfortable sharing.
+ Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.
Size unavailable
Transfer created.
Transfer details
@@ -53,8 +53,8 @@
Hold your device near a writable NFC tag.
Invitation saved.
Invitation written to the NFC tag.
- Preparing the selected files
- Transfer ready to share
+ Preparing your transfer
+ Ready to share
A receiver requested access
Receiver access approved
Receiver access refused
@@ -62,34 +62,21 @@
Sharing stopped
The transfer encountered a problem
Transfer updated
- Choose file
- Change file
- Share files
+ Start sharing
Preparing transfer…
- Copy transfer link
Create a new transfer
- Source
- Select a file to start a share. The app keeps bytes in Rust and platform file handles.
- Select file
Clear
Transfer details
Transfer name
Sender name
- Create share
- Creating share...
- Share details
- Receiver requests
- Copy
- Use locally
- Refresh
Refuse
Approve
Receive
- Files received directly on this device.
- Receive your first file
- Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.
- Receive files
- Received files
+ Transfers you’ve received on this device.
+ Nothing received yet
+ Open a VniDrop invitation, scan a QR code, or hold near an NFC tag.
+ Start receiving
+ History
Clear history
Delete from receive history
Remove from history?
@@ -109,54 +96,66 @@
Review transfer
VniDrop transfer
Transfer received.
- Inspect a ticket, request access, and stream files into the output directory.
- Ticket
- Ticket
- Output directory
Receiver name
- Inspect ticket
Receive
- Receiving...
Retry
- Stop sharing
- Cancel receive
- Receiving files
- Preparing transfer
+ Cancel
+ Receiving
+ Preparing
+ Ready
+ Ready to share
+ Connecting
+ Connected
+ Requesting access
+ Getting ready
+ Downloading
+ Saving
+ Sending
+ Completed
+ Cancelled
+ Failed
+ Transfer interrupted
+ Working…
+ Preparing
+ Available
+ Receiving
+ Completed
+ Cancelled
+ Stopped
+ Failed
%1$d waiting
%1$d completed
- Sending
- Downloading files
- Saving files
+ Downloading
+ Saving
Connecting to sender
- Ticket details
+ %1$d file
+ %1$d files
Settings
- Configure the local node and app appearance.
- Node
+ Your name, where transfers are saved, appearance, and notifications.
Appearance
Preferences
- Username
- Receive folder
+ Display name
+ Save received transfers to
Choose folder
- Reset default
+ Use default
Back
Close
Cancel
Delete transfer
- Writable
- Permission required
+ Ready
+ Permission needed
Unavailable
- Checking folder...
- Display mode
+ Checking folder…
System
Dark mode
Light mode
- VniDrop follows the theme selected on this device.
+ Match this device’s light or dark appearance.
About
Privacy policy
Report a bug
Share diagnostics
- Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Tickets, file paths, and transfer contents are never included.
+ Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.
Diagnostics sharing is on.
Diagnostics sharing is off.
Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).
@@ -183,36 +182,35 @@
Not available
Notifications
Allow notifications
- Let VniDrop notify you about new connection requests while the app is running in the background.
+ Get notified about new receive requests while VniDrop is in the background.
Notifications are turned off for VniDrop. You can enable them in Settings.
Notifications are not available on this device.
Notifications enabled.
Could not open notification settings.
Open Settings
Dismiss
- On
- Off
- Connection request
- Endpoint ID: %1$s
- %1$d requests are waiting
- Ready
- Event log
- No events have been emitted yet.
- Progress
- Not initialized
- Unknown
- Name
- Source
- Transfer
+ Receive request
+ %1$s wants to receive “%2$s”.
+ A nearby device
+ Device ID: %1$s
+ %1$d requests waiting
Size
- Kind
- Sender
Files
- Hash
- Platform
Status
- The ticket could not be read. Check that the full ticket was copied.
- The transfer is waiting for approval or was refused by the sender.
+ Something went wrong. Try again.
+ VniDrop is still starting. Open the invitation again in a moment.
+ This invitation could not be read. Ask the sender for a new one.
+ That invitation is empty. Try opening it again.
+ The sender has not approved this transfer, or it was refused.
+ VniDrop could not access the selected files or folder. Check permissions and try again.
+ The transfer could not be completed. Check your connection and try again.
+ VniDrop could not save transfer data on this device.
+ VniDrop could not finish starting up. Close the app and try again.
VniDrop could not open its network sockets on this device.
The native VniDrop library is missing from this build.
+ Could not open the selected item. Try choosing it again.
+ Could not load device information.
+ This NFC tag could not be used. Try another tag.
+ Camera access is required to scan a QR code.
+ Select at least one item to share.
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
index 1bc72cf..5c6547b 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt
@@ -109,7 +109,7 @@ fun App(
if (ready == null) {
receiveViewModel.onInvitationResult(
ReceiveMethod.InvitationFile,
- Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")),
+ Result.failure(IllegalStateException("VniDrop is still starting up")),
)
return@collect
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
index 82832f4..22b9aa5 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
@@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.getString
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.approval_connection_request
+import vnidrop.shared.generated.resources.approval_nearby_device
+import vnidrop.shared.generated.resources.approval_request_body
data class PendingApproval(
val id: String,
@@ -137,12 +142,14 @@ class ApprovalCoordinator(
return
}
context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request ->
- val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
+ val receiver = request.receiverName
+ ?: request.receiverDeviceName
+ ?: getString(Res.string.approval_nearby_device)
notifications.publish(
LocalNotification(
id = notificationId(request.id),
- title = "Connection request",
- body = "$receiver wants to receive ${request.transferName}",
+ title = getString(Res.string.approval_connection_request),
+ body = getString(Res.string.approval_request_body, receiver, request.transferName),
),
).onSuccess {
publishedNotificationIds += request.id
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt
index bb3ecc8..4d097f1 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt
@@ -32,7 +32,9 @@ import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_connection_request
import vnidrop.shared.generated.resources.approval_endpoint_id
+import vnidrop.shared.generated.resources.approval_nearby_device
import vnidrop.shared.generated.resources.approval_pending_count
+import vnidrop.shared.generated.resources.approval_request_body
import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_refuse
@@ -44,7 +46,9 @@ fun ApprovalModalHost(
) {
val request = state.current ?: return
val busy = request.id in state.respondingIds
- val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
+ val receiver = request.receiverName
+ ?: request.receiverDeviceName
+ ?: stringResource(Res.string.approval_nearby_device)
val colors = LocalVniDropColors.current
Dialog(
onDismissRequest = {},
@@ -75,7 +79,7 @@ fun ApprovalModalHost(
fontWeight = FontWeight.Bold,
)
Text(
- "$receiver wants to receive ${request.transferName}.",
+ stringResource(Res.string.approval_request_body, receiver, request.transferName),
style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight,
)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
index e3d772d..c99987e 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
@@ -53,6 +53,7 @@ import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton
+import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
@@ -270,7 +271,7 @@ private fun InvitationReviewPanel(
?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId
val progress = progressId?.let { progressForTransfer(events, it) }
ProgressRow(
- label = progress?.label ?: stringResource(Res.string.progress_receiving),
+ label = progress?.label ?: Res.string.progress_receiving,
progress = progress?.progress,
detail = progress?.detail,
)
@@ -288,7 +289,14 @@ private fun InvitationReviewPanel(
)
}
state.lastReceiveError?.let { error ->
- Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall)
+ Text(
+ when (error) {
+ is UiText.Dynamic -> error.value
+ is UiText.Resource -> stringResource(error.resource)
+ },
+ color = LocalVniDropColors.current.destructiveDefault,
+ style = MaterialTheme.typography.bodySmall,
+ )
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
index c693195..006026a 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
@@ -16,6 +16,8 @@ import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
+import com.vnidrop.app.ui.feedback.isUserCancellation
+import com.vnidrop.app.ui.feedback.toUiText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry
+import vnidrop.shared.generated.resources.error_invitation_empty
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted
@@ -43,7 +46,7 @@ data class ReceiveState(
val isInspecting: Boolean = false,
val isReceiving: Boolean = false,
val activeReceiveTransferId: ULong? = null,
- val lastReceiveError: String? = null,
+ val lastReceiveError: UiText? = null,
val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false,
@@ -170,17 +173,27 @@ class ReceiveViewModel(
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
},
onFailure = { error ->
- val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong."
+ if (error.isUserCancellation()) {
+ _state.update {
+ it.copy(
+ isReceiving = false,
+ activeReceiveTransferId = null,
+ lastReceiveError = null,
+ )
+ }
+ return@fold
+ }
+ val uiText = error.toUiText()
_state.update {
it.copy(
isReceiving = false,
activeReceiveTransferId = null,
- lastReceiveError = message,
+ lastReceiveError = uiText,
)
}
messages.tryShow(
UiMessage(
- text = UiText.Dynamic(message),
+ text = uiText,
tone = UiMessageTone.Error,
actionLabel = UiText.Resource(Res.string.button_retry),
onAction = { receive() },
@@ -215,7 +228,7 @@ class ReceiveViewModel(
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim()
- if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
+ if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty))
viewModelScope.launch {
_state.update {
it.copy(
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
index 2918f59..63f1a09 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted
+import vnidrop.shared.generated.resources.transfer_invitation_saved
import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState(
@@ -149,7 +150,7 @@ class SendViewModel(
}
}
- fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
+ fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed"))
fun clearSelectedSource() {
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
@@ -227,8 +228,9 @@ class SendViewModel(
result.fold(
onSuccess = {
val message = when (action) {
- InvitationAction.Export -> null
+ InvitationAction.Export -> Res.string.transfer_invitation_saved
InvitationAction.Nfc -> Res.string.transfer_nfc_written
+ // System share sheet already confirms the action on most platforms.
InvitationAction.Share -> null
}
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
index 1cbaeee..0d772b5 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
@@ -198,7 +198,7 @@ private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferPr
}
if (showLiveSend) {
ProgressRow(
- label = stringResource(Res.string.transfer_receiver_sending),
+ label = sendProgress.label,
progress = sendProgress.progress,
detail = sendProgress.detail,
)
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt
index 9349518..8144651 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -12,6 +14,7 @@ import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.appearance_auto_description
import vnidrop.shared.generated.resources.appearance_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_system_mode
@@ -39,6 +42,13 @@ internal fun AppearanceSettings(
onModeChanged(ThemeMode.Light)
}
}
+ if (mode == ThemeMode.System) {
+ Text(
+ stringResource(Res.string.appearance_auto_description),
+ color = LocalVniDropColors.current.foregroundLighter,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
index 583b7e8..258a4d9 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
@@ -9,6 +9,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
+import com.vnidrop.app.ui.theme.LocalVniDropColors
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode
@@ -17,6 +18,7 @@ import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title
+import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.settings_title
@Composable
@@ -26,11 +28,18 @@ internal fun SettingsOverview(
largeTitle: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Text(
- stringResource(Res.string.settings_title),
- style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- )
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ stringResource(Res.string.settings_title),
+ style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ stringResource(Res.string.settings_subtitle),
+ color = LocalVniDropColors.current.foregroundLighter,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Device,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
index c9ad186..8de22bd 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
@@ -319,7 +319,13 @@ class SettingsViewModel(
} catch (error: Throwable) {
if (error is CancellationException) throw error
_state.update { it.copy(isLoadingDeviceInfo = false) }
- messages.error(error, "Could not load device information.")
+ messages.error(
+ if (error.message.isNullOrBlank()) {
+ IllegalStateException("Could not load device information.")
+ } else {
+ error
+ },
+ )
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
index 98b8196..4638d5c 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
@@ -13,14 +13,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
@Composable
fun ProgressRow(
- label: String,
+ label: StringResource,
progress: Float?,
modifier: Modifier = Modifier,
detail: String? = null,
) {
+ val labelText = stringResource(label)
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
Modifier.fillMaxWidth(),
@@ -28,7 +31,7 @@ fun ProgressRow(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- label,
+ labelText,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -55,15 +58,3 @@ fun ProgressRow(
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}
}
-
-@Composable
-fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.Top,
- ) {
- Text(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
- Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall)
- }
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt
index 902f384..b77dfa8 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt
@@ -1,5 +1,6 @@
package com.vnidrop.app.ui.feedback
+import com.vnidrop.app.logging.AppLogger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,12 +44,29 @@ class UiMessageController {
_dismissals.tryEmit(Unit)
}
- fun error(error: Throwable, fallback: String = "Something went wrong.") {
+ /**
+ * Surfaces a user-facing error snackbar. Logs the full technical error.
+ * User cancellations are logged and suppressed.
+ */
+ fun error(error: Throwable) {
+ if (error.isUserCancellation()) {
+ AppLogger.info(
+ "ui",
+ "suppressed user cancellation",
+ mapOf("detail" to error.technicalDetail().ifBlank { error::class.simpleName.orEmpty() }),
+ )
+ return
+ }
+ AppLogger.error("ui", "user-facing error", error)
tryShow(
UiMessage(
- text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback),
+ text = error.toUiText(),
tone = UiMessageTone.Error,
),
)
}
+
+ fun error(text: UiText) {
+ tryShow(UiMessage(text = text, tone = UiMessageTone.Error))
+ }
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt
new file mode 100644
index 0000000..8a2cf98
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt
@@ -0,0 +1,122 @@
+package com.vnidrop.app.ui.feedback
+
+import uniffi.vnidrop.VnidropException
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.error_device_info
+import vnidrop.shared.generated.resources.error_filesystem
+import vnidrop.shared.generated.resources.error_generic
+import vnidrop.shared.generated.resources.error_initialization
+import vnidrop.shared.generated.resources.error_invalid_ticket
+import vnidrop.shared.generated.resources.error_invitation_empty
+import vnidrop.shared.generated.resources.error_missing_native_library
+import vnidrop.shared.generated.resources.error_permission
+import vnidrop.shared.generated.resources.error_repository
+import vnidrop.shared.generated.resources.error_selection_failed
+import vnidrop.shared.generated.resources.error_socket_bind
+import vnidrop.shared.generated.resources.error_camera
+import vnidrop.shared.generated.resources.error_nfc
+import vnidrop.shared.generated.resources.error_share_empty
+import vnidrop.shared.generated.resources.error_starting_up
+import vnidrop.shared.generated.resources.error_transfer
+
+/**
+ * Maps technical failures to stable, user-facing copy.
+ *
+ * Never expose raw exception messages (especially UniFFI `reason=…` blobs) in snackbars.
+ */
+fun Throwable.toUiText(): UiText =
+ when (this) {
+ is VnidropException.Ticket -> UiText.Resource(Res.string.error_invalid_ticket)
+ is VnidropException.Permission -> UiText.Resource(Res.string.error_permission)
+ is VnidropException.Filesystem -> UiText.Resource(Res.string.error_filesystem)
+ is VnidropException.Transfer -> transferUiText(reason)
+ is VnidropException.Repository -> UiText.Resource(Res.string.error_repository)
+ is VnidropException.Initialization -> initializationUiText(reason)
+ is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic)
+ else -> reasonHints(technicalDetail()) ?: UiText.Resource(Res.string.error_generic)
+ }
+
+/** User intentionally backed out of a flow — do not treat as a failure snackbar. */
+fun Throwable.isUserCancellation(): Boolean {
+ val haystack = technicalDetail().lowercase()
+ if (haystack.isBlank()) return false
+ return haystack.contains("cancelled") ||
+ haystack.contains("canceled") ||
+ haystack.contains("user cancelled") ||
+ haystack.contains("user canceled")
+}
+
+/** Prefer [VnidropException.reason] when present; else [Throwable.message]. */
+fun Throwable.technicalDetail(): String =
+ when (this) {
+ is VnidropException.Initialization -> reason
+ is VnidropException.Ticket -> reason
+ is VnidropException.Filesystem -> reason
+ is VnidropException.Transfer -> reason
+ is VnidropException.Permission -> reason
+ is VnidropException.Repository -> reason
+ is VnidropException.Internal -> reason
+ else -> message.orEmpty()
+ }
+
+private fun transferUiText(reason: String): UiText {
+ val detail = reason.lowercase()
+ return when {
+ detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") ->
+ UiText.Resource(Res.string.error_permission)
+ else -> UiText.Resource(Res.string.error_transfer)
+ }
+}
+
+private fun initializationUiText(reason: String): UiText {
+ val detail = reason.lowercase()
+ return when {
+ detail.contains("native") && detail.contains("library") ->
+ UiText.Resource(Res.string.error_missing_native_library)
+ detail.contains("socket") || detail.contains("bind") ->
+ UiText.Resource(Res.string.error_socket_bind)
+ else -> UiText.Resource(Res.string.error_initialization)
+ }
+}
+
+private fun reasonHints(detailRaw: String): UiText? {
+ val detail = detailRaw.lowercase()
+ if (detail.isBlank()) return null
+
+ return when {
+ detail.contains("still starting") || detail.contains("starting up") ->
+ UiText.Resource(Res.string.error_starting_up)
+ detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) ->
+ UiText.Resource(Res.string.error_invitation_empty)
+ detail.contains("select at least one") || detail.contains("no files found") ->
+ UiText.Resource(Res.string.error_share_empty)
+ detail.contains("camera") ->
+ UiText.Resource(Res.string.error_camera)
+ detail.contains("nfc") || detail.contains("ndef") ||
+ (detail.contains("read-only") && detail.contains("tag")) ||
+ detail.contains("tag is too small") || detail.contains("no nfc tag") ->
+ UiText.Resource(Res.string.error_nfc)
+ detail.contains("native") && detail.contains("library") ->
+ UiText.Resource(Res.string.error_missing_native_library)
+ detail.contains("socket") || detail.contains("bind") ->
+ UiText.Resource(Res.string.error_socket_bind)
+ detail.contains("device information") || detail.contains("device info") ->
+ UiText.Resource(Res.string.error_device_info)
+ detail.contains("refused") || detail.contains("denied") || detail.contains("permission") ||
+ detail.contains("not approved") || detail.contains("waiting for approval") ->
+ UiText.Resource(Res.string.error_permission)
+ detail.contains("invalid ticket") || detail.contains("ticket error") ||
+ detail.contains("could not be read") || detail.contains("malformed") ||
+ detail.contains("invitation could not be opened") ->
+ UiText.Resource(Res.string.error_invalid_ticket)
+ detail.contains("selected") &&
+ (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) ->
+ UiText.Resource(Res.string.error_selection_failed)
+ detail.contains("could not open the selected") || detail.contains("could not open selected") ->
+ UiText.Resource(Res.string.error_selection_failed)
+ detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor") ||
+ detail.contains("view controller") ->
+ UiText.Resource(Res.string.error_selection_failed)
+ else -> null
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt
deleted file mode 100644
index 92db048..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.vnidrop.app.ui.screens
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import com.vnidrop.app.core.CoreState
-import com.vnidrop.app.core.TicketInspectionModel
-import com.vnidrop.app.ui.components.AppCard
-import com.vnidrop.app.ui.components.MetadataRow
-import com.vnidrop.app.ui.components.ProgressRow
-import com.vnidrop.app.ui.state.formatBytes
-import com.vnidrop.app.ui.state.summarizeProgress
-import com.vnidrop.app.ui.theme.LocalVniDropColors
-import org.jetbrains.compose.resources.stringResource
-import vnidrop.shared.generated.resources.Res
-import vnidrop.shared.generated.resources.metadata_files
-import vnidrop.shared.generated.resources.metadata_hash
-import vnidrop.shared.generated.resources.metadata_kind
-import vnidrop.shared.generated.resources.metadata_sender
-import vnidrop.shared.generated.resources.metadata_size
-import vnidrop.shared.generated.resources.metadata_transfer
-import vnidrop.shared.generated.resources.progress_title
-import vnidrop.shared.generated.resources.ticket_details_title
-import vnidrop.shared.generated.resources.unknown_sender
-
-@Composable
-fun ScreenHeader(title: String, subtitle: String) {
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
- Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
- Text(subtitle, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
- }
-}
-
-@Composable
-fun EmptyText(text: String) {
- Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
-}
-
-@Composable
-fun ProgressSection(coreState: CoreState) {
- val progress = summarizeProgress(coreState.events)
- if (progress.isNotEmpty()) {
- AppCard(title = stringResource(Res.string.progress_title)) {
- progress.forEach { item -> ProgressRow(label = item.label, progress = item.progress) }
- }
- }
-}
-
-@Composable
-fun TicketInspectionCard(inspection: TicketInspectionModel) {
- val metadata = inspection.metadata
- AppCard(title = stringResource(Res.string.ticket_details_title)) {
- MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
- MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName)
- MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender))
- MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString())
- MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize))
- MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash)
- }
-}
-
-@Composable
-fun TicketText(ticket: String) {
- SelectionContainer {
- Text(
- text = ticket,
- modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
- .background(LocalVniDropColors.current.backgroundSurface200).padding(12.dp),
- style = MaterialTheme.typography.bodySmall,
- )
- }
-}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
index b068ea0..3adbf74 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
@@ -1,9 +1,37 @@
package com.vnidrop.app.ui.state
+import androidx.compose.runtime.Composable
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import kotlin.math.roundToInt
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.progress_cancelled
+import vnidrop.shared.generated.resources.progress_completed
+import vnidrop.shared.generated.resources.progress_connected
+import vnidrop.shared.generated.resources.progress_connecting
+import vnidrop.shared.generated.resources.progress_downloading
+import vnidrop.shared.generated.resources.progress_failed
+import vnidrop.shared.generated.resources.progress_getting_ready
+import vnidrop.shared.generated.resources.progress_interrupted
+import vnidrop.shared.generated.resources.progress_preparing
+import vnidrop.shared.generated.resources.progress_ready
+import vnidrop.shared.generated.resources.progress_requesting_access
+import vnidrop.shared.generated.resources.progress_saving
+import vnidrop.shared.generated.resources.progress_sending
+import vnidrop.shared.generated.resources.progress_share_ready
+import vnidrop.shared.generated.resources.progress_working
+import vnidrop.shared.generated.resources.status_available
+import vnidrop.shared.generated.resources.status_cancelled
+import vnidrop.shared.generated.resources.status_completed
+import vnidrop.shared.generated.resources.status_failed
+import vnidrop.shared.generated.resources.status_preparing
+import vnidrop.shared.generated.resources.status_receiving
+import vnidrop.shared.generated.resources.status_stopped
+import vnidrop.shared.generated.resources.transfer_file_count_one
+import vnidrop.shared.generated.resources.transfer_file_count_other
enum class WindowClass {
Phone,
@@ -25,22 +53,26 @@ data class TransferProgress(
val transferId: ULong?,
val phase: String,
val kind: String,
- val label: String,
+ val label: StringResource,
val progress: Float?,
val detail: String? = null,
)
-fun displayNameForStatus(status: TransferStatus): String =
+fun statusLabelResource(status: TransferStatus): StringResource =
when (status) {
- TransferStatus.Importing -> "Preparing"
- TransferStatus.Sharing -> "Available"
- TransferStatus.Receiving -> "Receiving"
- TransferStatus.Done -> "Completed"
- TransferStatus.Cancelled -> "Cancelled"
- TransferStatus.Stopped -> "Stopped"
- TransferStatus.Failed -> "Failed"
+ TransferStatus.Importing -> Res.string.status_preparing
+ TransferStatus.Sharing -> Res.string.status_available
+ TransferStatus.Receiving -> Res.string.status_receiving
+ TransferStatus.Done -> Res.string.status_completed
+ TransferStatus.Cancelled -> Res.string.status_cancelled
+ TransferStatus.Stopped -> Res.string.status_stopped
+ TransferStatus.Failed -> Res.string.status_failed
}
+@Composable
+fun displayNameForStatus(status: TransferStatus): String =
+ stringResource(statusLabelResource(status))
+
fun Transfer.isActiveTransfer(): Boolean =
status in activeTransferStatuses
@@ -101,7 +133,7 @@ fun progressForReceiver(
transferId = transferId,
phase = "transfer",
kind = "aborted",
- label = "Send interrupted",
+ label = Res.string.progress_interrupted,
progress = null,
detail = null,
)
@@ -111,7 +143,7 @@ fun progressForReceiver(
transferId = transferId,
phase = "transfer",
kind = "completed",
- label = "Send completed",
+ label = Res.string.progress_completed,
progress = 1f,
detail = null,
)
@@ -122,7 +154,7 @@ fun progressForReceiver(
transferId = transferId,
phase = "transfer",
kind = latest.kind,
- label = "Sending",
+ label = Res.string.progress_sending,
progress = progress,
detail = progressDetail(latest),
)
@@ -156,7 +188,7 @@ fun activeSendProgress(
transferId = transferId,
phase = "transfer",
kind = relevant.first().kind,
- label = "Sending to receiver",
+ label = Res.string.progress_sending,
progress = aggregateReceiverProgress(relevant, totalSizeHint),
detail = progressDetail(relevant.first()),
)
@@ -174,14 +206,9 @@ fun summarizeProgress(events: List): List =
.take(6)
.mapNotNull { progressForTransfer(events, it) }
-fun transferSubtitle(transfer: Transfer): String {
- val pieces = listOfNotNull(
- transfer.transferName,
- "${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
- formatBytes(transfer.totalSize),
- )
- return pieces.joinToString(" | ")
-}
+/** File-count string resource for transfer subtitles (resolve with [stringResource]). */
+fun transferFileCountResource(fileCount: ULong): StringResource =
+ if (fileCount == 1UL) Res.string.transfer_file_count_one else Res.string.transfer_file_count_other
fun formatBytes(size: ULong): String {
val value = size.toDouble()
@@ -217,29 +244,26 @@ private val activeTransferStatuses = setOf(
TransferStatus.Receiving,
)
-private fun humanProgressLabel(event: CoreEventModel): String = when {
- event.phase == "import" && event.kind == "copy-progress" -> "Preparing files"
- event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files"
- event.phase == "import" && event.kind == "started" -> "Preparing transfer"
- event.phase == "import" && event.kind == "done" -> "Files ready"
- event.phase == "ticket" && event.kind == "created" -> "Share ready"
- event.phase == "network" && event.kind == "connecting" -> "Connecting to sender"
- event.phase == "network" && event.kind == "connected" -> "Connected"
- event.phase == "handshake" -> "Requesting access"
- event.phase == "download" && event.kind == "found-collection" -> "Found files"
- event.phase == "download" && event.kind == "progress" -> "Downloading"
- event.phase == "export" && event.kind == "progress" -> "Saving files"
- event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver"
- event.phase == "transfer" && event.kind == "started" -> "Receiver connected"
- event.phase == "transfer" && event.kind == "completed" -> "Send completed"
- event.phase == "lifecycle" && event.kind == "done" -> "Completed"
- event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled"
- event.kind == "failed" -> "Failed"
- else -> listOfNotNull(
- event.direction?.replaceFirstChar { it.uppercase() },
- event.phase.replaceFirstChar { it.uppercase() },
- event.kind.replace('-', ' '),
- ).joinToString(" · ")
+private fun humanProgressLabel(event: CoreEventModel): StringResource = when {
+ event.phase == "import" && event.kind == "copy-progress" -> Res.string.progress_preparing
+ event.phase == "import" && event.kind == "outboard-progress" -> Res.string.progress_preparing
+ event.phase == "import" && event.kind == "started" -> Res.string.progress_preparing
+ event.phase == "import" && event.kind == "done" -> Res.string.progress_ready
+ event.phase == "ticket" && event.kind == "created" -> Res.string.progress_share_ready
+ event.phase == "network" && event.kind == "connecting" -> Res.string.progress_connecting
+ event.phase == "network" && event.kind == "connected" -> Res.string.progress_connected
+ event.phase == "handshake" -> Res.string.progress_requesting_access
+ event.phase == "download" && event.kind == "found-collection" -> Res.string.progress_getting_ready
+ event.phase == "download" && event.kind == "progress" -> Res.string.progress_downloading
+ event.phase == "export" && event.kind == "progress" -> Res.string.progress_saving
+ event.phase == "transfer" && event.kind == "progress" -> Res.string.progress_sending
+ event.phase == "transfer" && event.kind == "started" -> Res.string.progress_connected
+ event.phase == "transfer" && event.kind == "completed" -> Res.string.progress_completed
+ event.phase == "lifecycle" && event.kind == "done" -> Res.string.progress_completed
+ event.phase == "lifecycle" && event.kind == "cancelled" -> Res.string.progress_cancelled
+ event.kind == "failed" -> Res.string.progress_failed
+ // Never dump raw phase/kind tokens into the UI.
+ else -> Res.string.progress_working
}
private fun progressDetail(event: CoreEventModel): String? {
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
index 9ca228c..6db3377 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
@@ -30,6 +30,7 @@ import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakeNotificationService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
+import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers
@@ -45,6 +46,8 @@ import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.error_permission
@OptIn(ExperimentalCoroutinesApi::class)
class ViewModelsTest {
@@ -431,7 +434,10 @@ class ViewModelsTest {
assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null)
- assertEquals("sender refused", viewModel.state.value.lastReceiveError)
+ assertEquals(
+ UiText.Resource(Res.string.error_permission),
+ viewModel.state.value.lastReceiveError,
+ )
}
@Test
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt
new file mode 100644
index 0000000..59e96f3
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt
@@ -0,0 +1,124 @@
+package com.vnidrop.app.ui.feedback
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import uniffi.vnidrop.VnidropException
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.error_filesystem
+import vnidrop.shared.generated.resources.error_generic
+import vnidrop.shared.generated.resources.error_initialization
+import vnidrop.shared.generated.resources.error_invalid_ticket
+import vnidrop.shared.generated.resources.error_invitation_empty
+import vnidrop.shared.generated.resources.error_missing_native_library
+import vnidrop.shared.generated.resources.error_permission
+import vnidrop.shared.generated.resources.error_repository
+import vnidrop.shared.generated.resources.error_selection_failed
+import vnidrop.shared.generated.resources.error_socket_bind
+import vnidrop.shared.generated.resources.error_camera
+import vnidrop.shared.generated.resources.error_nfc
+import vnidrop.shared.generated.resources.error_share_empty
+import vnidrop.shared.generated.resources.error_starting_up
+import vnidrop.shared.generated.resources.error_transfer
+
+class UserFacingErrorTest {
+ @Test
+ fun mapsVnidropExceptionVariantsToStableCopy() {
+ assertEquals(
+ UiText.Resource(Res.string.error_invalid_ticket),
+ VnidropException.Ticket("decode failed: bad base32").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_permission),
+ VnidropException.Permission("receiver refused").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_filesystem),
+ VnidropException.Filesystem("permission denied opening path").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_transfer),
+ VnidropException.Transfer("connection reset").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_repository),
+ VnidropException.Repository("database locked").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_initialization),
+ VnidropException.Initialization("endpoint bootstrap failed").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_generic),
+ VnidropException.Internal("unexpected").toUiText(),
+ )
+ }
+
+ @Test
+ fun neverSurfacesRawReasonPrefixFromUniffi() {
+ val text = VnidropException.Transfer("blob fetch failed").toUiText()
+ assertTrue(text is UiText.Resource)
+ assertFalse(text.toString().contains("reason="))
+ }
+
+ @Test
+ fun mapsCommonPlatformReasons() {
+ assertEquals(
+ UiText.Resource(Res.string.error_permission),
+ IllegalStateException("sender refused").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_invalid_ticket),
+ IllegalArgumentException("invalid ticket").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_invitation_empty),
+ IllegalArgumentException("The invitation is empty").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_selection_failed),
+ IllegalStateException("Could not open the selected files").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_socket_bind),
+ IllegalStateException("socket bind failed on port").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_missing_native_library),
+ IllegalStateException("native library missing from this build").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_starting_up),
+ IllegalStateException("VniDrop is still starting up").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_nfc),
+ IllegalStateException("This NFC tag is read-only").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_camera),
+ IllegalStateException("Camera access is required to scan QR codes").toUiText(),
+ )
+ assertEquals(
+ UiText.Resource(Res.string.error_share_empty),
+ IllegalStateException("Select at least one file to share").toUiText(),
+ )
+ }
+
+ @Test
+ fun unknownErrorsUseGenericCopy() {
+ assertEquals(
+ UiText.Resource(Res.string.error_generic),
+ RuntimeException("opaque backend code 0xdead").toUiText(),
+ )
+ }
+
+ @Test
+ fun detectsUserCancellations() {
+ assertTrue(IllegalStateException("QR scanning was cancelled").isUserCancellation())
+ assertTrue(IllegalStateException("NFC writing was cancelled").isUserCancellation())
+ assertTrue(VnidropException.Transfer("transfer cancelled by user").isUserCancellation())
+ assertFalse(IllegalStateException("sender refused").isUserCancellation())
+ }
+}
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
index 705056a..2a4ffcb 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
@@ -14,6 +14,14 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
+import vnidrop.shared.generated.resources.Res
+import vnidrop.shared.generated.resources.progress_completed
+import vnidrop.shared.generated.resources.progress_interrupted
+import vnidrop.shared.generated.resources.progress_saving
+import vnidrop.shared.generated.resources.progress_sending
+import vnidrop.shared.generated.resources.progress_working
+import vnidrop.shared.generated.resources.transfer_file_count_one
+import vnidrop.shared.generated.resources.transfer_file_count_other
class AppUiModelsTest {
@Test
@@ -102,7 +110,7 @@ class AppUiModelsTest {
)
val progress = progressForTransfer(events, 7UL)
assertEquals(0.75f, progress?.progress)
- assertEquals("Saving files", progress?.label)
+ assertEquals(Res.string.progress_saving, progress?.label)
assertEquals("a.bin", progress?.detail)
}
@@ -150,7 +158,7 @@ class AppUiModelsTest {
// blob1 complete 50 + blob2 40 = 90 / total hint 100
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
assertEquals(0.9f, progress?.progress)
- assertEquals("Sending", progress?.label)
+ assertEquals(Res.string.progress_sending, progress?.label)
assertEquals(null, progressForReceiver(events, 7UL, "missing"))
}
@@ -206,7 +214,7 @@ class AppUiModelsTest {
)
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL)
assertEquals(0.3f, progress?.progress)
- assertEquals("Sending", progress?.label)
+ assertEquals(Res.string.progress_sending, progress?.label)
}
@Test
@@ -216,6 +224,55 @@ class AppUiModelsTest {
assertFalse(storedTransfer(status = TransferStatus.Done).canCancelTransfer())
}
+ @Test
+ fun unknownProgressEventsUseGenericWorkingLabel() {
+ // Tracked phase/kind that has no dedicated product copy must not dump tokens.
+ val progress = progressForTransfer(
+ listOf(event(id = "x", phase = "lifecycle", kind = "progress", data = "{}")),
+ 7UL,
+ )
+ assertEquals(Res.string.progress_working, progress?.label)
+ }
+
+ @Test
+ fun transferFileCountPicksSingularAndPluralResources() {
+ assertEquals(Res.string.transfer_file_count_one, transferFileCountResource(1UL))
+ assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(2UL))
+ assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(0UL))
+ }
+
+ @Test
+ fun progressForReceiverInterruptedUsesInterruptedLabel() {
+ val events = listOf(
+ event(
+ id = "aborted",
+ phase = "transfer",
+ kind = "aborted",
+ data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""",
+ direction = "send",
+ ),
+ )
+ val progress = progressForReceiver(events, 7UL, "peer-a")
+ assertEquals(Res.string.progress_interrupted, progress?.label)
+ assertEquals(null, progress?.progress)
+ }
+
+ @Test
+ fun progressForReceiverCompletedWithoutProgressUsesCompletedLabel() {
+ val events = listOf(
+ event(
+ id = "done",
+ phase = "transfer",
+ kind = "completed",
+ data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""",
+ direction = "send",
+ ),
+ )
+ val progress = progressForReceiver(events, 7UL, "peer-a")
+ assertEquals(Res.string.progress_completed, progress?.label)
+ assertEquals(1f, progress?.progress)
+ }
+
private fun storedTransfer(status: TransferStatus): Transfer =
Transfer(
localId = "local-1",
diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
index 0f2e2e0..e13b944 100644
--- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
+++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
@@ -97,7 +97,7 @@ class FoundationComposeTest {
}
}
onNodeWithText("Notifications").performClick()
- onNodeWithText("Let VniDrop notify you about new connection requests while the app is running in the background.").assertIsDisplayed()
+ onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed()
}
@Test
@@ -375,8 +375,8 @@ class FoundationComposeTest {
}
}
- onNodeWithText("Receive your first file").assertIsDisplayed()
- onNodeWithText("Receive files").performClick()
+ onNodeWithText("Nothing received yet").assertIsDisplayed()
+ onNodeWithText("Start receiving").performClick()
onNodeWithText("How would you like to connect?").assertIsDisplayed()
onNodeWithText("Open a .vnd invitation").assertIsDisplayed()
}