Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _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()
}
}
12 changes: 12 additions & 0 deletions android/app/src/main/java/com/vortex/a3/core/lan/FileConsent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<Boolean>(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/java/com/vortex/a3/ui/Strings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 "Автоматически — копии синхронизируются без плитки",
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +76,8 @@ fun SettingsScreen(
clipboardSyncOn: Boolean,
onClipboardSyncChange: (Boolean) -> Unit,
clipboardAutoGranted: Boolean,
fileAutoAcceptOn: Boolean,
onFileAutoAcceptChange: (Boolean) -> Unit,
screenControlOn: Boolean,
onScreenControlClick: () -> Unit,
onBack: () -> Unit,
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions linux/ui-tauri/src-tauri/src/file_consent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,68 @@
//! 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;

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<PathBuf> {
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<Mutex<HashMap<u32, oneshot::Sender<bool>>>> = OnceLock::new();

Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions linux/ui-tauri/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions linux/ui-tauri/src/lib/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions linux/ui-tauri/src/lib/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 и требует одного перезахода в сессию.",
Expand Down
2 changes: 2 additions & 0 deletions linux/ui-tauri/src/lib/locales/uz.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
26 changes: 26 additions & 0 deletions linux/ui-tauri/src/pages/settings/SettingsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LockOpen,
ClipboardList,
MousePointer2,
FileDown,
} from "lucide-vue-next";
import { theme } from "@/lib/theme";
import { smartSwitchEnabled, setSmartSwitch } from "@/lib/smartSwitch";
Expand All @@ -39,6 +40,9 @@ onMounted(() => {
void invoke<boolean>("get_clipboard_sync")
.then((v) => (clipboardSync.value = v))
.catch(() => {});
void invoke<boolean>("get_file_auto_accept")
.then((v) => (fileAutoAccept.value = v))
.catch(() => {});
void invoke<boolean>("uc_running")
.then((v) => (ucEnabled.value = v))
.catch(() => {});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -237,6 +255,14 @@ const pill = (active: boolean) =>
:model-value="clipboardSync"
@update:model-value="setClipboardSync"
/>
<SettingsRow
divider
:icon="FileDown"
:title="t('settings.file_auto_accept')"
:desc="t('settings.file_auto_accept_hint')"
:model-value="fileAutoAccept"
@update:model-value="setFileAutoAccept"
/>
<SettingsRow
divider
:icon="MousePointer2"
Expand Down