feat(shared): align non-Apple UX with Apple

This commit is contained in:
2026-07-24 19:58:33 +02:00
parent b66cb8c1f1
commit c81cb7c8b6
34 changed files with 902 additions and 91 deletions

View File

@@ -105,6 +105,30 @@ private class AndroidFileSystemService(
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir)
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
var reclaimed = 0UL
context.cacheDir.listFiles().orEmpty().forEach { entry ->
val size = if (entry.isDirectory) directorySize(entry) else entry.length().coerceAtLeast(0L).toULong()
if (entry.deleteRecursively()) reclaimed += size
}
val appDataRoot = File(appDataDir)
val appDataIsOwned = runCatching {
val appDataPath = appDataRoot.canonicalPath
val filesPath = context.filesDir.canonicalPath
appDataPath == filesPath || appDataPath.startsWith(filesPath + File.separator)
}.getOrDefault(false)
if (appDataIsOwned) {
appDataRoot.walkTopDown()
.filter { it.isDirectory && it.name == ".Trash" }
.toList()
.forEach { trash ->
val size = directorySize(trash)
if (trash.deleteRecursively()) reclaimed += size
}
}
return reclaimed
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
when (folder.kind) {
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Was ist passiert?</string>
<string name="button_approve">Genehmigen</string>
<string name="button_back">Zurück</string>
<string name="button_more_actions">Weitere Aktionen</string>
<string name="button_cancel">Abbrechen</string>
<string name="button_cancel_receive">Abbrechen</string>
<string name="button_change_files">Dateien ändern</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
<string name="notifications_local_title">Mitteilungen erlauben</string>
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
<string name="notifications_receive_completed_body">„%1$s“ wurde vollständig heruntergeladen.</string>
<string name="notifications_receive_completed_title">Download abgeschlossen</string>
<string name="notifications_receive_failed_body">„%1$s“ konnte nicht empfangen werden.</string>
<string name="notifications_receive_failed_title">Download fehlgeschlagen</string>
<string name="notifications_receiver_completed_body">%1$s hat „%2$s“ vollständig empfangen.</string>
<string name="notifications_receiver_completed_title">Übertragung empfangen</string>
<string name="notifications_receiver_failed_body">%1$s konnte „%2$s“ nicht empfangen</string>
<string name="notifications_receiver_failed_title">Übertragung fehlgeschlagen</string>
<string name="notifications_send_failed_body">„%1$s“ konnte nicht geteilt werden.</string>
<string name="notifications_send_failed_title">Freigabe fehlgeschlagen</string>
<string name="notifications_settings_open_failed">Die Mitteilungseinstellungen konnten nicht geöffnet werden.</string>
<string name="notifications_title">Mitteilungen</string>
<string name="notifications_unsupported">Mitteilungen sind auf diesem Gerät nicht verfügbar.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">¿Qué ocurrió?</string>
<string name="button_approve">Aprobar</string>
<string name="button_back">Atrás</string>
<string name="button_more_actions">Más acciones</string>
<string name="button_cancel">Cancelar</string>
<string name="button_cancel_receive">Cancelar</string>
<string name="button_change_files">Cambiar archivos</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Notificaciones activadas.</string>
<string name="notifications_local_title">Permitir notificaciones</string>
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
<string name="notifications_receive_completed_body">«%1$s» terminó de descargarse.</string>
<string name="notifications_receive_completed_title">Descarga completada</string>
<string name="notifications_receive_failed_body">No se pudo recibir «%1$s».</string>
<string name="notifications_receive_failed_title">Error en la descarga</string>
<string name="notifications_receiver_completed_body">%1$s terminó de recibir «%2$s».</string>
<string name="notifications_receiver_completed_title">Transferencia recibida</string>
<string name="notifications_receiver_failed_body">%1$s no pudo recibir «%2$s»</string>
<string name="notifications_receiver_failed_title">Error en la entrega</string>
<string name="notifications_send_failed_body">No se pudo compartir «%1$s».</string>
<string name="notifications_send_failed_title">Error al compartir</string>
<string name="notifications_settings_open_failed">No se pudieron abrir los ajustes de notificaciones.</string>
<string name="notifications_title">Notificaciones</string>
<string name="notifications_unsupported">Las notificaciones no están disponibles en este dispositivo.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Que sest-il passé ?</string>
<string name="button_approve">Approuver</string>
<string name="button_back">Retour</string>
<string name="button_more_actions">Plus dactions</string>
<string name="button_cancel">Annuler</string>
<string name="button_cancel_receive">Annuler</string>
<string name="button_change_files">Modifier les fichiers</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Notifications activées.</string>
<string name="notifications_local_title">Autoriser les notifications</string>
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
<string name="notifications_receive_completed_body">« %1$s » a fini de se télécharger.</string>
<string name="notifications_receive_completed_title">Téléchargement terminé</string>
<string name="notifications_receive_failed_body">« %1$s » na pas pu être reçu.</string>
<string name="notifications_receive_failed_title">Échec du téléchargement</string>
<string name="notifications_receiver_completed_body">%1$s a fini de recevoir « %2$s ».</string>
<string name="notifications_receiver_completed_title">Transfert reçu</string>
<string name="notifications_receiver_failed_body">%1$s n\'a pas pu recevoir « %2$s »</string>
<string name="notifications_receiver_failed_title">Échec de l\'envoi</string>
<string name="notifications_send_failed_body">« %1$s » na pas pu être partagé.</string>
<string name="notifications_send_failed_title">Échec du partage</string>
<string name="notifications_settings_open_failed">Impossible douvrir les réglages de notifications.</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_unsupported">Les notifications ne sont pas disponibles sur cet appareil.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Cosa è accaduto?</string>
<string name="button_approve">Approva</string>
<string name="button_back">Indietro</string>
<string name="button_more_actions">Altre azioni</string>
<string name="button_cancel">Annulla</string>
<string name="button_cancel_receive">Annulla</string>
<string name="button_change_files">Cambia file</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Notifiche attivate.</string>
<string name="notifications_local_title">Consenti le notifiche</string>
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
<string name="notifications_receive_completed_body">«%1$s» è stato scaricato.</string>
<string name="notifications_receive_completed_title">Download completato</string>
<string name="notifications_receive_failed_body">Impossibile ricevere «%1$s».</string>
<string name="notifications_receive_failed_title">Download non riuscito</string>
<string name="notifications_receiver_completed_body">%1$s ha finito di ricevere «%2$s».</string>
<string name="notifications_receiver_completed_title">Trasferimento ricevuto</string>
<string name="notifications_receiver_failed_body">%1$s non ha potuto ricevere “%2$s”</string>
<string name="notifications_receiver_failed_title">Consegna non riuscita</string>
<string name="notifications_send_failed_body">Impossibile condividere «%1$s».</string>
<string name="notifications_send_failed_title">Condivisione non riuscita</string>
<string name="notifications_settings_open_failed">Impossibile aprire le impostazioni delle notifiche.</string>
<string name="notifications_title">Notifiche</string>
<string name="notifications_unsupported">Le notifiche non sono disponibili su questo dispositivo.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Wat is er gebeurd?</string>
<string name="button_approve">Goedkeuren</string>
<string name="button_back">Terug</string>
<string name="button_more_actions">Meer acties</string>
<string name="button_cancel">Annuleren</string>
<string name="button_cancel_receive">Annuleren</string>
<string name="button_change_files">Bestanden wijzigen</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
<string name="notifications_local_title">Meldingen toestaan</string>
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
<string name="notifications_receive_completed_body">%1$s is volledig gedownload.</string>
<string name="notifications_receive_completed_title">Download voltooid</string>
<string name="notifications_receive_failed_body">%1$s kon niet worden ontvangen.</string>
<string name="notifications_receive_failed_title">Download mislukt</string>
<string name="notifications_receiver_completed_body">%1$s heeft %2$s volledig ontvangen.</string>
<string name="notifications_receiver_completed_title">Overdracht ontvangen</string>
<string name="notifications_receiver_failed_body">%1$s kon “%2$s” niet ontvangen</string>
<string name="notifications_receiver_failed_title">Levering mislukt</string>
<string name="notifications_send_failed_body">%1$s kon niet worden gedeeld.</string>
<string name="notifications_send_failed_title">Delen mislukt</string>
<string name="notifications_settings_open_failed">De meldingsinstellingen konden niet worden geopend.</string>
<string name="notifications_title">Meldingen</string>
<string name="notifications_unsupported">Meldingen zijn niet beschikbaar op dit apparaat.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Co się stało?</string>
<string name="button_approve">Zatwierdź</string>
<string name="button_back">Wstecz</string>
<string name="button_more_actions">Więcej działań</string>
<string name="button_cancel">Anuluj</string>
<string name="button_cancel_receive">Anuluj</string>
<string name="button_change_files">Zmień pliki</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
<string name="notifications_local_title">Zezwól na powiadomienia</string>
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
<string name="notifications_receive_completed_body">Zakończono pobieranie „%1$s”.</string>
<string name="notifications_receive_completed_title">Pobieranie zakończone</string>
<string name="notifications_receive_failed_body">Nie udało się odebrać „%1$s”.</string>
<string name="notifications_receive_failed_title">Pobieranie nie powiodło się</string>
<string name="notifications_receiver_completed_body">%1$s zakończył odbieranie „%2$s”.</string>
<string name="notifications_receiver_completed_title">Transfer odebrany</string>
<string name="notifications_receiver_failed_body">%1$s nie mógł odebrać „%2$s”</string>
<string name="notifications_receiver_failed_title">Dostarczenie nie powiodło się</string>
<string name="notifications_send_failed_body">Nie udało się udostępnić „%1$s”.</string>
<string name="notifications_send_failed_title">Udostępnianie nie powiodło się</string>
<string name="notifications_settings_open_failed">Nie udało się otworzyć ustawień powiadomień.</string>
<string name="notifications_title">Powiadomienia</string>
<string name="notifications_unsupported">Powiadomienia nie są dostępne na tym urządzeniu.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">O que aconteceu?</string>
<string name="button_approve">Aprovar</string>
<string name="button_back">Voltar</string>
<string name="button_more_actions">Mais ações</string>
<string name="button_cancel">Cancelar</string>
<string name="button_cancel_receive">Cancelar</string>
<string name="button_change_files">Alterar ficheiros</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Notificações ativadas.</string>
<string name="notifications_local_title">Permitir notificações</string>
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
<string name="notifications_receive_completed_body">«%1$s» concluiu a transferência.</string>
<string name="notifications_receive_completed_title">Transferência concluída</string>
<string name="notifications_receive_failed_body">Não foi possível receber «%1$s».</string>
<string name="notifications_receive_failed_title">Falha na transferência</string>
<string name="notifications_receiver_completed_body">%1$s terminou de receber «%2$s».</string>
<string name="notifications_receiver_completed_title">Transferência recebida</string>
<string name="notifications_receiver_failed_body">%1$s não conseguiu receber “%2$s”</string>
<string name="notifications_receiver_failed_title">Falha na entrega</string>
<string name="notifications_send_failed_body">Não foi possível partilhar «%1$s».</string>
<string name="notifications_send_failed_title">Falha na partilha</string>
<string name="notifications_settings_open_failed">Não foi possível abrir as definições de notificações.</string>
<string name="notifications_title">Notificações</string>
<string name="notifications_unsupported">As notificações não estão disponíveis neste dispositivo.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">Что произошло?</string>
<string name="button_approve">Одобрить</string>
<string name="button_back">Назад</string>
<string name="button_more_actions">Другие действия</string>
<string name="button_cancel">Отмена</string>
<string name="button_cancel_receive">Отмена</string>
<string name="button_change_files">Изменить файлы</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Уведомления включены.</string>
<string name="notifications_local_title">Разрешить уведомления</string>
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
<string name="notifications_receive_completed_body">«%1$s» завершил загрузку.</string>
<string name="notifications_receive_completed_title">Загрузка завершена</string>
<string name="notifications_receive_failed_body">Не удалось получить «%1$s».</string>
<string name="notifications_receive_failed_title">Ошибка загрузки</string>
<string name="notifications_receiver_completed_body">%1$s завершил получение «%2$s».</string>
<string name="notifications_receiver_completed_title">Передача получена</string>
<string name="notifications_receiver_failed_body">%1$s не удалось получить «%2$s»</string>
<string name="notifications_receiver_failed_title">Ошибка доставки</string>
<string name="notifications_send_failed_body">Не удалось поделиться «%1$s».</string>
<string name="notifications_send_failed_title">Не удалось поделиться</string>
<string name="notifications_settings_open_failed">Не удалось открыть настройки уведомлений.</string>
<string name="notifications_title">Уведомления</string>
<string name="notifications_unsupported">Уведомления недоступны на этом устройстве.</string>

View File

@@ -55,6 +55,7 @@
<string name="bug_report_what_label">What happened?</string>
<string name="button_approve">Approve</string>
<string name="button_back">Back</string>
<string name="button_more_actions">More actions</string>
<string name="button_cancel">Cancel</string>
<string name="button_cancel_receive">Cancel</string>
<string name="button_change_files">Change files</string>
@@ -122,6 +123,16 @@
<string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_local_title">Allow notifications</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_receive_completed_body">“%1$s” finished downloading.</string>
<string name="notifications_receive_completed_title">Download complete</string>
<string name="notifications_receive_failed_body">“%1$s” couldnt be received.</string>
<string name="notifications_receive_failed_title">Download failed</string>
<string name="notifications_receiver_completed_body">%1$s finished receiving “%2$s”.</string>
<string name="notifications_receiver_completed_title">Transfer received</string>
<string name="notifications_receiver_failed_body">%1$s couldn\'t receive “%2$s”</string>
<string name="notifications_receiver_failed_title">Delivery failed</string>
<string name="notifications_send_failed_body">“%1$s” couldnt be shared.</string>
<string name="notifications_send_failed_title">Sharing failed</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string>

View File

@@ -6,6 +6,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -14,6 +21,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -49,6 +58,9 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.app_starting
@Composable
fun App(
@@ -217,6 +229,32 @@ fun App(
)
}
windowChrome?.invoke()
val startingLabel = stringResource(Res.string.app_starting)
AnimatedVisibility(
visible = !sendCoreState.isInitialized,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface100)
.semantics { contentDescription = startingLabel },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
startingLabel,
style = MaterialTheme.typography.titleMedium,
color = LocalVniDropColors.current.foregroundLighter,
)
}
}
}
}
}
}

View File

@@ -8,6 +8,7 @@ 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.notifications.TransferNotificationCoordinator
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults
import com.vnidrop.app.preferences.AppPreferencesRepository
@@ -62,6 +63,14 @@ class AppGraph(
messages = messages,
scope = applicationScope,
)
val transferNotificationCoordinator = TransferNotificationCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
notifications = dependencies.localNotificationService,
visibility = visibility,
messages = messages,
scope = applicationScope,
)
init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)

View File

@@ -41,6 +41,8 @@ interface FileSystemService {
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong
/** Reclaims only app-owned temporary files and returns the number of bytes removed. */
suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =

View File

@@ -294,7 +294,7 @@ private fun InvitationReviewPanel(
Text(
when (error) {
is UiText.Dynamic -> error.value
is UiText.Resource -> stringResource(error.resource)
is UiText.Resource -> stringResource(error.resource, *error.formatArgs.toTypedArray())
},
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,

View File

@@ -19,17 +19,25 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -58,12 +66,16 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_create_new_transfer
import vnidrop.shared.generated.resources.button_delete_transfer
import vnidrop.shared.generated.resources.button_more_actions
import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
import vnidrop.shared.generated.resources.send_new_transfer_description
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.send_stop_sharing
import vnidrop.shared.generated.resources.send_transfers_title
import vnidrop.shared.generated.resources.transfer_share_title
@Composable
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
@@ -86,6 +98,9 @@ internal fun TransferCatalog(
windowClass: WindowClass,
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onShare: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
onDelete: (ULong) -> Unit = {},
) {
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn(
@@ -129,6 +144,9 @@ internal fun TransferCatalog(
thumbnailBytes = transferThumbnails[transfer.transferId],
progress = progress,
onClick = { onTransferSelected(transfer.transferId) },
onShare = { onShare(transfer.transferId) },
onStopSharing = { onStopSharing(transfer.transferId) },
onDelete = { onDelete(transfer.transferId) },
)
}
}
@@ -192,6 +210,9 @@ private fun TransferListItem(
thumbnailBytes: ByteArray?,
progress: TransferProgress?,
onClick: () -> Unit,
onShare: () -> Unit,
onStopSharing: () -> Unit,
onDelete: () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
@@ -227,8 +248,68 @@ private fun TransferListItem(
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
}
}
Spacer(Modifier.width(8.dp))
PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
TransferActionsMenu(transfer, onShare, onStopSharing, onDelete)
}
}
}
@Composable
private fun TransferActionsMenu(
transfer: Transfer,
onShare: () -> Unit,
onStopSharing: () -> Unit,
onDelete: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val moreActionsLabel = stringResource(Res.string.button_more_actions)
Box {
IconButton(
onClick = { expanded = true },
modifier = Modifier.semantics {
contentDescription = moreActionsLabel
},
) {
Text(
"",
style = MaterialTheme.typography.headlineSmall,
color = LocalVniDropColors.current.foregroundLighter,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (transfer.ticket != null) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.transfer_share_title)) },
onClick = {
expanded = false
onShare()
},
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null) },
)
}
if (transfer.status == TransferStatus.Sharing) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.send_stop_sharing)) },
onClick = {
expanded = false
onStopSharing()
},
leadingIcon = { PlatformIcon(AppIcon.Close, contentDescription = null) },
)
}
DropdownMenuItem(
text = { Text(stringResource(Res.string.button_delete_transfer)) },
onClick = {
expanded = false
onDelete()
},
leadingIcon = {
PlatformIcon(
AppIcon.Delete,
contentDescription = null,
tint = LocalVniDropColors.current.destructiveDefault,
)
},
)
}
}
}

View File

@@ -46,6 +46,11 @@ fun SendRoute(
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare,
onTransferSelected = viewModel::openTransfer,
onShareTransfer = { transferId ->
viewModel.openTransfer(transferId)
viewModel.openShare()
},
onStopSharing = viewModel::stopSharing,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket,
onActivity = viewModel::openActivity,
@@ -53,7 +58,8 @@ fun SendRoute(
onShare = viewModel::openShare,
onCloseDetailPanel = viewModel::closeDetailPanel,
onInvitationResult = viewModel::onInvitationResult,
onRequestDelete = viewModel::requestDeleteTransfer,
onRequestDelete = { viewModel.requestDeleteTransfer() },
onRequestDeleteTransfer = { viewModel.requestDeleteTransfer(it) },
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)

View File

@@ -33,6 +33,8 @@ fun SendScreen(
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onShareTransfer: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit,
onActivity: () -> Unit = {},
@@ -41,11 +43,13 @@ fun SendScreen(
onCloseDetailPanel: () -> Unit = {},
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
onRequestDelete: () -> Unit = {},
onRequestDeleteTransfer: (ULong) -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
val deleteTarget = state.deleteTargetTransferId?.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())
@@ -64,6 +68,7 @@ fun SendScreen(
onActivity = onActivity,
onReceivers = onReceivers,
onShare = onShare,
onStopSharing = { onStopSharing(selectedTransfer.transferId) },
onDelete = onRequestDelete,
)
} else {
@@ -75,6 +80,9 @@ fun SendScreen(
windowClass = windowClass,
onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected,
onShare = onShareTransfer,
onStopSharing = onStopSharing,
onDelete = onRequestDeleteTransfer,
)
}
}
@@ -123,10 +131,10 @@ fun SendScreen(
}
}
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
if (deleteTarget != null && state.isDeleteConfirmationOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
DeleteTransferPanel(
transferName = selectedTransfer.transferName,
transferName = deleteTarget.transferName,
isDeleting = state.isDeleting,
onCancel = onDismissDelete,
onConfirm = onConfirmDelete,

View File

@@ -44,6 +44,7 @@ data class SendState(
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false,
val deleteTargetTransferId: ULong? = null,
val isDeleting: Boolean = false,
) {
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
@@ -221,12 +222,19 @@ class SendViewModel(
refreshReceivers(transferId)
}
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
fun requestDeleteTransfer(transferId: ULong? = null) = _state.update {
it.copy(
isDeleteConfirmationOpen = true,
deleteTargetTransferId = transferId ?: it.selectedTransferId,
)
}
fun dismissDeleteTransfer() {
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
if (!_state.value.isDeleting) {
_state.update { it.copy(isDeleteConfirmationOpen = false, deleteTargetTransferId = null) }
}
}
fun confirmDeleteTransfer() {
val transferId = _state.value.selectedTransferId ?: return
val transferId = _state.value.deleteTargetTransferId ?: return
if (_state.value.isDeleting) return
viewModelScope.launch {
_state.update { it.copy(isDeleting = true) }
@@ -239,6 +247,7 @@ class SendViewModel(
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
deleteTargetTransferId = null,
isDeleting = false,
)
}
@@ -285,6 +294,7 @@ class SendViewModel(
onSuccess = { share ->
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
?.let { filePreviewRepository.save(share.transferId, it) }
repository.refresh()
_state.update {
it.copy(
isComposerOpen = false,
@@ -292,8 +302,11 @@ class SendViewModel(
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
selectedTransferId = share.transferId,
detailPanel = TransferDetailPanel.Share,
)
}
refreshReceivers(share.transferId)
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
},
onFailure = { error ->
@@ -304,6 +317,15 @@ class SendViewModel(
}
}
fun stopSharing(transferId: ULong) {
viewModelScope.launch {
repository.cancel(transferId).fold(
onSuccess = { repository.refresh() },
onFailure = messages::error,
)
}
}
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
files.isEmpty() -> ""
files.size == 1 && files.first().isDirectory -> files.first().displayName

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -174,22 +175,51 @@ private fun ReviewFileStep(
style = MaterialTheme.typography.bodySmall,
)
}
if (windowClass == WindowClass.Phone) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare)
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SourceButton(
text = stringResource(Res.string.button_change_files),
icon = AppIcon.File,
onClick = onSelectFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
)
SourceButton(
text = stringResource(Res.string.button_choose_folder),
icon = AppIcon.Folder,
onClick = onSelectFolder,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
)
if (windowClass != WindowClass.Phone) {
SourceButton(
text = stringResource(Res.string.button_clear),
icon = AppIcon.Close,
onClick = onClearFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
)
}
}
}
}
@Composable
private fun SourceButton(
text: String,
icon: AppIcon,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean,
) {
OutlinedButton(onClick = onClick, modifier = modifier, enabled = enabled) {
PlatformIcon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton(

View File

@@ -84,6 +84,7 @@ internal fun TransferDetails(
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onStopSharing: () -> Unit,
onDelete: () -> Unit,
) {
LazyColumn(
@@ -100,8 +101,14 @@ internal fun TransferDetails(
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onDelete) {
PlatformIcon(AppIcon.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
if (transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) {
IconButton(onClick = onShare) {
PlatformIcon(
AppIcon.Send,
stringResource(Res.string.transfer_share_title),
tint = LocalVniDropColors.current.brandLink,
)
}
}
}
}
@@ -130,32 +137,25 @@ internal fun TransferDetails(
count = pendingReceivers + completedReceivers,
onClick = onReceivers,
)
when (transfer.status) {
TransferStatus.Sharing -> {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_share_title),
description = stringResource(Res.string.transfer_share_description),
onClick = onShare,
)
}
TransferStatus.Importing -> {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_share_title),
description = stringResource(Res.string.transfer_event_preparing),
)
}
TransferStatus.Receiving,
TransferStatus.Done,
TransferStatus.Failed,
TransferStatus.Cancelled,
TransferStatus.Stopped,
-> Unit
}
}
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (transfer.status == TransferStatus.Sharing) {
DestructiveButton(
stringResource(Res.string.send_stop_sharing),
onClick = onStopSharing,
modifier = Modifier.fillMaxWidth(),
)
}
DestructiveButton(
stringResource(Res.string.button_delete_transfer),
onClick = onDelete,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View File

@@ -42,5 +42,7 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onSubmitBugReport = viewModel::submitBugReport,
onDeleteAllTransfers = viewModel::deleteAllTransfers,
onClearTransferCache = viewModel::clearTransferCache,
onFreeUpSpace = viewModel::freeUpSpace,
onRefreshStorage = viewModel::loadStorageUsage,
)
}

View File

@@ -32,6 +32,8 @@ fun SettingsScreen(
onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {},
onClearTransferCache: () -> Unit = {},
onFreeUpSpace: () -> Unit = {},
onRefreshStorage: () -> Unit = {},
onRelayModeChanged: (RelayMode) -> Unit = {},
onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
onAddRelayUrl: () -> Unit = {},
@@ -69,6 +71,8 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache,
onFreeUpSpace = onFreeUpSpace,
onRefreshStorage = onRefreshStorage,
onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl,
@@ -110,6 +114,8 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache,
onFreeUpSpace = onFreeUpSpace,
onRefreshStorage = onRefreshStorage,
onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl,
@@ -143,6 +149,8 @@ private fun SettingsSectionContent(
onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit,
onFreeUpSpace: () -> Unit,
onRefreshStorage: () -> Unit,
onRelayModeChanged: (RelayMode) -> Unit,
onRelayUrlChanged: (Int, String) -> Unit,
onAddRelayUrl: () -> Unit,
@@ -169,6 +177,8 @@ private fun SettingsSectionContent(
windowClass,
onDeleteAllTransfers,
onClearTransferCache,
onFreeUpSpace,
onRefreshStorage,
onBack,
showBack,
)

View File

@@ -25,6 +25,7 @@ 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.state.formatBytes
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
@@ -51,6 +52,8 @@ import vnidrop.shared.generated.resources.notifications_unsupported
import vnidrop.shared.generated.resources.relay_settings_applied
import vnidrop.shared.generated.resources.storage_transfer_cache_cleared
import vnidrop.shared.generated.resources.storage_transfers_deleted
import vnidrop.shared.generated.resources.storage_cleanup_busy
import vnidrop.shared.generated.resources.storage_cleanup_freed
enum class SettingsSection {
Overview,
@@ -112,8 +115,10 @@ data class SettingsState(
val bugLogPreviewBytes: Int = 0,
val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false,
val storageLoadFailed: Boolean = false,
val isDeletingTransfers: Boolean = false,
val isClearingTransferCache: Boolean = false,
val isCleaningStorage: Boolean = false,
) {
val hasRelaySettingsChanges: Boolean
get() = relayMode != savedRelaySettings.mode ||
@@ -188,6 +193,14 @@ class SettingsViewModel(
endpointId = coreState.status?.endpointId,
)
}
if (
coreState.isInitialized &&
_state.value.selectedSection == SettingsSection.Storage &&
_state.value.storage == null &&
_state.value.storageLoadFailed
) {
loadStorageUsage()
}
}
}
refreshNotificationPermission()
@@ -209,7 +222,7 @@ class SettingsViewModel(
fun loadStorageUsage() {
if (_state.value.isCalculatingStorage) return
viewModelScope.launch {
_state.update { it.copy(isCalculatingStorage = true) }
_state.update { it.copy(isCalculatingStorage = true, storageLoadFailed = false) }
try {
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
val coreUsage = repository.storageUsage().getOrThrow()
@@ -227,12 +240,42 @@ class SettingsViewModel(
inaccessibleReceivedFileCount = received.inaccessibleCount,
),
isCalculatingStorage = false,
storageLoadFailed = false,
)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(isCalculatingStorage = false) }
_state.update { it.copy(isCalculatingStorage = false, storageLoadFailed = true) }
messages.error(error)
}
}
}
fun freeUpSpace() {
val current = _state.value
if (current.isCleaningStorage) return
if (current.hasActiveNetworkWork) {
messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_cleanup_busy), UiMessageTone.Warning))
return
}
viewModelScope.launch {
_state.update { it.copy(isCleaningStorage = true) }
try {
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
val reclaimed = fileSystemService.reclaimTemporaryStorage(environment.defaultCoreDataDir, receiveFolder)
_state.update { it.copy(isCleaningStorage = false) }
loadStorageUsage()
messages.tryShow(
UiMessage(
UiText.Resource(Res.string.storage_cleanup_freed, formatArgs = listOf(formatBytes(reclaimed))),
UiMessageTone.Success,
),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(isCleaningStorage = false) }
messages.error(error)
}
}

View File

@@ -36,9 +36,16 @@ import vnidrop.shared.generated.resources.storage_calculating
import vnidrop.shared.generated.resources.storage_clear_transfer_cache
import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description
import vnidrop.shared.generated.resources.storage_clearing_transfer_cache
import vnidrop.shared.generated.resources.storage_cleaning
import vnidrop.shared.generated.resources.storage_delete_transfers_caption
import vnidrop.shared.generated.resources.storage_delete_transfers
import vnidrop.shared.generated.resources.storage_delete_transfers_description
import vnidrop.shared.generated.resources.storage_deleting
import vnidrop.shared.generated.resources.storage_free_up_space
import vnidrop.shared.generated.resources.storage_free_up_space_caption
import vnidrop.shared.generated.resources.storage_refresh
import vnidrop.shared.generated.resources.storage_unavailable
import vnidrop.shared.generated.resources.storage_usage_header
import vnidrop.shared.generated.resources.storage_total
import vnidrop.shared.generated.resources.storage_received_files
import vnidrop.shared.generated.resources.storage_temporary
@@ -52,6 +59,8 @@ internal fun StorageSettings(
windowClass: WindowClass,
onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit,
onFreeUpSpace: () -> Unit,
onRefreshStorage: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
@@ -59,8 +68,30 @@ internal fun StorageSettings(
var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(Res.string.storage_usage_header),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
SecondaryButton(
stringResource(Res.string.storage_refresh),
onClick = onRefreshStorage,
enabled = !state.isCalculatingStorage &&
!state.isCleaningStorage &&
!state.isDeletingTransfers &&
!state.isClearingTransferCache,
)
}
val storage = state.storage
if (storage == null || state.isCalculatingStorage) {
if (storage == null && state.storageLoadFailed && !state.isCalculatingStorage) {
SecondaryButton(
stringResource(Res.string.storage_unavailable),
onClick = onRefreshStorage,
modifier = Modifier.fillMaxWidth(),
)
} else if (storage == null || state.isCalculatingStorage) {
SettingsGroup {
StorageRow(
title = stringResource(Res.string.storage_calculating),
@@ -84,6 +115,23 @@ internal fun StorageSettings(
)
}
}
SecondaryButton(
text = stringResource(
if (state.isCleaningStorage) Res.string.storage_cleaning else Res.string.storage_free_up_space,
),
onClick = onFreeUpSpace,
modifier = Modifier.fillMaxWidth(),
enabled = !state.isCleaningStorage &&
!state.isDeletingTransfers &&
!state.isClearingTransferCache &&
!state.isCalculatingStorage &&
!state.hasActiveNetworkWork,
)
Text(
stringResource(Res.string.storage_free_up_space_caption),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
)
SecondaryButton(
text = stringResource(
if (state.isClearingTransferCache) {
@@ -107,6 +155,11 @@ internal fun StorageSettings(
) {
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers))
}
Text(
stringResource(Res.string.storage_delete_transfers_caption),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
)
Text(
stringResource(Res.string.storage_footer),
style = MaterialTheme.typography.bodySmall,

View File

@@ -0,0 +1,190 @@
package com.vnidrop.app.notifications
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_nearby_device
import vnidrop.shared.generated.resources.notifications_receive_completed_body
import vnidrop.shared.generated.resources.notifications_receive_completed_title
import vnidrop.shared.generated.resources.notifications_receive_failed_body
import vnidrop.shared.generated.resources.notifications_receive_failed_title
import vnidrop.shared.generated.resources.notifications_receiver_completed_body
import vnidrop.shared.generated.resources.notifications_receiver_completed_title
import vnidrop.shared.generated.resources.notifications_receiver_failed_body
import vnidrop.shared.generated.resources.notifications_receiver_failed_title
import vnidrop.shared.generated.resources.notifications_send_failed_body
import vnidrop.shared.generated.resources.notifications_send_failed_title
import vnidrop.shared.generated.resources.receive_unknown_transfer
internal enum class TransferNotificationKind {
SendFailed,
ReceiveCompleted,
ReceiveFailed,
ReceiverCompleted,
ReceiverFailed,
}
internal data class PlannedTransferNotification(
val id: String,
val kind: TransferNotificationKind,
val transferName: String?,
val receiver: String? = null,
)
internal fun plannedTransferNotifications(
transfers: List<Transfer>,
published: Set<String>,
): List<PlannedTransferNotification> = transfers.mapNotNull { transfer ->
val kind = when {
transfer.direction == TransferDirection.Send && transfer.status == TransferStatus.Failed ->
TransferNotificationKind.SendFailed
transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Done ->
TransferNotificationKind.ReceiveCompleted
transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Failed ->
TransferNotificationKind.ReceiveFailed
else -> return@mapNotNull null
}
val id = "${kind.idPrefix}-${transfer.transferId}"
PlannedTransferNotification(id, kind, transfer.transferName).takeUnless { id in published }
}
internal fun plannedReceiverNotifications(
requests: List<ReceiverRequestModel>,
published: Set<String>,
): List<PlannedTransferNotification> = requests.mapNotNull { request ->
val kind = when (request.status) {
ReceiverDeliveryStatus.Completed -> TransferNotificationKind.ReceiverCompleted
ReceiverDeliveryStatus.Failed -> TransferNotificationKind.ReceiverFailed
else -> return@mapNotNull null
}
val id = "${kind.idPrefix}-${request.id}"
PlannedTransferNotification(
id = id,
kind = kind,
transferName = request.transferName,
receiver = request.receiverName ?: request.receiverDeviceName,
).takeUnless { id in published }
}
class TransferNotificationCoordinator(
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository,
private val notifications: LocalNotificationService,
private val visibility: AppVisibility,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
private val published = mutableSetOf<String>()
private var transfersPrimed = false
private var notificationsEnabled = false
init {
scope.launch {
preferencesRepository.preferences.collectLatest { preferences ->
notificationsEnabled = preferences.notificationsEnabled
}
}
scope.launch {
repository.state.collect { core ->
if (core.isInitialized) syncTransfers(core.transfers)
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.ReceiverHistoryChanged -> syncReceivers(signal.transferId)
is CoreSignal.TransfersChanged -> syncReceivers(signal.transferId)
is CoreSignal.ApprovalChanged -> Unit
}
}
}
}
private suspend fun syncTransfers(transfers: List<Transfer>) {
val planned = plannedTransferNotifications(transfers, published)
if (!transfersPrimed) {
transfersPrimed = true
published += planned.map(PlannedTransferNotification::id)
return
}
planned.forEach { deliver(it) }
}
private suspend fun syncReceivers(transferId: ULong) {
val isOutgoing = repository.state.value.transfers.any {
it.transferId == transferId && it.direction == TransferDirection.Send
}
if (!isOutgoing) return
repository.receiverRequests(transferId).fold(
onSuccess = { requests ->
plannedReceiverNotifications(requests, published).forEach { deliver(it) }
},
onFailure = messages::error,
)
}
private suspend fun deliver(plan: PlannedTransferNotification) {
published += plan.id
if (
!notificationsEnabled ||
visibility.isForeground.value ||
notifications.permission.value != NotificationPermission.Granted
) return
val transferName = plan.transferName ?: getString(Res.string.receive_unknown_transfer)
val notification = when (plan.kind) {
TransferNotificationKind.SendFailed -> LocalNotification(
plan.id,
getString(Res.string.notifications_send_failed_title),
getString(Res.string.notifications_send_failed_body, transferName),
)
TransferNotificationKind.ReceiveCompleted -> LocalNotification(
plan.id,
getString(Res.string.notifications_receive_completed_title),
getString(Res.string.notifications_receive_completed_body, transferName),
)
TransferNotificationKind.ReceiveFailed -> LocalNotification(
plan.id,
getString(Res.string.notifications_receive_failed_title),
getString(Res.string.notifications_receive_failed_body, transferName),
)
TransferNotificationKind.ReceiverCompleted -> {
val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device)
LocalNotification(
plan.id,
getString(Res.string.notifications_receiver_completed_title),
getString(Res.string.notifications_receiver_completed_body, receiver, transferName),
)
}
TransferNotificationKind.ReceiverFailed -> {
val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device)
LocalNotification(
plan.id,
getString(Res.string.notifications_receiver_failed_title),
getString(Res.string.notifications_receiver_failed_body, receiver, transferName),
)
}
}
notifications.publish(notification).onFailure(messages::error)
}
}
private val TransferNotificationKind.idPrefix: String
get() = when (this) {
TransferNotificationKind.SendFailed -> "send-failed"
TransferNotificationKind.ReceiveCompleted -> "receive-completed"
TransferNotificationKind.ReceiveFailed -> "receive-failed"
TransferNotificationKind.ReceiverCompleted -> "receiver-completed"
TransferNotificationKind.ReceiverFailed -> "receiver-failed"
}

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import org.jetbrains.compose.resources.StringResource
sealed interface UiText {
data class Resource(val resource: StringResource) : UiText
data class Resource(val resource: StringResource, val formatArgs: List<Any> = emptyList()) : UiText
data class Dynamic(val value: String) : UiText
}

View File

@@ -138,5 +138,5 @@ private fun DismissButton(onClick: () -> Unit) {
private suspend fun UiText.resolve(): String = when (this) {
is UiText.Dynamic -> value
is UiText.Resource -> getString(resource)
is UiText.Resource -> getString(resource, *formatArgs.toTypedArray())
}

View File

@@ -5,6 +5,7 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreStorageUsageModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
@@ -26,6 +27,7 @@ import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.send.TransferDetailPanel
import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.RelaySettingsApplyError
import com.vnidrop.app.feature.settings.RelaySettingsInputError
@@ -168,6 +170,43 @@ class ViewModelsTest {
assertEquals(0, core.clearTransferCacheCount)
}
@Test
fun settingsFreesOnlyPlatformOwnedTemporaryStorage() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val fileSystem = FakeFileSystemService(folder).apply {
reclaimedTemporaryBytes = 12_000UL
}
val viewModel = settingsViewModel(fileSystem = fileSystem)
advanceUntilIdle()
viewModel.freeUpSpace()
advanceUntilIdle()
assertEquals(1, fileSystem.reclaimTemporaryStorageCount)
assertFalse(viewModel.state.value.isCleaningStorage)
}
@Test
fun settingsRetriesStorageAfterCoreFinishesStarting() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
storageUsageResult = Result.failure(IllegalStateException("not initialized"))
}
val viewModel = settingsViewModel(repository = core)
advanceUntilIdle()
viewModel.selectSection(SettingsSection.Storage)
advanceUntilIdle()
assertTrue(viewModel.state.value.storageLoadFailed)
core.storageUsageResult = Result.success(CoreStorageUsageModel(25UL, 10UL, 5UL, 0UL, 0UL))
core.mutableState.value = core.mutableState.value.copy(isInitialized = true)
advanceUntilIdle()
assertFalse(viewModel.state.value.storageLoadFailed)
assertEquals(25UL, viewModel.state.value.storage?.transferCacheBytes)
}
@Test
fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -533,10 +572,33 @@ class ViewModelsTest {
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
assertEquals(7UL, viewModel.state.value.selectedTransferId)
assertEquals(TransferDetailPanel.Share, viewModel.state.value.detailPanel)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
fun sendViewModelStopsSharingFromCatalogAction() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
}
val viewModel = SendViewModel(
core,
FakeFileSystemService(folder),
preferences(),
FakeFilePreviewRepository(),
UiMessageController(),
)
advanceUntilIdle()
viewModel.stopSharing(7UL)
advanceUntilIdle()
assertEquals(listOf(7UL), core.cancelledTransfers)
}
@Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -0,0 +1,72 @@
package com.vnidrop.app.notifications
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import kotlin.test.Test
import kotlin.test.assertEquals
class TransferNotificationCoordinatorTest {
@Test
fun plansOnlyNewTerminalTransferOutcomes() {
val transfers = listOf(
transfer(1UL, TransferDirection.Receive, TransferStatus.Done),
transfer(2UL, TransferDirection.Receive, TransferStatus.Failed),
transfer(3UL, TransferDirection.Send, TransferStatus.Failed),
transfer(4UL, TransferDirection.Send, TransferStatus.Sharing),
)
assertEquals(
listOf("receive-completed-1", "send-failed-3"),
plannedTransferNotifications(transfers, setOf("receive-failed-2")).map { it.id },
)
}
@Test
fun plansCompletedAndFailedReceiverOutcomesOnce() {
val requests = listOf(
request("completed", ReceiverDeliveryStatus.Completed),
request("failed", ReceiverDeliveryStatus.Failed),
request("accepted", ReceiverDeliveryStatus.Accepted),
)
assertEquals(
listOf("receiver-failed-failed"),
plannedReceiverNotifications(requests, setOf("receiver-completed-completed")).map { it.id },
)
}
private fun transfer(id: ULong, direction: TransferDirection, status: TransferStatus) = Transfer(
localId = "local-$id",
transferId = id,
direction = direction,
status = status,
peerId = null,
transferName = "Transfer $id",
contentHash = null,
fileCount = 1UL,
totalSize = 10UL,
ticket = null,
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 1L,
)
private fun request(id: String, status: ReceiverDeliveryStatus) = ReceiverRequestModel(
id = id,
transferId = 1UL,
remoteEndpointId = "peer-$id",
transferName = "Transfer",
receiverName = "Receiver",
receiverDeviceName = null,
appVersion = "1.0",
status = status,
reason = null,
requestedAt = 1L,
respondedAt = 2L,
completedAt = 3L,
)
}

View File

@@ -48,6 +48,9 @@ class FakeCoreGateway : CoreGateway {
private var receiveGate: CompletableDeferred<Unit>? = null
var deleteResult: Result<Unit> = Result.success(Unit)
var clearTransferCacheResult: Result<ULong> = Result.success(0UL)
var storageUsageResult: Result<CoreStorageUsageModel> = Result.success(
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>()
val cancelledTransfers = mutableListOf<ULong>()
@@ -153,9 +156,7 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success(
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
)
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = storageUsageResult
override suspend fun clearTransferCache(): Result<ULong> {
clearTransferCacheCount += 1
return clearTransferCacheResult
@@ -248,6 +249,8 @@ class FakeFileSystemService(
var supportsCustomFolders = true
var effectiveFolder: ReceiveFolder? = null
var canRevealFolder = false
var reclaimedTemporaryBytes = 0UL
var reclaimTemporaryStorageCount = 0
var revealFolderResult: Result<Unit> = Result.success(Unit)
val revealedFolders = mutableListOf<ReceiveFolder>()
val discardedPickedFiles = mutableListOf<PickedShareFile>()
@@ -259,6 +262,10 @@ class FakeFileSystemService(
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) =
ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0)
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
reclaimTemporaryStorageCount += 1
return reclaimedTemporaryBytes
}
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {

View File

@@ -48,6 +48,9 @@ private class JvmFileSystemService : FileSystemService {
return desktopTemporaryUsage(receiveFolder)
}
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong =
desktopReclaimTemporaryStorage(appDataDir, receiveFolder)
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override suspend fun sharePickedFiles(
@@ -88,3 +91,35 @@ internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong {
}
}.getOrDefault(0UL)
}
internal fun desktopReclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
var reclaimed = 0UL
if (receiveFolder.kind == ReceiveFolderKind.FileSystemPath) {
val receiveRoot = File(receiveFolder.value)
receiveRoot.walkTopDown()
.filter { file ->
file.isFile &&
file.name.startsWith(".") &&
file.name.contains(".vnidrop-") &&
file.name.endsWith(".part")
}
.toList()
.forEach { file ->
val size = file.length().coerceAtLeast(0L).toULong()
if (file.delete()) reclaimed += size
}
}
val appDataRoot = File(appDataDir)
if (appDataRoot.isDirectory) {
appDataRoot.walkTopDown()
.filter { it.isDirectory && it.name == ".Trash" }
.toList()
.forEach { trash ->
val size = trash.walkTopDown()
.filter(File::isFile)
.fold(0UL) { total, file -> total + file.length().coerceAtLeast(0L).toULong() }
if (trash.deleteRecursively()) reclaimed += size
}
}
return reclaimed
}

View File

@@ -5,6 +5,8 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FileSystemServiceTest {
@Test
@@ -26,4 +28,32 @@ class FileSystemServiceTest {
root.toFile().deleteRecursively()
}
}
@Test
fun desktopReclaimTemporaryStorageKeepsUserFiles() {
val root = createTempDirectory("vnidrop-storage-cleanup")
try {
val receive = root.resolve("receive").createDirectories()
val appData = root.resolve("app-data").createDirectories()
val partial = receive.resolve(".photo.jpg.vnidrop-test.part")
val received = receive.resolve("photo.jpg")
val trash = appData.resolve("nested/.Trash").createDirectories()
Files.write(partial, ByteArray(7))
Files.write(received, ByteArray(13))
Files.write(trash.resolve("stale.bin"), ByteArray(11))
assertEquals(
18UL,
desktopReclaimTemporaryStorage(
appData.toString(),
ReceiveFolder(ReceiveFolderKind.FileSystemPath, receive.toString(), "Test"),
),
)
assertFalse(Files.exists(partial))
assertFalse(Files.exists(trash))
assertTrue(Files.exists(received))
} finally {
root.toFile().deleteRecursively()
}
}
}

View File

@@ -111,7 +111,7 @@ class FoundationComposeTest {
}
}
onNodeWithText("Notifications").performClick()
onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed()
onNodeWithText("Get notified about transfer activity while VniDrop is in the background.").assertIsDisplayed()
}
@Test
@@ -212,10 +212,11 @@ class FoundationComposeTest {
runOnIdle { assertTrue(cacheClearRequested) }
onNodeWithText("Delete all transfers").performClick()
onNodeWithText(
"This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " +
"Ongoing transfers and received files are not deleted. This cant be undone.",
).assertIsDisplayed()
onNodeWithText(
"This clears all sent and received transfer records from your history. Your received files are not deleted. " +
"Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. " +
"This cant be undone.",
).assertIsDisplayed()
runOnIdle { assertFalse(deleteRequested) }
onNodeWithTag("confirm-delete-all-transfers").performClick()
@@ -636,10 +637,13 @@ class FoundationComposeTest {
}
}
onNodeWithText("Share").assertIsDisplayed()
onNodeWithContentDescription("Share").assertIsDisplayed()
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0)
onNode(hasText("Share") and hasClickAction()).performClick()
onNodeWithContentDescription("Share").performClick()
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
waitUntil(timeoutMillis = 5_000) {
onAllNodesWithText("Scan with VniDrop to receive this transfer").fetchSemanticsNodes().isNotEmpty()
}
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed()
onNodeWithText("Save .vnd file").assertIsDisplayed()
onNodeWithContentDescription("Close").assertIsDisplayed()