mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge origin/master into feat/apple-typed-resources-and-fixes
Brings in custom relays, relay connection policies, storage cache clearing, and receiver-failure reporting. Apple-side conflict resolutions: - CoreRepository: keep CoreDispatcher, adopt master's relay factory + network transition guard, drop the now-unused serial queue. - TransferDetailsView: keep the toolbar-share layout; adopt master's invitationPresentation-based QR panel and the new .failed receiver case (typed). - SettingsModel/SettingsScreen: typed L10n titleKey with master's .network case; relay controls and the Free up space / storage redesign coexist. - Add the missing transfer_receiver_failed localization key. - Regenerate l10n from the merged strings.json; keep the Apple catalog untracked. - Drop the notificationsEnabled test assertion (notifications preference was intentionally removed on this branch).
This commit is contained in:
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Übertragung prüfen</string>
|
||||
<string name="receive_title">Empfangen</string>
|
||||
<string name="receive_unknown_transfer">VniDrop-Übertragung</string>
|
||||
<string name="relay_add_url">Relay-Server hinzufügen</string>
|
||||
<string name="relay_apply">Netzwerkeinstellungen anwenden</string>
|
||||
<string name="relay_applying">Wird angewendet…</string>
|
||||
<string name="relay_apply_active_transfers">Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.</string>
|
||||
<string name="relay_apply_failed">Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.</string>
|
||||
<string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string>
|
||||
<string name="relay_custom_urls_help">Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.</string>
|
||||
<string name="relay_custom_urls_label">Relay-URLs</string>
|
||||
<string name="relay_mode_automatic">Automatisch (empfohlen)</string>
|
||||
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>
|
||||
<string name="relay_mode_custom">Strikt benutzerdefiniert</string>
|
||||
<string name="relay_mode_custom_description">Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Benutzerdefiniert mit direktem Rückfall</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.</string>
|
||||
<string name="relay_mode_local_only">Nur lokales Netzwerk</string>
|
||||
<string name="relay_mode_local_only_description">Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.</string>
|
||||
<string name="relay_privacy_description">Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.</string>
|
||||
<string name="relay_remove_url">Relay-Server entfernen</string>
|
||||
<string name="relay_restore_failed">Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.</string>
|
||||
<string name="relay_settings_applied">Netzwerkeinstellungen angewendet.</string>
|
||||
<string name="relay_strict_warning">Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.</string>
|
||||
<string name="relay_validation_duplicate_url">Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen.</string>
|
||||
<string name="relay_validation_https_required">Die Relay-URL in Zeile %1$d muss mit https:// beginnen.</string>
|
||||
<string name="relay_validation_invalid_url">Die Relay-URL in Zeile %1$d ist ungültig.</string>
|
||||
<string name="relay_validation_missing_url">Fügen Sie mindestens eine Relay-URL hinzu.</string>
|
||||
<string name="relay_validation_too_many_urls">Sie können bis zu %1$d Relay-Server konfigurieren.</string>
|
||||
<string name="send_access_anyone">Jeder mit dieser Übertragung</string>
|
||||
<string name="send_access_anyone_description">Keine Genehmigung erforderlich. Verwenden Sie dies nur für Objekte, die Sie unbedenklich teilen können.</string>
|
||||
<string name="send_access_anyone_warning">Jeder mit der Einladung kann herunterladen, bis Sie die Freigabe beenden. Verwenden Sie dies nicht für private oder sensible Objekte.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Übertragung erstellt.</string>
|
||||
<string name="send_transfer_details_title">Übertragungsdetails</string>
|
||||
<string name="send_transfers_title">Ihre Übertragungen</string>
|
||||
<string name="settings_advanced_title">Erweitert</string>
|
||||
<string name="settings_network_title">Netzwerk</string>
|
||||
<string name="settings_subtitle">Ihr Name, wo Übertragungen gesichert werden, Darstellung und Mitteilungen.</string>
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="snackbar_dismiss">Ausblenden</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Genehmigt – wartet auf Abschluss</string>
|
||||
<string name="transfer_receiver_completed">Erfolgreich empfangen</string>
|
||||
<string name="transfer_receiver_expired">Anfrage abgelaufen</string>
|
||||
<string name="transfer_receiver_failed">Übertragung fehlgeschlagen</string>
|
||||
<string name="transfer_receiver_refused">Anfrage abgelehnt</string>
|
||||
<string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string>
|
||||
<string name="transfer_receiver_unknown">Status nicht verfügbar</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Anfragen, Genehmigungen und abgeschlossene Zustellungen</string>
|
||||
<string name="transfer_receivers_pending">%1$d warten</string>
|
||||
<string name="transfer_receivers_title">Empfänger</string>
|
||||
<string name="transfer_qr_unavailable">Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen.</string>
|
||||
<string name="transfer_scan_qr">Mit VniDrop scannen, um diese Übertragung zu empfangen</string>
|
||||
<string name="transfer_share_description">QR-Code, Einladungsdatei und Optionen in der Nähe</string>
|
||||
<string name="transfer_share_title">Teilen</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Revisar transferencia</string>
|
||||
<string name="receive_title">Recibir</string>
|
||||
<string name="receive_unknown_transfer">Transferencia de VniDrop</string>
|
||||
<string name="relay_add_url">Añadir servidor de retransmisión</string>
|
||||
<string name="relay_apply">Aplicar ajustes de red</string>
|
||||
<string name="relay_applying">Aplicando…</string>
|
||||
<string name="relay_apply_active_transfers">Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.</string>
|
||||
<string name="relay_apply_failed">No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.</string>
|
||||
<string name="relay_apply_restart_description">Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes.</string>
|
||||
<string name="relay_custom_urls_help">Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.</string>
|
||||
<string name="relay_custom_urls_label">URL de relés</string>
|
||||
<string name="relay_mode_automatic">Automático (recomendado)</string>
|
||||
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>
|
||||
<string name="relay_mode_custom">Personalizado estricto</string>
|
||||
<string name="relay_mode_custom_description">Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizado con conexión directa de reserva</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.</string>
|
||||
<string name="relay_mode_local_only">Solo red local</string>
|
||||
<string name="relay_mode_local_only_description">Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.</string>
|
||||
<string name="relay_privacy_description">Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.</string>
|
||||
<string name="relay_remove_url">Eliminar servidor de retransmisión</string>
|
||||
<string name="relay_restore_failed">No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.</string>
|
||||
<string name="relay_settings_applied">Ajustes de red aplicados.</string>
|
||||
<string name="relay_strict_warning">El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.</string>
|
||||
<string name="relay_validation_duplicate_url">La URL de relé de la línea %1$d duplica una entrada anterior.</string>
|
||||
<string name="relay_validation_https_required">La URL de relé de la línea %1$d debe empezar por https://.</string>
|
||||
<string name="relay_validation_invalid_url">La URL de relé de la línea %1$d no es válida.</string>
|
||||
<string name="relay_validation_missing_url">Añada al menos una URL de relé.</string>
|
||||
<string name="relay_validation_too_many_urls">Puede configurar hasta %1$d servidores de retransmisión.</string>
|
||||
<string name="send_access_anyone">Cualquiera que tenga esta transferencia</string>
|
||||
<string name="send_access_anyone_description">No se requiere aprobación. Úselo solo para elementos que no le importe compartir.</string>
|
||||
<string name="send_access_anyone_warning">Cualquiera que tenga la invitación puede descargar hasta que deje de compartir. No lo use para elementos privados o sensibles.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Transferencia creada.</string>
|
||||
<string name="send_transfer_details_title">Detalles de la transferencia</string>
|
||||
<string name="send_transfers_title">Sus transferencias</string>
|
||||
<string name="settings_advanced_title">Avanzado</string>
|
||||
<string name="settings_network_title">Red</string>
|
||||
<string name="settings_subtitle">Su nombre, dónde se guardan las transferencias, la apariencia y las notificaciones.</string>
|
||||
<string name="settings_title">Ajustes</string>
|
||||
<string name="snackbar_dismiss">Descartar</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string>
|
||||
<string name="transfer_receiver_completed">Recibido correctamente</string>
|
||||
<string name="transfer_receiver_expired">Solicitud caducada</string>
|
||||
<string name="transfer_receiver_failed">Error en la entrega</string>
|
||||
<string name="transfer_receiver_refused">Solicitud rechazada</string>
|
||||
<string name="transfer_receiver_requested">Esperando su aprobación</string>
|
||||
<string name="transfer_receiver_unknown">Estado no disponible</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Solicitudes, aprobaciones y entregas completadas</string>
|
||||
<string name="transfer_receivers_pending">%1$d en espera</string>
|
||||
<string name="transfer_receivers_title">Destinatarios</string>
|
||||
<string name="transfer_qr_unavailable">El QR no está disponible para esta invitación. Use Compartir o Descargar.</string>
|
||||
<string name="transfer_scan_qr">Escanee con VniDrop para recibir esta transferencia</string>
|
||||
<string name="transfer_share_description">Código QR, archivo de invitación y opciones cercanas</string>
|
||||
<string name="transfer_share_title">Compartir</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Vérifier le transfert</string>
|
||||
<string name="receive_title">Recevoir</string>
|
||||
<string name="receive_unknown_transfer">Transfert VniDrop</string>
|
||||
<string name="relay_add_url">Ajouter un serveur relais</string>
|
||||
<string name="relay_apply">Appliquer les réglages réseau</string>
|
||||
<string name="relay_applying">Application…</string>
|
||||
<string name="relay_apply_active_transfers">Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau.</string>
|
||||
<string name="relay_apply_failed">Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string>
|
||||
<string name="relay_apply_restart_description">L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string>
|
||||
<string name="relay_custom_urls_help">Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.</string>
|
||||
<string name="relay_custom_urls_label">URL des relais</string>
|
||||
<string name="relay_mode_automatic">Automatique (recommandé)</string>
|
||||
<string name="relay_mode_automatic_description">Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.</string>
|
||||
<string name="relay_mode_custom">Personnalisé strict</string>
|
||||
<string name="relay_mode_custom_description">Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personnalisé avec repli direct</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.</string>
|
||||
<string name="relay_mode_local_only">Réseau local uniquement</string>
|
||||
<string name="relay_mode_local_only_description">Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.</string>
|
||||
<string name="relay_privacy_description">Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion.</string>
|
||||
<string name="relay_remove_url">Supprimer le serveur relais</string>
|
||||
<string name="relay_restore_failed">Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.</string>
|
||||
<string name="relay_settings_applied">Réglages réseau appliqués.</string>
|
||||
<string name="relay_strict_warning">Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.</string>
|
||||
<string name="relay_validation_duplicate_url">L’URL de relais à la ligne %1$d est identique à une entrée précédente.</string>
|
||||
<string name="relay_validation_https_required">L’URL de relais à la ligne %1$d doit commencer par https://.</string>
|
||||
<string name="relay_validation_invalid_url">L’URL de relais à la ligne %1$d n’est pas valide.</string>
|
||||
<string name="relay_validation_missing_url">Ajoutez au moins une URL de relais.</string>
|
||||
<string name="relay_validation_too_many_urls">Vous pouvez configurer jusqu’à %1$d serveurs relais.</string>
|
||||
<string name="send_access_anyone">Toute personne disposant de ce transfert</string>
|
||||
<string name="send_access_anyone_description">Aucune approbation requise. À n’utiliser que pour des éléments que vous êtes à l’aise de partager.</string>
|
||||
<string name="send_access_anyone_warning">Toute personne disposant de l’invitation peut télécharger jusqu’à ce que vous arrêtiez le partage. Ne l’utilisez pas pour des éléments privés ou sensibles.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Transfert créé.</string>
|
||||
<string name="send_transfer_details_title">Détails du transfert</string>
|
||||
<string name="send_transfers_title">Vos transferts</string>
|
||||
<string name="settings_advanced_title">Avancé</string>
|
||||
<string name="settings_network_title">Réseau</string>
|
||||
<string name="settings_subtitle">Votre nom, l’emplacement d’enregistrement des transferts, l’apparence et les notifications.</string>
|
||||
<string name="settings_title">Réglages</string>
|
||||
<string name="snackbar_dismiss">Ignorer</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string>
|
||||
<string name="transfer_receiver_completed">Reçu avec succès</string>
|
||||
<string name="transfer_receiver_expired">Demande expirée</string>
|
||||
<string name="transfer_receiver_failed">Échec de l\'envoi</string>
|
||||
<string name="transfer_receiver_refused">Demande refusée</string>
|
||||
<string name="transfer_receiver_requested">En attente de votre approbation</string>
|
||||
<string name="transfer_receiver_unknown">Statut indisponible</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Demandes, approbations et livraisons terminées</string>
|
||||
<string name="transfer_receivers_pending">%1$d en attente</string>
|
||||
<string name="transfer_receivers_title">Destinataires</string>
|
||||
<string name="transfer_qr_unavailable">Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger.</string>
|
||||
<string name="transfer_scan_qr">Scannez avec VniDrop pour recevoir ce transfert</string>
|
||||
<string name="transfer_share_description">QR code, fichier d’invitation et options à proximité</string>
|
||||
<string name="transfer_share_title">Partager</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Rivedi trasferimento</string>
|
||||
<string name="receive_title">Ricevi</string>
|
||||
<string name="receive_unknown_transfer">Trasferimento VniDrop</string>
|
||||
<string name="relay_add_url">Aggiungi server relay</string>
|
||||
<string name="relay_apply">Applica impostazioni di rete</string>
|
||||
<string name="relay_applying">Applicazione…</string>
|
||||
<string name="relay_apply_active_transfers">Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.</string>
|
||||
<string name="relay_apply_failed">Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.</string>
|
||||
<string name="relay_apply_restart_description">L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string>
|
||||
<string name="relay_custom_urls_help">Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.</string>
|
||||
<string name="relay_custom_urls_label">URL relay</string>
|
||||
<string name="relay_mode_automatic">Automatica (consigliata)</string>
|
||||
<string name="relay_mode_automatic_description">Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>
|
||||
<string name="relay_mode_custom">Personalizzata rigorosa</string>
|
||||
<string name="relay_mode_custom_description">Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizzata con ripiego diretto</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.</string>
|
||||
<string name="relay_mode_local_only">Solo rete locale</string>
|
||||
<string name="relay_mode_local_only_description">Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.</string>
|
||||
<string name="relay_privacy_description">I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.</string>
|
||||
<string name="relay_remove_url">Rimuovi server relay</string>
|
||||
<string name="relay_restore_failed">Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.</string>
|
||||
<string name="relay_settings_applied">Impostazioni di rete applicate.</string>
|
||||
<string name="relay_strict_warning">La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.</string>
|
||||
<string name="relay_validation_duplicate_url">L’URL relay alla riga %1$d duplica una voce precedente.</string>
|
||||
<string name="relay_validation_https_required">L’URL relay alla riga %1$d deve iniziare con https://.</string>
|
||||
<string name="relay_validation_invalid_url">L’URL relay alla riga %1$d non è valido.</string>
|
||||
<string name="relay_validation_missing_url">Aggiunga almeno un URL relay.</string>
|
||||
<string name="relay_validation_too_many_urls">Può configurare fino a %1$d server relay.</string>
|
||||
<string name="send_access_anyone">Chiunque abbia questo trasferimento</string>
|
||||
<string name="send_access_anyone_description">Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere.</string>
|
||||
<string name="send_access_anyone_warning">Chiunque abbia l’invito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Trasferimento creato.</string>
|
||||
<string name="send_transfer_details_title">Dettagli del trasferimento</string>
|
||||
<string name="send_transfers_title">I suoi trasferimenti</string>
|
||||
<string name="settings_advanced_title">Avanzate</string>
|
||||
<string name="settings_network_title">Rete</string>
|
||||
<string name="settings_subtitle">Il suo nome, dove vengono salvati i trasferimenti, l’aspetto e le notifiche.</string>
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="snackbar_dismiss">Ignora</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string>
|
||||
<string name="transfer_receiver_completed">Ricevuto correttamente</string>
|
||||
<string name="transfer_receiver_expired">Richiesta scaduta</string>
|
||||
<string name="transfer_receiver_failed">Consegna non riuscita</string>
|
||||
<string name="transfer_receiver_refused">Richiesta rifiutata</string>
|
||||
<string name="transfer_receiver_requested">In attesa della sua approvazione</string>
|
||||
<string name="transfer_receiver_unknown">Stato non disponibile</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Richieste, approvazioni e consegne completate</string>
|
||||
<string name="transfer_receivers_pending">%1$d in attesa</string>
|
||||
<string name="transfer_receivers_title">Destinatari</string>
|
||||
<string name="transfer_qr_unavailable">Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica.</string>
|
||||
<string name="transfer_scan_qr">Scansioni con VniDrop per ricevere questo trasferimento</string>
|
||||
<string name="transfer_share_description">Codice QR, file di invito e opzioni nelle vicinanze</string>
|
||||
<string name="transfer_share_title">Condividi</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Overdracht controleren</string>
|
||||
<string name="receive_title">Ontvangen</string>
|
||||
<string name="receive_unknown_transfer">VniDrop-overdracht</string>
|
||||
<string name="relay_add_url">Relayserver toevoegen</string>
|
||||
<string name="relay_apply">Netwerkinstellingen toepassen</string>
|
||||
<string name="relay_applying">Toepassen…</string>
|
||||
<string name="relay_apply_active_transfers">Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.</string>
|
||||
<string name="relay_apply_failed">Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.</string>
|
||||
<string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string>
|
||||
<string name="relay_custom_urls_help">Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.</string>
|
||||
<string name="relay_custom_urls_label">Relay-URL\'s</string>
|
||||
<string name="relay_mode_automatic">Automatisch (aanbevolen)</string>
|
||||
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>
|
||||
<string name="relay_mode_custom">Strikt aangepast</string>
|
||||
<string name="relay_mode_custom_description">Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Aangepast met directe terugval</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.</string>
|
||||
<string name="relay_mode_local_only">Alleen lokaal netwerk</string>
|
||||
<string name="relay_mode_local_only_description">Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.</string>
|
||||
<string name="relay_privacy_description">Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.</string>
|
||||
<string name="relay_remove_url">Relayserver verwijderen</string>
|
||||
<string name="relay_restore_failed">De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.</string>
|
||||
<string name="relay_settings_applied">Netwerkinstellingen toegepast.</string>
|
||||
<string name="relay_strict_warning">De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.</string>
|
||||
<string name="relay_validation_duplicate_url">De relay-URL op regel %1$d is gelijk aan een eerdere invoer.</string>
|
||||
<string name="relay_validation_https_required">De relay-URL op regel %1$d moet beginnen met https://.</string>
|
||||
<string name="relay_validation_invalid_url">De relay-URL op regel %1$d is ongeldig.</string>
|
||||
<string name="relay_validation_missing_url">Voeg ten minste één relay-URL toe.</string>
|
||||
<string name="relay_validation_too_many_urls">U kunt maximaal %1$d relayservers instellen.</string>
|
||||
<string name="send_access_anyone">Iedereen met deze overdracht</string>
|
||||
<string name="send_access_anyone_description">Geen goedkeuring vereist. Gebruik dit alleen voor items die u gerust kunt delen.</string>
|
||||
<string name="send_access_anyone_warning">Iedereen met de uitnodiging kan downloaden totdat u stopt met delen. Gebruik dit niet voor privé- of gevoelige items.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Overdracht aangemaakt.</string>
|
||||
<string name="send_transfer_details_title">Overdrachtsdetails</string>
|
||||
<string name="send_transfers_title">Uw overdrachten</string>
|
||||
<string name="settings_advanced_title">Geavanceerd</string>
|
||||
<string name="settings_network_title">Netwerk</string>
|
||||
<string name="settings_subtitle">Uw naam, waar overdrachten worden bewaard, weergave en meldingen.</string>
|
||||
<string name="settings_title">Instellingen</string>
|
||||
<string name="snackbar_dismiss">Sluiten</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string>
|
||||
<string name="transfer_receiver_completed">Succesvol ontvangen</string>
|
||||
<string name="transfer_receiver_expired">Verzoek verlopen</string>
|
||||
<string name="transfer_receiver_failed">Levering mislukt</string>
|
||||
<string name="transfer_receiver_refused">Verzoek geweigerd</string>
|
||||
<string name="transfer_receiver_requested">Wachten op uw goedkeuring</string>
|
||||
<string name="transfer_receiver_unknown">Status niet beschikbaar</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Verzoeken, goedkeuringen en voltooide leveringen</string>
|
||||
<string name="transfer_receivers_pending">%1$d in behandeling</string>
|
||||
<string name="transfer_receivers_title">Ontvangers</string>
|
||||
<string name="transfer_qr_unavailable">QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden.</string>
|
||||
<string name="transfer_scan_qr">Scan met VniDrop om deze overdracht te ontvangen</string>
|
||||
<string name="transfer_share_description">QR-code, uitnodigingsbestand en opties in de buurt</string>
|
||||
<string name="transfer_share_title">Delen</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Przejrzyj transfer</string>
|
||||
<string name="receive_title">Odbierz</string>
|
||||
<string name="receive_unknown_transfer">Transfer VniDrop</string>
|
||||
<string name="relay_add_url">Dodaj serwer przekaźnikowy</string>
|
||||
<string name="relay_apply">Zastosuj ustawienia sieci</string>
|
||||
<string name="relay_applying">Stosowanie…</string>
|
||||
<string name="relay_apply_active_transfers">Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.</string>
|
||||
<string name="relay_apply_failed">Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.</string>
|
||||
<string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string>
|
||||
<string name="relay_custom_urls_help">Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.</string>
|
||||
<string name="relay_custom_urls_label">Adresy URL przekaźników</string>
|
||||
<string name="relay_mode_automatic">Automatyczny (zalecany)</string>
|
||||
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>
|
||||
<string name="relay_mode_custom">Ścisły niestandardowy</string>
|
||||
<string name="relay_mode_custom_description">Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Niestandardowy z trybem bezpośrednim</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.</string>
|
||||
<string name="relay_mode_local_only">Tylko sieć lokalna</string>
|
||||
<string name="relay_mode_local_only_description">Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.</string>
|
||||
<string name="relay_privacy_description">Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.</string>
|
||||
<string name="relay_remove_url">Usuń serwer przekaźnikowy</string>
|
||||
<string name="relay_restore_failed">Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.</string>
|
||||
<string name="relay_settings_applied">Zastosowano ustawienia sieci.</string>
|
||||
<string name="relay_strict_warning">Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.</string>
|
||||
<string name="relay_validation_duplicate_url">Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis.</string>
|
||||
<string name="relay_validation_https_required">Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://.</string>
|
||||
<string name="relay_validation_invalid_url">Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy.</string>
|
||||
<string name="relay_validation_missing_url">Dodaj co najmniej jeden adres URL przekaźnika.</string>
|
||||
<string name="relay_validation_too_many_urls">Możesz skonfigurować maksymalnie %1$d serwerów przekaźnikowych.</string>
|
||||
<string name="send_access_anyone">Każdy, kto ma ten transfer</string>
|
||||
<string name="send_access_anyone_description">Nie jest wymagane zatwierdzenie. Używaj tylko dla elementów, które możesz swobodnie udostępniać.</string>
|
||||
<string name="send_access_anyone_warning">Każdy, kto ma zaproszenie, może pobierać, dopóki nie zatrzymasz udostępniania. Nie używaj tego dla prywatnych ani wrażliwych elementów.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Transfer utworzony.</string>
|
||||
<string name="send_transfer_details_title">Szczegóły transferu</string>
|
||||
<string name="send_transfers_title">Twoje transfery</string>
|
||||
<string name="settings_advanced_title">Zaawansowane</string>
|
||||
<string name="settings_network_title">Sieć</string>
|
||||
<string name="settings_subtitle">Twoja nazwa, miejsce zapisu transferów, wygląd i powiadomienia.</string>
|
||||
<string name="settings_title">Ustawienia</string>
|
||||
<string name="snackbar_dismiss">Zamknij</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string>
|
||||
<string name="transfer_receiver_completed">Odebrano pomyślnie</string>
|
||||
<string name="transfer_receiver_expired">Prośba wygasła</string>
|
||||
<string name="transfer_receiver_failed">Dostarczenie nie powiodło się</string>
|
||||
<string name="transfer_receiver_refused">Prośba odrzucona</string>
|
||||
<string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string>
|
||||
<string name="transfer_receiver_unknown">Status niedostępny</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Prośby, zatwierdzenia i ukończone dostawy</string>
|
||||
<string name="transfer_receivers_pending">Oczekujące: %1$d</string>
|
||||
<string name="transfer_receivers_title">Odbiorcy</string>
|
||||
<string name="transfer_qr_unavailable">Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz.</string>
|
||||
<string name="transfer_scan_qr">Zeskanuj za pomocą VniDrop, aby odebrać ten transfer</string>
|
||||
<string name="transfer_share_description">Kod QR, plik zaproszenia i opcje w pobliżu</string>
|
||||
<string name="transfer_share_title">Udostępnij</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Rever transferência</string>
|
||||
<string name="receive_title">Receber</string>
|
||||
<string name="receive_unknown_transfer">Transferência VniDrop</string>
|
||||
<string name="relay_add_url">Adicionar servidor de retransmissão</string>
|
||||
<string name="relay_apply">Aplicar definições de rede</string>
|
||||
<string name="relay_applying">A aplicar…</string>
|
||||
<string name="relay_apply_active_transfers">Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.</string>
|
||||
<string name="relay_apply_failed">Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.</string>
|
||||
<string name="relay_apply_restart_description">A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes.</string>
|
||||
<string name="relay_custom_urls_help">Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.</string>
|
||||
<string name="relay_custom_urls_label">URLs dos retransmissores</string>
|
||||
<string name="relay_mode_automatic">Automático (recomendado)</string>
|
||||
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>
|
||||
<string name="relay_mode_custom">Personalizado estrito</string>
|
||||
<string name="relay_mode_custom_description">Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizado com alternativa direta</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.</string>
|
||||
<string name="relay_mode_local_only">Apenas rede local</string>
|
||||
<string name="relay_mode_local_only_description">Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.</string>
|
||||
<string name="relay_privacy_description">Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.</string>
|
||||
<string name="relay_remove_url">Remover servidor de retransmissão</string>
|
||||
<string name="relay_restore_failed">Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.</string>
|
||||
<string name="relay_settings_applied">Definições de rede aplicadas.</string>
|
||||
<string name="relay_strict_warning">O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.</string>
|
||||
<string name="relay_validation_duplicate_url">O URL do retransmissor na linha %1$d duplica uma entrada anterior.</string>
|
||||
<string name="relay_validation_https_required">O URL do retransmissor na linha %1$d tem de começar por https://.</string>
|
||||
<string name="relay_validation_invalid_url">O URL do retransmissor na linha %1$d não é válido.</string>
|
||||
<string name="relay_validation_missing_url">Adicione pelo menos um URL de retransmissor.</string>
|
||||
<string name="relay_validation_too_many_urls">Pode configurar até %1$d servidores de retransmissão.</string>
|
||||
<string name="send_access_anyone">Qualquer pessoa com esta transferência</string>
|
||||
<string name="send_access_anyone_description">Não é necessária aprovação. Utilize apenas para itens que não se importe de partilhar.</string>
|
||||
<string name="send_access_anyone_warning">Qualquer pessoa com o convite pode descarregar até parar de partilhar. Não utilize para itens privados ou sensíveis.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Transferência criada.</string>
|
||||
<string name="send_transfer_details_title">Detalhes da transferência</string>
|
||||
<string name="send_transfers_title">As suas transferências</string>
|
||||
<string name="settings_advanced_title">Avançado</string>
|
||||
<string name="settings_network_title">Rede</string>
|
||||
<string name="settings_subtitle">O seu nome, onde as transferências são guardadas, o aspeto e as notificações.</string>
|
||||
<string name="settings_title">Definições</string>
|
||||
<string name="snackbar_dismiss">Ignorar</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string>
|
||||
<string name="transfer_receiver_completed">Recebido com sucesso</string>
|
||||
<string name="transfer_receiver_expired">Pedido expirado</string>
|
||||
<string name="transfer_receiver_failed">Falha na entrega</string>
|
||||
<string name="transfer_receiver_refused">Pedido recusado</string>
|
||||
<string name="transfer_receiver_requested">A aguardar a sua aprovação</string>
|
||||
<string name="transfer_receiver_unknown">Estado indisponível</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Pedidos, aprovações e entregas concluídas</string>
|
||||
<string name="transfer_receivers_pending">%1$d em espera</string>
|
||||
<string name="transfer_receivers_title">Destinatários</string>
|
||||
<string name="transfer_qr_unavailable">O código QR não está disponível para este convite. Utilize Partilhar ou Transferir.</string>
|
||||
<string name="transfer_scan_qr">Leia com o VniDrop para receber esta transferência</string>
|
||||
<string name="transfer_share_description">Código QR, ficheiro de convite e opções por perto</string>
|
||||
<string name="transfer_share_title">Partilhar</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Проверить передачу</string>
|
||||
<string name="receive_title">Получить</string>
|
||||
<string name="receive_unknown_transfer">Передача VniDrop</string>
|
||||
<string name="relay_add_url">Добавить сервер-ретранслятор</string>
|
||||
<string name="relay_apply">Применить настройки сети</string>
|
||||
<string name="relay_applying">Применение…</string>
|
||||
<string name="relay_apply_active_transfers">Остановите все активные передачи и раздачи перед применением настроек сети.</string>
|
||||
<string name="relay_apply_failed">Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.</string>
|
||||
<string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string>
|
||||
<string name="relay_custom_urls_help">Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
|
||||
<string name="relay_custom_urls_label">URL-адреса ретрансляторов</string>
|
||||
<string name="relay_mode_automatic">Автоматически (рекомендуется)</string>
|
||||
<string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string>
|
||||
<string name="relay_mode_custom">Строго пользовательский</string>
|
||||
<string name="relay_mode_custom_description">Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Пользовательский с прямым резервом</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются.</string>
|
||||
<string name="relay_mode_local_only">Только локальная сеть</string>
|
||||
<string name="relay_mode_local_only_description">Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети.</string>
|
||||
<string name="relay_privacy_description">Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения.</string>
|
||||
<string name="relay_remove_url">Удалить сервер-ретранслятор</string>
|
||||
<string name="relay_restore_failed">Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов.</string>
|
||||
<string name="relay_settings_applied">Настройки сети применены.</string>
|
||||
<string name="relay_strict_warning">Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение.</string>
|
||||
<string name="relay_validation_duplicate_url">URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись.</string>
|
||||
<string name="relay_validation_https_required">URL-адрес ретранслятора в строке %1$d должен начинаться с https://.</string>
|
||||
<string name="relay_validation_invalid_url">URL-адрес ретранслятора в строке %1$d недействителен.</string>
|
||||
<string name="relay_validation_missing_url">Добавьте хотя бы один URL-адрес ретранслятора.</string>
|
||||
<string name="relay_validation_too_many_urls">Можно настроить до %1$d серверов-ретрансляторов.</string>
|
||||
<string name="send_access_anyone">Любой, у кого есть эта передача</string>
|
||||
<string name="send_access_anyone_description">Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться.</string>
|
||||
<string name="send_access_anyone_warning">Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Передача создана.</string>
|
||||
<string name="send_transfer_details_title">Сведения о передаче</string>
|
||||
<string name="send_transfers_title">Ваши передачи</string>
|
||||
<string name="settings_advanced_title">Дополнительно</string>
|
||||
<string name="settings_network_title">Сеть</string>
|
||||
<string name="settings_subtitle">Ваше имя, место сохранения передач, оформление и уведомления.</string>
|
||||
<string name="settings_title">Настройки</string>
|
||||
<string name="snackbar_dismiss">Закрыть</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string>
|
||||
<string name="transfer_receiver_completed">Успешно получено</string>
|
||||
<string name="transfer_receiver_expired">Запрос истёк</string>
|
||||
<string name="transfer_receiver_failed">Ошибка доставки</string>
|
||||
<string name="transfer_receiver_refused">Запрос отклонён</string>
|
||||
<string name="transfer_receiver_requested">Ожидание вашего одобрения</string>
|
||||
<string name="transfer_receiver_unknown">Статус недоступен</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Запросы, одобрения и завершённые доставки</string>
|
||||
<string name="transfer_receivers_pending">Ожидают: %1$d</string>
|
||||
<string name="transfer_receivers_title">Получатели</string>
|
||||
<string name="transfer_qr_unavailable">QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать».</string>
|
||||
<string name="transfer_scan_qr">Отсканируйте с помощью VniDrop, чтобы получить эту передачу</string>
|
||||
<string name="transfer_share_description">QR-код, файл приглашения и варианты поблизости</string>
|
||||
<string name="transfer_share_title">Поделиться</string>
|
||||
|
||||
@@ -170,6 +170,32 @@
|
||||
<string name="receive_review_title">Review transfer</string>
|
||||
<string name="receive_title">Receive</string>
|
||||
<string name="receive_unknown_transfer">VniDrop transfer</string>
|
||||
<string name="relay_add_url">Add relay server</string>
|
||||
<string name="relay_apply">Apply network settings</string>
|
||||
<string name="relay_applying">Applying…</string>
|
||||
<string name="relay_apply_active_transfers">Stop all active transfers and shares before applying network settings.</string>
|
||||
<string name="relay_apply_failed">Could not apply these settings. The previous network settings were restored.</string>
|
||||
<string name="relay_apply_restart_description">Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string>
|
||||
<string name="relay_custom_urls_help">Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
|
||||
<string name="relay_custom_urls_label">Relay URLs</string>
|
||||
<string name="relay_mode_automatic">Automatic (recommended)</string>
|
||||
<string name="relay_mode_automatic_description">Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.</string>
|
||||
<string name="relay_mode_custom">Strict custom</string>
|
||||
<string name="relay_mode_custom_description">Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Custom with direct fallback</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.</string>
|
||||
<string name="relay_mode_local_only">Local only</string>
|
||||
<string name="relay_mode_local_only_description">Disable all relays and allow only direct connections, primarily for devices on the same network.</string>
|
||||
<string name="relay_privacy_description">Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.</string>
|
||||
<string name="relay_remove_url">Remove relay server</string>
|
||||
<string name="relay_restore_failed">Could not restore the previous network settings. Restart VniDrop and review your relay configuration.</string>
|
||||
<string name="relay_settings_applied">Network settings applied.</string>
|
||||
<string name="relay_strict_warning">Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.</string>
|
||||
<string name="relay_validation_duplicate_url">Relay URL on line %1$d duplicates an earlier entry.</string>
|
||||
<string name="relay_validation_https_required">Relay URL on line %1$d must start with https://.</string>
|
||||
<string name="relay_validation_invalid_url">Relay URL on line %1$d is not valid.</string>
|
||||
<string name="relay_validation_missing_url">Add at least one relay URL.</string>
|
||||
<string name="relay_validation_too_many_urls">You can configure up to %1$d relay servers.</string>
|
||||
<string name="send_access_anyone">Anyone with this transfer</string>
|
||||
<string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string>
|
||||
<string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string>
|
||||
@@ -193,6 +219,8 @@
|
||||
<string name="send_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</string>
|
||||
<string name="send_transfers_title">Your transfers</string>
|
||||
<string name="settings_advanced_title">Advanced</string>
|
||||
<string name="settings_network_title">Network</string>
|
||||
<string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="snackbar_dismiss">Dismiss</string>
|
||||
@@ -253,6 +281,7 @@
|
||||
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
|
||||
<string name="transfer_receiver_completed">Received successfully</string>
|
||||
<string name="transfer_receiver_expired">Request expired</string>
|
||||
<string name="transfer_receiver_failed">Delivery failed</string>
|
||||
<string name="transfer_receiver_refused">Request refused</string>
|
||||
<string name="transfer_receiver_requested">Waiting for your approval</string>
|
||||
<string name="transfer_receiver_unknown">Status unavailable</string>
|
||||
@@ -260,6 +289,7 @@
|
||||
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
|
||||
<string name="transfer_receivers_pending">%1$d waiting</string>
|
||||
<string name="transfer_receivers_title">Receivers</string>
|
||||
<string name="transfer_qr_unavailable">QR unavailable for this invitation. Use Share or Download instead.</string>
|
||||
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
|
||||
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
|
||||
<string name="transfer_share_title">Share</string>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class CoreLifecycleBusyException(message: String) : IllegalStateException(message)
|
||||
|
||||
internal class CoreLifecycleGate {
|
||||
private val mutex = Mutex()
|
||||
private var reconfiguring = false
|
||||
private var inFlightCalls = 0
|
||||
|
||||
suspend fun <C, T> withCall(
|
||||
capture: () -> C,
|
||||
block: suspend (C) -> T,
|
||||
): T {
|
||||
val captured = mutex.withLock {
|
||||
if (reconfiguring) throw CoreLifecycleBusyException("Core network configuration is changing")
|
||||
val value = capture()
|
||||
inFlightCalls += 1
|
||||
value
|
||||
}
|
||||
return try {
|
||||
block(captured)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
check(inFlightCalls > 0)
|
||||
inFlightCalls -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> withReconfiguration(block: suspend () -> T): T {
|
||||
mutex.withLock {
|
||||
if (reconfiguring) throw CoreLifecycleBusyException("Core network configuration is already changing")
|
||||
if (inFlightCalls > 0) throw CoreLifecycleBusyException("Core calls are still active")
|
||||
reconfiguring = true
|
||||
}
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
mutex.withLock { reconfiguring = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@ enum class ShareAccessPolicy {
|
||||
AnyoneWithTransfer,
|
||||
}
|
||||
|
||||
enum class RelayMode {
|
||||
Automatic,
|
||||
StrictCustom,
|
||||
CustomWithDirectFallback,
|
||||
LocalOnly,
|
||||
}
|
||||
|
||||
data class RelaySettings(
|
||||
val mode: RelayMode = RelayMode.Automatic,
|
||||
val relayUrls: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
val RelayMode.usesCustomRelayUrls: Boolean
|
||||
get() = this == RelayMode.StrictCustom || this == RelayMode.CustomWithDirectFallback
|
||||
|
||||
enum class TransferDirection {
|
||||
Send,
|
||||
Receive,
|
||||
@@ -102,6 +117,7 @@ enum class ReceiverDeliveryStatus {
|
||||
Refused,
|
||||
Expired,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -142,7 +158,10 @@ interface CoreGateway {
|
||||
val state: StateFlow<CoreState>
|
||||
val signals: SharedFlow<CoreSignal>
|
||||
|
||||
suspend fun initialize(appDataDir: String): Result<Unit>
|
||||
suspend fun initialize(
|
||||
appDataDir: String,
|
||||
relaySettings: RelaySettings = RelaySettings(),
|
||||
): Result<Unit>
|
||||
fun shutdown()
|
||||
suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share>
|
||||
suspend fun shareFileDescriptor(
|
||||
@@ -164,6 +183,7 @@ interface CoreGateway {
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit>
|
||||
suspend fun storageUsage(): Result<CoreStorageUsageModel>
|
||||
suspend fun clearTransferCache(): Result<ULong>
|
||||
suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>>
|
||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||
suspend fun delete(transferId: ULong): Result<Unit>
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.CoreNetworkConfig
|
||||
import uniffi.vnidrop.CoreRelayMode
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import uniffi.vnidrop.ReceiveOutputSinkV2
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
@@ -28,6 +30,8 @@ import uniffi.vnidrop.TicketInspection
|
||||
import uniffi.vnidrop.TransferMetadata
|
||||
import uniffi.vnidrop.TransferAccessMode
|
||||
import uniffi.vnidrop.VnidropCore
|
||||
import uniffi.vnidrop.clearInactiveTransferCache
|
||||
import uniffi.vnidrop.defaultCoreNetworkConfig
|
||||
|
||||
class CoreRepository(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
@@ -42,6 +46,9 @@ class CoreRepository(
|
||||
override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow()
|
||||
|
||||
private var core: VnidropCore? = null
|
||||
private var currentAppDataDir: String? = null
|
||||
private var currentRelaySettings = RelaySettings()
|
||||
private val lifecycleGate = CoreLifecycleGate()
|
||||
|
||||
private val sink = object : CoreEventSink {
|
||||
override fun onEvent(event: CoreEvent) {
|
||||
@@ -60,16 +67,30 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize(appDataDir: String): Result<Unit> = runCore {
|
||||
core?.shutdown()
|
||||
core = VnidropCore.initialize(appDataDir, sink)
|
||||
refreshSnapshot()
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result<Unit> =
|
||||
runReconfiguration {
|
||||
val previousCore = core
|
||||
if (previousCore != null) {
|
||||
val status = previousCore.status()
|
||||
require(status.activeTransfers == 0UL && status.activeShares == 0UL) {
|
||||
"Stop active transfers and shares before changing relay settings"
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(isInitialized = false, status = null) }
|
||||
core = null
|
||||
disposeCore(previousCore)
|
||||
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative())
|
||||
currentAppDataDir = appDataDir
|
||||
currentRelaySettings = relaySettings
|
||||
refreshSnapshot(requireCore())
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
|
||||
override fun shutdown() {
|
||||
core?.shutdown()
|
||||
val activeCore = core
|
||||
core = null
|
||||
disposeCore(activeCore)
|
||||
currentAppDataDir = null
|
||||
_state.value = CoreState()
|
||||
}
|
||||
|
||||
@@ -84,7 +105,7 @@ class CoreRepository(
|
||||
ShareSource(
|
||||
kind = SourceKind.PATH,
|
||||
value = path,
|
||||
displayName = path.substringAfterLast('/').ifBlank { null },
|
||||
displayName = null,
|
||||
isDirectory = false,
|
||||
),
|
||||
),
|
||||
@@ -114,37 +135,37 @@ class CoreRepository(
|
||||
accessPolicy = accessPolicy,
|
||||
)
|
||||
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore { activeCore ->
|
||||
activeCore.inspectTicket(ticket).toModel().also { inspection ->
|
||||
_state.update { it.copy(lastInspection = inspection) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore {
|
||||
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun receiveWithOutputSink(
|
||||
ticket: String,
|
||||
outputSink: ReceiveOutputSink,
|
||||
receiverName: String,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun receiveWithOutputSinkV2(
|
||||
ticket: String,
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
receiverName: String,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore {
|
||||
val usage = requireCore().storageUsage()
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore { activeCore ->
|
||||
val usage = activeCore.storageUsage()
|
||||
CoreStorageUsageModel(
|
||||
blobStoreBytes = usage.blobStoreBytes,
|
||||
databaseBytes = usage.databaseBytes,
|
||||
@@ -154,8 +175,40 @@ class CoreRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore {
|
||||
requireCore().listReceivedArtifacts().map { artifact ->
|
||||
override suspend fun clearTransferCache(): Result<ULong> = runReconfiguration {
|
||||
val activeCore = requireCore()
|
||||
val status = activeCore.status()
|
||||
require(status.activeTransfers == 0UL && status.activeShares == 0UL) {
|
||||
"Wait for active transfers and shares to finish before clearing the transfer cache"
|
||||
}
|
||||
val appDataDir = requireNotNull(currentAppDataDir) { "Core data directory is unavailable" }
|
||||
val relaySettings = currentRelaySettings
|
||||
_state.update { it.copy(isInitialized = false, status = null) }
|
||||
core = null
|
||||
disposeCore(activeCore)
|
||||
var reclaimed = 0UL
|
||||
try {
|
||||
reclaimed = clearInactiveTransferCache(appDataDir)
|
||||
} finally {
|
||||
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative())
|
||||
refreshSnapshot(requireCore())
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
reclaimed
|
||||
}
|
||||
|
||||
private fun disposeCore(activeCore: VnidropCore?) {
|
||||
if (activeCore == null) return
|
||||
try {
|
||||
activeCore.shutdown()
|
||||
} finally {
|
||||
// UniFFI owns the Rust Arc; explicit disposal releases Windows file handles before cache deletion.
|
||||
activeCore.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore ->
|
||||
activeCore.listReceivedArtifacts().map { artifact ->
|
||||
ReceivedArtifactModel(
|
||||
id = artifact.id,
|
||||
locator = artifact.locator,
|
||||
@@ -165,47 +218,47 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().cancelTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.cancelTransfer(transferId)
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().deleteTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.deleteTransfer(transferId)
|
||||
refreshSnapshot(activeCore)
|
||||
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||
}
|
||||
|
||||
override suspend fun clearReceiveHistory(): Result<ULong> = runCore {
|
||||
val deleted = requireCore().deleteReceiveHistory()
|
||||
refreshSnapshot()
|
||||
override suspend fun clearReceiveHistory(): Result<ULong> = runCore { activeCore ->
|
||||
val deleted = activeCore.deleteReceiveHistory()
|
||||
refreshSnapshot(activeCore)
|
||||
deleted
|
||||
}
|
||||
|
||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
|
||||
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { activeCore ->
|
||||
activeCore.listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||
}
|
||||
|
||||
override suspend fun respondReceiverRequest(
|
||||
requestId: String,
|
||||
accepted: Boolean,
|
||||
reason: String?,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().respondReceiverRequest(requestId, accepted, reason)
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.respondReceiverRequest(requestId, accepted, reason)
|
||||
}
|
||||
|
||||
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
|
||||
override suspend fun refresh(): Result<Unit> = runCore(::refreshSnapshot)
|
||||
|
||||
override suspend fun shareSources(
|
||||
sources: List<ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = runCore {
|
||||
): Result<Share> = runCore { activeCore ->
|
||||
require(sources.isNotEmpty()) { "Select at least one file to share" }
|
||||
withPlatformPathAccess(sources) {
|
||||
requireCore().shareFiles(
|
||||
activeCore.shareFiles(
|
||||
sources = sources,
|
||||
metadata = ShareMetadataInput(
|
||||
transferId = nextTransferId(),
|
||||
@@ -215,7 +268,7 @@ class CoreRepository(
|
||||
),
|
||||
).toModel()
|
||||
}.also { share ->
|
||||
refreshSnapshot()
|
||||
refreshSnapshot(activeCore)
|
||||
_state.update { it.copy(lastShare = share) }
|
||||
}
|
||||
}
|
||||
@@ -232,8 +285,7 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSnapshot() {
|
||||
val activeCore = requireCore()
|
||||
private fun refreshSnapshot(activeCore: VnidropCore) {
|
||||
val status = activeCore.status()
|
||||
_state.update {
|
||||
it.copy(
|
||||
@@ -244,10 +296,25 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runCore(block: suspend () -> T): Result<T> =
|
||||
private suspend fun <T> runCore(block: suspend (VnidropCore) -> T): Result<T> =
|
||||
withContext(dispatcher) {
|
||||
try {
|
||||
Result.success(block())
|
||||
Result.success(
|
||||
lifecycleGate.withCall(
|
||||
capture = ::requireCore,
|
||||
block = block,
|
||||
),
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runReconfiguration(block: suspend () -> T): Result<T> =
|
||||
withContext(dispatcher) {
|
||||
try {
|
||||
Result.success(lifecycleGate.withReconfiguration(block))
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
Result.failure(error)
|
||||
@@ -263,6 +330,22 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) {
|
||||
RelayMode.Automatic -> defaultCoreNetworkConfig()
|
||||
RelayMode.StrictCustom -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.STRICT_CUSTOM,
|
||||
relayUrls = relayUrls,
|
||||
)
|
||||
RelayMode.CustomWithDirectFallback -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.CUSTOM_WITH_DIRECT_FALLBACK,
|
||||
relayUrls = relayUrls,
|
||||
)
|
||||
RelayMode.LocalOnly -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.LOCAL_ONLY,
|
||||
relayUrls = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
|
||||
id = id,
|
||||
timestamp = timestamp,
|
||||
@@ -361,6 +444,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
|
||||
"refused" -> ReceiverDeliveryStatus.Refused
|
||||
"expired" -> ReceiverDeliveryStatus.Expired
|
||||
"completed" -> ReceiverDeliveryStatus.Completed
|
||||
"failed" -> ReceiverDeliveryStatus.Failed
|
||||
else -> ReceiverDeliveryStatus.Unknown
|
||||
},
|
||||
reason = reason,
|
||||
|
||||
@@ -40,7 +40,7 @@ interface FileSystemService {
|
||||
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
|
||||
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
|
||||
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
|
||||
suspend fun temporaryUsage(): ULong
|
||||
suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong
|
||||
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
|
||||
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
|
||||
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -46,7 +47,8 @@ class AppViewModel(
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
||||
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
||||
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collect { preferences ->
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
|
||||
@@ -96,7 +97,12 @@ fun SendScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.detailPanel != null) {
|
||||
val canShowDetailPanel = selectedTransfer != null && when (state.detailPanel) {
|
||||
TransferDetailPanel.Share -> selectedTransfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
|
||||
TransferDetailPanel.Activity, TransferDetailPanel.Receivers -> true
|
||||
null -> false
|
||||
}
|
||||
if (selectedTransfer != null && state.detailPanel != null && canShowDetailPanel) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
||||
when (state.detailPanel) {
|
||||
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
||||
|
||||
@@ -209,7 +209,12 @@ class SendViewModel(
|
||||
)
|
||||
}
|
||||
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
|
||||
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||
fun openShare() {
|
||||
val selectedId = _state.value.selectedTransferId ?: return
|
||||
val selected = coreState.value.transfers.firstOrNull { it.transferId == selectedId } ?: return
|
||||
if (selected.status !in setOf(TransferStatus.Importing, TransferStatus.Sharing)) return
|
||||
_state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||
}
|
||||
fun openReceivers() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
|
||||
|
||||
@@ -46,6 +46,7 @@ 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.TransferStatus
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.DestructiveButton
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
@@ -60,12 +61,19 @@ import com.vnidrop.app.ui.state.progressForReceiver
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import vnidrop.shared.generated.resources.*
|
||||
|
||||
enum class InvitationAction { Export, Share, Nfc }
|
||||
|
||||
private sealed interface TransferQrRenderState {
|
||||
data object Loading : TransferQrRenderState
|
||||
data object Unavailable : TransferQrRenderState
|
||||
data class Ready(val bitmap: androidx.compose.ui.graphics.ImageBitmap) : TransferQrRenderState
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TransferDetails(
|
||||
transfer: Transfer,
|
||||
@@ -122,12 +130,29 @@ internal fun TransferDetails(
|
||||
count = pendingReceivers + completedReceivers,
|
||||
onClick = onReceivers,
|
||||
)
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailDestination(
|
||||
title = stringResource(Res.string.transfer_share_title),
|
||||
description = stringResource(Res.string.transfer_share_description),
|
||||
onClick = onShare,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,9 +169,12 @@ private fun receiversDescription(pending: Int, completed: Int): String = when {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
|
||||
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: (() -> Unit)? = null) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
@@ -157,7 +185,9 @@ private fun DetailDestination(title: String, description: String, count: Int? =
|
||||
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
if (onClick != null) {
|
||||
PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,41 +270,71 @@ internal fun TransferSharePanel(
|
||||
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
|
||||
val ticket = transfer.ticket
|
||||
PanelContainer(stringResource(Res.string.transfer_share_title)) {
|
||||
if (transfer.status == TransferStatus.Importing) {
|
||||
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||
return@PanelContainer
|
||||
}
|
||||
if (transfer.status != TransferStatus.Sharing) return@PanelContainer
|
||||
if (ticket == null) {
|
||||
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||
return@PanelContainer
|
||||
}
|
||||
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
|
||||
if (value == null) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
runCatching { buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||
val qrRenderState by produceState<TransferQrRenderState>(
|
||||
initialValue = qrBitmap?.let(TransferQrRenderState::Ready) ?: TransferQrRenderState.Loading,
|
||||
key1 = ticket,
|
||||
key2 = qrBitmap,
|
||||
) {
|
||||
if (qrBitmap != null) {
|
||||
value = TransferQrRenderState.Ready(qrBitmap)
|
||||
return@produceState
|
||||
}
|
||||
value = try {
|
||||
val bitmap = withContext(Dispatchers.Default) {
|
||||
buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap()
|
||||
}
|
||||
value?.let { onQrRendered(ticket, it) }
|
||||
onQrRendered(ticket, bitmap)
|
||||
TransferQrRenderState.Ready(bitmap)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Throwable) {
|
||||
TransferQrRenderState.Unavailable
|
||||
}
|
||||
}
|
||||
val renderedQr = renderedBitmap
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
if (renderedQr != null) {
|
||||
when (val qrState = qrRenderState) {
|
||||
is TransferQrRenderState.Ready -> Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Image(
|
||||
bitmap = renderedQr,
|
||||
bitmap = qrState.bitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(14.dp).fillMaxSize(),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
} else {
|
||||
}
|
||||
TransferQrRenderState.Loading -> Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
}
|
||||
TransferQrRenderState.Unavailable -> Text(
|
||||
stringResource(Res.string.transfer_qr_unavailable),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (qrRenderState is TransferQrRenderState.Ready) {
|
||||
Text(
|
||||
stringResource(Res.string.transfer_scan_qr),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.transfer_scan_qr),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
|
||||
var writingNfc by remember(ticket) { mutableStateOf(false) }
|
||||
SecondaryButton(
|
||||
@@ -349,13 +409,15 @@ private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(
|
||||
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
|
||||
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
|
||||
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
|
||||
ReceiverDeliveryStatus.Failed -> Res.string.transfer_receiver_failed
|
||||
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
|
||||
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
|
||||
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
|
||||
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired, ReceiverDeliveryStatus.Failed ->
|
||||
LocalVniDropColors.current.destructiveDefault
|
||||
else -> LocalVniDropColors.current.foregroundLighter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.icons.PlatformIcon
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.approval_endpoint_id
|
||||
import vnidrop.shared.generated.resources.relay_add_url
|
||||
import vnidrop.shared.generated.resources.relay_apply
|
||||
import vnidrop.shared.generated.resources.relay_apply_active_transfers
|
||||
import vnidrop.shared.generated.resources.relay_apply_failed
|
||||
import vnidrop.shared.generated.resources.relay_apply_restart_description
|
||||
import vnidrop.shared.generated.resources.relay_applying
|
||||
import vnidrop.shared.generated.resources.relay_custom_urls_help
|
||||
import vnidrop.shared.generated.resources.relay_custom_urls_label
|
||||
import vnidrop.shared.generated.resources.relay_mode_automatic
|
||||
import vnidrop.shared.generated.resources.relay_mode_automatic_description
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_description
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback_description
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only_description
|
||||
import vnidrop.shared.generated.resources.relay_privacy_description
|
||||
import vnidrop.shared.generated.resources.relay_remove_url
|
||||
import vnidrop.shared.generated.resources.relay_restore_failed
|
||||
import vnidrop.shared.generated.resources.relay_strict_warning
|
||||
import vnidrop.shared.generated.resources.relay_validation_duplicate_url
|
||||
import vnidrop.shared.generated.resources.relay_validation_https_required
|
||||
import vnidrop.shared.generated.resources.relay_validation_invalid_url
|
||||
import vnidrop.shared.generated.resources.relay_validation_missing_url
|
||||
import vnidrop.shared.generated.resources.relay_validation_too_many_urls
|
||||
import vnidrop.shared.generated.resources.settings_network_title
|
||||
|
||||
@Composable
|
||||
internal fun NetworkSettings(
|
||||
state: SettingsState,
|
||||
onModeChanged: (RelayMode) -> Unit,
|
||||
onUrlChanged: (Int, String) -> Unit,
|
||||
onAddUrl: () -> Unit,
|
||||
onRemoveUrl: (Int) -> Unit,
|
||||
onApply: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(stringResource(Res.string.settings_network_title), onBack, showBack)
|
||||
SettingsGroup {
|
||||
RelayModeRow(
|
||||
icon = AppIcon.Globe,
|
||||
title = stringResource(Res.string.relay_mode_automatic),
|
||||
description = stringResource(Res.string.relay_mode_automatic_description),
|
||||
selected = state.relayMode == RelayMode.Automatic,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.Automatic) },
|
||||
)
|
||||
SettingsDivider()
|
||||
RelayModeRow(
|
||||
icon = AppIcon.Shield,
|
||||
title = stringResource(Res.string.relay_mode_custom),
|
||||
description = stringResource(Res.string.relay_mode_custom_description),
|
||||
selected = state.relayMode == RelayMode.StrictCustom,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.StrictCustom) },
|
||||
)
|
||||
SettingsDivider()
|
||||
RelayModeRow(
|
||||
icon = AppIcon.Radio,
|
||||
title = stringResource(Res.string.relay_mode_custom_direct_fallback),
|
||||
description = stringResource(Res.string.relay_mode_custom_direct_fallback_description),
|
||||
selected = state.relayMode == RelayMode.CustomWithDirectFallback,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.CustomWithDirectFallback) },
|
||||
)
|
||||
SettingsDivider()
|
||||
RelayModeRow(
|
||||
icon = AppIcon.CloudOff,
|
||||
title = stringResource(Res.string.relay_mode_local_only),
|
||||
description = stringResource(Res.string.relay_mode_local_only_description),
|
||||
selected = state.relayMode == RelayMode.LocalOnly,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.LocalOnly) },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.relay_privacy_description),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
stringResource(Res.string.approval_endpoint_id, endpointId),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.relayMode.usesCustomRelayUrls) {
|
||||
Text(
|
||||
stringResource(Res.string.relay_custom_urls_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
SettingsGroup {
|
||||
state.relayUrls.forEachIndexed { index, url ->
|
||||
RelayUrlRow(
|
||||
value = url,
|
||||
error = relayUrlErrorText(state.relayInputError, index),
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onValueChange = { onUrlChanged(index, it) },
|
||||
onRemove = { onRemoveUrl(index) },
|
||||
)
|
||||
if (index < state.relayUrls.lastIndex) {
|
||||
SettingsDivider(startPadding = 14.dp)
|
||||
}
|
||||
}
|
||||
if (state.relayUrls.isNotEmpty()) {
|
||||
SettingsDivider(startPadding = 14.dp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onAddUrl,
|
||||
enabled = state.relayUrls.size < MaximumRelayUrls && !state.isApplyingRelaySettings,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
PlatformIcon(AppIcon.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.relay_add_url))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.relay_custom_urls_help),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.relayMode == RelayMode.StrictCustom) {
|
||||
Text(
|
||||
stringResource(Res.string.relay_strict_warning),
|
||||
color = colors.foregroundLight,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
relayGlobalErrorText(state)?.let { error ->
|
||||
Text(
|
||||
error,
|
||||
color = colors.destructiveDefault,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.relay_apply_restart_description),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PrimaryButton(
|
||||
text = stringResource(
|
||||
if (state.isApplyingRelaySettings) Res.string.relay_applying else Res.string.relay_apply,
|
||||
),
|
||||
onClick = onApply,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state.hasRelaySettingsChanges &&
|
||||
!state.isApplyingRelaySettings &&
|
||||
!state.hasActiveNetworkWork,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayModeRow(
|
||||
icon: AppIcon,
|
||||
title: String,
|
||||
description: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
SettingsRow(
|
||||
icon = icon,
|
||||
title = title,
|
||||
subtitle = description,
|
||||
selected = selected,
|
||||
onClick = onClick.takeIf { enabled },
|
||||
showsDisclosure = false,
|
||||
trailing = if (selected) {
|
||||
{
|
||||
PlatformIcon(
|
||||
AppIcon.Check,
|
||||
contentDescription = null,
|
||||
tint = LocalVniDropColors.current.brandLink,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayUrlRow(
|
||||
value: String,
|
||||
error: String?,
|
||||
enabled: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 14.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
placeholder = { Text("https://relay.example.com") },
|
||||
supportingText = error?.let {
|
||||
{
|
||||
Text(it, color = colors.destructiveDefault)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
IconButton(onClick = onRemove, enabled = enabled) {
|
||||
PlatformIcon(
|
||||
AppIcon.Delete,
|
||||
contentDescription = stringResource(Res.string.relay_remove_url),
|
||||
tint = colors.destructiveDefault,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayUrlErrorText(error: RelaySettingsInputError?, index: Int): String? = when (error) {
|
||||
is RelaySettingsInputError.HttpsRequired -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_https_required, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
is RelaySettingsInputError.InvalidUrl -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_invalid_url, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
is RelaySettingsInputError.DuplicateUrl -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_duplicate_url, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
RelaySettingsInputError.MissingUrl,
|
||||
is RelaySettingsInputError.TooManyUrls,
|
||||
null,
|
||||
-> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayGlobalErrorText(state: SettingsState): String? {
|
||||
if (state.hasActiveNetworkWork || state.relayApplyError == RelaySettingsApplyError.ActiveTransfers) {
|
||||
return stringResource(Res.string.relay_apply_active_transfers)
|
||||
}
|
||||
state.relayInputError?.let { error ->
|
||||
return when (error) {
|
||||
RelaySettingsInputError.MissingUrl -> stringResource(Res.string.relay_validation_missing_url)
|
||||
is RelaySettingsInputError.TooManyUrls -> stringResource(
|
||||
Res.string.relay_validation_too_many_urls,
|
||||
error.maximum,
|
||||
)
|
||||
is RelaySettingsInputError.HttpsRequired,
|
||||
is RelaySettingsInputError.InvalidUrl,
|
||||
is RelaySettingsInputError.DuplicateUrl,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
return when (state.relayApplyError) {
|
||||
RelaySettingsApplyError.ApplyFailed -> stringResource(Res.string.relay_apply_failed)
|
||||
RelaySettingsApplyError.RestoreFailed -> stringResource(Res.string.relay_restore_failed)
|
||||
RelaySettingsApplyError.ActiveTransfers,
|
||||
null,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.RelaySettings
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
|
||||
sealed interface RelaySettingsInputError {
|
||||
data object MissingUrl : RelaySettingsInputError
|
||||
data class TooManyUrls(val maximum: Int) : RelaySettingsInputError
|
||||
data class HttpsRequired(val line: Int) : RelaySettingsInputError
|
||||
data class InvalidUrl(val line: Int) : RelaySettingsInputError
|
||||
data class DuplicateUrl(val line: Int) : RelaySettingsInputError
|
||||
}
|
||||
|
||||
data class RelaySettingsValidation(
|
||||
val settings: RelaySettings? = null,
|
||||
val error: RelaySettingsInputError? = null,
|
||||
)
|
||||
|
||||
fun validateRelaySettings(
|
||||
mode: RelayMode,
|
||||
relayUrls: List<String>,
|
||||
retainedUrls: List<String> = emptyList(),
|
||||
): RelaySettingsValidation {
|
||||
if (!mode.usesCustomRelayUrls) {
|
||||
return RelaySettingsValidation(RelaySettings(mode, retainedUrls))
|
||||
}
|
||||
val lines = relayUrls
|
||||
.mapIndexedNotNull { index, raw -> raw.trim().takeIf(String::isNotEmpty)?.let { index + 1 to it } }
|
||||
.toList()
|
||||
if (lines.isEmpty()) return RelaySettingsValidation(error = RelaySettingsInputError.MissingUrl)
|
||||
if (lines.size > MaximumRelayUrls) {
|
||||
return RelaySettingsValidation(error = RelaySettingsInputError.TooManyUrls(MaximumRelayUrls))
|
||||
}
|
||||
|
||||
val normalized = mutableListOf<String>()
|
||||
for ((line, raw) in lines) {
|
||||
when (val result = normalizeRelayUrl(raw)) {
|
||||
RelayUrlResult.HttpsRequired -> {
|
||||
return RelaySettingsValidation(error = RelaySettingsInputError.HttpsRequired(line))
|
||||
}
|
||||
RelayUrlResult.Invalid -> {
|
||||
return RelaySettingsValidation(error = RelaySettingsInputError.InvalidUrl(line))
|
||||
}
|
||||
is RelayUrlResult.Valid -> {
|
||||
if (result.url in normalized) {
|
||||
return RelaySettingsValidation(error = RelaySettingsInputError.DuplicateUrl(line))
|
||||
}
|
||||
normalized += result.url
|
||||
}
|
||||
}
|
||||
}
|
||||
return RelaySettingsValidation(RelaySettings(mode, normalized))
|
||||
}
|
||||
|
||||
private sealed interface RelayUrlResult {
|
||||
data object HttpsRequired : RelayUrlResult
|
||||
data object Invalid : RelayUrlResult
|
||||
data class Valid(val url: String) : RelayUrlResult
|
||||
}
|
||||
|
||||
private fun normalizeRelayUrl(raw: String): RelayUrlResult {
|
||||
if (!raw.startsWith(HttpsPrefix, ignoreCase = true)) return RelayUrlResult.HttpsRequired
|
||||
if (raw.encodeToByteArray().size > MaximumRelayUrlLength || raw.any { it.isWhitespace() || it.isISOControl() }) {
|
||||
return RelayUrlResult.Invalid
|
||||
}
|
||||
val remainder = raw.substring(HttpsPrefix.length)
|
||||
if (remainder.isEmpty() || '?' in remainder || '#' in remainder) return RelayUrlResult.Invalid
|
||||
val authority = remainder.substringBefore('/')
|
||||
val path = remainder.removePrefix(authority)
|
||||
if (!isValidAuthority(authority) || path !in setOf("", "/")) return RelayUrlResult.Invalid
|
||||
|
||||
val normalizedAuthority = if (authority.startsWith('[')) {
|
||||
val closingBracket = authority.indexOf(']')
|
||||
authority.substring(0, closingBracket + 1).lowercase() + authority.substring(closingBracket + 1)
|
||||
} else {
|
||||
val portSeparator = authority.lastIndexOf(':').takeIf { authority.count { char -> char == ':' } == 1 }
|
||||
if (portSeparator == null) authority.lowercase()
|
||||
else authority.substring(0, portSeparator).lowercase() + authority.substring(portSeparator)
|
||||
}
|
||||
return RelayUrlResult.Valid("$HttpsPrefix${normalizedAuthority.removeSuffix(":443")}")
|
||||
}
|
||||
|
||||
private fun isValidAuthority(authority: String): Boolean {
|
||||
if (authority.isBlank() || '@' in authority) return false
|
||||
if (authority.startsWith('[')) {
|
||||
val closingBracket = authority.indexOf(']')
|
||||
if (closingBracket <= 1) return false
|
||||
val address = authority.substring(1, closingBracket)
|
||||
if (!isValidIpv6Address(address)) return false
|
||||
return isValidPortSuffix(authority.substring(closingBracket + 1))
|
||||
}
|
||||
if (authority.count { it == ':' } > 1) return false
|
||||
val host = authority.substringBeforeLast(':', authority)
|
||||
val portSuffix = authority.removePrefix(host)
|
||||
if (host.isBlank() || host.startsWith('.') || host.endsWith('.') || host.startsWith('-') || host.endsWith('-')) return false
|
||||
if (host.any { !it.isLetterOrDigit() && it != '.' && it != '-' }) return false
|
||||
return isValidPortSuffix(portSuffix)
|
||||
}
|
||||
|
||||
private fun isValidIpv6Address(address: String): Boolean {
|
||||
if (address.isEmpty() || ":::" in address) return false
|
||||
val compressionIndex = address.indexOf("::")
|
||||
val hasCompression = compressionIndex >= 0
|
||||
if (hasCompression && address.indexOf("::", compressionIndex + 2) >= 0) return false
|
||||
if (!hasCompression && (address.startsWith(':') || address.endsWith(':'))) return false
|
||||
|
||||
val left = if (hasCompression) address.substring(0, compressionIndex) else address
|
||||
val right = if (hasCompression) address.substring(compressionIndex + 2) else ""
|
||||
val segments = buildList {
|
||||
if (left.isNotEmpty()) addAll(left.split(':'))
|
||||
if (right.isNotEmpty()) addAll(right.split(':'))
|
||||
}
|
||||
if (segments.any(String::isEmpty)) return false
|
||||
|
||||
var addressUnits = 0
|
||||
for ((index, segment) in segments.withIndex()) {
|
||||
if ('.' in segment) {
|
||||
if (index != segments.lastIndex || !isValidIpv4Tail(segment)) return false
|
||||
addressUnits += 2
|
||||
} else {
|
||||
if (segment.length !in 1..4 || segment.any { !it.isHexDigit() }) return false
|
||||
addressUnits += 1
|
||||
}
|
||||
}
|
||||
return if (hasCompression) addressUnits < 8 else addressUnits == 8
|
||||
}
|
||||
|
||||
private fun isValidIpv4Tail(address: String): Boolean {
|
||||
val octets = address.split('.')
|
||||
return octets.size == 4 && octets.all { octet ->
|
||||
octet.isNotEmpty() && octet.all(Char::isDigit) && octet.toIntOrNull() in 0..255
|
||||
}
|
||||
}
|
||||
|
||||
private fun Char.isHexDigit(): Boolean =
|
||||
this in '0'..'9' || lowercaseChar() in 'a'..'f'
|
||||
|
||||
private fun isValidPortSuffix(suffix: String): Boolean {
|
||||
if (suffix.isEmpty()) return true
|
||||
if (!suffix.startsWith(':')) return false
|
||||
val port = suffix.drop(1).toIntOrNull() ?: return false
|
||||
return port in 1..65535
|
||||
}
|
||||
|
||||
private const val HttpsPrefix = "https://"
|
||||
internal const val MaximumRelayUrls = 8
|
||||
private const val MaximumRelayUrlLength = 2_048
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -18,7 +19,12 @@ 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.relay_mode_automatic
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only
|
||||
import vnidrop.shared.generated.resources.settings_title
|
||||
import vnidrop.shared.generated.resources.settings_network_title
|
||||
import vnidrop.shared.generated.resources.storage_title
|
||||
|
||||
@Composable
|
||||
@@ -64,7 +70,17 @@ internal fun SettingsOverview(
|
||||
selected = state.selectedSection == SettingsSection.Storage,
|
||||
onClick = { onSectionSelected(SettingsSection.Storage) },
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Globe,
|
||||
title = stringResource(Res.string.settings_network_title),
|
||||
value = relayModeLabel(state.savedRelaySettings.mode),
|
||||
selected = state.selectedSection == SettingsSection.Network,
|
||||
onClick = { onSectionSelected(SettingsSection.Network) },
|
||||
)
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Info,
|
||||
title = stringResource(Res.string.about_title),
|
||||
@@ -76,6 +92,14 @@ internal fun SettingsOverview(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayModeLabel(mode: RelayMode): String = when (mode) {
|
||||
RelayMode.Automatic -> stringResource(Res.string.relay_mode_automatic)
|
||||
RelayMode.StrictCustom -> stringResource(Res.string.relay_mode_custom)
|
||||
RelayMode.CustomWithDirectFallback -> stringResource(Res.string.relay_mode_custom_direct_fallback)
|
||||
RelayMode.LocalOnly -> stringResource(Res.string.relay_mode_local_only)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeModeLabel(mode: ThemeMode): String = when (mode) {
|
||||
ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
|
||||
|
||||
@@ -24,6 +24,11 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onSectionSelected = viewModel::selectSection,
|
||||
onUsernameChanged = viewModel::setUsername,
|
||||
onThemeModeChanged = viewModel::setThemeMode,
|
||||
onRelayModeChanged = viewModel::setRelayMode,
|
||||
onRelayUrlChanged = viewModel::setRelayUrl,
|
||||
onAddRelayUrl = viewModel::addRelayUrl,
|
||||
onRemoveRelayUrl = viewModel::removeRelayUrl,
|
||||
onApplyRelaySettings = viewModel::applyRelaySettings,
|
||||
onChooseFolder = viewModel::chooseReceiveFolder,
|
||||
onResetFolder = viewModel::resetReceiveFolder,
|
||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||
@@ -36,5 +41,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onBugIncludeLogsChanged = viewModel::setBugIncludeLogs,
|
||||
onSubmitBugReport = viewModel::submitBugReport,
|
||||
onDeleteAllTransfers = viewModel::deleteAllTransfers,
|
||||
onClearTransferCache = viewModel::clearTransferCache,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
|
||||
@Composable
|
||||
@@ -30,6 +31,12 @@ fun SettingsScreen(
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
onDeleteAllTransfers: () -> Unit = {},
|
||||
onClearTransferCache: () -> Unit = {},
|
||||
onRelayModeChanged: (RelayMode) -> Unit = {},
|
||||
onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
|
||||
onAddRelayUrl: () -> Unit = {},
|
||||
onRemoveRelayUrl: (Int) -> Unit = {},
|
||||
onApplyRelaySettings: () -> Unit = {},
|
||||
) {
|
||||
if (windowClass == WindowClass.Desktop) {
|
||||
Row(
|
||||
@@ -42,6 +49,7 @@ fun SettingsScreen(
|
||||
Column(Modifier.weight(1f)) {
|
||||
SettingsSectionContent(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
@@ -60,6 +68,12 @@ fun SettingsScreen(
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
onDeleteAllTransfers = onDeleteAllTransfers,
|
||||
onClearTransferCache = onClearTransferCache,
|
||||
onRelayModeChanged = onRelayModeChanged,
|
||||
onRelayUrlChanged = onRelayUrlChanged,
|
||||
onAddRelayUrl = onAddRelayUrl,
|
||||
onRemoveRelayUrl = onRemoveRelayUrl,
|
||||
onApplyRelaySettings = onApplyRelaySettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +82,7 @@ fun SettingsScreen(
|
||||
SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true)
|
||||
else -> SettingsSectionContent(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
section = state.selectedSection,
|
||||
onBack = {
|
||||
onSectionSelected(
|
||||
@@ -94,6 +109,12 @@ fun SettingsScreen(
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
onDeleteAllTransfers = onDeleteAllTransfers,
|
||||
onClearTransferCache = onClearTransferCache,
|
||||
onRelayModeChanged = onRelayModeChanged,
|
||||
onRelayUrlChanged = onRelayUrlChanged,
|
||||
onAddRelayUrl = onAddRelayUrl,
|
||||
onRemoveRelayUrl = onRemoveRelayUrl,
|
||||
onApplyRelaySettings = onApplyRelaySettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +123,7 @@ fun SettingsScreen(
|
||||
@Composable
|
||||
private fun SettingsSectionContent(
|
||||
state: SettingsState,
|
||||
windowClass: WindowClass,
|
||||
section: SettingsSection,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
@@ -120,13 +142,36 @@ private fun SettingsSectionContent(
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
onDeleteAllTransfers: () -> Unit,
|
||||
onClearTransferCache: () -> Unit,
|
||||
onRelayModeChanged: (RelayMode) -> Unit,
|
||||
onRelayUrlChanged: (Int, String) -> Unit,
|
||||
onAddRelayUrl: () -> Unit,
|
||||
onRemoveRelayUrl: (Int) -> Unit,
|
||||
onApplyRelaySettings: () -> Unit,
|
||||
) {
|
||||
when (section) {
|
||||
SettingsSection.Overview -> Unit
|
||||
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
|
||||
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
|
||||
SettingsSection.Network -> NetworkSettings(
|
||||
state = state,
|
||||
onModeChanged = onRelayModeChanged,
|
||||
onUrlChanged = onRelayUrlChanged,
|
||||
onAddUrl = onAddRelayUrl,
|
||||
onRemoveUrl = onRemoveRelayUrl,
|
||||
onApply = onApplyRelaySettings,
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
)
|
||||
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
|
||||
SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack)
|
||||
SettingsSection.Storage -> StorageSettings(
|
||||
state,
|
||||
windowClass,
|
||||
onDeleteAllTransfers,
|
||||
onClearTransferCache,
|
||||
onBack,
|
||||
showBack,
|
||||
)
|
||||
SettingsSection.About -> AboutSettings(
|
||||
state = state,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
|
||||
@@ -7,8 +7,13 @@ import com.vnidrop.app.DeviceInfoProvider
|
||||
import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreLifecycleBusyException
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.RelaySettings
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
import com.vnidrop.app.diagnostics.BugReportDraft
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
@@ -43,17 +48,27 @@ import vnidrop.shared.generated.resources.notifications_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||
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
|
||||
|
||||
enum class SettingsSection {
|
||||
Overview,
|
||||
Preferences,
|
||||
Appearance,
|
||||
Network,
|
||||
Notifications,
|
||||
Storage,
|
||||
About,
|
||||
BugReport,
|
||||
}
|
||||
|
||||
enum class RelaySettingsApplyError {
|
||||
ActiveTransfers,
|
||||
ApplyFailed,
|
||||
RestoreFailed,
|
||||
}
|
||||
|
||||
data class StorageBreakdown(
|
||||
val transferCacheBytes: ULong,
|
||||
val appDataBytes: ULong,
|
||||
@@ -74,6 +89,14 @@ data class SettingsState(
|
||||
val isValidatingFolder: Boolean = false,
|
||||
val supportsCustomReceiveFolders: Boolean = true,
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val savedRelaySettings: RelaySettings = RelaySettings(),
|
||||
val relayMode: RelayMode = RelayMode.Automatic,
|
||||
val relayUrls: List<String> = emptyList(),
|
||||
val relayInputError: RelaySettingsInputError? = null,
|
||||
val relayApplyError: RelaySettingsApplyError? = null,
|
||||
val isApplyingRelaySettings: Boolean = false,
|
||||
val hasActiveNetworkWork: Boolean = false,
|
||||
val endpointId: String? = null,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
@@ -90,7 +113,12 @@ data class SettingsState(
|
||||
val storage: StorageBreakdown? = null,
|
||||
val isCalculatingStorage: Boolean = false,
|
||||
val isDeletingTransfers: Boolean = false,
|
||||
)
|
||||
val isClearingTransferCache: Boolean = false,
|
||||
) {
|
||||
val hasRelaySettingsChanges: Boolean
|
||||
get() = relayMode != savedRelaySettings.mode ||
|
||||
(relayMode.usesCustomRelayUrls && relayUrls != savedRelaySettings.relayUrls)
|
||||
}
|
||||
|
||||
sealed interface SettingsEffect {
|
||||
data object OpenReceiveFolderPicker : SettingsEffect
|
||||
@@ -121,6 +149,7 @@ class SettingsViewModel(
|
||||
private var enableNotificationsAfterSettings = false
|
||||
private var usernamePersistJob: Job? = null
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var hasLocalRelayDraft = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@@ -134,6 +163,13 @@ class SettingsViewModel(
|
||||
themeMode = preferences.themeMode,
|
||||
notificationsEnabled = preferences.notificationsEnabled,
|
||||
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||
savedRelaySettings = preferences.relaySettings,
|
||||
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
|
||||
relayUrls = if (hasLocalRelayDraft) {
|
||||
current.relayUrls
|
||||
} else {
|
||||
preferences.relaySettings.relayUrls
|
||||
},
|
||||
)
|
||||
}
|
||||
if (receiveFolder != previousFolder) {
|
||||
@@ -141,6 +177,19 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.state.collect { coreState ->
|
||||
val status = coreState.status
|
||||
val hasActiveWork = status?.let { it.activeTransfers > 0UL || it.activeShares > 0UL } == true ||
|
||||
coreState.transfers.any { it.status in ActiveTransferStatuses }
|
||||
_state.update {
|
||||
it.copy(
|
||||
hasActiveNetworkWork = hasActiveWork,
|
||||
endpointId = coreState.status?.endpointId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshNotificationPermission()
|
||||
loadDeviceInfo()
|
||||
}
|
||||
@@ -162,6 +211,7 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isCalculatingStorage = true) }
|
||||
try {
|
||||
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
|
||||
val coreUsage = repository.storageUsage().getOrThrow()
|
||||
val artifacts = repository.receivedArtifacts().getOrThrow()
|
||||
val received = fileSystemService.inspectReceivedArtifacts(artifacts)
|
||||
@@ -170,7 +220,7 @@ class SettingsViewModel(
|
||||
storage = StorageBreakdown(
|
||||
transferCacheBytes = coreUsage.blobStoreBytes,
|
||||
appDataBytes = coreUsage.appDataBytes,
|
||||
temporaryBytes = fileSystemService.temporaryUsage(),
|
||||
temporaryBytes = fileSystemService.temporaryUsage(receiveFolder),
|
||||
receivedBytes = received.existingBytes,
|
||||
receivedFileCount = received.existingCount,
|
||||
missingReceivedFileCount = received.missingCount,
|
||||
@@ -189,18 +239,45 @@ class SettingsViewModel(
|
||||
}
|
||||
|
||||
fun deleteAllTransfers() {
|
||||
if (_state.value.isDeletingTransfers) return
|
||||
if (_state.value.isDeletingTransfers || _state.value.isClearingTransferCache) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isDeletingTransfers = true) }
|
||||
val failures = repository.state.value.transfers
|
||||
.map { repository.delete(it.transferId) }
|
||||
.count { it.isFailure }
|
||||
val cleanup = repository.clearTransferCache()
|
||||
_state.update { it.copy(isDeletingTransfers = false) }
|
||||
if (failures == 0) {
|
||||
loadStorageUsage()
|
||||
} else {
|
||||
loadStorageUsage()
|
||||
if (failures > 0) {
|
||||
messages.error(IllegalStateException("Could not delete $failures transfer records"))
|
||||
} else if (cleanup.isSuccess) {
|
||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_transfers_deleted), UiMessageTone.Success))
|
||||
}
|
||||
cleanup.onFailure(messages::error)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransferCache() {
|
||||
if (
|
||||
_state.value.isDeletingTransfers ||
|
||||
_state.value.isClearingTransferCache ||
|
||||
_state.value.hasActiveNetworkWork
|
||||
) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isClearingTransferCache = true) }
|
||||
repository.clearTransferCache().fold(
|
||||
onSuccess = {
|
||||
_state.update { it.copy(isClearingTransferCache = false) }
|
||||
loadStorageUsage()
|
||||
messages.tryShow(
|
||||
UiMessage(UiText.Resource(Res.string.storage_transfer_cache_cleared), UiMessageTone.Success),
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.update { it.copy(isClearingTransferCache = false) }
|
||||
messages.error(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +295,132 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { preferencesRepository.setThemeMode(mode) }
|
||||
}
|
||||
|
||||
fun setRelayMode(mode: RelayMode) {
|
||||
hasLocalRelayDraft = true
|
||||
_state.update {
|
||||
it.copy(
|
||||
relayMode = mode,
|
||||
relayUrls = if (mode.usesCustomRelayUrls && it.relayUrls.isEmpty()) listOf("") else it.relayUrls,
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
|
||||
}
|
||||
|
||||
fun setRelayUrl(index: Int, value: String) {
|
||||
if (index !in _state.value.relayUrls.indices) return
|
||||
hasLocalRelayDraft = true
|
||||
_state.update {
|
||||
it.copy(
|
||||
relayUrls = it.relayUrls.toMutableList().apply { this[index] = value },
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
|
||||
}
|
||||
|
||||
fun addRelayUrl() {
|
||||
if (_state.value.relayUrls.size >= MaximumRelayUrls) return
|
||||
hasLocalRelayDraft = true
|
||||
_state.update {
|
||||
it.copy(
|
||||
relayUrls = it.relayUrls + "",
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
|
||||
}
|
||||
|
||||
fun removeRelayUrl(index: Int) {
|
||||
if (index !in _state.value.relayUrls.indices) return
|
||||
hasLocalRelayDraft = true
|
||||
_state.update {
|
||||
val remaining = it.relayUrls.toMutableList().apply { removeAt(index) }
|
||||
it.copy(
|
||||
relayUrls = remaining.ifEmpty { listOf("") },
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
|
||||
}
|
||||
|
||||
fun applyRelaySettings() {
|
||||
val snapshot = _state.value
|
||||
if (snapshot.isApplyingRelaySettings || !snapshot.hasRelaySettingsChanges) return
|
||||
val validation = validateRelaySettings(
|
||||
mode = snapshot.relayMode,
|
||||
relayUrls = snapshot.relayUrls,
|
||||
retainedUrls = snapshot.savedRelaySettings.relayUrls,
|
||||
)
|
||||
val desired = validation.settings
|
||||
if (desired == null) {
|
||||
_state.update { it.copy(relayInputError = validation.error, relayApplyError = null) }
|
||||
return
|
||||
}
|
||||
if (snapshot.hasActiveNetworkWork) {
|
||||
_state.update {
|
||||
it.copy(relayInputError = null, relayApplyError = RelaySettingsApplyError.ActiveTransfers)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
isApplyingRelaySettings = true,
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val previous = snapshot.savedRelaySettings
|
||||
val applied = repository.initialize(environment.defaultCoreDataDir, desired)
|
||||
if (applied.isFailure) {
|
||||
val busy = applied.exceptionOrNull() is CoreLifecycleBusyException
|
||||
if (busy || repository.state.value.isInitialized) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isApplyingRelaySettings = false,
|
||||
relayApplyError = if (repository.state.value.isInitialized) {
|
||||
RelaySettingsApplyError.ActiveTransfers
|
||||
} else {
|
||||
RelaySettingsApplyError.ApplyFailed
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
finishFailedRelayApply(previous)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
preferencesRepository.setRelaySettings(desired)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Throwable) {
|
||||
finishFailedRelayApply(previous)
|
||||
return@launch
|
||||
}
|
||||
hasLocalRelayDraft = false
|
||||
_state.update {
|
||||
it.copy(
|
||||
savedRelaySettings = desired,
|
||||
relayMode = desired.mode,
|
||||
relayUrls = desired.relayUrls,
|
||||
isApplyingRelaySettings = false,
|
||||
relayInputError = null,
|
||||
relayApplyError = null,
|
||||
)
|
||||
}
|
||||
messages.show(
|
||||
UiMessage(UiText.Resource(Res.string.relay_settings_applied), UiMessageTone.Success),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseReceiveFolder() {
|
||||
if (!fileSystemService.supportsCustomReceiveFolders) return
|
||||
viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) }
|
||||
@@ -417,7 +620,22 @@ class SettingsViewModel(
|
||||
_state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) }
|
||||
}
|
||||
|
||||
private suspend fun finishFailedRelayApply(previous: RelaySettings) {
|
||||
val restored = repository.initialize(environment.defaultCoreDataDir, previous).isSuccess
|
||||
_state.update {
|
||||
it.copy(
|
||||
isApplyingRelaySettings = false,
|
||||
relayApplyError = if (restored) {
|
||||
RelaySettingsApplyError.ApplyFailed
|
||||
} else {
|
||||
RelaySettingsApplyError.RestoreFailed
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UsernamePersistDebounceMs = 350L
|
||||
val ActiveTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,42 @@ package com.vnidrop.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||
import com.vnidrop.app.ui.components.DestructiveButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_cancel
|
||||
import vnidrop.shared.generated.resources.storage_app_data
|
||||
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_delete_transfers
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers_description
|
||||
import vnidrop.shared.generated.resources.storage_deleting
|
||||
import vnidrop.shared.generated.resources.storage_total
|
||||
import vnidrop.shared.generated.resources.storage_received_files
|
||||
@@ -28,41 +49,60 @@ import vnidrop.shared.generated.resources.storage_footer
|
||||
@Composable
|
||||
internal fun StorageSettings(
|
||||
state: SettingsState,
|
||||
windowClass: WindowClass,
|
||||
onDeleteAllTransfers: () -> Unit,
|
||||
onClearTransferCache: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack)
|
||||
val storage = state.storage
|
||||
if (storage == null || state.isCalculatingStorage) {
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Storage,
|
||||
StorageRow(
|
||||
title = stringResource(Res.string.storage_calculating),
|
||||
trailing = { CircularProgressIndicator() },
|
||||
trailing = { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SettingsGroup {
|
||||
StorageRow(AppIcon.TransferData, stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes)
|
||||
SettingsDivider()
|
||||
StorageRow(AppIcon.Storage, stringResource(Res.string.storage_app_data), storage.appDataBytes)
|
||||
SettingsDivider()
|
||||
StorageRow(AppIcon.Temporary, stringResource(Res.string.storage_temporary), storage.temporaryBytes)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = AppIcon.Folder,
|
||||
title = stringResource(Res.string.storage_received_files),
|
||||
value = formatBytes(storage.receivedBytes),
|
||||
StorageRow(stringResource(Res.string.storage_received_files), storage.receivedBytes)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
StorageRow(stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
StorageRow(stringResource(Res.string.storage_app_data), storage.appDataBytes)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
StorageRow(stringResource(Res.string.storage_temporary), storage.temporaryBytes)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
StorageRow(
|
||||
title = stringResource(Res.string.storage_total),
|
||||
bytes = storage.deviceImpactBytes,
|
||||
emphasized = true,
|
||||
)
|
||||
SettingsDivider()
|
||||
StorageRow(AppIcon.TotalStorage, stringResource(Res.string.storage_total), storage.deviceImpactBytes)
|
||||
}
|
||||
}
|
||||
SecondaryButton(
|
||||
text = stringResource(
|
||||
if (state.isClearingTransferCache) {
|
||||
Res.string.storage_clearing_transfer_cache
|
||||
} else {
|
||||
Res.string.storage_clear_transfer_cache
|
||||
},
|
||||
),
|
||||
onClick = { showClearCacheConfirmation = true },
|
||||
enabled = !state.isDeletingTransfers &&
|
||||
!state.isClearingTransferCache &&
|
||||
!state.isCalculatingStorage &&
|
||||
!state.hasActiveNetworkWork,
|
||||
)
|
||||
Button(
|
||||
onClick = onDeleteAllTransfers,
|
||||
enabled = !state.isDeletingTransfers,
|
||||
onClick = { showDeleteConfirmation = true },
|
||||
enabled = !state.isDeletingTransfers &&
|
||||
!state.isClearingTransferCache &&
|
||||
!state.isCalculatingStorage,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers))
|
||||
@@ -73,9 +113,124 @@ internal fun StorageSettings(
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
}
|
||||
if (showClearCacheConfirmation) {
|
||||
AdaptiveDrawer(
|
||||
windowClass = windowClass,
|
||||
onDismissRequest = { showClearCacheConfirmation = false },
|
||||
) {
|
||||
ClearTransferCachePanel(
|
||||
onCancel = { showClearCacheConfirmation = false },
|
||||
onConfirm = {
|
||||
showClearCacheConfirmation = false
|
||||
onClearTransferCache()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDeleteConfirmation) {
|
||||
AdaptiveDrawer(
|
||||
windowClass = windowClass,
|
||||
onDismissRequest = { showDeleteConfirmation = false },
|
||||
) {
|
||||
DeleteAllTransfersPanel(
|
||||
onCancel = { showDeleteConfirmation = false },
|
||||
onConfirm = {
|
||||
showDeleteConfirmation = false
|
||||
onDeleteAllTransfers()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageRow(icon: AppIcon, title: String, bytes: ULong) {
|
||||
SettingsRow(icon = icon, title = title, value = formatBytes(bytes))
|
||||
private fun ClearTransferCachePanel(
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.storage_clear_transfer_cache),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.storage_clear_transfer_cache_description),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
|
||||
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel)
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.storage_clear_transfer_cache),
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.testTag("confirm-clear-transfer-cache"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteAllTransfersPanel(
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.storage_delete_transfers),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.storage_delete_transfers_description),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
|
||||
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel)
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.storage_delete_transfers),
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.testTag("confirm-delete-all-transfers"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageRow(
|
||||
title: String,
|
||||
bytes: ULong? = null,
|
||||
emphasized: Boolean = false,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
if (bytes != null) {
|
||||
Text(
|
||||
formatBytes(bytes),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.vnidrop.app.preferences
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.preferencesOf
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.RelaySettings
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -26,6 +28,7 @@ data class AppPreferences(
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
/** Stable anonymous install id; never an account or advertising id. */
|
||||
val diagnosticsInstallId: String = "",
|
||||
val relaySettings: RelaySettings = RelaySettings(),
|
||||
)
|
||||
|
||||
class AppPreferencesDefaults(
|
||||
@@ -44,6 +47,7 @@ interface PreferencesRepository {
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
||||
suspend fun setDiagnosticsEnabled(enabled: Boolean)
|
||||
suspend fun setRelaySettings(settings: RelaySettings)
|
||||
/** Ensures a durable install id exists and returns it. */
|
||||
suspend fun ensureDiagnosticsInstallId(): String
|
||||
}
|
||||
@@ -53,8 +57,27 @@ class AppPreferencesRepository(
|
||||
private val defaults: AppPreferencesDefaults,
|
||||
) : PreferencesRepository {
|
||||
override val preferences: Flow<AppPreferences> = dataStore.data
|
||||
.catch { emit(emptyPreferences()) }
|
||||
.catch {
|
||||
emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.StrictCustom.name))
|
||||
}
|
||||
.map { prefs ->
|
||||
val storedRelayMode = prefs[PreferenceKeys.RelayMode]
|
||||
val parsedRelayMode = storedRelayMode?.let(::relayModeOrNull)
|
||||
val relayMode = when (storedRelayMode) {
|
||||
null -> RelayMode.Automatic
|
||||
else -> parsedRelayMode ?: RelayMode.StrictCustom
|
||||
}
|
||||
val relayUrls = if (storedRelayMode != null && parsedRelayMode == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
prefs[PreferenceKeys.RelayUrls]
|
||||
.orEmpty()
|
||||
.lineSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
AppPreferences(
|
||||
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
@@ -62,6 +85,10 @@ class AppPreferencesRepository(
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
|
||||
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
|
||||
relaySettings = RelaySettings(
|
||||
mode = relayMode,
|
||||
relayUrls = relayUrls,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,6 +128,13 @@ class AppPreferencesRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRelaySettings(settings: RelaySettings) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.RelayMode] = settings.mode.name
|
||||
prefs[PreferenceKeys.RelayUrls] = settings.relayUrls.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureDiagnosticsInstallId(): String {
|
||||
val existing = preferences.first().diagnosticsInstallId
|
||||
if (existing.isNotBlank()) return existing
|
||||
@@ -128,6 +162,8 @@ private object PreferenceKeys {
|
||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||
val RelayMode = stringPreferencesKey("relay_mode")
|
||||
val RelayUrls = stringPreferencesKey("relay_urls")
|
||||
}
|
||||
|
||||
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
|
||||
@@ -162,4 +198,8 @@ private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
|
||||
private fun themeModeOrNull(raw: String): ThemeMode? =
|
||||
runCatching { ThemeMode.valueOf(raw) }.getOrNull()
|
||||
|
||||
private fun relayModeOrNull(raw: String): RelayMode? =
|
||||
if (raw == "Custom") RelayMode.StrictCustom
|
||||
else runCatching { RelayMode.valueOf(raw) }.getOrNull()
|
||||
|
||||
private const val AppPreferencesFileName = "app_preferences.preferences_pb"
|
||||
|
||||
Reference in New Issue
Block a user