diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/FileAutoAcceptSetting.kt b/android/app/src/main/java/com/vortex/a3/core/lan/FileAutoAcceptSetting.kt new file mode 100644 index 0000000..b02f598 --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/core/lan/FileAutoAcceptSetting.kt @@ -0,0 +1,46 @@ +package com.vortex.a3.core.lan + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether files pushed from the paired laptop are saved without asking. + * + * LOCAL-only (like [com.vortex.a3.core.clipboard.ClipboardSyncSetting]) — the + * laptop has its own, independent switch for the other direction, because + * "I trust files landing on my phone" and "I trust files landing on my laptop" + * are separate decisions and must not be coupled. + * + * Default **OFF**: this removes the [FileConsent] gate, so it only ever turns + * on because the user asked for it. Process-wide singleton over SharedPreferences + * `vortex_ui_settings` (the same store the other UI settings use). + */ +object FileAutoAcceptSetting { + private const val PREFS = "vortex_ui_settings" + private const val KEY = "file_auto_accept" + + private var prefs: SharedPreferences? = null + private val _enabled = MutableStateFlow(false) + + /** Observable: whether incoming files skip the consent prompt. */ + val enabled: StateFlow = _enabled.asStateFlow() + + /** Load persisted state. Idempotent — safe to call from the UI and the service. */ + @Synchronized + fun init(context: Context) { + if (prefs != null) return + val p = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs = p + _enabled.value = p.getBoolean(KEY, false) + } + + fun isEnabled(): Boolean = _enabled.value + + fun setEnabled(enabled: Boolean) { + _enabled.value = enabled + prefs?.edit()?.putBoolean(KEY, enabled)?.apply() + } +} diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/FileConsent.kt b/android/app/src/main/java/com/vortex/a3/core/lan/FileConsent.kt index 42d2856..bf2a45c 100644 --- a/android/app/src/main/java/com/vortex/a3/core/lan/FileConsent.kt +++ b/android/app/src/main/java/com/vortex/a3/core/lan/FileConsent.kt @@ -20,6 +20,9 @@ import java.util.concurrent.atomic.AtomicInteger * (FILE_PUSH_OFFER), pop a notification with Accept / Decline and BLOCK the * LAN handler thread until the user chooses (or a timeout → decline). The * laptop only streams the bytes after an accept. + * + * [FileAutoAcceptSetting] short-circuits the whole prompt when the user has + * opted in — see [request]. */ object FileConsent { private const val TAG = "VortexFileConsent" @@ -43,6 +46,15 @@ object FileConsent { timeoutMs: Long = 45_000, ): Boolean { val app = ctx.applicationContext + // Auto-accept: skip the prompt entirely and answer immediately, so the + // laptop starts streaming without the 45 s wait. `init` is idempotent — + // this call also covers the case where the LAN handler races ahead of + // the UI/service having loaded the setting. + FileAutoAcceptSetting.init(app) + if (FileAutoAcceptSetting.isEnabled()) { + Log.i(TAG, "auto-accept on → '$label' accepted without asking") + return true + } ensureReceiver(app) val id = nextId.getAndIncrement() val q = ArrayBlockingQueue(1) diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 6225c88..0691d43 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -272,6 +272,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { com.vortex.a3.core.media.SmartSwitchSetting.init(ctx) com.vortex.a3.core.notif.NotificationMirrorSetting.init(ctx) com.vortex.a3.core.clipboard.ClipboardSyncSetting.init(ctx) + // Load early so an incoming file-push offer is answered from the user's + // real choice even if the UI has never been opened this run. + com.vortex.a3.core.lan.FileAutoAcceptSetting.init(ctx) scope.launch { com.vortex.a3.core.media.SmartSwitchSetting.enabled.collect { on -> media.smartSwitchEnabled = on diff --git a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt index 9787c76..f139bfd 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt @@ -148,6 +148,8 @@ private val EN = mapOf( "settings.peer_notif_hint" to "Show the laptop's notifications on this phone", "settings.clipboard_sync" to "Clipboard sync", "settings.clipboard_sync_hint" to "Share copied text both ways with the laptop", + "settings.file_auto_accept" to "Auto-accept files from laptop", + "settings.file_auto_accept_hint" to "Save files sent by the laptop without asking. Only your paired laptop can send them.", "settings.clipboard_adb_title" to "Make phone→laptop automatic", "settings.clipboard_adb_body" to "Android blocks background clipboard reads. Run this once from a computer (USB debugging on) to copy without the Quick Settings tile:", "settings.clipboard_auto_on" to "Automatic — copies sync without the tile", @@ -269,6 +271,8 @@ private val UZ = mapOf( "settings.peer_notif_hint" to "Laptop bildirishnomalari shu telefonda ko‘rinsin", "settings.clipboard_sync" to "Clipboard sinxron", "settings.clipboard_sync_hint" to "Nusxalangan matnni laptop bilan ikki tomonlama ulashish", + "settings.file_auto_accept" to "Laptopdan fayllarni avtomatik qabul qilish", + "settings.file_auto_accept_hint" to "Laptop yuborgan fayllarni so‘ramasdan saqlash. Ularni faqat bog‘langan laptopingiz yubora oladi.", "settings.clipboard_adb_title" to "Telefon→laptop avtomatik bo‘lsin", "settings.clipboard_adb_body" to "Android fonda clipboard o‘qishni bloklaydi. Tile’siz nusxalash uchun kompyuterdan (USB debugging yoniq) bir marta shuni bajaring:", "settings.clipboard_auto_on" to "Avtomatik — nusxa tile’siz sinxron bo‘ladi", @@ -390,6 +394,8 @@ private val RU = mapOf( "settings.peer_notif_hint" to "Показывать уведомления ноутбука на этом телефоне", "settings.clipboard_sync" to "Синхронизация буфера", "settings.clipboard_sync_hint" to "Обмен скопированным текстом с ноутбуком в обе стороны", + "settings.file_auto_accept" to "Автоприём файлов с ноутбука", + "settings.file_auto_accept_hint" to "Сохранять файлы с ноутбука без подтверждения. Отправить их может только сопряжённый ноутбук.", "settings.clipboard_adb_title" to "Сделать телефон→ноутбук автоматическим", "settings.clipboard_adb_body" to "Android блокирует чтение буфера в фоне. Выполните один раз с компьютера (USB-отладка вкл), чтобы копировать без плитки:", "settings.clipboard_auto_on" to "Автоматически — копии синхронизируются без плитки", diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 6f773c8..629f94c 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -140,6 +140,9 @@ fun VortexRoot( val peerNotifShowOn = NotificationMirrorSetting.showPeer.collectAsState().value remember { ClipboardSyncSetting.init(activity); 0 } val clipboardSyncOn = ClipboardSyncSetting.enabled.collectAsState().value + remember { com.vortex.a3.core.lan.FileAutoAcceptSetting.init(activity); 0 } + val fileAutoAcceptOn = + com.vortex.a3.core.lan.FileAutoAcceptSetting.enabled.collectAsState().value // Re-evaluated each time Settings opens (the user may have just // run the ADB grant); a hint tells them to re-open if so. val clipboardAutoGranted = remember(showSettings) { @@ -178,6 +181,10 @@ fun VortexRoot( clipboardSyncOn = clipboardSyncOn, onClipboardSyncChange = { ClipboardSyncSetting.setEnabled(it) }, clipboardAutoGranted = clipboardAutoGranted, + fileAutoAcceptOn = fileAutoAcceptOn, + onFileAutoAcceptChange = { + com.vortex.a3.core.lan.FileAutoAcceptSetting.setEnabled(it) + }, screenControlOn = screenControlOn, onScreenControlClick = actions.onOpenScreenControl, onBack = { showSettings = false }, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt index 126620f..f8d1d30 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Headset import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode @@ -75,6 +76,8 @@ fun SettingsScreen( clipboardSyncOn: Boolean, onClipboardSyncChange: (Boolean) -> Unit, clipboardAutoGranted: Boolean, + fileAutoAcceptOn: Boolean, + onFileAutoAcceptChange: (Boolean) -> Unit, screenControlOn: Boolean, onScreenControlClick: () -> Unit, onBack: () -> Unit, @@ -180,6 +183,14 @@ fun SettingsScreen( checked = clipboardSyncOn, onCheckedChange = onClipboardSyncChange, ) + RowDivider() + ToggleRow( + icon = Icons.Outlined.FileDownload, + title = str("settings.file_auto_accept"), + hint = str("settings.file_auto_accept_hint"), + checked = fileAutoAcceptOn, + onCheckedChange = onFileAutoAcceptChange, + ) } // When sync is on but background reads aren't granted, phone→laptop // needs the QS tile — show the one-time ADB grant command. diff --git a/linux/ui-tauri/src-tauri/src/file_consent.rs b/linux/ui-tauri/src-tauri/src/file_consent.rs index cb39088..617384c 100644 --- a/linux/ui-tauri/src-tauri/src/file_consent.rs +++ b/linux/ui-tauri/src-tauri/src/file_consent.rs @@ -10,6 +10,8 @@ //! key prefix), so the two never collide. use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; @@ -17,6 +19,59 @@ use tokio::sync::oneshot; use vortex_l3_daemon::core::notification_display; +/// Auto-accept incoming file batches instead of asking. OFF unless the user +/// turns it on: this removes a consent gate, so it can only ever be a +/// deliberate opt-in — never a default, and never inferred from anything else. +static AUTO_ACCEPT: AtomicBool = AtomicBool::new(false); +/// Guards the one-time load of the persisted choice. +static AUTO_ACCEPT_LOADED: OnceLock<()> = OnceLock::new(); + +/// `~/.local/share/vortex/file_auto_accept` — "1" / "0". Persisted (unlike the +/// clipboard-sync toggle) because a consent setting that silently reverted on +/// restart would leave the user believing files are still gated when they are +/// not, or waiting for a prompt that no longer comes. +fn auto_accept_path() -> Option { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/share/vortex/file_auto_accept")) +} + +/// The current setting, loading the persisted value on first use. Anything +/// unreadable or unrecognised means OFF — a consent bypass must never be the +/// consequence of a missing or corrupt file. +fn auto_accept() -> bool { + AUTO_ACCEPT_LOADED.get_or_init(|| { + let on = auto_accept_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| s.trim() == "1") + .unwrap_or(false); + AUTO_ACCEPT.store(on, Ordering::Relaxed); + tracing::info!(auto_accept = on, "file receive consent"); + }); + AUTO_ACCEPT.load(Ordering::Relaxed) +} + +/// Settings read: whether incoming files are accepted without a prompt. +#[tauri::command] +pub fn get_file_auto_accept() -> bool { + auto_accept() +} + +/// Settings toggle: accept incoming file batches without prompting. +#[tauri::command] +pub fn set_file_auto_accept(enabled: bool) -> Result<(), String> { + // Run the loader FIRST: if it fired later it would clobber this choice + // with the on-disk value. + let _ = auto_accept(); + AUTO_ACCEPT.store(enabled, Ordering::Relaxed); + let path = auto_accept_path().ok_or("no HOME")?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&path, if enabled { "1" } else { "0" }).map_err(|e| e.to_string())?; + tracing::info!(enabled, "file auto-accept setting changed"); + Ok(()) +} + /// notification id → the waiter to resolve when the user clicks Accept/Decline. static REGISTRY: OnceLock>>> = OnceLock::new(); @@ -40,6 +95,12 @@ fn fmt_bytes(n: u64) -> String { /// out to a decline after 45 s. Fails CLOSED (decline) if the banner can't show /// — consent must never be silently bypassed. pub(crate) async fn request(label: &str, count: usize, total: u64) -> bool { + if auto_accept() { + // No banner at all — the transfer pill (see `transfers`) still reports + // what arrived and where it was saved, so the receive stays visible. + tracing::info!(count, bytes = total, "auto-accept on → file batch accepted without asking"); + return true; + } let title = if count > 1 { format!("Phone wants to send {count} files") } else { diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index af86462..fe7357b 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -365,6 +365,8 @@ pub fn run() { clipboard::clipboard_capture_now, clipboard_sync::set_clipboard_sync, clipboard_sync::get_clipboard_sync, + file_consent::set_file_auto_accept, + file_consent::get_file_auto_accept, clipboard::clipboard_get, clipboard_window::clipboard_set_preview, clipboard::clipboard_select, diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index 46c5bc8..ae61300 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -139,6 +139,8 @@ "close": "Done", "clipboard_sync": "Clipboard sync with phone", "clipboard_sync_hint": "Text copied on either device appears on the other. Phone→laptop needs the phone's Quick Settings tile.", + "file_auto_accept": "Auto-accept files from phone", + "file_auto_accept_hint": "Save files shared from your phone without asking. Only your paired phone can send them.", "uc": "Universal Control", "uc_hint": "Glide the laptop cursor & keyboard onto the phone. Push the screen edge to cross; push back to return.", "uc_needs": "Needs USB debugging (adb) and Wayland — the screen edge is held through the compositor's input-capture portal. Tested on GNOME 45+; KDE Plasma 6.1+ and Hyprland ship that portal too but are untested here, Sway does not have it, and X11 has no such thing. Two-finger scrolling is drawn as a real finger, which only phones that let adb write /dev/uinput allow — elsewhere it falls back to wheel steps. Hiding the laptop's own cursor is GNOME-only, and needs one log-out after installing.", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index af53e1f..70add44 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -139,6 +139,8 @@ "close": "Готово", "clipboard_sync": "Синхронизация буфера с телефоном", "clipboard_sync_hint": "Текст, скопированный на одном устройстве, появляется на другом. Телефон→ноутбук — через плитку быстрых настроек телефона.", + "file_auto_accept": "Автоматически принимать файлы с телефона", + "file_auto_accept_hint": "Сохранять файлы с телефона без подтверждения. Отправить их может только сопряжённый телефон.", "uc": "Universal Control", "uc_hint": "Курсор и клавиатура ноутбука переходят на телефон. Толкните курсор в край экрана, чтобы перейти; толкните обратно, чтобы вернуться.", "uc_needs": "Нужны USB-отладка (adb) и Wayland — край экрана удерживается через портал input-capture, который даёт композитор. Проверено на GNOME 45+; в KDE Plasma 6.1+ и Hyprland этот портал тоже есть, но нами не проверен; в Sway его нет, в X11 нет вовсе. Прокрутка двумя пальцами рисуется настоящим пальцем — это умеют лишь телефоны, где adb может писать в /dev/uinput, иначе остаются шаги колеса. Скрытие курсора ноутбука работает только в GNOME и требует одного перезахода в сессию.", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index 664ed59..7941bb5 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -139,6 +139,8 @@ "close": "Tayyor", "clipboard_sync": "Telefon bilan clipboard sync", "clipboard_sync_hint": "Bir qurilmada nusxalangan matn ikkinchisida paydo bo'ladi. Telefon→laptop uchun telefon Quick Settings plitkasi kerak.", + "file_auto_accept": "Telefondan fayllarni avtomatik qabul qilish", + "file_auto_accept_hint": "Telefondan ulashilgan fayllarni so'ramasdan saqlash. Ularni faqat bog'langan telefoningiz yubora oladi.", "uc": "Universal Control", "uc_hint": "Noutbuk kursori va klaviaturasi telefonga o'tadi. O'tish uchun ekran chetiga itaring; qaytish uchun orqaga itaring.", "uc_needs": "USB debugging (adb) va Wayland kerak — ekran cheti kompozitorning input-capture portali orqali ushlanadi. GNOME 45+ da sinalgan; KDE Plasma 6.1+ va Hyprland'da ham bu portal bor, lekin biz sinamaganmiz; Sway'da yo'q, X11'da umuman yo'q. Ikki barmoq bilan surish telefonda haqiqiy barmoq bo'lib chiziladi — buni faqat adb'ga /dev/uinput'ga yozishga ruxsat bergan telefonlar qila oladi, qolganlarida g'ildirak zinapoyasiga tushadi. Noutbukning o'z kursorini yashirish faqat GNOME'da, o'rnatgandan keyin bir marta tizimdan chiqib kirish kerak.", diff --git a/linux/ui-tauri/src/pages/settings/SettingsPage.vue b/linux/ui-tauri/src/pages/settings/SettingsPage.vue index b88e2f1..610c1e5 100644 --- a/linux/ui-tauri/src/pages/settings/SettingsPage.vue +++ b/linux/ui-tauri/src/pages/settings/SettingsPage.vue @@ -14,6 +14,7 @@ import { LockOpen, ClipboardList, MousePointer2, + FileDown, } from "lucide-vue-next"; import { theme } from "@/lib/theme"; import { smartSwitchEnabled, setSmartSwitch } from "@/lib/smartSwitch"; @@ -39,6 +40,9 @@ onMounted(() => { void invoke("get_clipboard_sync") .then((v) => (clipboardSync.value = v)) .catch(() => {}); + void invoke("get_file_auto_accept") + .then((v) => (fileAutoAccept.value = v)) + .catch(() => {}); void invoke("uc_running") .then((v) => (ucEnabled.value = v)) .catch(() => {}); @@ -127,6 +131,20 @@ function setClipboardSync(v: boolean) { void invoke("set_clipboard_sync", { enabled: v }).catch(() => {}); } +// Files shared FROM the phone normally raise an Accept / Decline banner. With +// this on they are saved straight away. Off by default (it drops a consent +// gate) and persisted on the Rust side, so the choice survives a restart. +// Revert the switch if the write fails — it must never show "on" over a +// setting that didn't stick. +const fileAutoAccept = ref(false); +function setFileAutoAccept(v: boolean) { + const prev = fileAutoAccept.value; + fileAutoAccept.value = v; + void invoke("set_file_auto_accept", { enabled: v }).catch(() => { + fileAutoAccept.value = prev; + }); +} + function pickLocale(code: LocaleCode) { // Local-only: laptop's language is independent of the phone's. setLocale(code); @@ -237,6 +255,14 @@ const pill = (active: boolean) => :model-value="clipboardSync" @update:model-value="setClipboardSync" /> +