34 Commits

Author SHA1 Message Date
zarazaex69 518edd096b upd design 2026-05-06 20:05:03 +03:00
zarazaex69 450542d3b8 add olcng text bage 2026-05-06 17:44:49 +03:00
zarazaex69 b775851960 remove тень 2026-05-06 17:34:31 +03:00
zarazaex69 4c52c1e45c fix bs wl 2026-05-06 17:31:28 +03:00
zarazaex69 e55ad93c52 fix icons BTW 2026-05-06 17:23:45 +03:00
zarazaex69 db931be24e fix upd text 2026-05-06 17:10:11 +03:00
zarazaex69 1f1d110a82 tutorial chge text 2026-05-06 16:52:47 +03:00
zarazaex69 2912d17aca rename select app to split tunelling 2026-05-06 16:50:08 +03:00
zarazaex69 261bd389f6 rename no connect to wait 2026-05-06 16:49:26 +03:00
zarazaex69 2c9eb2d8af add server unselect 2026-05-06 16:48:28 +03:00
zarazaex69 35a396ea0a add space to wl and bl 2026-05-06 16:45:21 +03:00
zarazaex69 0fa72f5ee1 upd color 2026-05-06 16:41:02 +03:00
zarazaex69 bea7c8a0d3 mv sub 2 down 2026-05-06 01:20:35 +03:00
zarazaex69 45779f0bce add material you color 2026-05-06 01:13:22 +03:00
zarazaex69 642bc7a437 fix server select 2026-05-06 01:03:50 +03:00
zarazaex69 74f6762cf6 fix icon bug 2026-05-06 00:46:34 +03:00
zarazaex69 8aa17ae4bd fix: крестик вместо кнопки Отмена в диалогах + правильная блокировка кнопок
- Все диалоги (обновление, страны) теперь имеют крестик (×) вверху справа вместо кнопки Отмена/Позже
- Добавлен layout dialog_title_with_close.xml с кастомным заголовком и кнопкой закрытия
- Исправлена блокировка кнопок: setButtonsEnabled теперь блокирует и btnSummaryLite тоже
- applyRunningState упрощён — при isRunning разблокирует через setButtonsEnabled
- Во время теста (isTesting): fab/меню блокируются, молния остаётся активной как стоп
- По завершении теста: всё разблокируется корректно
2026-05-06 00:20:21 +03:00
zarazaex69 9f5f3580e0 fix: множество исправлений UI и логики
- Адаптивная иконка: пересоздана с safe-zone отступами (62%) для всех плотностей — лого не обрезается кругом на Android 16
- Строгий таймаут 6 сек: measureOutboundDelayInternal теперь использует context с таймаутом — больше нет бесконечных зависаний пинга
- Индикатор теста: 'Проверено успешно: X / Y' вместо 'Number of running test tasks: left / total'
- Текст начала теста: 'Тестирование N серверов' вместо 'Testing N configs'
- Цвет прогресс-бара: адаптирован к теме (md_theme_primary) вместо оранжевого
- Все диалоги переведены на MaterialAlertDialogBuilder — скруглённые углы, Material3 стиль
- Кнопка FAB '>': блокирует ВСЕ кнопки сразу при нажатии (не только себя)
- Кнопка молнии: блокирует всё сразу при нажатии, а не после обновления списков
- 'Подключаемся к быстрейшему серверу': блокирует UI до завершения подключения
- sub_update кнопка: блокирует интерфейс на время обновления, разблокирует по завершении
- isRunning: applyRunningState теперь явно разблокирует/блокирует кнопки при любом переходе состояния
- Обновление подписок через VPN: при каждом подключении автоматически обновляем подписки через VPN-прокси (через 2 сек после старта)
2026-05-06 00:02:38 +03:00
zarazaex69 5123d996f4 style: update FAB colors and add ripple effect 2026-05-05 16:49:55 +03:00
zarazaex69 cb1ea3c3a3 feat(ui): enable edge-to-edge layout and fix status bar color 2026-05-04 19:03:12 +03:00
zarazaex69 9249ff9bce feat: sync status bar color with toolbar background 2026-05-04 15:05:29 +03:00
zarazaex69 b8cbcac477 refactor(ui): update icons and remove import/export proxy app menu items 2026-05-04 14:45:42 +03:00
zarazaex69 a4c4d764a0 fix: экзорцизм сабмодуля material-design-icons 2026-05-04 14:11:06 +03:00
zarazaex69 b6122866d5 fix: полное удаление сломанного сабмодуля material-design-icons 2026-05-04 14:08:21 +03:00
zarazaex69 b87a0e8a6e REMOVEE 2026-05-04 14:03:08 +03:00
zarazaex69 3ccd7493a2 feat: кнопки блокируются во время теста, молния останавливает скан
- Кнопки FAB и меню серые и недоступны пока идёт пинг-тест
- Нажатие на молнию во время теста — останавливает его (не перезапускает)
- Иконка молнии меняется на стоп пока идёт тест
- isTesting LiveData управляет состоянием UI
2026-05-04 08:26:12 +03:00
zarazaex69 60ce213f67 Replace delay test URL with api.ipify.org 2026-04-26 23:39:29 +03:00
zarazaex69 d0161826e2 Reduce MeasureDelay timeout from 12s to 6s and remove unnecessary delays 2026-04-26 23:14:39 +03:00
zarazaex69 641fcb943d feat: Parallelize config preparation for speed tests 2026-04-26 22:54:17 +03:00
zarazaex69 1fbb4f2bd3 feat: add copy server button to clipboard 2026-04-22 18:48:34 +03:00
zarazaex69 d3a5a1af9c fix: preserve favorite status during subscription update 2026-04-21 23:07:51 +03:00
zarazaex69 33971a576a fix: #7 2026-04-21 23:00:36 +03:00
zarazaex69 a04c53a045 fix: real ping IPC stalls by batching and throttling UI updates 2026-04-21 04:27:37 +03:00
zarazaex69 278095015b feat: remove apk 2026-04-21 03:06:16 +03:00
68 changed files with 2528 additions and 1026 deletions
+1
View File
@@ -65,3 +65,4 @@ Thumbs.db
.DS_Store
add_subscription_mmkv.py
.gitignore
material-design-icons
+12 -5
View File
@@ -50,6 +50,7 @@ type PingCallback interface {
OnResult(guid string, delay int64)
}
// CoreCallbackHandler defines interface for receiving callbacks and notifications from the core service
type CoreCallbackHandler interface {
Startup() int
@@ -159,10 +160,10 @@ func (x *CoreController) QueryStats(tag string, direct string) int64 {
}
// MeasureDelay measures network latency to a specified URL through the current core instance
// Uses a 12-second timeout context and returns the round-trip time in milliseconds
// Uses a 6-second timeout context and returns the round-trip time in milliseconds
// An error is returned if the connection fails or returns an unexpected status
func (x *CoreController) MeasureDelay(url string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
return measureInstDelay(ctx, x.coreInstance, url)
@@ -212,6 +213,12 @@ func MeasureOutboundDelayBatch(itemsJson string, url string, callback PingCallba
}
func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int64, error) {
return measureOutboundDelayWithContext(ConfigureFileContent, url)
}
func measureOutboundDelayWithContext(ConfigureFileContent string, url string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
config, err := coreserial.LoadJSONConfig(strings.NewReader(ConfigureFileContent))
if err != nil {
return -1, fmt.Errorf("config load error: %w", err)
@@ -238,7 +245,7 @@ func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int6
return -1, fmt.Errorf("startup failed: %w", err)
}
defer inst.Close()
return measureInstDelay(context.Background(), inst, url)
return measureInstDelay(ctx, inst, url)
}
// CheckVersionX returns the library and Xray versions
@@ -307,11 +314,11 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
client := &http.Client{
Transport: tr,
Timeout: 5 * time.Second,
Timeout: 6 * time.Second,
}
if url == "" {
url = "https://www.google.com/generate_204"
url = "https://api.ipify.org"
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@@ -2,11 +2,13 @@ package xyz.zarazaex.olc
import android.app.Application
import android.content.Context
import android.os.Build
import androidx.work.Configuration
import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import xyz.zarazaex.olc.AppConfig.ANG_PACKAGE
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsManager
class AngApplication : Application() {
@@ -32,8 +34,6 @@ class AngApplication : Application() {
*/
override fun onCreate() {
super.onCreate()
// Apply Material You dynamic colors (Android 12+)
DynamicColors.applyToActivitiesIfAvailable(this)
val mmkvDir = java.io.File(filesDir, "mmkv")
if (!java.io.File(mmkvDir, "MAIN").exists()) {
@@ -53,6 +53,11 @@ class AngApplication : Application() {
MMKV.initialize(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLORS, false)) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
@@ -73,6 +73,10 @@ object AppConfig {
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_SHOW_COPY_BUTTON = "pref_show_copy_button"
const val PREF_SHOW_SERVER_IP = "pref_show_server_ip"
const val PREF_DYNAMIC_COLORS = "pref_dynamic_colors"
const val PREF_SUBSCRIPTIONS_BOTTOM = "pref_subscriptions_bottom"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -119,7 +123,7 @@ object AppConfig {
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://icanhazip.com"
const val DELAY_TEST_URL = "https://api.ipify.org"
const val DELAY_TEST_URL2 = "https://api64.ipify.org"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
@@ -164,6 +168,7 @@ object AppConfig {
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_MEASURE_CONFIG_BATCH = 75
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -10,4 +10,6 @@ interface MainAdapterListener :BaseAdapterListener {
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
fun onCopyToClipboard(guid: String)
}
@@ -0,0 +1,14 @@
package xyz.zarazaex.olc.dto
import java.io.Serializable
data class PingResultItem(
val guid: String,
val delay: Long
) : Serializable
data class PingProgressUpdate(
val results: ArrayList<PingResultItem>,
val finished: Int,
val total: Int
) : Serializable
@@ -128,4 +128,39 @@ data class ProfileItem(
&& this.pinnedCA256 == obj.pinnedCA256
)
}
override fun hashCode(): Int {
var result = server?.hashCode() ?: 0
result = 31 * result + (serverPort?.hashCode() ?: 0)
result = 31 * result + (password?.hashCode() ?: 0)
result = 31 * result + (method?.hashCode() ?: 0)
result = 31 * result + (flow?.hashCode() ?: 0)
result = 31 * result + (username?.hashCode() ?: 0)
result = 31 * result + (network?.hashCode() ?: 0)
result = 31 * result + (headerType?.hashCode() ?: 0)
result = 31 * result + (host?.hashCode() ?: 0)
result = 31 * result + (path?.hashCode() ?: 0)
result = 31 * result + (seed?.hashCode() ?: 0)
result = 31 * result + (quicSecurity?.hashCode() ?: 0)
result = 31 * result + (quicKey?.hashCode() ?: 0)
result = 31 * result + (mode?.hashCode() ?: 0)
result = 31 * result + (serviceName?.hashCode() ?: 0)
result = 31 * result + (authority?.hashCode() ?: 0)
result = 31 * result + (xhttpMode?.hashCode() ?: 0)
result = 31 * result + (security?.hashCode() ?: 0)
result = 31 * result + (sni?.hashCode() ?: 0)
result = 31 * result + (alpn?.hashCode() ?: 0)
result = 31 * result + (fingerPrint?.hashCode() ?: 0)
result = 31 * result + (publicKey?.hashCode() ?: 0)
result = 31 * result + (shortId?.hashCode() ?: 0)
result = 31 * result + (secretKey?.hashCode() ?: 0)
result = 31 * result + (localAddress?.hashCode() ?: 0)
result = 31 * result + (reserved?.hashCode() ?: 0)
result = 31 * result + (mtu ?: 0)
result = 31 * result + (obfsPassword?.hashCode() ?: 0)
result = 31 * result + (portHopping?.hashCode() ?: 0)
result = 31 * result + (portHoppingInterval?.hashCode() ?: 0)
result = 31 * result + (pinnedCA256?.hashCode() ?: 0)
return result
}
}
@@ -26,6 +26,10 @@ import xyz.zarazaex.olc.util.JsonUtil
import xyz.zarazaex.olc.util.QRCodeDecoder
import xyz.zarazaex.olc.util.Utils
import java.net.URI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
object AngConfigManager {
@@ -241,8 +245,8 @@ object AngConfigManager {
val subItem = MmkvManager.decodeSubscription(subid)
val oldPingData = if (!append) {
saveOldPingData(subid)
val oldServerData = if (!append) {
saveOldServerData(subid)
} else {
emptyMap()
}
@@ -263,7 +267,7 @@ object AngConfigManager {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid, append)
restoreOldPingData(keyToProfile, oldPingData)
restoreOldServerData(keyToProfile, oldServerData)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
@@ -330,31 +334,39 @@ object AngConfigManager {
return keyToProfile
}
private fun saveOldPingData(subid: String): Map<ProfileItem, Long> {
val pingData = mutableMapOf<ProfileItem, Long>()
private fun saveOldServerData(subid: String): Map<ProfileItem, Pair<Long, Boolean>> {
val serverData = mutableMapOf<ProfileItem, Pair<Long, Boolean>>()
val serverList = MmkvManager.decodeServerList(subid)
serverList.forEach { guid ->
val profile = MmkvManager.decodeServerConfig(guid)
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
if (profile != null && aff != null && aff.testDelayMillis > 0) {
pingData[profile] = aff.testDelayMillis
if (profile != null) {
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
val delay = aff?.testDelayMillis ?: 0L
if (delay > 0 || profile.isFavorite) {
serverData[profile] = Pair(delay, profile.isFavorite)
}
}
}
return pingData
return serverData
}
private fun restoreOldPingData(keyToProfile: Map<String, ProfileItem>, oldPingData: Map<ProfileItem, Long>) {
if (oldPingData.isEmpty()) return
private fun restoreOldServerData(keyToProfile: Map<String, ProfileItem>, oldServerData: Map<ProfileItem, Pair<Long, Boolean>>) {
if (oldServerData.isEmpty()) return
keyToProfile.forEach { (key, newProfile) ->
val oldPing = oldPingData.entries.firstOrNull { (oldProfile, _) ->
oldProfile == newProfile
}?.value
val oldData = oldServerData[newProfile]
if (oldPing != null && oldPing > 0) {
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
if (oldData != null) {
val (oldPing, isFavorite) = oldData
if (oldPing > 0) {
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
}
if (isFavorite) {
newProfile.isFavorite = true
MmkvManager.encodeServerConfig(key, newProfile)
}
}
}
}
@@ -554,9 +566,13 @@ object AngConfigManager {
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
return try {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
acc + updateConfigViaSub(subscription)
// Parallel fetch — each sub downloads concurrently on IO pool
val results = runBlocking(Dispatchers.IO) {
subscriptions.map { sub ->
async { updateConfigViaSub(sub) }
}.awaitAll()
}
results.fold(SubscriptionUpdateResult()) { acc, r -> acc + r }
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
@@ -596,7 +612,7 @@ object AngConfigManager {
Log.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 15000
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 6000
var configText = try {
val httpPort = SettingsManager.getHttpPort()
@@ -0,0 +1,196 @@
package xyz.zarazaex.olc.handler
import android.util.Log
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.util.HttpUtil
import xyz.zarazaex.olc.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
object CountryDetector {
const val UNKNOWN = "??"
// ── Emoji flag → ISO 2-letter country code ────────────────────────────────
/** Extract first flag emoji found in [text] and return its ISO country code (e.g. "RU"). */
fun extractFlagCode(text: String): String? {
val codePoints = text.codePoints().toArray()
var i = 0
while (i < codePoints.size - 1) {
val cp1 = codePoints[i]
val cp2 = codePoints[i + 1]
if (cp1 in 0x1F1E6..0x1F1FF && cp2 in 0x1F1E6..0x1F1FF) {
val c1 = ('A'.code + (cp1 - 0x1F1E6)).toChar()
val c2 = ('A'.code + (cp2 - 0x1F1E6)).toChar()
return "$c1$c2"
}
i++
}
return null
}
/** Get best country code for a server (emoji first, then cache). */
fun getCountryCode(remarks: String, serverIp: String?): String {
extractFlagCode(remarks)?.let { return it }
if (!serverIp.isNullOrBlank() && !isPrivateIp(serverIp)) {
MmkvManager.getCountryCache(serverIp)?.let { return it }
}
return UNKNOWN
}
// ── Flag emoji rendering ──────────────────────────────────────────────────
/** ISO code → flag emoji string */
fun codeToFlag(code: String): String {
if (code.length != 2 || code == UNKNOWN) return "🌍"
return try {
val first = 0x1F1E6 + (code[0].uppercaseChar().code - 'A'.code)
val second = 0x1F1E6 + (code[1].uppercaseChar().code - 'A'.code)
String(intArrayOf(first, second), 0, 2)
} catch (e: Exception) { "🌍" }
}
/** ISO code → human-readable country name (or code if unknown) */
fun codeToName(code: String): String = COUNTRY_NAMES[code.uppercase()] ?: code
// ── Background lookup ─────────────────────────────────────────────────────
private val semaphore = Semaphore(5)
/**
* Looks up countries for all [ips] not yet cached via ip-api.com/batch.
* Saves results to MmkvManager cache. Called from IO coroutine.
*/
suspend fun lookupAndCacheAll(ips: List<String>) {
val uncached = ips
.filter { !it.isNullOrBlank() && !isPrivateIp(it) }
.distinct()
.filter { MmkvManager.getCountryCache(it) == null }
if (uncached.isEmpty()) return
// ip-api.com/batch: max 100 per request, returns [{query, countryCode}]
uncached.chunked(100).forEach { chunk ->
semaphore.withPermit {
try {
lookupBatch(chunk)
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Country batch lookup failed: ${e.message}")
}
}
}
}
private data class IpApiRequest(val query: String, val fields: String = "countryCode")
private suspend fun lookupBatch(ips: List<String>) = withContext(Dispatchers.IO) {
val body = JsonUtil.toJson(ips.map { IpApiRequest(it) })
val response = HttpUtil.postJson("http://ip-api.com/batch", body, 10000) ?: return@withContext
try {
val arr = com.google.gson.JsonParser.parseString(response).asJsonArray
arr.forEach { el ->
val obj = el.asJsonObject
val ip = obj.get("query")?.asString ?: return@forEach
val code = obj.get("countryCode")?.asString?.uppercase()
?.takeIf { it.length == 2 } ?: return@forEach
MmkvManager.setCountryCache(ip, code)
}
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Country batch parse failed: ${e.message}")
}
}
// ── Private IP check ─────────────────────────────────────────────────────
fun isPrivateIp(ip: String): Boolean {
if (ip.contains('.').not()) return false // IPv6 skip for now
return try {
val parts = ip.split('.').map { it.toInt() }
if (parts.size != 4) return false
val a = parts[0]; val b = parts[1]
a == 10 || a == 127 ||
(a == 172 && b in 16..31) ||
(a == 192 && b == 168) ||
(a == 100 && b in 64..127)
} catch (e: Exception) { false }
}
// ── Country names map ─────────────────────────────────────────────────────
val COUNTRY_NAMES: Map<String, String> = mapOf(
"AF" to "Афганистан", "AL" to "Албания", "DZ" to "Алжир",
"AD" to "Андорра", "AO" to "Ангола", "AG" to "Антигуа и Барбуда",
"AR" to "Аргентина", "AM" to "Армения", "AU" to "Австралия",
"AT" to "Австрия", "AZ" to "Азербайджан", "BS" to "Багамы",
"BH" to "Бахрейн", "BD" to "Бангладеш", "BB" to "Барбадос",
"BY" to "Беларусь", "BE" to "Бельгия", "BZ" to "Белиз",
"BJ" to "Бенин", "BT" to "Бутан", "BO" to "Боливия",
"BA" to "Босния и Герцеговина", "BW" to "Ботсвана",
"BR" to "Бразилия", "BN" to "Бруней", "BG" to "Болгария",
"BF" to "Буркина-Фасо", "BI" to "Бурунди", "CV" to "Кабо-Верде",
"KH" to "Камбоджа", "CM" to "Камерун", "CA" to "Канада",
"CF" to "ЦАР", "TD" to "Чад", "CL" to "Чили",
"CN" to "Китай", "CO" to "Колумбия", "KM" to "Коморы",
"CG" to "Конго", "CD" to "ДР Конго", "CR" to "Коста-Рика",
"HR" to "Хорватия", "CU" to "Куба", "CY" to "Кипр",
"CZ" to "Чехия", "DK" to "Дания", "DJ" to "Джибути",
"DM" to "Доминика", "DO" to "Доминикана", "EC" to "Эквадор",
"EG" to "Египет", "SV" to "Сальвадор", "GQ" to "Экв. Гвинея",
"ER" to "Эритрея", "EE" to "Эстония", "SZ" to "Эсватини",
"ET" to "Эфиопия", "FJ" to "Фиджи", "FI" to "Финляндия",
"FR" to "Франция", "GA" to "Габон", "GM" to "Гамбия",
"GE" to "Грузия", "DE" to "Германия", "GH" to "Гана",
"GR" to "Греция", "GD" to "Гренада", "GT" to "Гватемала",
"GN" to "Гвинея", "GW" to "Гвинея-Бисау", "GY" to "Гайана",
"HT" to "Гаити", "HN" to "Гондурас", "HU" to "Венгрия",
"IS" to "Исландия", "IN" to "Индия", "ID" to "Индонезия",
"IR" to "Иран", "IQ" to "Ирак", "IE" to "Ирландия",
"IL" to "Израиль", "IT" to "Италия", "JM" to "Ямайка",
"JP" to "Япония", "JO" to "Иордания", "KZ" to "Казахстан",
"KE" to "Кения", "KI" to "Кирибати", "KP" to "Сев. Корея",
"KR" to "Юж. Корея", "KW" to "Кувейт", "KG" to "Киргизия",
"LA" to "Лаос", "LV" to "Латвия", "LB" to "Ливан",
"LS" to "Лесото", "LR" to "Либерия", "LY" to "Ливия",
"LI" to "Лихтенштейн", "LT" to "Литва", "LU" to "Люксембург",
"MG" to "Мадагаскар", "MW" to "Малави", "MY" to "Малайзия",
"MV" to "Мальдивы", "ML" to "Мали", "MT" to "Мальта",
"MH" to "Маршалловы о-ва", "MR" to "Мавритания",
"MU" to "Маврикий", "MX" to "Мексика", "FM" to "Микронезия",
"MD" to "Молдова", "MC" to "Монако", "MN" to "Монголия",
"ME" to "Черногория", "MA" to "Марокко", "MZ" to "Мозамбик",
"MM" to "Мьянма", "NA" to "Намибия", "NR" to "Науру",
"NP" to "Непал", "NL" to "Нидерланды", "NZ" to "Нов. Зеландия",
"NI" to "Никарагуа", "NE" to "Нигер", "NG" to "Нигерия",
"MK" to "Сев. Македония", "NO" to "Норвегия", "OM" to "Оман",
"PK" to "Пакистан", "PW" to "Палау", "PA" to "Панама",
"PG" to "Папуа — Нов. Гвинея", "PY" to "Парагвай",
"PE" to "Перу", "PH" to "Филиппины", "PL" to "Польша",
"PT" to "Португалия", "QA" to "Катар", "RO" to "Румыния",
"RU" to "Россия", "RW" to "Руанда",
"KN" to "Сент-Китс и Невис", "LC" to "Сент-Люсия",
"VC" to "Сент-Винсент", "WS" to "Самоа",
"SM" to "Сан-Марино", "ST" to "Сан-Томе и Принсипи",
"SA" to "Саудовская Аравия", "SN" to "Сенегал",
"RS" to "Сербия", "SC" to "Сейшелы", "SL" to "Сьерра-Леоне",
"SG" to "Сингапур", "SK" to "Словакия", "SI" to "Словения",
"SB" to "Соломоновы о-ва", "SO" to "Сомали",
"ZA" to "ЮАР", "SS" to "Юж. Судан", "ES" to "Испания",
"LK" to "Шри-Ланка", "SD" to "Судан", "SR" to "Суринам",
"SE" to "Швеция", "CH" to "Швейцария", "SY" to "Сирия",
"TW" to "Тайвань", "TJ" to "Таджикистан", "TZ" to "Танзания",
"TH" to "Таиланд", "TL" to "Восточный Тимор", "TG" to "Того",
"TO" to "Тонга", "TT" to "Тринидад и Тобаго",
"TN" to "Тунис", "TR" to "Турция", "TM" to "Туркменистан",
"TV" to "Тувалу", "UG" to "Уганда", "UA" to "Украина",
"AE" to "ОАЭ", "GB" to "Великобритания", "US" to "США",
"UY" to "Уругвай", "UZ" to "Узбекистан", "VU" to "Вануату",
"VE" to "Венесуэла", "VN" to "Вьетнам", "YE" to "Йемен",
"ZM" to "Замбия", "ZW" to "Зимбабве",
"HK" to "Гонконг", "MO" to "Макао", "PS" to "Палестина",
"XK" to "Косово", "EU" to "Европейский союз",
"NL" to "Нидерланды"
)
}
@@ -26,6 +26,7 @@ object MmkvManager {
private const val ID_SUB = "SUB"
private const val ID_ASSET = "ASSET"
private const val ID_SETTING = "SETTING"
private const val ID_COUNTRY_CACHE = "COUNTRY_CACHE"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
@@ -39,6 +40,7 @@ object MmkvManager {
private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
private val countryCacheStorage by lazy { MMKV.mmkvWithID(ID_COUNTRY_CACHE, MMKV.MULTI_PROCESS_MODE) }
//endregion
@@ -689,4 +691,23 @@ object MmkvManager {
}
//endregion
//region Country Cache
/** Returns cached ISO country code for [ip], or null if not cached. */
fun getCountryCache(ip: String): String? = countryCacheStorage.decodeString(ip)
/** Persists ISO country code for [ip]. */
fun setCountryCache(ip: String, code: String) { countryCacheStorage.encode(ip, code) }
/** Loads the user's country filter preference (set of ISO codes to SHOW, empty = show all). */
fun getCountryFilter(): Set<String> =
settingsStorage.decodeStringSet("pref_country_filter") ?: emptySet()
/** Saves the user's country filter preference. */
fun setCountryFilter(codes: Set<String>) {
settingsStorage.encode("pref_country_filter", codes)
}
//endregion
}
@@ -94,7 +94,7 @@ object SpeedtestManager {
var result: String
var elapsed = -1L
val testUrl = "https://icanhazip.com"
val testUrl = "https://api.ipify.org"
val conn = HttpUtil.createProxyConnection(testUrl, port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val start = SystemClock.elapsedRealtime()
@@ -6,8 +6,14 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.PingResultItem
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.handler.V2rayConfigManager
@@ -29,27 +35,42 @@ class RealPingWorkerService(
private val totalCount = AtomicInteger(guids.size)
private val finishedCount = AtomicInteger(0)
private val pendingResults = ArrayList<PingResultItem>()
private val pendingLock = Any()
private val delayTestUrl = SettingsManager.getDelayTestUrl()
companion object {
private const val RESULT_BATCH_SIZE = 32
private const val FLUSH_INTERVAL_MS = 1000L
}
data class PingItem(val guid: String, val config: String)
fun start() {
scope.launch(Dispatchers.IO) {
while (isActive) {
delay(FLUSH_INTERVAL_MS)
flushPendingResults()
}
}
scope.launch(Dispatchers.IO) {
try {
// Prepare configurations for batch test and shuffle for better async feel
val items =
guids.shuffled().mapNotNull { guid ->
val configResult =
V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
// Notify failure immediately for invalid configs
reportResult(guid, -1L)
null
}
// Prepare configurations in parallel for faster startup
val shuffledGuids = guids.shuffled()
val deferredItems = shuffledGuids.map { guid ->
async(Dispatchers.IO) {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
reportResult(guid, -1L)
null
}
}
}
val items = deferredItems.awaitAll().filterNotNull()
if (items.isNotEmpty()) {
val configsJson = JsonUtil.toJson(items)
@@ -67,8 +88,10 @@ class RealPingWorkerService(
)
}
flushPendingResults()
onFinish("0")
} catch (e: Exception) {
flushPendingResults()
onFinish("-1")
} finally {
cancel()
@@ -78,14 +101,41 @@ class RealPingWorkerService(
private fun reportResult(guid: String, delay: Long) {
val finished = finishedCount.incrementAndGet()
val total = guids.size
var readyBatch: PingProgressUpdate? = null
synchronized(pendingLock) {
pendingResults.add(PingResultItem(guid, delay))
if (pendingResults.size >= RESULT_BATCH_SIZE || finished >= totalCount.get()) {
readyBatch = createProgressUpdateLocked(finished)
pendingResults.clear()
}
}
readyBatch?.let(::sendBatchUpdate)
}
// Notify UI about the individual result
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, delay))
private fun flushPendingResults() {
val finished = finishedCount.get()
val update =
synchronized(pendingLock) {
if (pendingResults.isEmpty()) {
null
} else {
createProgressUpdateLocked(finished).also { pendingResults.clear() }
}
}
update?.let(::sendBatchUpdate)
}
// Notify UI about progress
val left = total - finished
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $total")
private fun createProgressUpdateLocked(finished: Int): PingProgressUpdate {
return PingProgressUpdate(
results = ArrayList(pendingResults),
finished = finished,
total = totalCount.get()
)
}
private fun sendBatchUpdate(update: PingProgressUpdate) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_BATCH, update)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "${update.finished} / ${update.total}")
}
fun cancel() {
@@ -2,6 +2,7 @@ package xyz.zarazaex.olc.ui
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
@@ -10,6 +11,7 @@ import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
@@ -121,6 +123,10 @@ abstract class BaseActivity : AppCompatActivity() {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> this.title = t }
val typedValue = TypedValue()
theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true)
it.setTitleTextColor(typedValue.data)
syncStatusBarWithToolbar(it)
}
progressBar = findViewById(R.id.progress_bar)
}
@@ -178,9 +184,18 @@ abstract class BaseActivity : AppCompatActivity() {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> supportActionBar?.title = t }
syncStatusBarWithToolbar(it)
}
}
private fun syncStatusBarWithToolbar(toolbar: Toolbar) {
val tv = TypedValue()
theme.resolveAttribute(com.google.android.material.R.attr.colorSurface, tv, true)
val bgColor = tv.data
WindowCompat.getInsetsController(window, window.decorView)?.isAppearanceLightStatusBars =
ColorUtils.calculateLuminance(bgColor) > 0.5
}
/**
* Show the base layout's ProgressBar.
*
@@ -2,7 +2,8 @@ package xyz.zarazaex.olc.ui
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.lifecycle.lifecycleScope
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.BuildConfig
@@ -12,9 +13,9 @@ import xyz.zarazaex.olc.dto.CheckUpdateResult
import xyz.zarazaex.olc.extension.toast
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.extension.toastSuccess
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.UpdateCheckerManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.util.MarkdownUtil
import xyz.zarazaex.olc.util.Utils
import kotlinx.coroutines.launch
@@ -24,33 +25,29 @@ class CheckUpdateActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
binding.layoutCheckUpdate.setOnClickListener {
checkForUpdates(binding.checkPreRelease.isChecked)
checkForUpdates()
}
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
}
binding.checkPreRelease.isChecked = true
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, true)
// Hide the pre-release toggle - we always check releases
binding.checkPreRelease.visibility = android.view.View.GONE
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
checkForUpdates(binding.checkPreRelease.isChecked)
checkForUpdates()
}
private fun checkForUpdates(includePreRelease: Boolean) {
private fun checkForUpdates() {
toast(R.string.update_checking_for_update)
showLoading()
lifecycleScope.launch {
try {
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
val result = UpdateCheckerManager.checkForUpdate(false)
if (result.hasUpdate) {
showUpdateDialog(result)
} else {
@@ -67,15 +64,21 @@ class CheckUpdateActivity : BaseActivity() {
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
val message = result.releaseNotes?.let { MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleStr)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
.create()
dialog.show()
val titleView = layoutInflater.inflate(R.layout.dialog_title_with_close, null)
titleView.findViewById<TextView>(R.id.dialog_title_text).text = titleStr
titleView.findViewById<android.widget.ImageButton>(R.id.dialog_close_btn).setOnClickListener { dialog.dismiss() }
dialog.setCustomTitle(titleView)
}
}
}
@@ -12,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.MainAdapterListener
@@ -64,7 +65,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
} else {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.server_list_divider)
binding.recyclerView.adapter = adapter
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
@@ -137,9 +138,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) != 0) {
ownerActivity.toastError(R.string.toast_failure)
}
}
@@ -230,12 +229,18 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
if (guid == selected) {
MmkvManager.setSelectServer("")
val position = mainViewModel.getPosition(guid)
adapter.setSelectServer(position, position)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
} else {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
@@ -264,6 +269,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
setSelectServer(guid)
}
override fun onCopyToClipboard(guid: String) {
share2Clipboard(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
@@ -284,6 +293,13 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the top of the list (called on double-tap on tab)
*/
fun scrollToTop() {
binding.recyclerView.smoothScrollToPosition(0)
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
@@ -317,4 +333,4 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
}
@@ -2,6 +2,7 @@ package xyz.zarazaex.olc.ui
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
@@ -9,14 +10,21 @@ import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.core.graphics.drawable.DrawableCompat
import com.google.android.material.color.MaterialColors
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView
@@ -29,6 +37,7 @@ import xyz.zarazaex.olc.enums.PermissionType
import xyz.zarazaex.olc.extension.toast
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.CountryDetector
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsChangeManager
import xyz.zarazaex.olc.handler.SettingsManager
@@ -47,6 +56,8 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private var isLiteTesting = false
private var easterEggClickCount = 0
private var isEasterEggActive = false
/** Был ли VPN уже запущен в предыдущем колбэке — чтобы детектировать момент подключения */
private var wasRunning = false
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
@@ -71,7 +82,22 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupToolbar(binding.toolbar, false, getString(R.string.title_server))
setupToolbar(binding.toolbar, false, getString(R.string.app_name))
// edge-to-edge: контент идёт под статус-бар, AppBarLayout тянется под него же
WindowCompat.setDecorFitsSystemWindows(window, false)
ViewCompat.setOnApplyWindowInsetsListener(binding.appBarLayout) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
}
// Нижние кнопки поднимаются над навигационной панелью
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomContainer) { v, insets ->
val navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
v.setPadding(0, 0, 0, navBarHeight)
insets
}
placeTabGroup()
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter
@@ -84,15 +110,33 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
findViewById<android.widget.TextView>(R.id.drawer_settings)?.setOnClickListener {
val typedValue = android.util.TypedValue()
theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true)
val onSurface = typedValue.data
binding.toolbar.setTitleTextColor(onSurface)
// MaterialToolbar с titleCentered рисует отдельный TextView — красим его явно
for (i in 0 until binding.toolbar.childCount) {
val child = binding.toolbar.getChildAt(i)
if (child is android.widget.TextView) {
child.setTextColor(onSurface)
}
}
ViewCompat.setOnApplyWindowInsetsListener(binding.drawerContentLayout) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(0, systemBars.top, 0, systemBars.bottom)
insets
}
findViewById<android.view.View>(R.id.drawer_settings)?.setOnClickListener {
requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
}
findViewById<android.widget.TextView>(R.id.drawer_per_app)?.setOnClickListener {
findViewById<android.view.View>(R.id.drawer_per_app)?.setOnClickListener {
requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java))
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
}
findViewById<android.widget.TextView>(R.id.drawer_check_update)?.setOnClickListener {
findViewById<android.view.View>(R.id.drawer_check_update)?.setOnClickListener {
startActivity(Intent(this, CheckUpdateActivity::class.java))
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
}
@@ -159,26 +203,61 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isTesting.observe(this) { testing ->
if (testing) {
// Во время теста: блокируем всё кроме кнопки молнии (стоп)
binding.fab.isEnabled = false
binding.fab.alpha = 0.5f
val menu = binding.toolbar.menu
menu.findItem(R.id.real_ping_all)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
menu.findItem(R.id.filter_by_country)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
menu.findItem(R.id.sub_update)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
// Молния — стоп-кнопка, всегда активна во время теста
binding.btnSummaryLite.isEnabled = true
binding.btnSummaryLite.alpha = 1.0f
binding.btnSummaryLite.setIconResource(R.drawable.ic_stop_24dp)
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer, 0)
)
} else {
setButtonsEnabled(true)
binding.btnSummaryLite.setIconResource(R.drawable.bolt_24)
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
)
}
}
mainViewModel.liteTestFinished.observe(this) { finished ->
if (finished && isLiteTesting) {
isLiteTesting = false
mainViewModel.sortByTestResults()
mainViewModel.reloadServerList()
val firstReachable = mainViewModel.serversCache.firstOrNull { cache ->
(MmkvManager.decodeServerAffiliationInfo(cache.guid)?.testDelayMillis ?: 0L) > 0L
}
if (firstReachable != null) {
MmkvManager.setSelectServer(firstReachable.guid)
mainViewModel.reloadServerList() // reload AFTER selection so indicator renders correctly
showStatus("Подключаемся к быстрейшему серверу")
// Блокируем кнопки на время подключения
setButtonsEnabled(false)
applyRunningState(isLoading = true, isRunning = false)
startV2RayWithPermission()
} else {
mainViewModel.reloadServerList()
showStatus("Нет доступных серверов!")
setButtonsEnabled(true)
}
}
}
mainViewModel.isRunning.observe(this) { isRunning ->
applyRunningState(false, isRunning)
// Как только VPN только что подключился — обновляем подписки через него
if (isRunning && !wasRunning) {
updateSubsViaVpn()
}
wasRunning = isRunning
}
mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets)
@@ -200,6 +279,42 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
binding.viewPager.setCurrentItem(targetIndex, false)
binding.tabGroup.isVisible = groups.size > 1
// Double-tap on a tab scrolls to top of that group
binding.tabGroup.addOnTabSelectedListener(object : com.google.android.material.tabs.TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {
val currentItem = binding.viewPager.currentItem
val itemId = groupPagerAdapter.getItemId(currentItem)
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
fragment?.scrollToTop()
}
})
}
private fun setButtonsEnabled(enabled: Boolean) {
binding.fab.isEnabled = enabled
binding.fab.alpha = if (enabled) 1.0f else 0.5f
setSecondaryButtonsEnabled(enabled)
}
private fun setSecondaryButtonsEnabled(enabled: Boolean) {
binding.btnSummaryLite.isEnabled = enabled
binding.btnSummaryLite.alpha = if (enabled) 1.0f else 0.5f
val menu = binding.toolbar.menu
menu.findItem(R.id.real_ping_all)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
menu.findItem(R.id.filter_by_country)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
menu.findItem(R.id.sub_update)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
}
private fun handleFabAction() {
@@ -210,6 +325,8 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
val isRunning = mainViewModel.isRunning.value == true
// Блокируем все кнопки сразу
setButtonsEnabled(false)
applyRunningState(isLoading = true, isRunning = false)
lifecycleScope.launch {
@@ -240,11 +357,22 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun handleLiteAction() {
// If testing is in progress - stop it
if (mainViewModel.isTesting.value == true) {
mainViewModel.cancelAllTests()
showStatus("Тест остановлен")
isLiteTesting = false
return
}
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
// Блокируем все кнопки сразу при нажатии
setButtonsEnabled(false)
lifecycleScope.launch {
try {
if (mainViewModel.isRunning.value == true) {
@@ -258,17 +386,20 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
val removed = mainViewModel.removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
mainViewModel.reloadServerList()
if (result.configCount > 0) {
mainViewModel.reloadServerList()
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
val status = if (removed > 0)
"Обновлено ${result.configCount} профилей, удалено $removed дубл. IP. Запуск теста..."
else
"Обновлено ${result.configCount} профилей. Запуск теста..."
showStatus(status)
} else {
showStatus("Запуск теста...")
}
hideLoading()
delay(500L)
showStatus("Выполняется замер задержки. Ожидаем завершения...")
mainViewModel.testAllRealPing()
}
@@ -347,29 +478,61 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private fun showStatus(resId: Int) = showStatus(getString(resId))
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
private fun accentColor(): ColorStateList {
val typedValue = android.util.TypedValue()
theme.resolveAttribute(androidx.appcompat.R.attr.colorPrimary, typedValue, true)
val color = if (typedValue.resourceId != 0)
ContextCompat.getColor(this, typedValue.resourceId)
else
typedValue.data
return ColorStateList.valueOf(color)
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
val secContainer = ColorStateList.valueOf(
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
)
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
setButtonsEnabled(false)
binding.fab.backgroundTintList = secContainer
setStatusDot(DotState.LOADING)
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
setSecondaryButtonsEnabled(false)
binding.fab.isEnabled = true
binding.fab.alpha = 1.0f
binding.fab.backgroundTintList = accentColor()
binding.btnSummaryLite.backgroundTintList = secContainer
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
setStatusDot(DotState.CONNECTED)
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
setButtonsEnabled(true)
binding.fab.backgroundTintList = accentColor()
binding.btnSummaryLite.backgroundTintList = secContainer
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
setStatusDot(DotState.IDLE)
}
}
private enum class DotState { IDLE, CONNECTED, LOADING }
private fun setStatusDot(state: DotState) {
val dot = binding.statusDot
dot.animate().cancel()
dot.alpha = 1f; dot.scaleX = 1f; dot.scaleY = 1f
dot.backgroundTintList = ColorStateList.valueOf(when (state) {
DotState.CONNECTED -> ContextCompat.getColor(this, R.color.status_connected)
DotState.LOADING -> com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer, 0)
DotState.IDLE -> com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorOutline, 0)
})
}
override fun onResume() {
super.onResume()
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
@@ -382,6 +545,13 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
val iconColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
for (i in 0 until menu.size()) {
menu.getItem(i).icon?.let {
DrawableCompat.setTint(DrawableCompat.wrap(it).mutate(), iconColor)
}
}
val searchItem = menu.findItem(R.id.search_view)
val searchView = searchItem.actionView as SearchView
@@ -420,10 +590,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
R.id.sub_update -> {
setButtonsEnabled(false)
importConfigViaSub()
true
}
R.id.filter_by_country -> {
showCountryFilterDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -513,16 +689,45 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
return true
}
/**
* Обновляет подписки через уже поднятый VPN (httpPort > 0).
* Вызывается сразу после того, как VPN перешёл в состояние isRunning = true.
*/
private fun updateSubsViaVpn() {
lifecycleScope.launch(Dispatchers.IO) {
// Даём VPN пару секунд инициализироваться
delay(2000)
Log.d(AppConfig.TAG, "updateSubsViaVpn: starting post-connect subscription update")
val result = mainViewModel.updateConfigViaSubAll()
if (result.configCount > 0) {
val removed = mainViewModel.removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
mainViewModel.reloadServerList()
val msg = if (removed > 0)
"Подписки обновлены: ${result.configCount} профилей, удалено $removed дубл. IP"
else
"Подписки обновлены: ${result.configCount} профилей"
showStatus(msg)
}
}
}
}
private fun importAllSubsOnStartup() {
showLoading()
setTestState(getString(R.string.connection_updating_profiles))
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.updateConfigViaSubAll()
val removed = mainViewModel.removeDuplicateByIpAll()
delay(500L)
launch(Dispatchers.Main) {
if (result.configCount > 0) {
mainViewModel.reloadServerList()
showStatus(getString(R.string.title_update_config_count, result.configCount))
val status = if (removed > 0)
"${getString(R.string.title_update_config_count, result.configCount)} (удалено $removed дубл. IP)"
else
getString(R.string.title_update_config_count, result.configCount)
showStatus(status)
}
hideLoading()
}
@@ -533,6 +738,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
*/
fun importConfigViaSub(): Boolean {
showLoading()
setTestState(getString(R.string.connection_updating_profiles))
lifecycleScope.launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
@@ -554,6 +760,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
mainViewModel.reloadServerList()
}
hideLoading()
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
}
}
return true
@@ -574,7 +781,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun delAllConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
MaterialAlertDialogBuilder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
@@ -593,7 +800,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun delDuplicateConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
MaterialAlertDialogBuilder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
@@ -713,7 +920,64 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
tabMediator?.detach()
super.onDestroy()
}
// ── Country filter dialog ─────────────────────────────────────────────────
private fun showCountryFilterDialog() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.refreshCountryCache()
// Collect all countries including UNKNOWN
val allCountriesMap = mainViewModel.collectAllCountries().toMutableMap()
// Add Unknown entry
allCountriesMap[CountryDetector.UNKNOWN] = "🌐 Неизвестно"
val currentFilter = mainViewModel.countryFilter // empty = show all
withContext(Dispatchers.Main) {
hideLoading()
if (allCountriesMap.size <= 1) {
showStatus("Нет серверов с известной страной")
return@withContext
}
val codes = allCountriesMap.keys.toTypedArray()
val labels = allCountriesMap.values.toTypedArray()
// In exclude mode: checked = should be EXCLUDED
// currentFilter stores included set (empty = show all)
// Convert to excluded set for UI
val allCodes = codes.toSet()
val excludedByFilter = if (currentFilter.isEmpty()) emptySet()
else allCodes - currentFilter
val checked = BooleanArray(codes.size) { codes[it] in excludedByFilter }
val dialog = MaterialAlertDialogBuilder(this@MainActivity)
.setTitle("Исключить страны")
.setMultiChoiceItems(labels, checked) { _, which, isChecked ->
checked[which] = isChecked
}
.setPositiveButton("Применить") { _, _ ->
val excluded = codes.filterIndexed { i, _ -> checked[i] }.toSet()
val included = if (excluded.isEmpty()) emptySet()
else allCodes - excluded
mainViewModel.applyCountryFilter(included)
val msg = if (excluded.isEmpty()) "Показаны все страны"
else "Скрыто: ${excluded.joinToString { CountryDetector.codeToFlag(it) }}"
showStatus(msg)
}
.setNeutralButton("Сбросить") { _, _ ->
mainViewModel.applyCountryFilter(emptySet())
showStatus("Показаны все страны")
}
.create()
dialog.show()
dialog.setCustomTitle(buildDialogTitleWithClose("Исключить страны") { dialog.dismiss() })
}
}
}
private fun checkForUpdatesOnStartup() {
showStatus("Проверка обновлений...")
lifecycleScope.launch {
@@ -732,18 +996,28 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun showUpdateAvailableDialog(result: xyz.zarazaex.olc.dto.CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
val message = result.releaseNotes?.let { xyz.zarazaex.olc.util.MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleStr)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.ok, null)
.show()
.create()
dialog.show()
dialog.setCustomTitle(buildDialogTitleWithClose(titleStr) { dialog.dismiss() })
}
private fun buildDialogTitleWithClose(title: String, onClose: () -> Unit): View {
val view = layoutInflater.inflate(R.layout.dialog_title_with_close, null)
view.findViewById<TextView>(R.id.dialog_title_text).text = title
view.findViewById<android.widget.ImageButton>(R.id.dialog_close_btn).setOnClickListener { onClose() }
return view
}
private fun activateEasterEgg() {
if (isEasterEggActive) return
isEasterEggActive = true
@@ -788,4 +1062,17 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
}
}
private fun placeTabGroup() {
val tabGroup = binding.tabGroup
val bottomSlot = binding.tabSlotBottom
val topSlot = binding.tabSlotTop
val subsBottom = MmkvManager.decodeSettingsBool(AppConfig.PREF_SUBSCRIPTIONS_BOTTOM, false)
(tabGroup.parent as? android.view.ViewGroup)?.removeView(tabGroup)
if (subsBottom) {
bottomSlot.addView(tabGroup, 0)
} else {
topSlot.addView(tabGroup)
}
}
}
@@ -5,8 +5,10 @@ import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.ColorUtils
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import java.util.Collections
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
@@ -33,7 +35,13 @@ class MainRecyclerAdapter(
private val doubleColumnDisplay =
MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private val showCopyButton =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SHOW_COPY_BUTTON, false)
private val showServerIp =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SHOW_SERVER_IP, false)
private var data: MutableList<ServersCache> = mutableListOf()
private var minReachablePing: Long? = null
private var maxReachablePing: Long? = null
private var recyclerView: RecyclerView? = null
override fun onAttachedToRecyclerView(rv: RecyclerView) {
@@ -57,6 +65,7 @@ class MainRecyclerAdapter(
if (data.isEmpty() || parsedNewData.isEmpty() || position >= 0) {
data = parsedNewData.toMutableList()
recomputePingRange()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
@@ -84,16 +93,13 @@ class MainRecyclerAdapter(
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val oldProfile = oldData[oldPos].profile
val newProfile = parsedNewData[newPos].profile
val oldGuid = oldData[oldPos].guid
val newGuid = parsedNewData[newPos].guid
return oldProfile == newProfile &&
oldProfile.isFavorite == newProfile.isFavorite &&
MmkvManager.decodeServerAffiliationInfo(
oldData[oldPos].guid
)
?.testDelayMillis ==
MmkvManager.decodeServerAffiliationInfo(
parsedNewData[newPos].guid
)
?.testDelayMillis
(oldGuid == MmkvManager.getSelectServer()) == (newGuid == MmkvManager.getSelectServer()) &&
MmkvManager.decodeServerAffiliationInfo(oldGuid)?.testDelayMillis ==
MmkvManager.decodeServerAffiliationInfo(newGuid)?.testDelayMillis
}
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
@@ -107,6 +113,7 @@ class MainRecyclerAdapter(
)
data = parsedNewData.toMutableList()
recomputePingRange()
diffResult.dispatchUpdatesTo(this)
if (isAtTop) {
@@ -123,8 +130,13 @@ class MainRecyclerAdapter(
if (payloads.isNotEmpty() && holder is MainViewHolder) {
for (payload in payloads) {
if (payload == PAYLOAD_FAVORITE) {
val isFav = data[position].profile.isFavorite
animateFavorite(holder.itemMainBinding.ivFavorite, isFav)
val item = data.getOrNull(holder.bindingAdapterPosition) ?: data.getOrNull(position) ?: continue
val isFav = item.profile.isFavorite
// Set correct icon immediately, then animate scale bounce
holder.itemMainBinding.ivFavorite.setImageResource(
if (isFav) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
animateFavorite(holder.itemMainBinding.ivFavorite)
}
}
} else {
@@ -132,23 +144,20 @@ class MainRecyclerAdapter(
}
}
private fun animateFavorite(view: android.widget.ImageView, isFavorite: Boolean) {
private fun animateFavorite(view: android.widget.ImageView) {
view.animate().cancel()
view.animate()
.scaleX(1.3f)
.scaleY(1.3f)
.setDuration(150)
.withEndAction {
view.setImageResource(
if (isFavorite) R.drawable.ic_star_filled
else R.drawable.ic_star_empty
)
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.setDuration(150)
.start()
}
.start()
.scaleX(1.4f)
.scaleY(1.4f)
.setDuration(120)
.withEndAction {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.setDuration(120)
.start()
}
.start()
}
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
@@ -161,26 +170,45 @@ class MainRecyclerAdapter(
// Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
val addressText = getAddress(profile)
holder.itemMainBinding.tvStatistics.text = addressText
holder.itemMainBinding.tvStatistics.visibility =
if (addressText.isEmpty()) View.GONE else View.VISIBLE
// TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(
ContextCompat.getColor(context, R.color.colorPingRed)
)
} else {
holder.itemMainBinding.tvTestResult.setTextColor(
ContextCompat.getColor(context, R.color.colorPing)
)
}
holder.itemMainBinding.tvTestResult.setTextColor(
getPingColor(context, aff?.testDelayMillis)
)
(holder.itemMainBinding.tvTestResult.layoutParams as? ViewGroup.MarginLayoutParams)?.marginStart =
if (addressText.isEmpty()) 0 else 6.dpToPx(context)
// layoutIndicator
if (guid == MmkvManager.getSelectServer()) {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
} else {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
// Keep selected state very quiet: only a soft surface step, no hard outline.
val isSelected = guid == MmkvManager.getSelectServer()
holder.itemMainBinding.cardContainer.apply {
val surfaceColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurfaceContainerLow,
Color.TRANSPARENT
)
val selectedSurfaceColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurfaceContainerHigh,
surfaceColor
)
setCardBackgroundColor(if (isSelected) selectedSurfaceColor else surfaceColor)
strokeWidth = if (isSelected) 1.dpToPx(context) else 0
strokeColor = if (isSelected) {
MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorOutlineVariant,
Color.TRANSPARENT
)
} else {
Color.TRANSPARENT
}
}
// subscription remarks
@@ -191,15 +219,24 @@ class MainRecyclerAdapter(
val isFav = profile.isFavorite
holder.itemMainBinding.ivFavorite.setImageResource(
if (isFav) R.drawable.ic_star_filled else R.drawable.ic_star_empty
if (isFav) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
holder.itemMainBinding.ivFavorite.setOnClickListener {
profile.isFavorite = !profile.isFavorite
MmkvManager.encodeServerConfig(guid, profile)
holder.itemMainBinding.ivFavorite.setImageResource(
if (profile.isFavorite) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
animateFavorite(holder.itemMainBinding.ivFavorite)
mainViewModel.reloadServerList()
}
holder.itemMainBinding.ivCopy.visibility = if (showCopyButton) View.VISIBLE else View.GONE
holder.itemMainBinding.ivCopy.setOnClickListener {
adapterListener?.onCopyToClipboard(guid)
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
@@ -213,9 +250,52 @@ class MainRecyclerAdapter(
* @return Formatted address string
*/
private fun getAddress(profile: ProfileItem): String {
if (!showServerIp) {
return ""
}
return AngConfigManager.generateDescription(profile)
}
private fun getPingColor(context: android.content.Context, delayMillis: Long?): Int {
val delay = delayMillis ?: return MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorOnSurfaceVariant,
ContextCompat.getColor(context, R.color.colorPing)
)
if (delay == 0L) {
return MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorOnSurfaceVariant,
ContextCompat.getColor(context, R.color.colorPing)
)
}
return when {
delay < 0L -> ContextCompat.getColor(context, R.color.colorPingRed)
minReachablePing == null || maxReachablePing == null -> ContextCompat.getColor(context, R.color.colorPingGood)
minReachablePing == maxReachablePing -> ContextCompat.getColor(context, R.color.colorPingGood)
else -> {
val min = minReachablePing ?: delay
val max = maxReachablePing ?: delay
val relative = ((delay - min).toFloat() / (max - min).toFloat()).coerceIn(0f, 1f)
when {
relative <= 0.33f -> ContextCompat.getColor(context, R.color.colorPingGood)
relative <= 0.66f -> ContextCompat.getColor(context, R.color.colorPingMedium)
else -> ContextCompat.getColor(context, R.color.colorPingRed)
}
}
}
}
private fun recomputePingRange() {
val delays = data.mapNotNull { item ->
MmkvManager.decodeServerAffiliationInfo(item.guid)
?.testDelayMillis
?.takeIf { it > 0L }
}
minReachablePing = delays.minOrNull()
maxReachablePing = delays.maxOrNull()
}
/**
* Gets the subscription remarks information
* @param profile The server configuration
@@ -235,6 +315,7 @@ class MainRecyclerAdapter(
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
data.removeAt(idx)
recomputePingRange()
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
@@ -245,6 +326,10 @@ class MainRecyclerAdapter(
notifyItemChanged(toPosition)
}
private fun Int.dpToPx(context: android.content.Context): Int {
return (this * context.resources.displayMetrics.density).toInt()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
return when (viewType) {
VIEW_TYPE_ITEM ->
@@ -90,7 +90,7 @@ class PerAppProxyActivity : BaseActivity() {
}
appsAll = apps
adapter = PerAppProxyAdapter(apps, viewModel)
adapter = PerAppProxyAdapter(apps.toMutableList(), viewModel)
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
@@ -134,17 +134,6 @@ class PerAppProxyActivity : BaseActivity() {
true
}
R.id.import_proxy_app -> {
importProxyApp()
allowPerAppProxy()
true
}
R.id.export_proxy_app -> {
exportProxyApp()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -1,5 +1,6 @@
package xyz.zarazaex.olc.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -9,9 +10,9 @@ import xyz.zarazaex.olc.dto.AppInfo
import xyz.zarazaex.olc.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val apps: MutableList<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
@@ -29,17 +30,12 @@ class PerAppProxyAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
val ctx = parent.context
return when (viewType) {
VIEW_TYPE_HEADER -> {
val view = View(ctx)
view.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0
)
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0)
BaseViewHolder(view)
}
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
}
}
@@ -48,30 +44,53 @@ class PerAppProxyAdapter(
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
View.OnClickListener {
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) :
BaseViewHolder(itemBypassBinding.root), View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(appInfo: AppInfo) {
this.appInfo = appInfo
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
String.format("** %s", appInfo.appName)
"** ${appInfo.appName}"
} else {
appInfo.appName
}
itemBypassBinding.packageName.text = appInfo.packageName
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
itemView.setOnClickListener(this)
}
@SuppressLint("NotifyDataSetChanged")
override fun onClick(v: View?) {
val packageName = appInfo.packageName
viewModel.toggle(packageName)
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
val isNowSelected = viewModel.contains(packageName)
itemBypassBinding.checkBox.isChecked = isNowSelected
// Move selected items to top, unselected back to their position
val currentPos = apps.indexOf(appInfo)
if (currentPos < 0) return
if (isNowSelected) {
// Find first non-selected item position (insert before it)
val insertAt = apps.indexOfFirst { !viewModel.contains(it.packageName) }
.takeIf { it >= 0 } ?: 0
if (currentPos != insertAt) {
apps.removeAt(currentPos)
apps.add(insertAt, appInfo)
notifyItemMoved(currentPos + 1, insertAt + 1) // +1 for header
}
} else {
// Move to end of selected group
val lastSelected = apps.indexOfLast { viewModel.contains(it.packageName) }
val insertAt = if (lastSelected < 0) 0 else lastSelected + 1
if (currentPos != insertAt) {
apps.removeAt(currentPos)
apps.add(insertAt, appInfo)
notifyItemMoved(currentPos + 1, insertAt + 1)
}
}
}
}
}
@@ -1,7 +1,10 @@
package xyz.zarazaex.olc.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@@ -28,6 +31,7 @@ class SettingsActivity : BaseActivity() {
class SettingsFragment : PreferenceFragmentCompat() {
private val dynamicColors by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_COLORS) }
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
@@ -63,8 +67,22 @@ class SettingsActivity : BaseActivity() {
addPreferencesFromResource(R.xml.pref_settings)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
dynamicColors?.isVisible = false
}
initPreferenceSummaries()
dynamicColors?.setOnPreferenceChangeListener { _, _ ->
Toast.makeText(context, R.string.restart_required, Toast.LENGTH_SHORT).show()
val intent = requireActivity().packageManager
.getLaunchIntentForPackage(requireActivity().packageName)
?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
requireActivity().finish()
intent?.let { startActivity(it) }
true
}
localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean)
true
@@ -250,5 +250,27 @@ object HttpUtil {
}
}
}
/**
* POST JSON body to [url] and return response as String or null.
*/
fun postJson(url: String, body: String, timeout: Int = 10000): String? {
var conn: java.net.HttpURLConnection? = null
return try {
conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = timeout
conn.readTimeout = timeout
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Connection", "close")
conn.outputStream.use { it.write(body.toByteArray()) }
conn.inputStream.bufferedReader().readText()
} catch (_: Exception) {
null
} finally {
conn?.disconnect()
}
}
}
@@ -0,0 +1,82 @@
package xyz.zarazaex.olc.util
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.graphics.Typeface
/**
* Simple markdown to Spanned converter for basic ** bold ** syntax without external libraries.
*/
object MarkdownUtil {
/**
* Convert simple markdown to Spanned. Supports:
* - **bold**
* - # headers (rendered as bold)
* - - list items (bullet)
*/
fun parseBasic(text: String): CharSequence {
val lines = text.split("\n")
val builder = SpannableStringBuilder()
for (line in lines) {
val processedLine = processLine(line.trimEnd())
builder.append(processedLine)
builder.append("\n")
}
// Remove trailing newlines
while (builder.isNotEmpty() && builder.last() == '\n') {
builder.delete(builder.length - 1, builder.length)
}
return builder
}
private fun processLine(line: String): CharSequence {
// Handle headers
val headerLine = when {
line.startsWith("### ") -> line.removePrefix("### ")
line.startsWith("## ") -> line.removePrefix("## ")
line.startsWith("# ") -> line.removePrefix("# ")
else -> null
}
if (headerLine != null) {
val sb = SpannableStringBuilder(processBold(headerLine))
sb.setSpan(StyleSpan(Typeface.BOLD), 0, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return sb
}
// Handle list items
if (line.startsWith("- ") || line.startsWith("* ")) {
return SpannableStringBuilder("" + processBold(line.substring(2)))
}
return processBold(line)
}
private fun processBold(text: String): SpannableStringBuilder {
val sb = SpannableStringBuilder()
var i = 0
while (i < text.length) {
if (i + 1 < text.length && text[i] == '*' && text[i + 1] == '*') {
val end = text.indexOf("**", i + 2)
if (end > 0) {
val boldText = text.substring(i + 2, end)
val start = sb.length
sb.append(boldText)
sb.setSpan(StyleSpan(Typeface.BOLD), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
i = end + 2
} else {
sb.append(text[i])
i++
}
} else {
sb.append(text[i])
i++
}
}
return sb
}
}
@@ -15,6 +15,7 @@ import xyz.zarazaex.olc.AngApplication
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.dto.GroupMapItem
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionUpdateResult
@@ -22,6 +23,7 @@ import xyz.zarazaex.olc.dto.TestServiceMessage
import xyz.zarazaex.olc.extension.matchesPattern
import xyz.zarazaex.olc.extension.serializable
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.CountryDetector
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.SpeedtestManager
@@ -37,14 +39,18 @@ import java.util.Collections
import java.util.regex.PatternSyntaxException
class MainViewModel(application: Application) : AndroidViewModel(application) {
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
private var serverList = mutableListOf<String>()
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
var keywordFilter = ""
/** ISO codes to show (empty = show all) */
var countryFilter: Set<String> = MmkvManager.getCountryFilter()
private set
val serversCache = mutableListOf<ServersCache>()
val isRunning by lazy { MutableLiveData<Boolean>() }
val updateListAction by lazy { MutableLiveData<Int>() }
val updateTestResultAction by lazy { MutableLiveData<String>() }
val liteTestFinished = MutableLiveData<Boolean>()
val isTesting by lazy { MutableLiveData<Boolean>().also { it.value = false } }
private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
/**
@@ -114,6 +120,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
list
}
pinSelectedGuidToTop(serverList)
updateCache()
updateListAction.value = -1
}
@@ -157,10 +164,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val searchRegex = try {
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
} catch (e: PatternSyntaxException) {
null // Fallback to literal search if regex is invalid
null
}
val activeCountryFilter = countryFilter
for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// Country filter
if (activeCountryFilter.isNotEmpty()) {
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code !in activeCountryFilter) continue
}
if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile))
continue
@@ -180,6 +195,48 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** Sets a new country filter and reloads list. Pass empty set to show all. */
fun applyCountryFilter(codes: Set<String>) {
countryFilter = codes
MmkvManager.setCountryFilter(codes)
reloadServerList()
}
/**
* Returns all known countries from ALL servers across all subscriptions (for showing in filter dialog).
* Key = ISO code, Value = human-readable name + flag.
*/
fun collectAllCountries(): Map<String, String> {
val result = mutableMapOf<String, String>()
var hasUnknown = false
for (guid in MmkvManager.decodeAllServerList()) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code == CountryDetector.UNKNOWN) {
hasUnknown = true
} else {
result[code] = "${CountryDetector.codeToFlag(code)} ${CountryDetector.codeToName(code)}"
}
}
if (hasUnknown) {
result[CountryDetector.UNKNOWN] = "🌐 Неизвестно"
}
return result.toSortedMap()
}
/** Trigger background geo-lookup for IPs not yet cached. */
fun refreshCountryCache() {
viewModelScope.launch(Dispatchers.IO) {
val ips = MmkvManager.decodeAllServerList().mapNotNull {
MmkvManager.decodeServerConfig(it)?.server?.trim()
}.distinct()
CountryDetector.lookupAndCacheAll(ips)
withContext(Dispatchers.Main) {
reloadServerList()
}
}
}
/**
* Updates the configuration via subscription for all servers.
* @return Detailed result of the subscription update operation.
@@ -190,27 +247,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else if (subscriptionId.startsWith("group_")) {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subscriptionId) {
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("WHITE", ignoreCase = true)
}
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
var totalResult = SubscriptionUpdateResult()
groupSubs.forEach { sub ->
val result = AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription))
totalResult = SubscriptionUpdateResult(
configCount = totalResult.configCount + result.configCount,
successCount = totalResult.successCount + result.successCount,
failureCount = totalResult.failureCount + result.failureCount,
skipCount = totalResult.skipCount + result.skipCount
)
// Parallel fetch for group subs (sequential, called from IO context)
return groupSubs.fold(SubscriptionUpdateResult()) { acc, sub ->
acc + AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription))
}
return totalResult
} else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
@@ -262,6 +312,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Cancels all running ping tests.
*/
fun cancelAllTests() {
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
isTesting.value = false
}
/**
* Tests the real ping for all servers.
*/
@@ -270,35 +331,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
getApplication(),
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1
viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() }
}
if (serversCache.isEmpty()) {
return@launch
}
val actualSubId = if (subscriptionId.startsWith("group_")) {
""
} else {
subscriptionId
}
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
} else {
emptyList()
// Auto-deduplicate by IP before scanning so we don't waste time on dupes
viewModelScope.launch(Dispatchers.IO) {
val removed = removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
if (removed > 0) {
reloadServerList()
}
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1
isTesting.value = true
viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() }
}
)
)
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { isTesting.value = false }
return@launch
}
val actualSubId = if (subscriptionId.startsWith("group_")) "" else subscriptionId
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
} else {
emptyList()
}
)
)
}
}
}
}
@@ -435,6 +504,103 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return deleteServer.count()
}
/**
* Removes servers with duplicate IP addresses (same `server` field),
* keeping the one with the best ping result (or the first encountered if untested).
* @return Number of removed servers.
*/
fun removeDuplicateByIp(): Int {
val selectedGuid = MmkvManager.getSelectServer()
// Group all currently visible servers by their IP address
val byIp = LinkedHashMap<String, MutableList<ServersCache>>()
for (sc in serversCache) {
val ip = sc.profile.server?.trim()?.lowercase() ?: continue
byIp.getOrPut(ip) { mutableListOf() }.add(sc)
}
val toDelete = mutableListOf<String>()
for ((_, group) in byIp) {
if (group.size <= 1) continue
val best = group.minWithOrNull(compareBy(
{ it.guid != selectedGuid },
{ !it.profile.isFavorite },
{
val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
d > 0L -> d
d == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))!!
group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) }
}
for (guid in toDelete) {
MmkvManager.removeServer(guid)
}
return toDelete.size
}
/**
* Removes duplicate servers by IP across ALL subscriptions (for use after sub update / before scan).
* Per-subscription deduplication: within each sub keeps the best (favorite > lowest ping > first).
*/
/**
* Removes servers with duplicate IP addresses across ALL subscriptions globally.
* Keeps the best one per IP (favorite > lowest ping > first encountered).
* @return Number of removed servers.
*/
fun removeDuplicateByIpAll(): Int {
val selectedGuid = MmkvManager.getSelectServer()
// Collect every server GUID across all subscriptions
data class Entry(val guid: String, val ip: String, val isFav: Boolean)
val allEntries = mutableListOf<Entry>()
val allSubIds = MmkvManager.decodeSubsList().toMutableList()
// Add the default (no-sub) slot if not already present
if (!allSubIds.contains(AppConfig.DEFAULT_SUBSCRIPTION_ID)) {
allSubIds.add(0, AppConfig.DEFAULT_SUBSCRIPTION_ID)
}
for (subId in allSubIds) {
for (guid in MmkvManager.decodeServerList(subId)) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
val ip = profile.server?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: continue
allEntries.add(Entry(guid, ip, profile.isFavorite))
}
}
// Group by IP globally
val byIp = LinkedHashMap<String, MutableList<Entry>>()
for (e in allEntries) {
byIp.getOrPut(e.ip) { mutableListOf() }.add(e)
}
val toDelete = mutableListOf<String>()
for ((_, group) in byIp) {
if (group.size <= 1) continue
val best = group.minWith(compareBy(
{ it.guid != selectedGuid },
{ !it.isFav },
{
val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
d > 0L -> d
d == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))
group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) }
}
for (guid in toDelete) {
MmkvManager.removeServer(guid)
}
return toDelete.size
}
/**
* Removes all servers.
* @return The number of removed servers.
@@ -456,6 +622,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/**
* Sorts servers by their test results.
*/
/**
* Sorts serversCache in-place by test delay in real time (during a ping test).
* Favorites always come first, then sorted ascending by delay (failed/untested go to bottom).
*/
@Synchronized
fun sortServersCacheInPlace() {
serversCache.sortWith(compareBy(
{ !it.profile.isFavorite },
{
val delay = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1 // untested
else -> Long.MAX_VALUE // failed
}
}
))
pinSelectedCacheItemToTop(serversCache)
}
fun sortByTestResults() {
if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubsList().forEach { guid ->
@@ -505,6 +691,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val serversBySubId = allServerDelays.groupBy { it.subId }
serversBySubId.forEach { (subId, servers) ->
val sortedList = servers.map { it.guid }.toMutableList()
pinSelectedGuidToTop(sortedList)
MmkvManager.encodeServerList(sortedList, subId)
}
}
@@ -532,11 +719,32 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
serverDelays.sortWith(compareBy({ !it.isFav }, { it.testDelayMillis }))
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
pinSelectedGuidToTop(sortedServerList)
// Save the sorted list for this subscription
MmkvManager.encodeServerList(sortedServerList, subId)
}
private fun pinSelectedGuidToTop(list: MutableList<String>) {
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
if (selectedGuid.isEmpty()) return
val index = list.indexOf(selectedGuid)
if (index > 0) {
list.removeAt(index)
list.add(0, selectedGuid)
}
}
private fun pinSelectedCacheItemToTop(list: MutableList<ServersCache>) {
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
if (selectedGuid.isEmpty()) return
val index = list.indexOfFirst { it.guid == selectedGuid }
if (index > 0) {
val selectedItem = list.removeAt(index)
list.add(0, selectedItem)
}
}
/**
* Initializes assets.
@@ -589,6 +797,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
withContext(Dispatchers.Main) {
reloadServerList()
isTesting.value = false
liteTestFinished.value = true
liteTestFinished.value = false
}
@@ -625,7 +834,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
val resultPair = intent.serializable<Pair<String, Long>>("content") ?: return
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
updateListAction.value = getPosition(resultPair.first)
sortServersCacheInPlace()
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
val update = intent.serializable<PingProgressUpdate>("content") ?: return
update.results.forEach { result ->
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
}
sortServersCacheInPlace()
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -638,6 +857,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val content = intent.getStringExtra("content")
if (content == "0") {
onTestsFinished()
} else {
// cancelled or finished with non-zero count still in queue — mark as not testing
isTesting.value = false
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M320,880L360,600L160,600L520,80L600,80L560,400L800,400L400,880L320,880Z"/>
</vector>
@@ -1,7 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/divider_color_light" />
<solid android:color="?attr/colorSecondaryContainer" />
<size
android:width="48dp"
android:height="48dp" />
</shape>
android:width="24dp"
android:height="24dp" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12z" />
</vector>
@@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:fillColor="@android:color/white"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
</vector>
@@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorAccent">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256ZM354,325L160,389Q160,389 160,389Q160,389 160,389L284,568L280,759Q280,759 280,759Q280,759 280,759L480,704L680,760Q680,760 680,760Q680,760 680,760L676,568L800,391Q800,391 800,391Q800,391 800,391L606,325L480,160Q480,160 480,160Q480,160 480,160L354,325ZM480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM440,798L440,720Q407,720 383.5,696.5Q360,673 360,640L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,601 239.5,692Q319,783 440,798ZM716,696Q736,674 752,648.5Q768,623 778.5,595.5Q789,568 794.5,539Q800,510 800,480Q800,382 745.5,301Q691,220 600,184L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L560,480Q577,480 588.5,491.5Q600,503 600,520L600,640L640,640Q666,640 687,655.5Q708,671 716,696Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M98,423L266,255Q280,241 299,235Q318,229 338,233L390,244Q336,308 305,360Q274,412 245,486L98,423ZM303,514Q326,442 365.5,378Q405,314 461,258Q549,170 662,126.5Q775,83 873,100Q890,198 847,311Q804,424 716,512Q661,567 596,607.5Q531,648 459,671L303,514ZM579,394Q602,417 635.5,417Q669,417 692,394Q715,371 715,337.5Q715,304 692,281Q669,258 635.5,258Q602,258 579,281Q556,304 556,337.5Q556,371 579,394ZM551,875L487,728Q561,699 613.5,668Q666,637 730,583L740,635Q744,655 738,674.5Q732,694 718,708L551,875ZM162,642Q197,607 247,606.5Q297,606 332,641Q367,676 367,726Q367,776 332,811Q307,836 248.5,854Q190,872 87,886Q101,783 119,725Q137,667 162,642Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12L21,5l-9,-4zM12,11.99h7c-0.53,4.12 -3.28,7.79 -7,8.94L12,12L5,12L5,6.3l7,-3.11v8.8z"/>
</vector>
@@ -0,0 +1,12 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="0dp"
android:left="28dp"
android:right="28dp"
android:top="0dp">
<shape>
<size android:height="1dp" />
<solid android:color="@color/server_list_divider" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF888888" />
<size android:width="10dp" android:height="10dp" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
+186 -129
View File
@@ -1,171 +1,228 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top"
android:orientation="vertical">
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:id="@+id/layout_soure_ccode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
android:layout_height="match_parent"
android:gravity="top"
android:orientation="vertical">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/layout_soure_ccode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_source_code"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:text="@string/title_source_code"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:id="@+id/layout_oss_licenses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/license_24px"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:text="@string/title_oss_license"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:id="@+id/layout_feedback"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_feedback_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:text="@string/title_pref_feedback"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:id="@+id/layout_tg_channel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_telegram_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:text="@string/title_tg_channel"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:id="@+id/layout_privacy_policy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_privacy_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:text="@string/title_privacy_policy"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_oss_licenses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
</com.google.android.material.card.MaterialCardView>
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/license_24px" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_oss_license"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_feedback"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_feedback_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_pref_feedback"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_tg_channel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_telegram_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_tg_channel"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_privacy_policy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_privacy_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_privacy_policy"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/colorSurfaceContainerLowest"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
android:padding="20dp">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
android:textAppearance="@style/TextAppearance.Material3.LabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tv_app_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="?attr/colorOutline" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
@@ -5,33 +5,41 @@
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:titleTextAppearance="@style/TextAppearance.AppCompat.Title" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintTop_toBottomOf="@id/app_bar"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="@color/color_fab_active" />
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<FrameLayout
android:id="@+id/content_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintTop_toBottomOf="@id/app_bar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
</FrameLayout>
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -15,58 +15,58 @@
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/split_tunneling_description"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:alpha="0.7" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="horizontal"
android:gravity="center_horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/container_per_app_proxy"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/container_per_app_proxy"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_per_app_proxy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/per_app_proxy_settings_enable"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_per_app_proxy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/per_app_proxy_settings_enable"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/container_bypass_apps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_bypass_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/switch_bypass_apps_mode"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/container_bypass_apps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_bypass_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/switch_bypass_apps_mode"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -77,4 +77,4 @@
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />
</LinearLayout>
</LinearLayout>
@@ -1,83 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
android:fitsSystemWindows="true"
android:orientation="vertical">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
<LinearLayout
android:id="@+id/layout_check_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/check_pre_release"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_pre_release"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_check_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
</ScrollView>
<!-- Hidden pre-release toggle (kept in layout for binding compatibility) -->
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/check_pre_release"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="@dimen/padding_spacing_dp16"
android:gravity="center"
android:layout_marginBottom="16dp"
android:alpha="0.5"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
+163 -97
View File
@@ -3,22 +3,30 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<androidx.appcompat.widget.Toolbar
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
android:layout_height="?attr/actionBarSize"
app:title="@string/app_name"
app:titleCentered="true"
app:titleTextColor="?attr/colorOnSurface"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
@@ -42,17 +50,20 @@
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="invisible"
app:indicatorColor="@color/color_fab_active" />
app:indicatorColor="?attr/colorPrimary"
app:trackCornerRadius="0dp"
app:trackColor="@android:color/transparent" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
<LinearLayout
android:id="@+id/tab_slot_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIndicatorFullWidth="true"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
android:orientation="vertical" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
@@ -62,98 +73,147 @@
android:scrollbars="vertical"
android:layout_weight="1" />
<!-- Bottom container: tab + action card -->
<LinearLayout
android:id="@+id/bottom_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:background="?attr/colorSurfaceContainer">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider_color_light" />
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:id="@+id/tab_slot_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="12dp"
android:paddingBottom="8dp">
android:orientation="vertical">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_summary_lite"
android:layout_width="wrap_content"
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_lite_bolt"
app:tint="@color/colorWhite"
app:backgroundTint="@color/color_fab_inactive"
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
app:pressedTranslationZ="8dp"
app:hoveredFocusedTranslationZ="6dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:contentDescription="@string/tasker_start_service"
android:focusable="true"
android:nextFocusLeft="@+id/layout_test"
android:src="@drawable/ic_play_24dp"
app:tint="@color/colorWhite"
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
app:pressedTranslationZ="8dp"
app:hoveredFocusedTranslationZ="6dp" />
android:background="?attr/colorSurface"
app:tabIndicatorFullWidth="true"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabRippleColor="@android:color/transparent"
app:tabIndicatorColor="?attr/colorPrimary"
app:tabSelectedTextColor="?attr/colorPrimary"
app:tabTextColor="?attr/colorOnSurfaceVariant"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_test"
<!-- Bottom action row -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="4dp"
android:paddingBottom="12dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/tv_test_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:maxLines="2"
android:minLines="1"
android:text="@string/connection_test_pending"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<!-- Bolt icon button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_summary_lite"
style="@style/Widget.ActionSquareButton"
android:layout_width="52dp"
android:layout_height="52dp"
app:icon="@drawable/bolt_24"
app:iconSize="22dp"
app:backgroundTint="?attr/colorSecondaryContainer"
app:iconTint="?attr/colorOnSecondaryContainer"
app:rippleColor="?attr/colorOnSecondaryContainer"
app:layout_constraintEnd_toStartOf="@+id/fab"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="8dp" />
<!-- Connect / Disconnect icon button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/fab"
style="@style/Widget.ActionSquareButton"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="@string/tasker_start_service"
app:icon="@drawable/shield_24"
app:iconSize="26dp"
app:backgroundTint="?attr/colorPrimary"
app:iconTint="?attr/colorOnPrimary"
app:rippleColor="?attr/colorOnPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<!-- Status pill: dot + text, tappable for ping -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/layout_test"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_marginEnd="8dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/connection_test_pending"
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:cardCornerRadius="26dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_summary_lite"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="12dp">
<View
android:id="@+id/status_dot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@drawable/status_dot"
android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/tv_test_state"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:maxLines="1"
android:ellipsize="end"
android:text="@string/connection_test_pending"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</RelativeLayout>
</LinearLayout>
<!-- Navigation Drawer -->
<LinearLayout
android:layout_width="wrap_content"
android:id="@+id/drawer_content_layout"
android:layout_width="280dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="?android:attr/windowBackground"
android:background="?attr/colorSurfaceContainerLow"
android:orientation="vertical">
<com.google.android.material.navigation.NavigationView
@@ -161,6 +221,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@android:color/transparent"
app:headerLayout="@layout/nav_header" />
<LinearLayout
@@ -169,61 +230,65 @@
android:orientation="vertical"
android:padding="16dp">
<TextView
<com.google.android.material.button.MaterialButton
android:id="@+id/drawer_settings"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:drawablePadding="12dp"
android:gravity="center_vertical"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:text="@string/title_settings"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true" />
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface"
app:iconGravity="start"
app:iconTint="?attr/colorOnSurfaceVariant" />
<TextView
<com.google.android.material.button.MaterialButton
android:id="@+id/drawer_per_app"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:drawablePadding="12dp"
android:gravity="center_vertical"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:text="@string/per_app_proxy_settings"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true" />
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface"
app:iconGravity="start"
app:iconTint="?attr/colorOnSurfaceVariant" />
<TextView
<com.google.android.material.button.MaterialButton
android:id="@+id/drawer_check_update"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:drawablePadding="12dp"
android:gravity="center_vertical"
android:paddingTop="12dp"
android:paddingBottom="16dp"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true" />
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorOnSurface"
app:iconGravity="start"
app:iconTint="?attr/colorOnSurfaceVariant" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="12dp"
android:background="@color/divider_color_light" />
android:background="?attr/colorOutlineVariant" />
<TextView
android:id="@+id/tv_forked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/drawer_forked_text"
android:textColor="#9E9E9E"
android:textColorLink="#9E9E9E"
android:textColor="?attr/colorOnSurfaceVariant"
android:textColorLink="?attr/colorPrimary"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:linksClickable="true"
android:textIsSelectable="true" />
@@ -233,8 +298,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/drawer_developed_text"
android:textColor="#9E9E9E"
android:textColorLink="#9E9E9E"
android:textColor="?attr/colorOnSurfaceVariant"
android:textColorLink="?attr/colorPrimary"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:linksClickable="true"
android:textIsSelectable="true" />
</LinearLayout>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="8dp"
android:paddingTop="20dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/dialog_title_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/dialog_close_btn"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/dialog_close_btn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Закрыть"
android:src="@drawable/ic_close_24dp"
android:tint="?attr/colorOnSurfaceVariant" />
</RelativeLayout>
@@ -14,30 +14,23 @@
android:layout_height="@dimen/view_height_dp48"
android:padding="@dimen/padding_spacing_dp8" />
<LinearLayout
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.0"
android:gravity="center"
android:orientation="vertical">
android:maxLines="1"
android:paddingStart="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<!-- package_name hidden but kept for adapter compatibility -->
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/package_name"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/package_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="3"
android:paddingTop="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatCheckBox
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/check_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -45,4 +38,4 @@
android:focusable="false"
android:padding="@dimen/padding_spacing_dp8" />
</LinearLayout>
</LinearLayout>
@@ -1,144 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="5dp"
android:paddingEnd="12dp"
android:paddingBottom="5dp"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:paddingStart="@dimen/padding_spacing_dp4"
android:paddingTop="@dimen/padding_spacing_dp8"
android:paddingEnd="@dimen/padding_spacing_dp4"
android:paddingBottom="@dimen/padding_spacing_dp8">
<LinearLayout
android:id="@+id/layout_indicator"
android:layout_width="@dimen/padding_spacing_dp4"
android:layout_height="match_parent"
android:layout_gravity="center"
android:orientation="vertical" />
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:paddingStart="0dp"
android:paddingTop="11dp"
android:paddingEnd="4dp"
android:paddingBottom="11dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/padding_spacing_dp8">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="@dimen/padding_spacing_dp8">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_subscription"
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:layout_gravity="bottom"
android:layout_marginEnd="@dimen/padding_spacing_dp4"
android:background="@drawable/ic_circle">
<TextView
android:id="@+id/tv_subscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textSize="11sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:orientation="vertical"
android:paddingStart="12dp">
<TextView
android:id="@+id/tv_statistics"
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?attr/colorOnSurface" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_subscription"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="bottom"
android:layout_marginEnd="4dp"
android:background="@drawable/ic_circle">
<TextView
android:id="@+id/tv_subscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textSize="10sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/tv_statistics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tv_test_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="@color/colorPing"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:src="@drawable/kid_star_outline_24" />
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorPrimary" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_star_empty" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="@dimen/padding_spacing_dp8"
android:paddingTop="@dimen/padding_spacing_dp8"
android:paddingEnd="@dimen/padding_spacing_dp8">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/tv_test_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="@color/colorPing"
android:textSize="11sp"
tools:text="214ms" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -4,110 +4,127 @@
android:id="@+id/item_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:nextFocusRight="@+id/layout_edit"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="0dp"
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:nextFocusRight="@+id/layout_edit"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_weight="1"
android:orientation="vertical"
android:padding="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/remarks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<ImageView
android:id="@+id/img_locked"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="center"
android:layout_marginStart="16dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_lock_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<TextView
android:id="@+id/remarks"
android:id="@+id/domainIp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
android:layout_marginTop="6dp"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<ImageView
android:id="@+id/img_locked"
android:layout_width="@dimen/padding_spacing_dp16"
android:layout_height="@dimen/padding_spacing_dp16"
android:layout_gravity="center"
android:layout_marginStart="@dimen/padding_spacing_dp16"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_lock_24dp" />
<TextView
android:id="@+id/outboundTag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:lines="1"
android:textAppearance="@style/TextAppearance.Material3.LabelMedium"
android:textColor="?attr/colorPrimary" />
</LinearLayout>
<TextView
android:id="@+id/domainIp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:lines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<TextView
android:id="@+id/outboundTag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:lines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
<LinearLayout
android:id="@+id/layout_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
android:padding="8dp">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/chk_enable"
<LinearLayout
android:id="@+id/layout_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:theme="@style/BrandedSwitch" />
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/chk_enable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -4,195 +4,214 @@
android:id="@+id/item_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:nextFocusRight="@+id/layout_share"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:orientation="vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="@dimen/padding_spacing_dp8">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/layout_share"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/title_configuration_share"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
android:layout_weight="1"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
android:paddingStart="8dp">
<ImageView
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" />
android:layout_height="wrap_content"
android:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
android:orientation="horizontal">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp" />
<LinearLayout
android:id="@+id/layout_share"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/title_configuration_share"
android:focusable="true"
android:gravity="center"
android:nextFocusLeft="@+id/info_container"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_edit_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_edit_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp"
app:tint="?attr/colorError" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<LinearLayout
android:id="@+id/layout_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="@dimen/padding_spacing_dp8"
android:paddingEnd="@dimen/padding_spacing_dp8">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal"
android:paddingTop="6dp">
<TextView
android:id="@+id/tv_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="2"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/chk_enable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:id="@+id/layout_last_updated"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal"
android:paddingTop="@dimen/padding_spacing_dp8">
android:layout_marginTop="6dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tv_url"
android:id="@+id/tv_last_updated"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_update_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="2"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
android:layout_marginStart="8dp"
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/chk_enable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_last_updated"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:paddingStart="@dimen/padding_spacing_dp8"
android:paddingEnd="@dimen/padding_spacing_dp8">
<TextView
android:id="@+id/tv_last_updated"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:visibility="gone"/>
<TextView
android:id="@+id/tv_update_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
android:visibility="gone"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</LinearLayout>
@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.AppCompat.Button.Borderless"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:onClick="onModeHelpClicked"
android:text="@string/title_mode_help"
android:textAlignment="textStart"
android:textStyle="italic"
android:textColor="?attr/colorPrimary"
app:iconTint="?attr/colorPrimary"
tools:ignore="UsingOnClickInXml" />
@@ -19,16 +19,4 @@
android:title="@string/menu_item_invert_selection"
app:showAsAction="withText" />
<item
android:id="@+id/import_proxy_app"
android:icon="@drawable/ic_description_24dp"
android:title="@string/menu_item_import_proxy_app"
app:showAsAction="withText" />
<item
android:id="@+id/export_proxy_app"
android:icon="@drawable/ic_description_24dp"
android:title="@string/menu_item_export_proxy_app"
app:showAsAction="withText" />
</menu>
+7 -1
View File
@@ -15,7 +15,13 @@
<item
android:id="@+id/sub_update"
android:icon="@drawable/ic_check_update_24dp"
android:icon="@drawable/update_24"
android:title="@string/title_sub_update"
app:showAsAction="always" />
<item
android:id="@+id/filter_by_country"
android:icon="@drawable/public_24"
android:title="Фильтр по странам"
app:showAsAction="always" />
</menu>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -1,29 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_fab_active">#90CAF9</color>
<color name="color_fab_inactive">#646464</color>
<color name="divider_color_light">#424242</color>
<color name="colorPing">#90CAF9</color>
<color name="colorPingGood">#86D993</color>
<color name="colorPingMedium">#F1C76A</color>
<color name="status_connected">#66BB6A</color>
<color name="colorPingRed">#FFB4AB</color>
<color name="color_fab_active">@color/md_theme_primary</color>
<color name="color_fab_inactive">@color/md_theme_secondaryContainer</color>
<color name="divider_color_light">@color/md_theme_outlineVariant</color>
<color name="server_list_divider">#2649454F</color>
<color name="md_theme_primary">#C0C0C0</color>
<color name="md_theme_onPrimary">#303030</color>
<color name="md_theme_primaryContainer">#474747</color>
<color name="md_theme_onPrimaryContainer">#E0E0E0</color>
<!-- M3 Dark scheme — Purple/Violet tonal palette -->
<color name="md_theme_primary">#D0BCFF</color>
<color name="md_theme_onPrimary">#381E72</color>
<color name="md_theme_primaryContainer">#4F378B</color>
<color name="md_theme_onPrimaryContainer">#EADDFF</color>
<color name="md_theme_secondary">#90CAF9</color>
<color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#6F3800</color>
<color name="md_theme_onSecondaryContainer">#FFE8D6</color>
<color name="md_theme_secondary">#CCC2DC</color>
<color name="md_theme_onSecondary">#332D41</color>
<color name="md_theme_secondaryContainer">#4A4458</color>
<color name="md_theme_onSecondaryContainer">#E8DEF8</color>
<color name="md_theme_tertiary">#64B5F6</color>
<color name="md_theme_onTertiary">#00382E</color>
<color name="md_theme_tertiaryContainer">#005143</color>
<color name="md_theme_onTertiaryContainer">#BBDEFB</color>
<color name="md_theme_tertiary">#EFB8C8</color>
<color name="md_theme_onTertiary">#492532</color>
<color name="md_theme_tertiaryContainer">#633B48</color>
<color name="md_theme_onTertiaryContainer">#FFD8E4</color>
<!-- Error colors -->
<color name="md_theme_error">#FFB4AB</color>
<color name="md_theme_errorContainer">#93000A</color>
<color name="md_theme_onError">#690005</color>
<color name="md_theme_onErrorContainer">#FFDAD6</color>
<color name="md_theme_error">#F2B8B5</color>
<color name="md_theme_errorContainer">#8C1D18</color>
<color name="md_theme_onError">#601410</color>
<color name="md_theme_onErrorContainer">#F9DEDC</color>
<!-- Background colors -->
<color name="md_theme_background">#1C1B1F</color>
@@ -35,15 +42,22 @@
<color name="md_theme_surfaceVariant">#49454F</color>
<color name="md_theme_onSurfaceVariant">#CAC4D0</color>
<color name="md_theme_inverseSurface">#E6E1E5</color>
<color name="md_theme_inverseOnSurface">#1C1B1F</color>
<color name="md_theme_inverseOnSurface">#313033</color>
<!-- Surface containers — M3 dark elevation tones -->
<color name="md_theme_surfaceContainerLowest">#0F0D13</color>
<color name="md_theme_surfaceContainerLow">#1D1B20</color>
<color name="md_theme_surfaceContainer">#211F26</color>
<color name="md_theme_surfaceContainerHigh">#2B2930</color>
<color name="md_theme_surfaceContainerHighest">#36343B</color>
<!-- Outline colors -->
<color name="md_theme_outline">#938F99</color>
<color name="md_theme_outlineVariant">#49454F</color>
<!-- Other colors -->
<color name="md_theme_inversePrimary">#000000</color>
<color name="md_theme_inversePrimary">#6750A4</color>
<color name="md_theme_shadow">#000000</color>
<color name="md_theme_surfaceTint">#C0C0C0</color>
<color name="md_theme_surfaceTint">#D0BCFF</color>
<color name="md_theme_scrim">#000000</color>
</resources>
@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Night theme: inherit the shared AppThemeBase and only override night-specific items -->
<!-- Night theme: inherit shared base, only override night-specific items -->
<style name="AppThemeDayNight" parent="AppThemeBase">
<!-- Night mode specific overrides -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
</style>
</resources>
</resources>
+16 -4
View File
@@ -143,15 +143,15 @@
<string name="msg_downloading_content">Загрузка данных</string>
<string name="menu_item_export_proxy_app">Экспорт в буфер обмена</string>
<string name="menu_item_import_proxy_app">Импорт из буфера обмена</string>
<string name="per_app_proxy_settings">Выбор приложений</string>
<string name="per_app_proxy_settings_enable">Выбор приложений</string>
<string name="per_app_proxy_settings">Раздельное туннелирование</string>
<string name="per_app_proxy_settings_enable">Раздельное туннелирование</string>
<!-- Preferences -->
<string name="title_settings">Настройки</string>
<string name="title_advanced">Расширенные настройки</string>
<string name="title_core_settings">Настройки ядра</string>
<string name="title_vpn_settings">Настройки VPN</string>
<string name="title_pref_per_app_proxy">Прокси для выбранных приложений</string>
<string name="title_pref_per_app_proxy">Раздельное туннелирование</string>
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню.</string>
<string name="title_pref_is_booted">Автоподключение при запуске</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
@@ -159,6 +159,11 @@
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
<string name="title_pref_show_copy_button">Показывать кнопку копирования</string>
<string name="summary_pref_show_copy_button">Показывать кнопку для копирования конфигурации сервера в буфер обмена</string>
<string name="title_pref_show_server_ip">Показывать IP / хост сервера</string>
<string name="summary_pref_show_server_ip">Отображать IP-адрес или хост сервера под названием</string>
<string name="title_mux_settings">Настройки мультиплексирования</string>
<string name="title_pref_mux_enabled">Использовать мультиплексирование</string>
<string name="summary_pref_mux_enabled">Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
@@ -236,6 +241,7 @@
<string name="title_pref_group_all_display">Общая вкладка групп</string>
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -260,6 +266,11 @@
<string name="title_language">Язык</string>
<string name="title_ui_settings">Настройки интерфейса</string>
<string name="title_pref_ui_mode_night">Тема интерфейса</string>
<string name="title_pref_dynamic_colors">Динамические цвета (Material You)</string>
<string name="summary_pref_dynamic_colors">Использовать цвета обоев (Android 12+). Требует перезапуска.</string>
<string name="restart_required">Требуется перезапуск приложения</string>
<string name="title_pref_subscriptions_bottom">Подписки снизу</string>
<string name="summary_pref_subscriptions_bottom">Переместить вкладки подписок под список серверов</string>
<string name="title_pref_use_hev_tunnel">Использовать Hev TUN</string>
<string name="summary_pref_use_hev_tunnel">Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">Подробность журнала HevTun</string>
@@ -337,7 +348,8 @@
<string name="connection_test_fail">Интернет недоступен</string>
<string name="connection_test_error_status_code">Код ошибки: #%d</string>
<string name="connection_connected">Соединено, нажмите для проверки</string>
<string name="connection_not_connected">Нет соединения</string>
<string name="connection_not_connected">Ожидаем действий</string>
<string name="connection_updating_profiles">Обновление профилей…</string>
<string name="connection_runing_task_left">Запущено проверок: %s</string>
<string name="import_subscription_success">Подписка импортирована</string>
+1 -5
View File
@@ -1,7 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="TabLayoutTextStyle" parent="TextAppearance.Design.Tab">
<item name="textAllCaps">false</item>
</style>
</resources>
</resources>
+35 -23
View File
@@ -1,54 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPing">#1565C0</color>
<color name="colorPingRed">#FF0099</color>
<color name="colorConfigType">#1976D2</color>
<color name="colorPing">#006494</color>
<color name="colorPingGood">#2E7D32</color>
<color name="colorPingMedium">#9A6700</color>
<color name="status_connected">#4CAF50</color>
<color name="colorPingRed">#BA1A1A</color>
<color name="colorConfigType">#1565C0</color>
<color name="colorWhite">#FFFFFF</color>
<color name="color_fab_active">#1976D2</color>
<color name="color_fab_inactive">#9C9C9C</color>
<color name="divider_color_light">#E0E0E0</color>
<color name="color_fab_active">@color/md_theme_primary</color>
<color name="color_fab_inactive">@color/md_theme_secondaryContainer</color>
<color name="divider_color_light">@color/md_theme_outlineVariant</color>
<color name="server_list_divider">#22CAC4D0</color>
<color name="colorIndicator">@color/md_theme_primary</color>
<color name="md_theme_primary">#000000</color>
<!-- M3 Light scheme — Purple/Violet tonal palette -->
<color name="md_theme_primary">#6750A4</color>
<color name="md_theme_onPrimary">#FFFFFF</color>
<color name="md_theme_primaryContainer">#E0E0E0</color>
<color name="md_theme_onPrimaryContainer">#000000</color>
<color name="md_theme_primaryContainer">#EADDFF</color>
<color name="md_theme_onPrimaryContainer">#21005D</color>
<color name="md_theme_secondary">#1976D2</color>
<color name="md_theme_secondary">#625B71</color>
<color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#FFE8D6</color>
<color name="md_theme_onSecondaryContainer">#2B1700</color>
<color name="md_theme_secondaryContainer">#E8DEF8</color>
<color name="md_theme_onSecondaryContainer">#1D192B</color>
<color name="md_theme_tertiary">#1565C0</color>
<color name="md_theme_tertiary">#7D5260</color>
<color name="md_theme_onTertiary">#FFFFFF</color>
<color name="md_theme_tertiaryContainer">#BBDEFB</color>
<color name="md_theme_onTertiaryContainer">#00201A</color>
<color name="md_theme_tertiaryContainer">#FFD8E4</color>
<color name="md_theme_onTertiaryContainer">#31111D</color>
<!-- Error colors -->
<color name="md_theme_error">#BA1A1A</color>
<color name="md_theme_errorContainer">#FFDAD6</color>
<color name="md_theme_error">#B3261E</color>
<color name="md_theme_errorContainer">#F9DEDC</color>
<color name="md_theme_onError">#FFFFFF</color>
<color name="md_theme_onErrorContainer">#410002</color>
<color name="md_theme_onErrorContainer">#410E0B</color>
<!-- Background colors -->
<color name="md_theme_background">#FFFFFF</color>
<color name="md_theme_background">#FFFBFE</color>
<color name="md_theme_onBackground">#1C1B1F</color>
<!-- Surface colors -->
<color name="md_theme_surface">#FFFFFF</color>
<!-- Surface colors — M3 tonal surface hierarchy -->
<color name="md_theme_surface">#FFFBFE</color>
<color name="md_theme_onSurface">#1C1B1F</color>
<color name="md_theme_surfaceVariant">#E7E0EC</color>
<color name="md_theme_onSurfaceVariant">#49454F</color>
<color name="md_theme_inverseSurface">#313033</color>
<color name="md_theme_inverseOnSurface">#F4EFF4</color>
<!-- Surface containers — M3 elevation tones -->
<color name="md_theme_surfaceContainerLowest">#FFFFFF</color>
<color name="md_theme_surfaceContainerLow">#F7F2FA</color>
<color name="md_theme_surfaceContainer">#F3EDF7</color>
<color name="md_theme_surfaceContainerHigh">#ECE6F0</color>
<color name="md_theme_surfaceContainerHighest">#E6E0E9</color>
<!-- Outline colors -->
<color name="md_theme_outline">#79747E</color>
<color name="md_theme_outlineVariant">#CAC4D0</color>
<!-- Other colors -->
<color name="md_theme_inversePrimary">#C0C0C0</color>
<color name="md_theme_inversePrimary">#D0BCFF</color>
<color name="md_theme_shadow">#000000</color>
<color name="md_theme_surfaceTint">#000000</color>
<color name="md_theme_surfaceTint">#6750A4</color>
<color name="md_theme_scrim">#000000</color>
</resources>
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
<color name="ic_launcher_background">#1C1C1E</color>
</resources>
+18 -6
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">olcNG</string>
<string name="app_name" translatable="false">olcng</string>
<string name="app_widget_name">Switch</string>
<string name="app_tile_name">Switch</string>
<string name="app_tile_first_use">First use of this feature, please use the app to add server</string>
@@ -144,8 +144,9 @@
<string name="msg_downloading_content">Downloading content</string>
<string name="menu_item_export_proxy_app">Export to Clipboard</string>
<string name="menu_item_import_proxy_app">Import from Clipboard</string>
<string name="per_app_proxy_settings">Per-app settings</string>
<string name="per_app_proxy_settings_enable">Enable per-app</string>
<string name="per_app_proxy_settings">Раздельное туннелирование</string>
<string name="per_app_proxy_settings_enable">Включить</string>
<string name="split_tunneling_description">Выберите приложения, которые будут использовать VPN — остальные приложения, например банки, будут идти напрямую через ваш домашний интернет.\n\nВ режиме обхода всё зеркально — выбранные приложения не используют VPN.</string>
<!-- Preferences -->
<string name="title_settings">Settings</string>
@@ -160,6 +161,11 @@
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
<string name="title_pref_show_copy_button">Show copy button</string>
<string name="summary_pref_show_copy_button">Show button to copy server configuration to clipboard</string>
<string name="title_pref_show_server_ip">Show server IP / host</string>
<string name="summary_pref_show_server_ip">Display the server IP address or host under the server name</string>
<string name="title_mux_settings">Mux Settings</string>
<string name="title_pref_mux_enabled">Enable Mux</string>
<string name="summary_pref_mux_enabled">Faster, but it may cause unstable connectivity\nCustomize how to handle TCP, UDP and QUIC below</string>
@@ -266,11 +272,16 @@
<string name="title_language">Language</string>
<string name="title_ui_settings">UI settings</string>
<string name="title_pref_ui_mode_night">UI mode settings</string>
<string name="title_pref_dynamic_colors">Dynamic colors (Material You)</string>
<string name="summary_pref_dynamic_colors">Use wallpaper-based colors (Android 12+). Requires app restart.</string>
<string name="title_pref_subscriptions_bottom">Subscriptions panel at the bottom</string>
<string name="summary_pref_subscriptions_bottom">Move the subscription tabs below the server list</string>
<string name="title_pref_use_hev_tunnel">Enable Hev TUN Feature</string>
<string name="summary_pref_use_hev_tunnel">When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.</string>
<string name="title_pref_hev_tunnel_loglevel">Hev Tun Log Level</string>
<string name="title_pref_hev_tunnel_rw_timeout">Hev Tun read/write timeout (seconds) (tcp,udp default 300,60)</string>
<string name="restart_required">Restart required to apply changes</string>
<string name="title_logcat">Logcat</string>
<string name="logcat_copy">Copy</string>
<string name="logcat_clear">Clear</string>
@@ -337,14 +348,15 @@
<string name="connection_test_pending">Check Connectivity</string>
<string name="connection_test_testing">Testing…</string>
<string name="connection_test_testing_count">Testing %d configs</string>
<string name="connection_test_testing_count">Тестирование %d серверов</string>
<string name="connection_test_available">Success: Connection took %dms</string>
<string name="connection_test_error">Fail to detect internet connection: %s</string>
<string name="connection_test_fail">Internet Unavailable</string>
<string name="connection_test_error_status_code">Error code: #%d</string>
<string name="connection_connected">Connected, tap to check connection</string>
<string name="connection_not_connected">Not connected</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="connection_not_connected">Готово к подключению</string>
<string name="connection_updating_profiles">Updating profiles…</string>
<string name="connection_runing_task_left">Проверено успешно: %s</string>
<string name="import_subscription_success">Subscription imported Successfully</string>
<string name="import_subscription_failure">Import subscription failed</string>
+75 -21
View File
@@ -1,58 +1,65 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Common base theme: put all shared items here so day/night can reuse -->
<!-- Common base theme -->
<style name="AppThemeBase" parent="Theme.Material3.DayNight">
<!-- Primary colors - main tone: black -->
<!-- Primary -->
<item name="colorPrimary">@color/md_theme_primary</item>
<item name="colorOnPrimary">@color/md_theme_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item>
<!-- Secondary -->
<item name="colorSecondary">@color/md_theme_secondary</item>
<item name="colorOnSecondary">@color/md_theme_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_onSecondaryContainer</item>
<!-- Tertiary -->
<item name="colorTertiary">@color/md_theme_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_onTertiaryContainer</item>
<!-- Error colors -->
<!-- Error -->
<item name="colorError">@color/md_theme_error</item>
<item name="colorOnError">@color/md_theme_onError</item>
<item name="colorErrorContainer">@color/md_theme_errorContainer</item>
<item name="colorOnErrorContainer">@color/md_theme_onErrorContainer</item>
<!-- Surface colors -->
<!-- Surface -->
<item name="colorSurface">@color/md_theme_surface</item>
<item name="colorOnSurface">@color/md_theme_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_onSurfaceVariant</item>
<item name="colorSurfaceInverse">@color/md_theme_inverseSurface</item>
<item name="colorOnSurfaceInverse">@color/md_theme_inverseOnSurface</item>
<item name="colorSurfaceContainer">@color/md_theme_surface</item>
<item name="colorSurfaceContainerHigh">@color/md_theme_surface</item>
<item name="colorSurfaceContainerHighest">@color/md_theme_surface</item>
<item name="colorSurfaceContainerLow">@color/md_theme_surface</item>
<item name="colorSurfaceContainerLowest">@color/md_theme_surface</item>
<item name="colorSurfaceContainer">@color/md_theme_surfaceContainer</item>
<item name="colorSurfaceContainerHigh">@color/md_theme_surfaceContainerHigh</item>
<item name="colorSurfaceContainerHighest">@color/md_theme_surfaceContainerHighest</item>
<item name="colorSurfaceContainerLow">@color/md_theme_surfaceContainerLow</item>
<item name="colorSurfaceContainerLowest">@color/md_theme_surfaceContainerLowest</item>
<!-- Background colors -->
<!-- Background -->
<item name="android:colorBackground">@color/md_theme_background</item>
<item name="colorOnBackground">@color/md_theme_onBackground</item>
<!-- Outline colors -->
<!-- Outline -->
<item name="colorOutline">@color/md_theme_outline</item>
<item name="colorOutlineVariant">@color/md_theme_outlineVariant</item>
<!-- Other colors -->
<!-- Misc -->
<item name="colorPrimaryInverse">@color/md_theme_inversePrimary</item>
<!-- Status bar and navigation bar - system bars -->
<item name="android:statusBarColor">@color/md_theme_surface</item>
<item name="android:navigationBarColor">@color/md_theme_surface</item>
<!-- System bars — transparent so edge-to-edge works -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Typography: use Roboto (Google's Material font) everywhere -->
<!-- Shape — M3 uses larger corner radii -->
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.App.SmallComponent</item>
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.App.MediumComponent</item>
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.App.LargeComponent</item>
<!-- Typography: Roboto -->
<item name="android:fontFamily">sans-serif</item>
<item name="fontFamily">sans-serif</item>
<item name="android:editTextStyle">@style/RobotoEditTextStyle</item>
@@ -60,7 +67,23 @@
<item name="android:dialogTheme">@style/RobotoAlertDialogTheme</item>
</style>
<style name="RobotoEditTextStyle" parent="Widget.AppCompat.EditText">
<!-- M3 Shape tokens -->
<style name="ShapeAppearance.App.SmallComponent" parent="ShapeAppearance.Material3.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">8dp</item>
</style>
<style name="ShapeAppearance.App.MediumComponent" parent="ShapeAppearance.Material3.MediumComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">12dp</item>
</style>
<style name="ShapeAppearance.App.LargeComponent" parent="ShapeAppearance.Material3.LargeComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">16dp</item>
</style>
<style name="RobotoEditTextStyle" parent="Widget.Material3.TextInputEditText.OutlinedBox">
<item name="android:fontFamily">sans-serif</item>
<item name="android:textSize">16sp</item>
</style>
@@ -68,16 +91,15 @@
<style name="RobotoAlertDialogTheme" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="android:fontFamily">sans-serif</item>
<item name="fontFamily">sans-serif</item>
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.App.MediumComponent</item>
</style>
<!-- Day/Night theme: inherit common values and set light-mode-specific items -->
<!-- Light -->
<style name="AppThemeDayNight" parent="AppThemeBase">
<!-- day/night-specific overrides (light/default) -->
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
</style>
<!-- Theme without ActionBar -->
<style name="AppThemeDayNight.NoActionBar" parent="AppThemeDayNight">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
@@ -89,8 +111,40 @@
<item name="android:windowIsTranslucent">true</item>
</style>
<!-- Switch uses primary color -->
<style name="BrandedSwitch" parent="AppThemeDayNight">
<item name="colorPrimary">@color/color_fab_active</item>
<item name="colorPrimary">@color/md_theme_primary</item>
</style>
<!-- Rounded-square FAB shape overlay (16dp corners) -->
<style name="ShapeAppearance.App.RoundedSquare" parent="ShapeAppearance.Material3.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">16dp</item>
</style>
<!-- Square icon button: no insets, no minHeight, no text -->
<style name="Widget.ActionSquareButton" parent="Widget.Material3.Button">
<item name="android:minWidth">0dp</item>
<item name="android:minHeight">0dp</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="android:insetLeft">0dp</item>
<item name="android:insetRight">0dp</item>
<item name="android:paddingStart">0dp</item>
<item name="android:paddingEnd">0dp</item>
<item name="android:paddingTop">0dp</item>
<item name="android:paddingBottom">0dp</item>
<item name="iconGravity">textStart</item>
<item name="iconPadding">0dp</item>
<item name="android:text"></item>
<item name="shapeAppearance">@style/ShapeAppearance.App.RoundedSquare</item>
<item name="elevation">0dp</item>
<item name="android:stateListAnimator">@null</item>
</style>
<!-- Tab label -->
<style name="TabLayoutTextStyle" parent="TextAppearance.Material3.LabelLarge">
<item name="android:textSize">13sp</item>
</style>
</resources>
+25 -1
View File
@@ -28,6 +28,18 @@
android:summary="@string/summary_pref_group_all_display"
android:title="@string/title_pref_group_all_display" />
<CheckBoxPreference
android:key="pref_show_copy_button"
android:defaultValue="false"
android:summary="@string/summary_pref_show_copy_button"
android:title="@string/title_pref_show_copy_button" />
<CheckBoxPreference
android:key="pref_show_server_ip"
android:defaultValue="false"
android:summary="@string/summary_pref_show_server_ip"
android:title="@string/title_pref_show_server_ip" />
<ListPreference
android:defaultValue="auto"
android:entries="@array/language_select"
@@ -44,6 +56,18 @@
android:summary="%s"
android:title="@string/title_pref_ui_mode_night" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_subscriptions_bottom"
android:summary="@string/summary_pref_subscriptions_bottom"
android:title="@string/title_pref_subscriptions_bottom" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_dynamic_colors"
android:summary="@string/summary_pref_dynamic_colors"
android:title="@string/title_pref_dynamic_colors" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/title_vpn_settings">
@@ -292,4 +316,4 @@
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen>
+1
View File
@@ -66,6 +66,7 @@ recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "r
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
BIN
View File
Binary file not shown.