diff --git a/app/src/main/java/chat/vojo/proxy/ui/Components.kt b/app/src/main/java/chat/vojo/proxy/ui/Components.kt index 0efb344..485c16c 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/Components.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/Components.kt @@ -74,12 +74,14 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) { } /** - * The whole top chrome: the four tabs sit on the static backdrop (the row does - * NOT move with the panes). Tabs are sized to their content and distributed with - * even gaps — equal-width quarters left the centred labels with ragged spacing. - * The active tab carries a violet underline that slides AND resizes between the - * measured tab bounds; both the bounds and the pager position are read in the - * layout phase so a swipe glides the line without recomposing the row. + * The whole top chrome: the tabs sit on the static backdrop (the row does NOT move + * with the panes). Tabs are sized to their content and distributed with even gaps — + * equal-width slots left the centred labels with ragged spacing. Padding/size are + * tuned so all five labels fit a phone width without clipping (adding "Сайты" pushed + * the content past the edge at the old 14dp/14.5sp). The active tab carries a violet + * underline that slides AND resizes between the measured tab bounds; both the bounds + * and the pager position are read in the layout phase so a swipe glides the line + * without recomposing the row. */ @Composable fun SegmentTabs( @@ -113,13 +115,13 @@ fun SegmentTabs( .fillMaxHeight() .clip(RoundedCornerShape(10.dp)) .clickable { onSelect(i) } - .padding(horizontal = 14.dp), + .padding(horizontal = 9.dp), contentAlignment = Alignment.Center, ) { Text( label, color = if (active) Vojo.text else Vojo.muted, - fontSize = 14.5.sp, + fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt index b96a6e1..67bc5ed 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt @@ -50,8 +50,10 @@ fun MainScreen( val applied by vm.applied.collectAsState() val apps by vm.apps.collectAsState() val appsLoading by vm.appsLoading.collectAsState() + val geositeSuggestions by vm.geositeSuggestions.collectAsState() + val geositeLoading by vm.geositeLoading.collectAsState() - val tabs = listOf("Туннель", "Серверы", "Приложения", "Опции") + val tabs = listOf("Туннель", "Серверы", "Приложения", "Сайты", "Опции") val pagerState = rememberPagerState(pageCount = { tabs.size }) val scope = rememberCoroutineScope() @@ -151,7 +153,19 @@ fun MainScreen( onClear = { vm.clearApps() }, onLoad = { vm.loadApps() }, ) - 3 -> SettingsScreen( + 3 -> SitesScreen( + routedSites = settings.routedSites, + routingMode = settings.routingMode, + categorySuggestions = geositeSuggestions, + categoryLoading = geositeLoading, + onCategoryQuery = { vm.setGeositeQuery(it) }, + onAddSite = { vm.addSite(it) }, + onRemoveSite = { vm.removeSite(it) }, + onToggleCategory = { cat, on -> vm.toggleCategory(cat, on) }, + onSetMode = { vm.setRoutingMode(it) }, + onClear = { vm.clearSites() }, + ) + 4 -> SettingsScreen( settings = settings, onChange = { transform -> vm.updateSettings(transform) }, ) diff --git a/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt b/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt index caee2f3..e3b461d 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt @@ -6,18 +6,28 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import chat.vojo.proxy.core.AppSettings import chat.vojo.proxy.core.ProxyConfig +import chat.vojo.proxy.core.RoutingMode +import chat.vojo.proxy.core.route.GEOSITE_PREFIX +import chat.vojo.proxy.core.route.GeositeHit import chat.vojo.proxy.data.AppInfo import chat.vojo.proxy.data.AppRepository import chat.vojo.proxy.data.ConfigStore +import chat.vojo.proxy.data.GeositeRepository import chat.vojo.proxy.data.ProxyState import chat.vojo.proxy.service.ProxyVpnService import chat.vojo.proxy.service.VpnState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainViewModel(app: Application) : AndroidViewModel(app) { @@ -84,6 +94,69 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { reconnectIfRunning() } + // --- Сайты (domain routing) --- + // Each mutation persists then re-applies the tunnel live (mapped-DNS / forced-IPv4 are + // decided at establish()/buildEngineConfig() time), mirroring the per-app toggle path. + + fun addSite(domain: String) = viewModelScope.launch { + store.updateSettings { it.copy(routedSites = it.routedSites + domain) } + reconnectIfRunning() + } + + fun removeSite(entry: String) = viewModelScope.launch { + store.updateSettings { it.copy(routedSites = it.routedSites - entry) } + reconnectIfRunning() + } + + fun toggleCategory(category: String, enabled: Boolean) = viewModelScope.launch { + val key = GEOSITE_PREFIX + category + store.updateSettings { s -> + s.copy(routedSites = if (enabled) s.routedSites + key else s.routedSites - key) + } + reconnectIfRunning() + } + + fun clearSites() = viewModelScope.launch { + store.updateSettings { it.copy(routedSites = emptySet()) } + reconnectIfRunning() + } + + fun setRoutingMode(mode: RoutingMode) = viewModelScope.launch { + store.updateSettings { it.copy(routingMode = mode) } + reconnectIfRunning() + } + + // --- Geosite category search (full bundled DB, parsed lazily off the main thread) --- + + private val _geositeQuery = MutableStateFlow("") + private val _geositeLoading = MutableStateFlow(false) + val geositeLoading: StateFlow = _geositeLoading.asStateFlow() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val geositeSuggestions: StateFlow> = _geositeQuery + .debounce(200) + .mapLatest { q -> + if (q.isBlank()) { + emptyList() + } else { + _geositeLoading.value = true + try { + // First call parses the 2.2 MB asset (~hundreds of ms) — keep it off the UI thread. + withContext(Dispatchers.Default) { GeositeRepository.search(getApplication(), q, 40) } + } finally { + _geositeLoading.value = false + } + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun setGeositeQuery(query: String) { + // Flip the spinner on synchronously (before debounce + the first parse) so the UI shows + // "loading" instead of flashing "ничего не найдено" during the debounce window. + _geositeLoading.value = query.isNotBlank() + _geositeQuery.value = query + } + private fun reconnectIfRunning() { if (VpnState.status.value in RUNNING_STATES) ProxyVpnService.restart(getApplication()) } diff --git a/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt new file mode 100644 index 0000000..36939ad --- /dev/null +++ b/app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt @@ -0,0 +1,343 @@ +package chat.vojo.proxy.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.vojo.proxy.core.RoutingMode +import chat.vojo.proxy.core.route.GEOSITE_PREFIX +import chat.vojo.proxy.core.route.GeositeHit +import chat.vojo.proxy.core.route.normalizeDomain +import chat.vojo.proxy.ui.theme.Vojo + +/** + * Domain split-routing ("Сайты"). A global [routingMode] default plus an exception list: + * users type suffix domains or pick geosite categories via live search over the bundled + * database. Changes persist and hot-restart the tunnel via the view-model (mapped-DNS is + * rebuilt on restart). + */ +@Composable +fun SitesScreen( + routedSites: Set, + routingMode: RoutingMode, + categorySuggestions: List, + categoryLoading: Boolean, + onCategoryQuery: (String) -> Unit, + onAddSite: (String) -> Unit, + onRemoveSite: (String) -> Unit, + onToggleCategory: (String, Boolean) -> Unit, + onSetMode: (RoutingMode) -> Unit, + onClear: () -> Unit, +) { + var domainInput by rememberSaveable { mutableStateOf("") } + var catQuery by rememberSaveable { mutableStateOf("") } + // catQuery is saved across process death but the VM's query isn't — re-seed it so restored + // search text shows results instead of a stale "ничего не найдено". + LaunchedEffect(Unit) { if (catQuery.isNotBlank()) onCategoryQuery(catQuery) } + val normalized = remember(domainInput) { normalizeDomain(domainInput) } + val showInvalid = domainInput.isNotBlank() && normalized == null + + val domains = remember(routedSites) { + routedSites.filterNot { it.startsWith(GEOSITE_PREFIX) }.sorted() + } + val selectedCategories = remember(routedSites) { + routedSites.filter { it.startsWith(GEOSITE_PREFIX) }.map { it.removePrefix(GEOSITE_PREFIX) }.sorted() + } + val selectedCatSet = remember(selectedCategories) { selectedCategories.toSet() } + + fun commitDomain() { + val d = normalizeDomain(domainInput) ?: return + onAddSite(d) + domainInput = "" + } + + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Маршрутизация по доменам", color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + if (routedSites.isNotEmpty()) { + Spacer(Modifier.width(8.dp)) + Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Vojo.fleet).padding(horizontal = 8.dp, vertical = 2.dp)) { + Text("${routedSites.size}", color = Color(0xFF0C0C0E), fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.weight(1f)) + if (routedSites.isNotEmpty()) { + Text( + "Сбросить", + color = Vojo.fleetSoft, fontSize = 13.sp, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onClear() }.padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } + Spacer(Modifier.height(12.dp)) + + ModeToggle(routingMode, onSetMode) + Spacer(Modifier.height(6.dp)) + Text( + when (routingMode) { + RoutingMode.PROXY_ALL -> "Сайты из списка идут напрямую, остальной трафик — через прокси." + RoutingMode.DIRECT_ALL -> "Сайты из списка идут через прокси, остальной трафик — напрямую." + }, + color = Vojo.faint, fontSize = 12.sp, + ) + Spacer(Modifier.height(12.dp)) + + // Manual suffix domain (example.com covers subdomains). + InputField( + value = domainInput, + onValueChange = { domainInput = it }, + placeholder = "Добавить домен, напр. example.com", + mono = true, + invalid = showInvalid, + keyboardType = KeyboardType.Uri, + onSubmit = { commitDomain() }, + trailing = { + val canAdd = normalized != null + Box( + Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(if (canAdd) Vojo.fleet else Vojo.surface) + .then(if (canAdd) Modifier.clickable { commitDomain() } else Modifier), + contentAlignment = Alignment.Center, + ) { + Icon(VojoIcons.Plus, contentDescription = "Добавить", tint = if (canAdd) Color(0xFF0C0C0E) else Vojo.faint, modifier = Modifier.size(16.dp)) + } + }, + ) + if (showInvalid) { + Spacer(Modifier.height(4.dp)) + Text("Не похоже на домен", color = Vojo.danger, fontSize = 11.sp) + } + Spacer(Modifier.height(10.dp)) + + // Geosite category search (live suggestions over the bundled database). + InputField( + value = catQuery, + onValueChange = { catQuery = it; onCategoryQuery(it) }, + placeholder = "Найти категорию: google, netflix, ads…", + mono = false, + invalid = false, + keyboardType = KeyboardType.Text, + onSubmit = {}, + leading = { Icon(VojoIcons.Search, contentDescription = null, tint = Vojo.faint, modifier = Modifier.size(17.dp)) }, + trailing = if (catQuery.isNotEmpty()) { + { + Box( + Modifier.size(30.dp).clip(RoundedCornerShape(8.dp)).clickable { catQuery = ""; onCategoryQuery("") }, + contentAlignment = Alignment.Center, + ) { Icon(VojoIcons.Cross, contentDescription = "Очистить", tint = Vojo.muted, modifier = Modifier.size(14.dp)) } + } + } else null, + ) + } + + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (catQuery.isNotBlank()) { + // Search mode: show suggestions from the geosite DB. + item(key = "hdr-sug") { SectionLabel("Категории geosite", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) } + if (categoryLoading && categorySuggestions.isEmpty()) { + item(key = "sug-loading") { + Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Vojo.fleet, strokeWidth = 2.dp, modifier = Modifier.size(22.dp)) + } + } + } else if (categorySuggestions.isEmpty()) { + item(key = "sug-empty") { + Text("Ничего не найдено по «$catQuery»", color = Vojo.faint, fontSize = 13.sp, modifier = Modifier.padding(start = 6.dp, top = 10.dp, bottom = 10.dp)) + } + } else { + items(categorySuggestions, key = { "sug:${it.category}" }) { hit -> + CategoryRow( + category = hit.category, + ruleCount = hit.ruleCount, + byName = hit.matchedByName, + checked = hit.category in selectedCatSet, + onToggle = { on -> onToggleCategory(hit.category, on) }, + ) + } + } + } else { + // Browse mode: selected categories + manual domains. + item(key = "hdr-cat") { + SectionLabel(if (selectedCategories.isEmpty()) "Категории" else "Категории · ${selectedCategories.size}", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) + } + if (selectedCategories.isEmpty()) { + item(key = "cat-empty") { + Text("Найдите категорию выше (google, netflix, telegram…) и добавьте её.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) + } + } + items(selectedCategories, key = { "selcat:$it" }) { cat -> + CategoryRow(category = cat, ruleCount = null, byName = true, checked = true) { onToggleCategory(cat, false) } + } + item(key = "hdr-dom") { + SectionLabel(if (domains.isEmpty()) "Домены" else "Домены · ${domains.size}", Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp)) + } + if (domains.isEmpty()) { + item(key = "dom-empty") { + Text("Добавьте домен выше — он и все его поддомены попадут под правило.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp)) + } + } + items(domains, key = { "dom:$it" }) { domain -> + DomainRow(domain) { onRemoveSite(domain) } + } + } + } + } +} + +/** Shared boxed text field matching the Apps search composer. */ +@Composable +private fun InputField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + mono: Boolean, + invalid: Boolean, + keyboardType: KeyboardType, + onSubmit: () -> Unit, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Vojo.bgPanel) + .border(1.dp, if (invalid) Vojo.danger.copy(alpha = 0.55f) else Vojo.divider, RoundedCornerShape(12.dp)) + .padding(start = 14.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { leading(); Spacer(Modifier.width(10.dp)) } + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + modifier = Modifier.weight(1f), + textStyle = TextStyle(color = Vojo.text, fontSize = 15.sp, fontFamily = if (mono) Vojo.mono else androidx.compose.ui.text.font.FontFamily.Default), + cursorBrush = SolidColor(Vojo.fleet), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onSubmit() }), + decorationBox = { inner -> + Box(Modifier.padding(vertical = 14.dp)) { + if (value.isEmpty()) Text(placeholder, color = Vojo.faint, fontSize = 15.sp, fontFamily = if (mono) Vojo.mono else androidx.compose.ui.text.font.FontFamily.Default, maxLines = 1, overflow = TextOverflow.Ellipsis) + inner() + } + }, + ) + if (trailing != null) { Spacer(Modifier.width(6.dp)); trailing() } + } +} + +@Composable +private fun ModeToggle(mode: RoutingMode, onSet: (RoutingMode) -> Unit) { + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Vojo.bgPanel).border(1.dp, Vojo.divider, RoundedCornerShape(12.dp)).padding(3.dp), + ) { + ModePill("Всё через прокси", mode == RoutingMode.PROXY_ALL, Modifier.weight(1f)) { onSet(RoutingMode.PROXY_ALL) } + ModePill("Всё напрямую", mode == RoutingMode.DIRECT_ALL, Modifier.weight(1f)) { onSet(RoutingMode.DIRECT_ALL) } + } +} + +@Composable +private fun ModePill(text: String, active: Boolean, modifier: Modifier, onClick: () -> Unit) { + Box( + modifier.clip(RoundedCornerShape(9.dp)).background(if (active) Vojo.fleet else Color.Transparent).clickable { onClick() }.padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text(text, color = if (active) Color(0xFF0C0C0E) else Vojo.muted, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium) + } +} + +@Composable +private fun CategoryRow(category: String, ruleCount: Int?, byName: Boolean, checked: Boolean, onToggle: (Boolean) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (checked) Vojo.surface else Color.Transparent) + .clickable { onToggle(!checked) } + .padding(horizontal = 10.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(2.dp).height(28.dp).clip(RoundedCornerShape(1.dp)).background(if (checked) Vojo.fleet else Color.Transparent)) + Spacer(Modifier.width(11.dp)) + Column(Modifier.weight(1f)) { + Text(category, color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = buildString { + append("geosite") + if (ruleCount != null) append(" · ${ruleCount} доменов") + if (!byName) append(" · содержит домен") + } + Text(sub, color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Spacer(Modifier.width(10.dp)) + SquareCheck(checked) + } +} + +@Composable +private fun DomainRow(domain: String, onRemove: () -> Unit) { + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Vojo.surface).padding(horizontal = 10.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(2.dp).height(28.dp).clip(RoundedCornerShape(1.dp)).background(Vojo.fleet)) + Spacer(Modifier.width(11.dp)) + Text(domain, color = Vojo.text, fontFamily = Vojo.mono, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(10.dp)) + Box(Modifier.size(28.dp).clip(RoundedCornerShape(8.dp)).clickable { onRemove() }, contentAlignment = Alignment.Center) { + Icon(VojoIcons.Cross, contentDescription = "Удалить", tint = Vojo.muted, modifier = Modifier.size(14.dp)) + } + } +} + +@Composable +private fun SquareCheck(checked: Boolean) { + Box( + Modifier.size(24.dp).clip(RoundedCornerShape(7.dp)).background(if (checked) Vojo.fleet else Color.Transparent).border(1.dp, if (checked) Vojo.fleet else Vojo.hairline, RoundedCornerShape(7.dp)), + contentAlignment = Alignment.Center, + ) { + if (checked) Icon(VojoIcons.Check, contentDescription = null, tint = Color(0xFF0C0C0E), modifier = Modifier.size(14.dp)) + } +}