63 Commits

Author SHA1 Message Date
neuronori a73f8d044c merge: in-app apk updater (#12) 2026-06-29 15:32:23 +00:00
neuronori c7180b45db feat(update): download and install apk in-app instead of opening browser
closes #12
2026-06-29 15:30:29 +00:00
neuronori dff7deefe3 raise minsdk to android 11 (api 30)
closes #13
2026-06-29 15:27:21 +00:00
neuronori 688da9d0e1 raise minsdk to android 11 (api 30) 2026-06-29 15:26:18 +00:00
zarazaex69 29fd0d719d feat(ping): persist test result for selected server and update cache 2026-05-27 17:05:49 +03:00
zarazaex69 abeb58191e refactor(ui): improve connection testing, dialog styling, and status indicators
- major release, update app
2026-05-27 16:55:09 +03:00
zarazaex69 f55ac36b5a feat(ui): change dialog divider color from colorOutlineVariant to colorOutline 2026-05-27 14:03:55 +03:00
zarazaex69 63aec656d8 feat(ui): center dialog title, add divider, and flatten dialog corners 2026-05-27 13:55:45 +03:00
zarazaex69 2062d56fdb feat(country-filter): add flag emoji, country code, and circular background to dialog items 2026-05-27 13:43:40 +03:00
zarazaex69 0b02534724 upd git ignore 2026-05-24 17:08:30 +03:00
zarazaex69 a75deb1374 feat(ui): replace built-in multi-choice dialog with custom adapter 2026-05-24 16:31:09 +03:00
zarazaex69 fbb6da0c42 fix: prevent status reset during active connection test 2026-05-24 16:24:48 +03:00
zarazaex69 fc9f18858a refactor(ping): remove chunked batching in favor of single batch call 2026-05-24 16:22:31 +03:00
zarazaex69 9df74d3ad2 perf: increase ping chunk size from 5 to 20 2026-05-24 16:18:34 +03:00
zarazaex69 cbefd19abd refactor: process ping items in chunks of 5 2026-05-24 16:16:11 +03:00
zarazaex69 5dcba978a7 fix: prevent status reset during active operations and update RU string 2026-05-24 16:01:00 +03:00
zarazaex69 b26d1bde77 feat(country-filter): support multiple flag emojis per server 2026-05-24 15:51:44 +03:00
zarazaex69 2878456437 fix(ui): use secondary container color for FAB when disconnected 2026-05-24 15:42:10 +03:00
zarazaex69 d99677ca23 feat: replace MD3 round checkbox style with custom drawable 2026-05-24 15:25:25 +03:00
zarazaex69 4dff052c35 style(strings): improve split tunneling description wording 2026-05-24 15:22:53 +03:00
zarazaex69 4f1aca4928 style: apply round checkbox style to bypass list items 2026-05-24 15:22:26 +03:00
zarazaex69 35f2a1079b feat: add donate dialog shown on app startup 2026-05-24 15:19:43 +03:00
zarazaex69 0adcbfcaf5 chore: update dependencies and build tooling 2026-05-24 13:37:56 +03:00
zarazaex69 b81708ff5c fix(build): fallback to default paths when SDK/NDK dirs don't exist 2026-05-24 12:55:05 +03:00
zarazaex69 4ad24ec2dc build: update default ANDROID_HOME path to $HOME/android-sdk 2026-05-24 12:54:12 +03:00
zarazaex69 4fa5dd4bca fix: copy buton not display 2026-05-10 13:52:03 +03:00
zarazaex69 b2f3415421 feat: fix 2026-05-09 02:07:43 +03:00
zarazaex69 6e1c774d86 fix: ui lazyyyyy 2026-05-08 12:30:27 +03:00
zarazaex69 8e9e709d12 fix: server unselect bug 2026-05-08 12:04:49 +03:00
zarazaex69 649f305a82 fix: server select bug 2026-05-08 12:04:22 +03:00
zarazaex69 44005dffd3 fix: button lock 2026-05-08 11:58:42 +03:00
zarazaex69 bee7002f54 fix: big bugfix 2026-05-08 11:56:45 +03:00
zarazaex69 0363ebaabd fix: olcNG -> olcng 2026-05-08 11:22:08 +03:00
zarazaex69 88627bbf4f fix: titile remarke search 2026-05-08 11:19:53 +03:00
zarazaex69 ceec94e5db fix: unselect 2026-05-08 11:17:07 +03:00
zarazaex69 b30fc13b0d fix: race cond errors 2026-05-07 17:13:24 +03:00
zarazaex69 a9f5844b84 style: change selected item styling to transparent bg with primary
border
2026-05-07 16:51:02 +03:00
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
86 changed files with 4252 additions and 1618 deletions
+10
View File
@@ -65,3 +65,13 @@ Thumbs.db
.DS_Store
add_subscription_mmkv.py
.gitignore
material-design-icons
.kiro/agents/kirograph.json
.kiro/hooks/kirograph-compress-hint.kiro.hook
.kiro/settings/mcp.json
.kiro/hooks/kirograph-sync-if-dirty.kiro.hook
.kiro/steering/kirograph.md
.kirograph/config.json
.kirograph/kirograph.db
.kirograph/.session-id
.kirograph/token-savings.jsonl
+14 -12
View File
@@ -3,13 +3,13 @@ module github.com/2dust/AndroidLibXrayLite
go 1.26
require (
github.com/xtls/xray-core v1.260327.0
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
github.com/xtls/xray-core v1.260327.1-0.20260509173629-1bdb488c9ec0
golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect
github.com/google/btree v1.1.3 // indirect
@@ -19,9 +19,10 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.7.13 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
@@ -30,19 +31,20 @@ require (
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
go.uber.org/mock v0.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
+40 -36
View File
@@ -1,7 +1,7 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
@@ -40,14 +40,16 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM=
@@ -62,58 +64,60 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
github.com/xtls/xray-core v1.260327.1-0.20260509173629-1bdb488c9ec0 h1:ft6HiTHelF1z9i3zZVPG9Q1LcLcvEB5jzBxky/+wljk=
github.com/xtls/xray-core v1.260327.1-0.20260509173629-1bdb488c9ec0/go.mod h1:LWadz6mFBKGPHAe0KscKjugHjmQeyDt5Na6J86ol/hY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d h1:pWrEKZvKeqE2xPrylgBjgCyJSpPPt3L2WG2DmA+Xccg=
golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+8 -1
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
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ android {
defaultConfig {
applicationId = "xyz.zarazaex.olc"
minSdk = 24
minSdk = 30
targetSdk = 36
val envVersionName = System.getenv("VERSION_NAME")
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="app_name" type="string">olcNG</item>
<item name="app_name" type="string">olcng</item>
</resources>
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">olcNG</string>
<string name="app_name" translatable="false">olcng</string>
</resources>
+1
View File
@@ -41,6 +41,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".AngApplication"
@@ -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)
@@ -74,6 +74,13 @@ object AppConfig {
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"
/** Donate dialog flags. */
const val PREF_DONATE_DIALOG_DISMISSED = "pref_donate_dialog_dismissed"
const val PREF_DONATE_DIALOG_POSTPONE_UNTIL = "pref_donate_dialog_postpone_until"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -3,8 +3,8 @@ package xyz.zarazaex.olc.dto
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
return when {
testDelayMillis == 0L -> ""
testDelayMillis < 0L -> "Error"
testDelayMillis == 0L -> "—ms"
testDelayMillis < 0L -> "-ms"
else -> "${testDelayMillis}ms"
}
}
@@ -2,5 +2,7 @@ package xyz.zarazaex.olc.dto
data class ServersCache(
val guid: String,
val profile: ProfileItem
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
val isSelected: Boolean = false
)
@@ -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 {
@@ -562,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()
@@ -604,18 +612,19 @@ 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 proxyTimeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 5000
val directTimeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 11000
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, proxyTimeout, httpPort)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, directTimeout)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
@@ -0,0 +1,212 @@
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? = extractAllFlagCodes(text).firstOrNull()
/** Extract all flag emojis found in [text] and return their ISO country codes. */
fun extractAllFlagCodes(text: String): List<String> {
val result = mutableListOf<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()
result.add("$c1$c2")
i += 2
continue
}
i++
}
return result
}
/** 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
}
/** Get all country codes for a server (all emojis, then cache fallback). */
fun getCountryCodes(remarks: String, serverIp: String?): List<String> {
val flags = extractAllFlagCodes(remarks)
if (flags.isNotEmpty()) return flags
if (!serverIp.isNullOrBlank() && !isPrivateIp(serverIp)) {
MmkvManager.getCountryCache(serverIp)?.let { return listOf(it) }
}
return listOf(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,29 @@ 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 EXCLUDE, empty = show all). */
fun getCountryFilter(): Set<String> {
// v2: semantics changed from "included" to "excluded" — reset old data on first read
if (!settingsStorage.decodeBool("pref_country_filter_v2_migrated", false)) {
settingsStorage.removeValueForKey("pref_country_filter")
settingsStorage.encode("pref_country_filter_v2_migrated", true)
}
return 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
}
@@ -70,35 +70,41 @@ object UpdateCheckerManager {
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
// Try a direct connection first, then fall back to the local proxy
// (mirrors checkForUpdate). The proxy only listens while the VPN is up,
// so a direct attempt is what makes updating work when disconnected.
downloadApkVia(context, downloadUrl, 0)
?: downloadApkVia(context, downloadUrl, SettingsManager.getHttpPort())
}
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
private fun downloadApkVia(context: Context, downloadUrl: String, httpPort: Int): File? {
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: return null
return try {
val code = connection.responseCode
if (code !in 200..299) {
Log.e(AppConfig.TAG, "APK download failed, http $code (port=$httpPort)")
return null
}
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath} (port=$httpPort)")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
Log.e(AppConfig.TAG, "Failed to download APK (port=$httpPort): ${e.message}")
null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
}
@@ -305,20 +305,28 @@ object V2RayServiceManager {
var time = -1L
var errorStr = ""
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
val urls = listOf(
SettingsManager.getDelayTestUrl(),
SettingsManager.getDelayTestUrl(true)
)
for (url in urls) {
if (time >= 0) break
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
time = coreController.measureDelay(url)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
// One more retry after a brief pause to reduce false negatives
if (time == -1L) {
kotlinx.coroutines.delay(500)
try {
time = coreController.measureDelay(urls[0])
} catch (e: Exception) {
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
val result = if (time >= 0) {
service.getString(R.string.connection_test_available, time)
@@ -330,7 +338,7 @@ object V2RayServiceManager {
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result $ip")
}
}
}
@@ -14,6 +14,7 @@ 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.MmkvManager
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.handler.V2rayConfigManager
@@ -57,8 +58,15 @@ class RealPingWorkerService(
scope.launch(Dispatchers.IO) {
try {
// Prepare configurations in parallel for faster startup
val shuffledGuids = guids.shuffled()
// Prepare configurations in parallel for faster startup.
// Keep the currently selected server at the front so it gets a result first.
val selectedGuid = MmkvManager.getSelectServer()
val shuffledGuids = if (selectedGuid != null && guids.contains(selectedGuid)) {
val rest = guids.filter { it != selectedGuid }.shuffled()
listOf(selectedGuid) + rest
} else {
guids.shuffled()
}
val deferredItems = shuffledGuids.map { guid ->
async(Dispatchers.IO) {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
@@ -135,8 +143,7 @@ class RealPingWorkerService(
private fun sendBatchUpdate(update: PingProgressUpdate) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_BATCH, update)
val left = (update.total - update.finished).coerceAtLeast(0)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / ${update.total}")
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.
*
@@ -1,8 +1,12 @@
package xyz.zarazaex.olc.ui
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.appcompat.app.AlertDialog
import android.widget.TextView
import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.lifecycle.lifecycleScope
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.BuildConfig
@@ -12,9 +16,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 +28,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 {
@@ -66,16 +66,54 @@ class CheckUpdateActivity : BaseActivity() {
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
private fun downloadAndInstall(downloadUrl: String) {
if (!Utils.canInstallApk(this)) {
toast(R.string.update_install_permission_required)
try {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:$packageName".toUri()
)
startActivity(intent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to open install-sources settings: ${e.message}")
}
.setNegativeButton(android.R.string.cancel, null)
.show()
return
}
toast(R.string.update_downloading)
showLoading()
lifecycleScope.launch {
try {
val apk = UpdateCheckerManager.downloadApk(this@CheckUpdateActivity, downloadUrl)
if (apk != null && Utils.installApk(this@CheckUpdateActivity, apk)) {
return@launch
}
toastError(R.string.update_download_failed)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download/install update: ${e.message}")
toastError(e.message ?: getString(R.string.update_download_failed))
} finally {
hideLoading()
}
}
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
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 { downloadAndInstall(it) }
}
.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,7 +12,6 @@ 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
@@ -65,7 +64,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))
@@ -76,19 +75,21 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
// Each fragment subscribes independently to the shared flow and filters its own subId.
// No onResume subscription switch needed — the active fragment's subId is always correct.
lifecycleScope.launch {
mainViewModel.serverListFlow.collect { list ->
if (mainViewModel.subscriptionId == subId) {
adapter.setData(list)
}
}
}
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
super.onResume()
// Tell ViewModel which tab is active so it can rebuild the correct list.
// This is the only place subscriptionId changes — no more races.
mainViewModel.subscriptionIdChanged(subId)
}
@@ -219,7 +220,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
*/
private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
// adapter updates automatically via serverListFlow
}
/**
@@ -229,15 +230,15 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
if (guid == selected) {
MmkvManager.setSelectServer("")
} 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()
}
}
// Republish snapshot so DiffUtil picks up the selection change in card background
mainViewModel.reloadServerList()
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
@@ -287,6 +288,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
*/
@@ -297,9 +305,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val position = mainViewModel.serverListFlow.value.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
@@ -320,4 +326,4 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
}
@@ -1,7 +1,10 @@
package xyz.zarazaex.olc.ui
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
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,15 +12,20 @@ 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
@@ -31,6 +39,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
@@ -49,6 +58,9 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private var isLiteTesting = false
private var easterEggClickCount = 0
private var isEasterEggActive = false
private var liteActionJob: kotlinx.coroutines.Job? = null
/** Был ли VPN уже запущен в предыдущем колбэке — чтобы детектировать момент подключения */
private var wasRunning = false
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
@@ -73,7 +85,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
@@ -86,21 +113,33 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
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.widget.TextView>(R.id.drawer_settings)?.setOnClickListener {
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)
}
@@ -162,31 +201,82 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
checkForUpdatesOnStartup()
showDonateDialogIfNeeded()
}
private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.updateTestResultAction.observe(this) { result ->
setTestState(result)
if (result != null && mainViewModel.isRunning.value == true) {
val isSuccess = result.contains(Regex("\\d+\\s*(ms|мс|毫秒)"))
setStatusDot(if (isSuccess) DotState.CONNECTED else DotState.FAILURE)
}
}
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)
animateButtonTint(binding.btnSummaryLite,
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
)
} else {
setButtonsEnabled(true)
binding.btnSummaryLite.setIconResource(R.drawable.bolt_24)
animateButtonTint(binding.btnSummaryLite,
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
)
if (!isLiteTesting) {
showStatus("Проверка завершена")
}
}
}
mainViewModel.liteTestFinished.observe(this) { finished ->
if (finished && isLiteTesting) {
isLiteTesting = false
// Ищем лучший сервер ДО сортировки, прямо из текущего cache
val firstReachable = mainViewModel.serversCache
.filter { (MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L) > 0L }
.minByOrNull { MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: Long.MAX_VALUE }
if (firstReachable != null) {
MmkvManager.setSelectServer(firstReachable.guid)
}
mainViewModel.suppressPinSelected = 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)
showStatus("Подключаемся к быстрейшему серверу")
applyRunningState(isLoading = true, isRunning = false)
startV2RayWithPermission()
} else {
showStatus("Нет доступных серверов!")
setButtonsEnabled(true)
}
}
}
mainViewModel.isRunning.observe(this) { isRunning ->
applyRunningState(false, isRunning)
if (!isFabOperationInProgress) {
applyRunningState(false, isRunning)
}
// Как только VPN только что подключился — обновляем подписки через него
if (isRunning && !wasRunning) {
updateSubsViaVpn()
}
wasRunning = isRunning
}
mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets)
@@ -208,10 +298,52 @@ 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() {
// Если идёт подключение (isLoading) — позволяем прервать и остановить сервис
if (isFabOperationInProgress) {
Log.d(AppConfig.TAG, "FAB: cancel in-progress, stopping service")
isFabOperationInProgress = false
lifecycleScope.launch {
V2RayServiceManager.stopVService(this@MainActivity)
}
return
}
isFabOperationInProgress = true
@@ -248,12 +380,30 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun handleLiteAction() {
// Отмена на любом этапе: обновление подписок или тест
if (mainViewModel.isTesting.value == true || liteActionJob?.isActive == true) {
liteActionJob?.cancel()
liteActionJob = null
mainViewModel.cancelAllTests()
mainViewModel.suppressPinSelected = false
isLiteTesting = false
isFabOperationInProgress = false
showStatus("Остановлено")
setButtonsEnabled(true)
binding.btnSummaryLite.setIconResource(R.drawable.bolt_24)
animateButtonTint(binding.btnSummaryLite,
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
)
hideLoading()
return
}
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
lifecycleScope.launch {
liteActionJob = lifecycleScope.launch {
try {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this@MainActivity)
@@ -262,28 +412,45 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
showStatus("Обновление профилей...")
showLoading()
// Иконка молнии → стоп пока идёт обновление; FAB и меню блокируем
binding.btnSummaryLite.setIconResource(R.drawable.ic_stop_24dp)
binding.btnSummaryLite.isEnabled = true
binding.btnSummaryLite.alpha = 1.0f
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 }
isLiteTesting = true
mainViewModel.suppressPinSelected = true
launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
withContext(Dispatchers.Main) {
if (result.configCount > 0) {
mainViewModel.reloadServerList()
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
} else {
showStatus("Запуск теста...")
}
hideLoading()
val result = withContext(Dispatchers.IO) { mainViewModel.updateConfigViaSubAll() }
val removed = withContext(Dispatchers.IO) { mainViewModel.removeDuplicateByIpAll() }
showStatus("Выполняется замер задержки. Ожидаем завершения...")
mainViewModel.testAllRealPing()
}
mainViewModel.reloadServerList()
if (result.configCount > 0) {
val status = if (removed > 0)
"Обновлено ${result.configCount} профилей, удалено $removed дубл. IP. Запуск теста..."
else
"Обновлено ${result.configCount} профилей. Запуск теста..."
showStatus(status)
} else {
showStatus("Запуск теста...")
}
delay(1500)
hideLoading()
showStatus("Выполняется замер задержки. Ожидаем завершения...")
mainViewModel.testAllRealPing()
} catch (e: kotlinx.coroutines.CancellationException) {
// Пользователь нажал стоп — уже обработано выше
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error in handleLiteAction", e)
isLiteTesting = false
hideLoading()
} finally {
isFabOperationInProgress = false
liteActionJob = null
}
}
}
@@ -314,6 +481,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
return
}
isFabOperationInProgress = true
applyRunningState(isLoading = true, isRunning = false)
lifecycleScope.launch {
try {
@@ -321,12 +489,18 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
V2RayServiceManager.stopVService(this@MainActivity)
delay(1000)
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
// Сервер был снят с выбора — просто остановились, разблокируем UI
applyRunningState(isLoading = false, isRunning = false)
return@launch
}
startV2Ray()
delay(1000)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error in restartV2Ray", e)
} finally {
isFabOperationInProgress = false
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
}
}
}
@@ -341,6 +515,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private fun showStatus(message: String) {
statusResetJob?.cancel()
binding.tvTestState.text = message
if (isFabOperationInProgress || mainViewModel.isTesting.value == true) return
statusResetJob = lifecycleScope.launch {
delay(3000)
val isRunning = mainViewModel.isRunning.value == true
@@ -353,29 +528,112 @@ 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 animateButtonTint(view: com.google.android.material.button.MaterialButton, toColor: Int, duration: Long = 300L) {
val from = view.backgroundTintList?.defaultColor ?: toColor
if (from == toColor) { view.backgroundTintList = ColorStateList.valueOf(toColor); return }
ValueAnimator.ofArgb(from, toColor).apply {
this.duration = duration
addUpdateListener { view.backgroundTintList = ColorStateList.valueOf(it.animatedValue as Int) }
start()
}
}
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)
// Во время подключения: только FAB доступен для отмены, всё остальное заблокировано
binding.fab.isEnabled = true
binding.fab.alpha = 1.0f
binding.fab.backgroundTintList = secContainer
binding.btnSummaryLite.isEnabled = false
binding.btnSummaryLite.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 }
setStatusDot(DotState.LOADING)
return
}
val onPrimary = ColorStateList.valueOf(
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0)
)
val onSecContainer = ColorStateList.valueOf(
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSecondaryContainer, 0)
)
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.fab.iconTint = onPrimary
animateButtonTint(binding.btnSummaryLite, secContainer.defaultColor)
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 = secContainer
binding.fab.iconTint = onSecContainer
animateButtonTint(binding.btnSummaryLite, secContainer.defaultColor)
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
if (mainViewModel.isTesting.value != true && statusResetJob?.isActive != true) {
setTestState(getString(R.string.connection_not_connected))
}
binding.layoutTest.isFocusable = false
setStatusDot(DotState.IDLE)
}
}
private enum class DotState { IDLE, CONNECTED, LOADING, FAILURE }
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.FAILURE -> ContextCompat.getColor(this, R.color.status_failure)
DotState.LOADING -> com.google.android.material.color.MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary, 0)
DotState.IDLE -> com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorOutline, 0)
})
if (state == DotState.LOADING) {
pulseDot(dot)
}
}
private fun pulseDot(dot: android.view.View) {
dot.animate()
.alpha(0.25f)
.setDuration(600)
.withEndAction {
if (dot.isAttachedToWindow) {
dot.animate()
.alpha(1f)
.setDuration(600)
.withEndAction {
if (dot.isAttachedToWindow && mainViewModel.isTesting.value == true) {
pulseDot(dot)
}
}.start()
}
}.start()
}
override fun onResume() {
super.onResume()
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
@@ -388,6 +646,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
@@ -404,6 +669,8 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
searchView.alpha = 0f
searchView.animate().alpha(1f).setDuration(220).start()
return true
}
@@ -426,10 +693,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
R.id.sub_update -> {
setSecondaryButtonsEnabled(false)
importConfigViaSub()
true
}
R.id.filter_by_country -> {
showCountryFilterDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -519,16 +792,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()
}
@@ -539,6 +841,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()
@@ -560,6 +863,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
mainViewModel.reloadServerList()
}
hideLoading()
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
}
}
return true
@@ -579,8 +883,76 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
}
private fun showDonateDialogIfNeeded() {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DONATE_DIALOG_DISMISSED)) return
val postponeUntil = MmkvManager.decodeSettingsLong(AppConfig.PREF_DONATE_DIALOG_POSTPONE_UNTIL, 0L)
if (System.currentTimeMillis() < postponeUntil) return
// Откладываем на следующий тик, чтобы Activity полностью отрисовался
binding.root.post { showDonateDialog() }
}
private fun showDonateDialog() {
val view = layoutInflater.inflate(R.layout.dialog_donate, null)
val tonValue = view.findViewById<TextView>(R.id.donate_ton_value)
val trcValue = view.findViewById<TextView>(R.id.donate_trc_value)
val btcValue = view.findViewById<TextView>(R.id.donate_btc_value)
val openCard = View.OnClickListener { openUrl(getString(R.string.donate_card_link_url)) }
view.findViewById<View>(R.id.donate_card_row).setOnClickListener(openCard)
view.findViewById<View>(R.id.donate_card_open).setOnClickListener(openCard)
view.findViewById<View>(R.id.donate_ton_copy).setOnClickListener {
copyToClipboard(tonValue.text.toString())
}
view.findViewById<View>(R.id.donate_trc_copy).setOnClickListener {
copyToClipboard(trcValue.text.toString())
}
view.findViewById<View>(R.id.donate_btc_copy).setOnClickListener {
copyToClipboard(btcValue.text.toString())
}
val titleStr = getString(R.string.donate_dialog_title)
val dialog = MaterialAlertDialogBuilder(this)
.setView(view)
.setNegativeButton(R.string.donate_btn_dont_show) { d, _ ->
MmkvManager.encodeSettings(AppConfig.PREF_DONATE_DIALOG_DISMISSED, true)
d.dismiss()
}
.setCancelable(true)
.create()
// Closing (X / outside / back) postpones for 24h
val postpone = {
val postponeUntil = System.currentTimeMillis() + 24L * 60 * 60 * 1000
MmkvManager.encodeSettings(AppConfig.PREF_DONATE_DIALOG_POSTPONE_UNTIL, postponeUntil)
}
dialog.setOnCancelListener { postpone() }
dialog.setCustomTitle(buildDialogTitleWithClose(titleStr) {
postpone()
dialog.dismiss()
})
dialog.show()
}
private fun copyToClipboard(text: String) {
val clipboard = getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("addr", text))
toast(R.string.donate_toast_copied)
}
private fun openUrl(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
} catch (e: Exception) {
Log.w("MainActivity", "Failed to open url: $url", e)
}
}
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) {
@@ -599,7 +971,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) {
@@ -719,7 +1091,76 @@ 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()
// currentFilter stores excluded set (empty = show all)
val checked = BooleanArray(codes.size) { codes[it] in currentFilter }
val adapter = object : android.widget.BaseAdapter() {
override fun getCount() = codes.size
override fun getItem(pos: Int) = codes[pos]
override fun getItemId(pos: Int) = pos.toLong()
override fun getView(pos: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
val view = convertView ?: layoutInflater.inflate(R.layout.item_dialog_country, parent, false)
val code = codes[pos]
val isUnknown = code == CountryDetector.UNKNOWN
view.findViewById<android.widget.TextView>(R.id.flag).text =
if (isUnknown) "🌐" else CountryDetector.codeToFlag(code)
view.findViewById<android.widget.TextView>(R.id.text).text =
if (isUnknown) "Неизвестно" else CountryDetector.codeToName(code)
view.findViewById<android.widget.TextView>(R.id.code).text =
if (isUnknown) "" else code
val cb = view.findViewById<com.google.android.material.checkbox.MaterialCheckBox>(R.id.check_box)
cb.isChecked = checked[pos]
view.setOnClickListener {
checked[pos] = !checked[pos]
cb.isChecked = checked[pos]
}
return view
}
}
val dialog = MaterialAlertDialogBuilder(this@MainActivity)
.setAdapter(adapter, null)
.setPositiveButton("Применить") { _, _ ->
val excluded = codes.filterIndexed { i, _ -> checked[i] }.toSet()
mainViewModel.applyCountryFilter(excluded)
val msg = if (excluded.isEmpty()) "Показаны все страны"
else "Скрыто: ${excluded.joinToString { CountryDetector.codeToFlag(it) }}"
showStatus(msg)
}
.setNeutralButton("Сбросить") { _, _ ->
mainViewModel.applyCountryFilter(emptySet())
showStatus("Показаны все страны")
}
.create()
dialog.setCustomTitle(buildDialogTitleWithClose("Исключить страны") { dialog.dismiss() })
dialog.show()
}
}
}
private fun checkForUpdatesOnStartup() {
showStatus("Проверка обновлений...")
lifecycleScope.launch {
@@ -738,18 +1179,27 @@ 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)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.ok, null)
.show()
.create()
dialog.setCustomTitle(buildDialogTitleWithClose(titleStr) { dialog.dismiss() })
dialog.show()
}
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
@@ -794,4 +1244,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)
}
}
}
@@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup
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
@@ -35,7 +36,11 @@ class MainRecyclerAdapter(
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) {
@@ -54,61 +59,51 @@ class MainRecyclerAdapter(
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
val parsedNewData = newData?.toList() ?: emptyList()
fun setData(newData: List<ServersCache>) {
val oldData = data
val parsedNewData = newData
if (data.isEmpty() || parsedNewData.isEmpty() || position >= 0) {
if (oldData.isEmpty() || parsedNewData.isEmpty()) {
data = parsedNewData.toMutableList()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
recomputePingRange()
notifyDataSetChanged()
return
}
val oldData = data
val lm = recyclerView?.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager
val firstVisible = lm?.findFirstVisibleItemPosition()?.coerceAtLeast(0) ?: 0
val isAtTop = firstVisible == 0 && (lm?.findViewByPosition(0)?.top ?: 0) >= 0
val firstVisibleGuid = if (!isAtTop) oldData.getOrNull(firstVisible)?.guid else null
val diffResult =
androidx.recyclerview.widget.DiffUtil.calculateDiff(
object : androidx.recyclerview.widget.DiffUtil.Callback() {
override fun getOldListSize() = oldData.size
override fun getNewListSize() = parsedNewData.size
val diffResult = androidx.recyclerview.widget.DiffUtil.calculateDiff(
object : androidx.recyclerview.widget.DiffUtil.Callback() {
override fun getOldListSize() = oldData.size
override fun getNewListSize() = parsedNewData.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldData[oldPos].guid == parsedNewData[newPos].guid
}
override fun areItemsTheSame(oldPos: Int, newPos: Int) =
oldData[oldPos].guid == parsedNewData[newPos].guid
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val oldProfile = oldData[oldPos].profile
val newProfile = parsedNewData[newPos].profile
return oldProfile == newProfile &&
oldProfile.isFavorite == newProfile.isFavorite &&
MmkvManager.decodeServerAffiliationInfo(
oldData[oldPos].guid
)
?.testDelayMillis ==
MmkvManager.decodeServerAffiliationInfo(
parsedNewData[newPos].guid
)
?.testDelayMillis
}
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val old = oldData[oldPos]
val new = parsedNewData[newPos]
return old.profile == new.profile &&
old.profile.isFavorite == new.profile.isFavorite &&
old.isSelected == new.isSelected &&
old.testDelayMillis == new.testDelayMillis
}
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE
}
return super.getChangePayload(oldPos, newPos)
}
},
true
)
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE
}
return super.getChangePayload(oldPos, newPos)
}
},
true
)
data = parsedNewData.toMutableList()
recomputePingRange()
diffResult.dispatchUpdatesTo(this)
if (isAtTop) {
@@ -125,8 +120,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 {
@@ -134,23 +134,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) {
@@ -163,26 +160,35 @@ 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)
)
val delayMillis = data[position].testDelayMillis
holder.itemMainBinding.tvTestResult.text = when {
delayMillis == 0L -> "—ms"
delayMillis < 0L -> "-ms"
else -> "${delayMillis}ms"
}
holder.itemMainBinding.tvTestResult.setTextColor(
getPingColor(context, delayMillis)
)
(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)
val isSelected = data[position].isSelected
holder.itemMainBinding.cardContainer.apply {
val selectedColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurfaceContainerHigh,
Color.TRANSPARENT
)
setCardBackgroundColor(if (isSelected) selectedColor else Color.TRANSPARENT)
strokeWidth = 0
strokeColor = Color.TRANSPARENT
}
// subscription remarks
@@ -193,12 +199,16 @@ 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()
}
@@ -220,9 +230,50 @@ 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 ->
item.testDelayMillis.takeIf { it > 0L }
}
minReachablePing = delays.minOrNull()
maxReachablePing = delays.maxOrNull()
}
/**
* Gets the subscription remarks information
* @param profile The server configuration
@@ -238,18 +289,8 @@ class MainRecyclerAdapter(
return subRemarks?.toString() ?: ""
}
fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
data.removeAt(idx)
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
}
fun setSelectServer(fromPosition: Int, toPosition: Int) {
notifyItemChanged(fromPosition)
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 {
@@ -298,6 +339,8 @@ class MainRecyclerAdapter(
BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
// ViewModel swaps both serverList and _serversCache, then publishSnapshot triggers setData.
// We optimistically swap local data + animate immediately for smooth drag UX.
mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
@@ -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,10 +15,12 @@ import android.util.Log
import android.util.Patterns
import android.webkit.URLUtil
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.AppConfig.LOOPBACK
import xyz.zarazaex.olc.BuildConfig
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
@@ -291,6 +293,40 @@ object Utils {
}
}
/**
* Launch the system package installer for a downloaded APK file.
*
* @param context The context to use.
* @param apkFile The downloaded APK file (must live under the FileProvider paths).
* @return true if the installer intent was started, false otherwise.
*/
fun installApk(context: Context, apkFile: File): Boolean {
return try {
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.cache",
apkFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to install APK", e)
false
}
}
/**
* Whether the app is allowed to install APKs from unknown sources.
*/
fun canInstallApk(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
/**
* Generate a UUID.
*
@@ -23,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
@@ -32,20 +33,36 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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 = ""
val serversCache = mutableListOf<ServersCache>()
/** ISO codes to EXCLUDE (empty = show all) */
var countryFilter: Set<String> = MmkvManager.getCountryFilter()
private set
// Internal mutable cache — never exposed directly
private val _serversCache = mutableListOf<ServersCache>()
// Read-only snapshot for external consumers that need direct access (e.g. export, ping)
val serversCache: List<ServersCache> get() = _serversCache.toList()
// Single source of truth for the list UI — emits a new immutable snapshot on every change
private val _serverListFlow = MutableStateFlow<List<ServersCache>>(emptyList())
val serverListFlow: StateFlow<List<ServersCache>> = _serverListFlow.asStateFlow()
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 } }
var suppressPinSelected = false
private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
/**
@@ -115,8 +132,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
list
}
if (!suppressPinSelected) pinSelectedGuidToTop(serverList)
updateCache()
updateListAction.value = -1
}
/**
@@ -126,10 +143,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun removeServer(guid: String) {
serverList.remove(guid)
MmkvManager.removeServer(guid)
val index = getPosition(guid)
val index = _serversCache.indexOfFirst { it.guid == guid }
if (index >= 0) {
serversCache.removeAt(index)
_serversCache.removeAt(index)
}
publishSnapshot()
}
/**
@@ -143,27 +161,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
Collections.swap(serverList, fromPosition, toPosition)
Collections.swap(serversCache, fromPosition, toPosition)
Collections.swap(_serversCache, fromPosition, toPosition)
publishSnapshot()
MmkvManager.encodeServerList(serverList, subscriptionId)
}
/**
* Updates the cache of servers.
* Rebuilds _serversCache from serverList and publishes a new snapshot to serverListFlow.
*/
@Synchronized
fun updateCache() {
serversCache.clear()
_serversCache.clear()
val kw = keywordFilter.trim()
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
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
if (activeCountryFilter.isNotEmpty()) {
val codes = CountryDetector.getCountryCodes(profile.remarks, profile.server)
if (codes.all { it in activeCountryFilter }) continue
}
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile))
_serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
continue
}
@@ -176,7 +205,85 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|| server.matchesPattern(searchRegex, kw)
|| protocol.matchesPattern(searchRegex, kw)
) {
serversCache.add(ServersCache(guid, profile))
_serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
}
}
publishSnapshot()
}
/** Emits an immutable copy of _serversCache to the Flow. Must be called on Main or from @Synchronized blocks. */
private fun publishSnapshot() {
_serverListFlow.value = _serversCache.toList()
}
/** Builds a snapshot of ServersCache for the given subId without changing global state. */
fun reloadForSub(subId: String): MutableList<ServersCache>? {
val guids = when {
subId.isEmpty() -> MmkvManager.decodeAllServerList()
subId.startsWith("group_") -> {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subId) {
"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) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
groupSubs.flatMap { MmkvManager.decodeServerList(it.guid) }.toMutableList()
}
else -> MmkvManager.decodeServerList(subId)
}
return guids.mapNotNull { guid ->
val profile = MmkvManager.decodeServerConfig(guid) ?: return@mapNotNull null
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
ServersCache(guid, profile, delay)
}.toMutableList()
}
/** Sets excluded countries and reloads list. Pass empty set to show all. */
fun applyCountryFilter(excludedCodes: Set<String>) {
countryFilter = excludedCodes
MmkvManager.setCountryFilter(excludedCodes)
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 codes = CountryDetector.getCountryCodes(profile.remarks, profile.server)
for (code in codes) {
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()
}
}
}
@@ -191,27 +298,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))
@@ -225,9 +325,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun exportAllServer(): Int {
val serverListCopy =
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
serverList
serverList.toList()
} else {
serversCache.map { it.guid }.toList()
_serversCache.map { it.guid }
}
val ret = AngConfigManager.shareNonCustomConfigsToClipboard(
@@ -243,9 +343,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun testAllTcping() {
tcpingTestScope.coroutineContext[Job]?.cancelChildren()
SpeedtestManager.closeAllTcpSockets()
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
val serversCopy = serversCache.toList()
val serversCopy = _serversCache.toList()
for (item in serversCopy) {
item.profile.let { outbound ->
val serverAddress = outbound.server
@@ -255,7 +355,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt())
launch(Dispatchers.Main) {
MmkvManager.encodeServerTestDelayMillis(item.guid, testResult)
updateListAction.value = getPosition(item.guid)
refreshPingInCache(listOf(item.guid))
}
}
}
@@ -263,6 +363,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.
*/
@@ -271,35 +382,45 @@ 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()
}
if (!suppressPinSelected) {
MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
}
publishSnapshot()
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()
}
)
)
}
}
}
}
@@ -404,9 +525,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The position of the server.
*/
fun getPosition(guid: String): Int {
serversCache.forEachIndexed { index, it ->
if (it.guid == guid)
return index
_serversCache.forEachIndexed { index, it ->
if (it.guid == guid) return index
}
return -1
}
@@ -416,7 +536,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @return The number of removed servers.
*/
fun removeDuplicateServer(): Int {
val serversCacheCopy = serversCache.toList().toMutableList()
val serversCacheCopy = _serversCache.toList()
val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, sc ->
val profile = sc.profile
@@ -436,6 +556,102 @@ 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()
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.
@@ -445,11 +661,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
MmkvManager.removeAllServer()
} else {
val serversCopy = serversCache.toList()
val serversCopy = _serversCache.toList()
for (item in serversCopy) {
MmkvManager.removeServer(item.guid)
}
serversCache.toList().count()
serversCopy.count()
}
return count
}
@@ -457,6 +673,49 @@ 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 refreshPingInCache(guids: List<String>) {
val guidSet = guids.toHashSet()
for (i in _serversCache.indices) {
val item = _serversCache[i]
if (item.guid in guidSet) {
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
if (item.testDelayMillis != delay) {
_serversCache[i] = item.copy(testDelayMillis = delay)
}
}
}
publishSnapshot()
}
@Synchronized
fun sortServersCacheInPlace() {
for (i in _serversCache.indices) {
val item = _serversCache[i]
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
if (item.testDelayMillis != delay) {
_serversCache[i] = item.copy(testDelayMillis = delay)
}
}
_serversCache.sortWith(compareBy(
{ !it.profile.isFavorite },
{
val delay = it.testDelayMillis
when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))
if (!suppressPinSelected) pinSelectedCacheItemToTop(_serversCache)
publishSnapshot()
}
fun sortByTestResults() {
if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubsList().forEach { guid ->
@@ -506,6 +765,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)
}
}
@@ -533,11 +793,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,7 +870,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
withContext(Dispatchers.Main) {
reloadServerList()
reloadServerList() // rebuilds _serversCache + publishSnapshot
isTesting.value = false
liteTestFinished.value = true
liteTestFinished.value = false
}
@@ -620,13 +902,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
AppConfig.MSG_MEASURE_DELAY_SUCCESS -> {
updateTestResultAction.value = intent.getStringExtra("content")
val content = intent.getStringExtra("content")
updateTestResultAction.value = content
// Save ping for selected server so it shows in the list
val guid = MmkvManager.getSelectServer()
if (!guid.isNullOrEmpty() && content != null) {
val ms = Regex("\\d+").find(content)?.value?.toLongOrNull()
if (ms != null && ms > 0) {
MmkvManager.encodeServerTestDelayMillis(guid, ms)
refreshPingInCache(listOf(guid))
}
}
}
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)
refreshPingInCache(listOf(resultPair.first))
if (!suppressPinSelected) sortServersCacheInPlace()
// publishSnapshot() already called inside refresh/sort above
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
@@ -634,7 +928,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
update.results.forEach { result ->
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
}
updateListAction.value = -1
refreshPingInCache(update.results.map { it.guid })
if (!suppressPinSelected) sortServersCacheInPlace()
// publishSnapshot() already called inside refresh/sort above
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -647,6 +943,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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?attr/colorSurfaceContainerHighest" />
</shape>
@@ -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>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="?attr/colorPrimary" />
<size
android:width="20dp"
android:height="20dp" />
</shape>
</item>
<item
android:drawable="@drawable/ic_check_white"
android:width="14dp"
android:height="14dp"
android:gravity="center" />
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/checkbox_round_checked" android:state_checked="true" />
<item android:drawable="@drawable/checkbox_round_unchecked" />
</selector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:width="2dp"
android:color="?attr/colorOnSurfaceVariant" />
<size
android:width="20dp"
android:height="20dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="14dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/md_theme_onPrimary"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
</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>
@@ -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>
+165 -98
View File
@@ -4,22 +4,28 @@
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
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:titleTextColor="?attr/colorOnSurface"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
@@ -43,17 +49,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"
@@ -63,99 +72,151 @@
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"
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" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="56dp"
android:minHeight="56dp"
android:background="?attr/colorSurface"
app:tabIndicatorFullWidth="true"
app:tabIndicatorHeight="3dp"
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/colorSecondaryContainer"
app:iconTint="?attr/colorOnSecondaryContainer"
app:rippleColor="?attr/colorOnSecondaryContainer"
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:clipChildren="false"
android:clipToPadding="false"
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:id="@+id/drawer_content_layout"
android:layout_width="wrap_content"
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
@@ -163,6 +224,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
@@ -171,61 +233,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" />
@@ -235,8 +301,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,247 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="4dp"
android:paddingBottom="8dp">
<!-- Карта / СБП -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/donate_card_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingEnd="10dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_label_card"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/donate_card_url"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:typeface="monospace" />
</LinearLayout>
<ImageButton
android:id="@+id/donate_card_open"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/donate_open_link"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_share_24dp"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- TON -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingEnd="10dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_label_ton"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/donate_ton_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="middle"
android:maxLines="1"
android:text="@string/donate_addr_ton"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:typeface="monospace" />
</LinearLayout>
<ImageButton
android:id="@+id/donate_ton_copy"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/donate_copy"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_copy"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- TRC20 -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingEnd="10dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_label_trc20"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/donate_trc_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="middle"
android:maxLines="1"
android:text="@string/donate_addr_trc20"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:typeface="monospace" />
</LinearLayout>
<ImageButton
android:id="@+id/donate_trc_copy"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/donate_copy"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_copy"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- BTC -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingEnd="10dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/donate_label_btc"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/donate_btc_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="middle"
android:maxLines="1"
android:text="@string/donate_addr_btc"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:typeface="monospace" />
</LinearLayout>
<ImageButton
android:id="@+id/donate_btc_copy"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/donate_copy"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_copy"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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="16dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/dialog_title_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="start"
android:paddingEnd="32dp"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/dialog_close_btn"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="end|center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Закрыть"
android:src="@drawable/ic_close_24dp"
android:tint="?attr/colorOnSurfaceVariant" />
</FrameLayout>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="56dp"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<FrameLayout
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/bg_country_flag_circle">
<TextView
android:id="@+id/flag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:includeFontPadding="false"
android:textSize="18sp" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:layout_marginEnd="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textAllCaps="true"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:letterSpacing="0.08" />
</LinearLayout>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/check_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false" />
</LinearLayout>
@@ -14,35 +14,29 @@
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"
android:button="@drawable/checkbox_round_selector"
android:clickable="false"
android:focusable="false"
android:padding="@dimen/padding_spacing_dp8" />
</LinearLayout>
</LinearLayout>
@@ -1,44 +1,46 @@
<?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="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
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="@android:color/transparent"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="match_parent"
android:id="@+id/info_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_weight="1"
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="13dp"
android:paddingEnd="4dp"
android:paddingBottom="13dp">
<LinearLayout
android:layout_width="0dp"
@@ -47,51 +49,78 @@
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="match_parent"
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:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="@dimen/padding_spacing_dp8">
android:layout_weight="1"
android:orientation="vertical">
<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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="12dp">
<TextView
android:id="@+id/tv_subscription"
android:id="@+id/tv_name"
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:maxLines="2"
android:minLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tv_statistics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<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>
@@ -99,65 +128,41 @@
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end">
<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="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_star_empty" />
<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="@dimen/padding_spacing_dp8"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorAccent" />
</LinearLayout>
</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">
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:paddingEnd="4dp">
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/tv_test_result"
<ImageView
android:id="@+id/iv_favorite"
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" />
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>
</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,37 @@
<?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="status_failure">#EF5350</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 +43,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>
+374 -119
View File
@@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<string name="app_widget_name">v2rayNG</string>
<string name="app_tile_name">v2rayNG</string>
<string name="app_tile_first_use">Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить сервер</string>
<string
name="app_tile_first_use"
>Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить сервер</string>
<string name="navigation_drawer_open">Открыть панель навигации</string>
<string name="navigation_drawer_close">Закрыть панель навигации</string>
<string name="migration_success">Успешный перенос данных!</string>
<string name="drawer_forked_text">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
<string name="drawer_developed_text">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
<string name="drawer_forked_text">forked from <a
href="https://github.com/2dust/v2rayng"
>V2RayNG</a></string>
<string name="drawer_developed_text">developed by developers from <a
href="https://t.me/openlibrecommunity"
>Olc</a></string>
<string name="action_stop_service">Остановить службу</string>
<string name="migration_fail">Перенос данных не выполнен!</string>
<string name="pull_down_to_refresh">Потяните вниз для обновления!</string>
@@ -15,7 +21,9 @@
<!-- Notifications -->
<string name="notification_action_stop_v2ray">Остановить</string>
<string name="toast_permission_denied">Разрешение не получено</string>
<string name="toast_permission_denied_notification">Разрешение на отображение уведомлений не получено</string>
<string
name="toast_permission_denied_notification"
>Разрешение на отображение уведомлений не получено</string>
<string name="notification_action_more">Ещё…</string>
<string name="toast_services_start">Запуск служб</string>
<string name="toast_services_stop">Остановка служб</string>
@@ -29,19 +37,41 @@
<string name="menu_item_edit_config">Изменить профиль</string>
<string name="menu_item_del_config">Удалить профиль</string>
<string name="menu_item_import_config_qrcode">Импорт из QR-кода</string>
<string name="menu_item_import_config_clipboard">Импорт из буфера обмена</string>
<string
name="menu_item_import_config_clipboard"
>Импорт из буфера обмена</string>
<string name="menu_item_import_config_local">Импорт из файла</string>
<string name="menu_item_import_config_policy_group">Добавить группу политик</string>
<string name="menu_item_import_config_manually_vmess">Ручной ввод VMess</string>
<string name="menu_item_import_config_manually_vless">Ручной ввод VLESS</string>
<string name="menu_item_import_config_manually_ss">Ручной ввод Shadowsocks</string>
<string name="menu_item_import_config_manually_socks">Ручной ввод SOCKS</string>
<string name="menu_item_import_config_manually_http">Ручной ввод HTTP</string>
<string name="menu_item_import_config_manually_trojan">Ручной ввод Trojan</string>
<string name="menu_item_import_config_manually_wireguard">Ручной ввод WireGuard</string>
<string name="menu_item_import_config_manually_hysteria2">Ручной ввод Hysteria2</string>
<string
name="menu_item_import_config_policy_group"
>Добавить группу политик</string>
<string
name="menu_item_import_config_manually_vmess"
>Ручной ввод VMess</string>
<string
name="menu_item_import_config_manually_vless"
>Ручной ввод VLESS</string>
<string
name="menu_item_import_config_manually_ss"
>Ручной ввод Shadowsocks</string>
<string
name="menu_item_import_config_manually_socks"
>Ручной ввод SOCKS</string>
<string
name="menu_item_import_config_manually_http"
>Ручной ввод HTTP</string>
<string
name="menu_item_import_config_manually_trojan"
>Ручной ввод Trojan</string>
<string
name="menu_item_import_config_manually_wireguard"
>Ручной ввод WireGuard</string>
<string
name="menu_item_import_config_manually_hysteria2"
>Ручной ввод Hysteria2</string>
<string name="del_config_comfirm">Подтверждаете удаление?</string>
<string name="del_invalid_config_comfirm">Выполните проверку перед удалением! Подтверждаете удаление?</string>
<string
name="del_invalid_config_comfirm"
>Выполните проверку перед удалением! Подтверждаете удаление?</string>
<string name="server_lab_remarks">Название</string>
<string name="server_lab_address">Адрес</string>
<string name="server_lab_port">Порт</string>
@@ -82,45 +112,73 @@
<string name="server_lab_encryption">Шифрование</string>
<string name="server_lab_flow">Поток</string>
<string name="server_lab_public_key">Открытый ключ</string>
<string name="server_lab_preshared_key">Дополнительный ключ шифрования (необязательно)</string>
<string
name="server_lab_preshared_key"
>Дополнительный ключ шифрования (необязательно)</string>
<string name="server_lab_short_id">ShortID</string>
<string name="server_lab_spider_x">SpiderX</string>
<string name="server_lab_mldsa65_verify">mldsa65Verify</string>
<string name="server_lab_secret_key">Закрытый ключ</string>
<string name="server_lab_reserved">Reserved (необязательно, через запятую)</string>
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
<string
name="server_lab_reserved"
>Reserved (необязательно, через запятую)</string>
<string
name="server_lab_local_address"
>Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
<string
name="server_lab_local_mtu"
>MTU (необязательно, по умолчанию 1420)</string>
<string name="toast_success">Успешно</string>
<string name="toast_failure">Ошибка</string>
<string name="toast_none_data">Ничего нет</string>
<string name="toast_incorrect_protocol">Неправильный протокол</string>
<string name="toast_decoding_failed">Невозможно декодировать</string>
<string name="title_file_chooser">Выберите профиль</string>
<string name="toast_require_file_manager">Установите файловый менеджер</string>
<string
name="toast_require_file_manager"
>Установите файловый менеджер</string>
<string name="server_customize_config">Изменить профиль</string>
<string name="toast_config_file_invalid">Неправильный профиль</string>
<string name="server_lab_content">Данные</string>
<string name="toast_none_data_clipboard">В буфере обмена нет данных</string>
<string name="toast_invalid_url">Неправильный URL</string>
<string name="toast_insecure_url_protocol">Не используйте небезопасный HTTP-протокол в адресе подписки</string>
<string name="server_lab_need_inbound">Убедитесь, что входящий порт соответствует настройкам</string>
<string
name="toast_insecure_url_protocol"
>Не используйте небезопасный HTTP-протокол в адресе подписки</string>
<string
name="server_lab_need_inbound"
>Убедитесь, что входящий порт соответствует настройкам</string>
<string name="toast_malformed_josn">Профиль повреждён</string>
<string name="server_lab_request_host6">Узел (SNI) (необязательно)</string>
<string name="toast_action_not_allowed">Это действие запрещено</string>
<string name="server_obfs_password">Пароль obfs</string>
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
<string
name="server_lab_port_hop"
>Смена портов (переопределяет порт)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов</string>
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string
name="server_lab_bandwidth_down"
>Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string
name="server_lab_bandwidth_up"
>Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
<string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string
name="server_lab_xhttp_extra"
>Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
<string
name="server_lab_final_mask"
>finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
<string
name="server_lab_pinned_ca256"
>Отпечаток сертификата (SHA-256)</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
<string
name="toast_asset_copy_failed"
>Невозможно скопировать файл, используйте файловый менеджер</string>
<string name="menu_item_add_asset">Добавить ресурс</string>
<string name="menu_item_add_file">Добавить файлы</string>
<string name="menu_item_add_url">Добавить URL</string>
@@ -130,7 +188,9 @@
<string name="title_user_asset_add_url">Добавить URL ресурса</string>
<string name="msg_file_not_found">Файл не найден</string>
<string name="msg_remark_is_duplicate">Название уже существует</string>
<string name="asset_geo_files_sources">Источник геофайлов (необязательно)</string>
<string
name="asset_geo_files_sources"
>Источник геофайлов (необязательно)</string>
<!-- PerAppProxyActivity -->
<string name="msg_dialog_progress">Загрузка…</string>
@@ -143,28 +203,61 @@
<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="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню.</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>
<string
name="summary_pref_is_booted"
>Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
<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>
<string name="title_pref_mux_concurency">TCP-соединения (диапазон от 1 до 1024)</string>
<string name="title_pref_mux_xudp_concurency">XUDP-соединения (диапазон от 1 до 1024)</string>
<string name="title_pref_mux_xudp_quic">Обработка QUIC в мультиплексном туннеле</string>
<string
name="title_pref_mux_enabled"
>Использовать мультиплексирование</string>
<string
name="summary_pref_mux_enabled"
>Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
<string
name="title_pref_mux_concurency"
>TCP-соединения (диапазон от 1 до 1024)</string>
<string
name="title_pref_mux_xudp_concurency"
>XUDP-соединения (диапазон от 1 до 1024)</string>
<string
name="title_pref_mux_xudp_quic"
>Обработка QUIC в мультиплексном туннеле</string>
<string-array name="mux_xudp_quic_entries">
<item>Отклонять</item>
<item>Разрешать</item>
@@ -172,49 +265,87 @@
</string-array>
<string name="title_pref_speed_enabled">Показывать скорость</string>
<string name="summary_pref_speed_enabled">Показывать текущую скорость в уведомлении.\nЗначок будет меняться в зависимости от использования.</string>
<string
name="summary_pref_speed_enabled"
>Показывать текущую скорость в уведомлении.\nЗначок будет меняться в зависимости от использования.</string>
<string name="title_pref_sniffing_enabled">Анализировать пакеты</string>
<string name="summary_pref_sniffing_enabled">Пытаться определять доменные имена в пакетах (по умолчанию включено)</string>
<string name="title_pref_route_only_enabled">Домен только для маршрутизации</string>
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
<string
name="summary_pref_sniffing_enabled"
>Пытаться определять доменные имена в пакетах (по умолчанию включено)</string>
<string
name="title_pref_route_only_enabled"
>Домен только для маршрутизации</string>
<string
name="summary_pref_route_only_enabled"
>Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
<string name="title_pref_local_dns_enabled">Использовать локальную DNS</string>
<string name="summary_pref_local_dns_enabled">Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
<string
name="title_pref_local_dns_enabled"
>Использовать локальную DNS</string>
<string
name="summary_pref_local_dns_enabled"
>Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
<string name="title_pref_fake_dns_enabled">Использовать поддельную DNS</string>
<string name="summary_pref_fake_dns_enabled">Локальная DNS возвращает поддельные IP-адреса (быстрее, но может не работать с некоторыми приложениями)</string>
<string
name="title_pref_fake_dns_enabled"
>Использовать поддельную DNS</string>
<string
name="summary_pref_fake_dns_enabled"
>Локальная DNS возвращает поддельные IP-адреса (быстрее, но может не работать с некоторыми приложениями)</string>
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
<string name="summary_pref_prefer_ipv6">Использовать маршрутизацию IPv6 предпочитать IPv6-адреса</string>
<string
name="summary_pref_prefer_ipv6"
>Использовать маршрутизацию IPv6 предпочитать IPv6-адреса</string>
<string name="title_pref_remote_dns">Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string
name="title_pref_remote_dns"
>Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string name="summary_pref_remote_dns">DNS</string>
<string name="title_pref_vpn_dns">VPN DNS (только IPv4/v6)</string>
<string name="title_pref_vpn_bypass_lan">VPN обходит LAN</string>
<string name="title_pref_vpn_interface_address">Адрес интерфейса VPN</string>
<string
name="title_pref_vpn_interface_address"
>Адрес интерфейса VPN</string>
<string name="title_pref_vpn_mtu">VPN MTU (по умолчанию 1500)</string>
<string name="title_pref_domestic_dns">Внутренняя DNS (необязательно)</string>
<string
name="title_pref_domestic_dns"
>Внутренняя DNS (необязательно)</string>
<string name="summary_pref_domestic_dns">DNS</string>
<string name="title_pref_dns_hosts">Узлы DNS (формат: домен:адрес,…)</string>
<string
name="title_pref_dns_hosts"
>Узлы DNS (формат: домен:адрес,…)</string>
<string name="summary_pref_dns_hosts">домен:адрес,…</string>
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
<string
name="title_pref_ip_api_url"
>Сервис проверки текущего соединения</string>
<string name="summary_pref_ip_api_url">URL</string>
<string name="title_pref_proxy_sharing_enabled">Разрешать соединения из LAN</string>
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
<string name="toast_warning_pref_proxysharing_short">Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
<string
name="title_pref_proxy_sharing_enabled"
>Разрешать соединения из LAN</string>
<string
name="summary_pref_proxy_sharing_enabled"
>Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
<string
name="toast_warning_pref_proxysharing_short"
>Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
<string name="title_pref_allow_insecure">Разрешать небезопасные соединения</string>
<string name="summary_pref_allow_insecure">Для TLS по умолчанию разрешены небезопасные соединения</string>
<string
name="title_pref_allow_insecure"
>Разрешать небезопасные соединения</string>
<string
name="summary_pref_allow_insecure"
>Для TLS по умолчанию разрешены небезопасные соединения</string>
<string name="title_pref_socks_port">Порт локального прокси</string>
<string name="summary_pref_socks_port">Порт локального прокси</string>
@@ -222,28 +353,50 @@
<string name="title_pref_local_dns_port">Порт локальной DNS</string>
<string name="summary_pref_local_dns_port">Порт локальной DNS</string>
<string name="title_pref_confirm_remove">Подтверждать удаление профиля</string>
<string name="summary_pref_confirm_remove">Обязательное подтверждение удаления профиля</string>
<string
name="title_pref_confirm_remove"
>Подтверждать удаление профиля</string>
<string
name="summary_pref_confirm_remove"
>Обязательное подтверждение удаления профиля</string>
<string name="title_pref_start_scan_immediate">Сканировать при запуске</string>
<string name="summary_pref_start_scan_immediate">Начинать сканирование сразу при запуске приложения или запускать функцию сканирования камерой или из изображения через панель инструментов</string>
<string
name="title_pref_start_scan_immediate"
>Сканировать при запуске</string>
<string
name="summary_pref_start_scan_immediate"
>Начинать сканирование сразу при запуске приложения или запускать функцию сканирования камерой или из изображения через панель инструментов</string>
<string name="title_pref_append_http_proxy">Дополнительный HTTP-прокси</string>
<string name="summary_pref_append_http_proxy">HTTP-прокси будет использоваться напрямую (из браузера и других поддерживающих приложений), минуя виртуальный сетевой адаптер (Android 10+)</string>
<string
name="title_pref_append_http_proxy"
>Дополнительный HTTP-прокси</string>
<string
name="summary_pref_append_http_proxy"
>HTTP-прокси будет использоваться напрямую (из браузера и других поддерживающих приложений), минуя виртуальный сетевой адаптер (Android 10+)</string>
<string name="title_pref_double_column_display">Профили в два столбца</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
<string
name="title_pref_double_column_display"
>Профили в два столбца</string>
<string
name="summary_pref_double_column_display"
>Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
<string name="title_pref_group_all_display">Общая вкладка групп</string>
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<string
name="summary_pref_group_all_display"
>Показывать дополнительную вкладку со всеми профилями групп</string>
<string name="title_pref_show_copy_button">Кнопка копирования</string>
<string name="summary_pref_show_copy_button">Показывать кнопку копирования конфигурации сервера в буфер обмена</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
<string name="summary_pref_tg_group">Присоединиться к группе в Telegram</string>
<string name="toast_tg_app_not_found">Приложение Telegram не найдено</string>
<string
name="summary_pref_feedback"
>Предложить улучшение или сообщить об ошибке на GitHub</string>
<string
name="summary_pref_tg_group"
>Присоединиться к группе в Telegram</string>
<string
name="toast_tg_app_not_found"
>Приложение Telegram не найдено</string>
<string name="title_privacy_policy">Политика конфиденциальности</string>
<string name="title_about">О приложении</string>
<string name="title_source_code">Исходный код</string>
@@ -252,28 +405,57 @@
<string name="title_pref_promotion">Содействие</string>
<string name="title_pref_auto_update_subscription">Автоматически обновлять подписки</string>
<string name="summary_pref_auto_update_subscription">Автоматическое обновление подписок в фоновом режиме с указанным интервалом. В зависимости от устройства эта функция может работать не всегда.</string>
<string name="title_pref_auto_update_interval">Интервал автообновления (минут, не менее 15)</string>
<string
name="title_pref_auto_update_subscription"
>Автоматически обновлять подписки</string>
<string
name="summary_pref_auto_update_subscription"
>Автоматическое обновление подписок в фоновом режиме с указанным интервалом. В зависимости от устройства эта функция может работать не всегда.</string>
<string
name="title_pref_auto_update_interval"
>Интервал автообновления (минут, не менее 15)</string>
<string name="title_core_loglevel">Подробность ведения журнала</string>
<string name="title_outbound_domain_resolve_method">Предопределение исходящего домена</string>
<string
name="title_outbound_domain_resolve_method"
>Предопределение исходящего домена</string>
<string name="title_mode">Режим</string>
<string name="title_mode_help">Нажмите для получения дополнительной информации</string>
<string
name="title_mode_help"
>Нажмите для получения дополнительной информации</string>
<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>
<string name="title_pref_hev_tunnel_rw_timeout">Ожидание чтения/записи HevTun (секунд, по умолчанию TCP,UDP 300,60)</string>
<string
name="summary_pref_use_hev_tunnel"
>Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
<string
name="title_pref_hev_tunnel_loglevel"
>Подробность журнала HevTun</string>
<string
name="title_pref_hev_tunnel_rw_timeout"
>Ожидание чтения/записи HevTun (секунд, по умолчанию TCP,UDP 300,60)</string>
<string name="title_logcat">Журнал</string>
<string name="logcat_copy">Копировать</string>
<string name="logcat_clear">Очистить</string>
<string name="title_service_restart">Перезапуск службы</string>
<string name="title_del_all_config">Удалить профили</string>
<string name="title_del_duplicate_config">Удалить дубликаты профилей</string>
<string
name="title_del_duplicate_config"
>Удалить дубликаты профилей</string>
<string name="title_del_invalid_config">Удалить нерабочие профили</string>
<string name="title_export_all">Экспорт профилей в буфер обмена</string>
<string name="title_sub_setting">Группы</string>
@@ -283,26 +465,44 @@
<string name="sub_setting_filter">Название фильтра</string>
<string name="sub_setting_enable">Использовать обновление</string>
<string name="sub_auto_update">Использовать автообновление</string>
<string name="sub_allow_insecure_url">Разрешать незащищённые HTTP-адреса</string>
<string
name="sub_allow_insecure_url"
>Разрешать незащищённые HTTP-адреса</string>
<string name="sub_setting_pre_profile">Предыдущий профиль прокси</string>
<string name="sub_setting_next_profile">Следующий профиль прокси</string>
<string name="sub_setting_pre_profile_tip">Профиль должен быть уникальным</string>
<string
name="sub_setting_pre_profile_tip"
>Профиль должен быть уникальным</string>
<string name="title_sub_update">Обновить подписку</string>
<string name="title_ping_all_server">Проверить профили</string>
<string name="title_real_ping_all_server">Проверить задержку профилей</string>
<string
name="title_real_ping_all_server"
>Проверить задержку профилей</string>
<string name="title_user_asset_setting">Файлы ресурсов</string>
<string name="title_sort_by_test_results">Сортировать по результатам теста</string>
<string
name="title_sort_by_test_results"
>Сортировать по результатам теста</string>
<string name="title_filter_config">Фильтр профилей</string>
<string name="filter_config_all">Все</string>
<string name="title_del_duplicate_config_count">Удалено дубликатов профилей: %d</string>
<string
name="title_del_duplicate_config_count"
>Удалено дубликатов профилей: %d</string>
<string name="title_del_config_count">Удалено профилей: %d</string>
<string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_config_count">Экспортировано профилей: %d</string>
<string
name="title_export_config_count"
>Экспортировано профилей: %d</string>
<string name="title_update_config_count">Обновлено профилей: %d</string>
<string name="title_updating">Обновление…</string>
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
<string name="title_update_subscription_no_subscription">Нет подписок</string>
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
<string
name="title_update_subscription_result"
>Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
<string
name="title_update_subscription_no_subscription"
>Нет подписок</string>
<string
name="toast_server_not_found_in_group"
>Выбранный профиль не найден в текущей группе</string>
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
<string name="title_locate_selected_config">Найти выбранный профиль</string>
@@ -312,17 +512,33 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
<string name="routing_settings_title">Маршрутизация</string>
<string name="routing_settings_tips">Введите требуемые домены/IP через запятую</string>
<string
name="routing_settings_tips"
>Введите требуемые домены/IP через запятую</string>
<string name="routing_settings_save">Сохранить</string>
<string name="routing_settings_delete">Очистить</string>
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
<string
name="routing_settings_rule_title"
>Настройка правил маршрутизации</string>
<string name="routing_settings_add_rule">Добавить правило</string>
<string name="routing_settings_import_predefined_rulesets">Импорт набора правил</string>
<string name="routing_settings_import_rulesets_tip">Существующие правила будут удалены. Продолжить?</string>
<string name="routing_settings_import_rulesets_from_clipboard">Импорт правил из буфера обмена</string>
<string name="routing_settings_import_rulesets_from_qrcode">Импорт правил из QR-кода</string>
<string name="routing_settings_export_rulesets_to_clipboard">Экспорт правил в буфер обмена</string>
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
<string
name="routing_settings_import_predefined_rulesets"
>Импорт набора правил</string>
<string
name="routing_settings_import_rulesets_tip"
>Существующие правила будут удалены. Продолжить?</string>
<string
name="routing_settings_import_rulesets_from_clipboard"
>Импорт правил из буфера обмена</string>
<string
name="routing_settings_import_rulesets_from_qrcode"
>Импорт правил из QR-кода</string>
<string
name="routing_settings_export_rulesets_to_clipboard"
>Экспорт правил в буфер обмена</string>
<string
name="routing_settings_locked"
>Постоянное (сохранится при импорте правил)</string>
<string name="routing_settings_domain">Домен</string>
<string name="routing_settings_ip">IP</string>
<string name="routing_settings_port">Порт</string>
@@ -335,44 +551,71 @@
<string name="connection_test_pending">Проверить соединение</string>
<string name="connection_test_testing">Проверка…</string>
<string name="connection_test_testing_count">Проверка профилей (%d)</string>
<string name="connection_test_available">Успешно: соединение заняло %d мс</string>
<string name="connection_test_error">Сбой проверки интернет-соединения: %s</string>
<string name="connection_test_available">%d мс</string>
<string
name="connection_test_error"
>Сбой проверки интернет-соединения: %s</string>
<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_runing_task_left">Запущено проверок: %s</string>
<string name="connection_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>
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
<string
name="import_subscription_failure"
>Невозможно импортировать подписку</string>
<string name="title_fragment_settings">Настройки фрагментирования</string>
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
<string name="title_pref_fragment_length">Длина фрагмента (от - до)</string>
<string name="title_pref_fragment_interval">Интервал фрагментов (от - до)</string>
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
<string
name="title_pref_fragment_interval"
>Интервал фрагментов (от - до)</string>
<string
name="title_pref_fragment_enabled"
>Использовать фрагментирование</string>
<string name="update_check_for_update">Проверить обновление</string>
<string name="update_already_latest_version">Установлена последняя версия</string>
<string
name="update_already_latest_version"
>Установлена последняя версия</string>
<string name="update_new_version_found">Найдена новая версия: %s</string>
<string name="update_now">Обновить</string>
<string name="update_check_pre_release">Искать предварительный выпуск</string>
<string
name="update_check_pre_release"
>Искать предварительный выпуск</string>
<string name="update_checking_for_update">Проверка обновления…</string>
<string name="title_policy_group_type">Тип группы политик</string>
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Название фильтра</string>
<string
name="title_policy_group_subscription_id"
>Из группы подписки</string>
<string
name="title_policy_group_subscription_filter"
>Название фильтра</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Резервное копирование</string>
<string name="title_configuration_backup">Резервирование конфигурации</string>
<string name="title_configuration_restore">Восстановление конфигурации</string>
<string
name="title_configuration_backup_restore"
>Резервное копирование</string>
<string
name="title_configuration_backup"
>Резервирование конфигурации</string>
<string
name="title_configuration_restore"
>Восстановление конфигурации</string>
<string name="title_configuration_share">Поделиться конфигурацией</string>
<string name="title_webdav_config_setting">Настройки WebDAV</string>
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
<string
name="title_webdav_config_setting_unknown"
>Необходимо настроить WebDAV</string>
<string name="title_webdav_url">URL сервера</string>
<string name="title_webdav_user">Пользователь</string>
<string name="title_webdav_pass">Пароль</string>
<string name="title_webdav_remote_path">Удалённый путь (необязательно)</string>
<string
name="title_webdav_remote_path"
>Удалённый путь (необязательно)</string>
<string-array name="share_method">
<item>QR-код</item>
@@ -435,4 +678,16 @@
<item>WebDAV</item>
</string-array>
<!-- Donate dialog -->
<string name="donate_dialog_title">Поддержите нас</string>
<string name="donate_label_card">Карта</string>
<string name="donate_label_ton">TON</string>
<string name="donate_label_trc20">TRC20</string>
<string name="donate_label_btc">BTC</string>
<string name="donate_copy">Копировать</string>
<string name="donate_open_link">Открыть</string>
<string name="donate_btn_dont_show">Не показывать</string>
<string name="donate_btn_postpone">Позже</string>
<string name="donate_toast_copied">Скопировано</string>
</resources>
+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>
+36 -23
View File
@@ -1,54 +1,67 @@
<?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="status_failure">#E53935</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>
+356 -101
View File
@@ -1,22 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<?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>
<string
name="app_tile_first_use"
>First use of this feature, please use the app to add server</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="migration_success">Data migration success!</string>
<string name="drawer_forked_text" translatable="false">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
<string name="drawer_developed_text" translatable="false">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
<string name="drawer_forked_text" translatable="false">forked from <a
href="https://github.com/2dust/v2rayng"
>V2RayNG</a></string>
<string
name="drawer_developed_text"
translatable="false"
>developed by developers from <a
href="https://t.me/openlibrecommunity"
>Olc</a></string>
<string name="action_stop_service">Stop service</string>
<string name="migration_fail">Data migration failed!</string>
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
<!-- Notifications -->
<string name="notification_action_stop_v2ray">Stop</string>
<string name="toast_permission_denied">Unable to obtain the permission</string>
<string name="toast_permission_denied_notification">Unable to obtain the notification permission</string>
<string
name="toast_permission_denied"
>Unable to obtain the permission</string>
<string
name="toast_permission_denied_notification"
>Unable to obtain the notification permission</string>
<string name="notification_action_more">Click for more</string>
<string name="toast_services_start">Start Services</string>
<string name="toast_services_stop">Stop Services</string>
@@ -30,19 +43,31 @@
<string name="menu_item_edit_config">Edit config</string>
<string name="menu_item_del_config">Delete config</string>
<string name="menu_item_import_config_qrcode">Import from QRcode</string>
<string name="menu_item_import_config_clipboard">Import from Clipboard</string>
<string
name="menu_item_import_config_clipboard"
>Import from Clipboard</string>
<string name="menu_item_import_config_local">Import from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string
name="menu_item_import_config_policy_group"
>Add [Policy group]</string>
<string name="menu_item_import_config_manually_vmess">Add [VMess]</string>
<string name="menu_item_import_config_manually_vless">Add [VLESS]</string>
<string name="menu_item_import_config_manually_ss">Add [Shadowsocks]</string>
<string
name="menu_item_import_config_manually_ss"
>Add [Shadowsocks]</string>
<string name="menu_item_import_config_manually_socks">Add [SOCKS]</string>
<string name="menu_item_import_config_manually_http">Add [HTTP]</string>
<string name="menu_item_import_config_manually_trojan">Add [Trojan]</string>
<string name="menu_item_import_config_manually_wireguard">Add [Wireguard]</string>
<string name="menu_item_import_config_manually_hysteria2">Add [Hysteria2]</string>
<string
name="menu_item_import_config_manually_wireguard"
>Add [Wireguard]</string>
<string
name="menu_item_import_config_manually_hysteria2"
>Add [Hysteria2]</string>
<string name="del_config_comfirm">Confirm delete ?</string>
<string name="del_invalid_config_comfirm">Please test before deleting! Confirm delete ?</string>
<string
name="del_invalid_config_comfirm"
>Please test before deleting! Confirm delete ?</string>
<string name="server_lab_remarks">remarks</string>
<string name="server_lab_address">address</string>
<string name="server_lab_port">port</string>
@@ -70,7 +95,10 @@
<string name="server_lab_path_kcp">kcp seed</string>
<string name="server_lab_path_grpc">gRPC serviceName</string>
<string name="server_lab_stream_security">TLS</string>
<string name="server_lab_stream_fingerprint" translatable="false">Fingerprint</string>
<string
name="server_lab_stream_fingerprint"
translatable="false"
>Fingerprint</string>
<string name="server_lab_stream_alpn">Alpn</string>
<string name="server_lab_allow_insecure">allowInsecure</string>
<string name="server_lab_sni">SNI</string>
@@ -88,8 +116,12 @@
<string name="server_lab_spider_x">SpiderX</string>
<string name="server_lab_mldsa65_verify">Mldsa65Verify</string>
<string name="server_lab_secret_key">SecretKey</string>
<string name="server_lab_reserved">Reserved(Optional, separated by commas)</string>
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
<string
name="server_lab_reserved"
>Reserved(Optional, separated by commas)</string>
<string
name="server_lab_local_address"
>Local address (optional IPv4/IPv6, separated by commas)</string>
<string name="server_lab_local_mtu">Mtu(optional, default 1420)</string>
<string name="toast_success">Success</string>
<string name="toast_failure">Failure</string>
@@ -97,31 +129,53 @@
<string name="toast_incorrect_protocol">Incorrect protocol</string>
<string name="toast_decoding_failed">Decoding failed</string>
<string name="title_file_chooser">Select a config</string>
<string name="toast_require_file_manager">Please install a File Manager.</string>
<string
name="toast_require_file_manager"
>Please install a File Manager.</string>
<string name="server_customize_config">Customize config</string>
<string name="toast_config_file_invalid">Invalid config</string>
<string name="server_lab_content">Content</string>
<string name="toast_none_data_clipboard">There is no data in the clipboard</string>
<string
name="toast_none_data_clipboard"
>There is no data in the clipboard</string>
<string name="toast_invalid_url">Invalid URL</string>
<string name="toast_insecure_url_protocol">Please do not use the insecure HTTP protocol subscription address</string>
<string name="server_lab_need_inbound">Ensure inbounds port is consistent with the settings</string>
<string
name="toast_insecure_url_protocol"
>Please do not use the insecure HTTP protocol subscription address</string>
<string
name="server_lab_need_inbound"
>Ensure inbounds port is consistent with the settings</string>
<string name="toast_malformed_josn">Config malformed</string>
<string name="server_lab_request_host6">Host(SNI)(Optional)</string>
<string name="toast_action_not_allowed">Action not allowed</string>
<string name="server_obfs_password">Obfs password</string>
<string name="server_lab_port_hop">Port Hopping(will override the port)</string>
<string
name="server_lab_port_hop"
>Port Hopping(will override the port)</string>
<string name="server_lab_port_hop_interval">Port Hopping Interval</string>
<string name="server_lab_bandwidth_down">Bandwidth down (Supported units: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Bandwidth up (Supported units: k/m/g/t)</string>
<string
name="server_lab_bandwidth_down"
>Bandwidth down (Supported units: k/m/g/t)</string>
<string
name="server_lab_bandwidth_up"
>Bandwidth up (Supported units: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">XHTTP Mode</string>
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string
name="server_lab_xhttp_extra"
>XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string
name="server_lab_final_mask"
>finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<string
name="server_lab_pinned_ca256"
>Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">File copy failed, please use File Manager</string>
<string
name="toast_asset_copy_failed"
>File copy failed, please use File Manager</string>
<string name="menu_item_add_asset">Add asset</string>
<string name="menu_item_add_file">Add files</string>
<string name="menu_item_add_url">Add URL</string>
@@ -144,8 +198,11 @@
<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>
@@ -153,22 +210,44 @@
<string name="title_core_settings">Core Settings</string>
<string name="title_vpn_settings">VPN Settings</string>
<string name="title_pref_per_app_proxy">Per-app proxy</string>
<string name="summary_pref_per_app_proxy">General: Checked apps use proxy, unchecked apps connect directly; \nBypass mode: checked apps connect directly, unchecked apps use proxy. \nThe option to automatically select proxy applications is in the menu</string>
<string
name="summary_pref_per_app_proxy"
>General: Checked apps use proxy, unchecked apps connect directly; \nBypass mode: checked apps connect directly, unchecked apps use proxy. \nThe option to automatically select proxy applications is in the menu</string>
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string
name="summary_pref_is_booted"
>Automatically connects to the selected server at startup, which may be unsuccessful</string>
<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_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="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>
<string name="title_pref_mux_concurency">TCP connectionsrange -1 to 1024</string>
<string name="title_pref_mux_xudp_concurency">XUDP connectionsrange -1 to 1024</string>
<string name="title_pref_mux_xudp_quic">Handling of QUIC in mux tunnel</string>
<string
name="summary_pref_mux_enabled"
>Faster, but it may cause unstable connectivity\nCustomize how to handle TCP, UDP and QUIC below</string>
<string
name="title_pref_mux_concurency"
>TCP connectionsrange -1 to 1024</string>
<string
name="title_pref_mux_xudp_concurency"
>XUDP connectionsrange -1 to 1024</string>
<string
name="title_pref_mux_xudp_quic"
>Handling of QUIC in mux tunnel</string>
<string-array name="mux_xudp_quic_entries">
<item>reject</item>
<item>allow</item>
@@ -176,30 +255,46 @@
</string-array>
<string name="title_pref_speed_enabled">Enable speed display</string>
<string name="summary_pref_speed_enabled">Display current speed in the notification.\nNotification icon would change based on
<string
name="summary_pref_speed_enabled"
>Display current speed in the notification.\nNotification icon would change based on
usage.</string>
<string name="title_pref_sniffing_enabled">Enable Sniffing</string>
<string name="summary_pref_sniffing_enabled">Try sniff domain from the packet (default on)</string>
<string
name="summary_pref_sniffing_enabled"
>Try sniff domain from the packet (default on)</string>
<string name="title_pref_route_only_enabled">Enable routeOnly</string>
<string name="summary_pref_route_only_enabled">Use the sniffed domain name for routing only, and keep the target address as the IP address.</string>
<string
name="summary_pref_route_only_enabled"
>Use the sniffed domain name for routing only, and keep the target address as the IP address.</string>
<string name="title_pref_local_dns_enabled">Enable local DNS</string>
<string name="summary_pref_local_dns_enabled">DNS processed by cores DNS module (Recommended if you need routing bypassing LAN and mainland addresses)</string>
<string
name="summary_pref_local_dns_enabled"
>DNS processed by cores DNS module (Recommended if you need routing bypassing LAN and mainland addresses)</string>
<string name="title_pref_fake_dns_enabled">Enable fake DNS</string>
<string name="summary_pref_fake_dns_enabled">Local DNS returns fake IP addresses (faster, but it may not work for some apps)</string>
<string
name="summary_pref_fake_dns_enabled"
>Local DNS returns fake IP addresses (faster, but it may not work for some apps)</string>
<string name="title_pref_prefer_ipv6">Prefer IPv6</string>
<string name="summary_pref_prefer_ipv6">Enable IPv6 routes and Prefer IPv6 addresses</string>
<string
name="summary_pref_prefer_ipv6"
>Enable IPv6 routes and Prefer IPv6 addresses</string>
<string name="title_pref_remote_dns">Remote DNS (udp/tcp/https/quic)(Optional)</string>
<string
name="title_pref_remote_dns"
>Remote DNS (udp/tcp/https/quic)(Optional)</string>
<string name="summary_pref_remote_dns">DNS</string>
<string name="title_pref_vpn_dns">VPN DNS (only IPv4/v6)</string>
<string name="title_pref_vpn_bypass_lan">Does VPN bypass LAN</string>
<string name="title_pref_vpn_interface_address">VPN Interface Address</string>
<string
name="title_pref_vpn_interface_address"
>VPN Interface Address</string>
<string name="title_pref_vpn_mtu">VPN MTU (default 1500)</string>
@@ -207,21 +302,33 @@
<string name="title_pref_domestic_dns">Domestic DNS (Optional)</string>
<string name="summary_pref_domestic_dns">DNS</string>
<string name="title_pref_dns_hosts">DNS hosts (Format: domain:address,…)</string>
<string
name="title_pref_dns_hosts"
>DNS hosts (Format: domain:address,…)</string>
<string name="summary_pref_dns_hosts">domain:address,…</string>
<string name="title_pref_delay_test_url">True delay test url </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string
name="title_pref_ip_api_url"
>Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
<string name="title_pref_proxy_sharing_enabled">Allow connections from the LAN</string>
<string name="summary_pref_proxy_sharing_enabled">Other devices can connect to proxy by your IP address through local proxy. Only enable in trusted networks to avoid unauthorized connections</string>
<string name="toast_warning_pref_proxysharing_short">Allow connections from the LAN. Make sure you are in a trusted network</string>
<string
name="title_pref_proxy_sharing_enabled"
>Allow connections from the LAN</string>
<string
name="summary_pref_proxy_sharing_enabled"
>Other devices can connect to proxy by your IP address through local proxy. Only enable in trusted networks to avoid unauthorized connections</string>
<string
name="toast_warning_pref_proxysharing_short"
>Allow connections from the LAN. Make sure you are in a trusted network</string>
<string name="title_pref_allow_insecure">allowInsecure</string>
<string name="summary_pref_allow_insecure">When TLS is selected, allow insecure connections by default</string>
<string
name="summary_pref_allow_insecure"
>When TLS is selected, allow insecure connections by default</string>
<string name="title_pref_socks_port">Local proxy port</string>
<string name="summary_pref_socks_port">Local proxy port</string>
@@ -230,23 +337,41 @@
<string name="summary_pref_local_dns_port">Local DNS port</string>
<string name="title_pref_confirm_remove">Delete config confirmation</string>
<string name="summary_pref_confirm_remove">Whether deleting a config requires a second confirmation by the user</string>
<string
name="summary_pref_confirm_remove"
>Whether deleting a config requires a second confirmation by the user</string>
<string name="title_pref_start_scan_immediate">Start scanning immediately</string>
<string name="summary_pref_start_scan_immediate">Open the camera to scan immediately at startup, otherwise you can choose to scan the code or select a photo in the toolbar</string>
<string
name="title_pref_start_scan_immediate"
>Start scanning immediately</string>
<string
name="summary_pref_start_scan_immediate"
>Open the camera to scan immediately at startup, otherwise you can choose to scan the code or select a photo in the toolbar</string>
<string name="title_pref_append_http_proxy">Append HTTP Proxy to VPN</string>
<string name="summary_pref_append_http_proxy">HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+)</string>
<string
name="title_pref_append_http_proxy"
>Append HTTP Proxy to VPN</string>
<string
name="summary_pref_append_http_proxy"
>HTTP proxy will be used directly from (browser/ some supported apps), without going through the virtual NIC device (Android 10+)</string>
<string name="title_pref_double_column_display">Enable double column display</string>
<string name="summary_pref_double_column_display">The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string
name="title_pref_double_column_display"
>Enable double column display</string>
<string
name="summary_pref_double_column_display"
>The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.</string>
<string name="title_pref_group_all_display">Enable show all groups</string>
<string name="summary_pref_group_all_display">Add an extra "All Tabs" page</string>
<string
name="summary_pref_group_all_display"
>Add an extra "All Tabs" page</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Feedback</string>
<string name="summary_pref_feedback">Feedback enhancements or bugs to GitHub</string>
<string
name="summary_pref_feedback"
>Feedback enhancements or bugs to GitHub</string>
<string name="summary_pref_tg_group">Join Telegram Group</string>
<string name="toast_tg_app_not_found">Telegram app not found</string>
<string name="title_privacy_policy">Privacy policy</string>
@@ -258,22 +383,47 @@
<string name="title_pref_promotion">Promotion</string>
<string name="title_pref_auto_update_subscription">Automatic update subscriptions</string>
<string name="summary_pref_auto_update_subscription">Update your subscriptions automatically at set intervals in the background. Depending on the device, this feature may not always work</string>
<string name="title_pref_auto_update_interval">Auto Update Interval (Minutes, Min value 15)</string>
<string
name="title_pref_auto_update_subscription"
>Automatic update subscriptions</string>
<string
name="summary_pref_auto_update_subscription"
>Update your subscriptions automatically at set intervals in the background. Depending on the device, this feature may not always work</string>
<string
name="title_pref_auto_update_interval"
>Auto Update Interval (Minutes, Min value 15)</string>
<string name="title_core_loglevel">Log Level</string>
<string name="title_outbound_domain_resolve_method">Outbound domain pre-resolve method</string>
<string
name="title_outbound_domain_resolve_method"
>Outbound domain pre-resolve method</string>
<string name="title_mode">Mode</string>
<string name="title_mode_help">Click me for more help</string>
<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="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="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>
@@ -290,9 +440,13 @@
<string name="sub_setting_enable">Enable update</string>
<string name="sub_auto_update">Enable automatic update</string>
<string name="sub_allow_insecure_url">Allow insecure HTTP address</string>
<string name="sub_setting_pre_profile">Previous proxy config remarks</string>
<string
name="sub_setting_pre_profile"
>Previous proxy config remarks</string>
<string name="sub_setting_next_profile">Next proxy config remarks</string>
<string name="sub_setting_pre_profile_tip">The config remarks exist and are unique</string>
<string
name="sub_setting_pre_profile_tip"
>The config remarks exist and are unique</string>
<string name="title_sub_update">Update subscription</string>
<string name="title_ping_all_server">Tcping config</string>
<string name="title_real_ping_all_server">Real delay config</string>
@@ -300,17 +454,29 @@
<string name="title_sort_by_test_results">Sorting by test results</string>
<string name="title_filter_config">Filter config</string>
<string name="filter_config_all">All</string>
<string name="title_del_duplicate_config_count">Delete %d duplicate configs</string>
<string
name="title_del_duplicate_config_count"
>Delete %d duplicate configs</string>
<string name="title_del_config_count">Delete %d configs</string>
<string name="title_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %d configs</string>
<string name="title_update_config_count">Update %d configs</string>
<string name="title_updating">Updating…</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="title_locate_selected_config">Locate the selected config</string>
<string
name="title_update_subscription_result"
>Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string
name="title_update_subscription_no_subscription"
>No subscriptions</string>
<string
name="toast_server_not_found_in_group"
>Selected server not found in current group</string>
<string
name="toast_fragment_not_available"
>Unable to locate current view</string>
<string
name="title_locate_selected_config"
>Locate the selected config</string>
<string name="tasker_start_service">Start Service</string>
<string name="tasker_setting_confirm">Confirm</string>
@@ -318,63 +484,120 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Domain strategy</string>
<string name="routing_settings_title">Routing Settings</string>
<string name="routing_settings_tips">Separated by commas(,), choose domain or ip</string>
<string
name="routing_settings_tips"
>Separated by commas(,), choose domain or ip</string>
<string name="routing_settings_save">Save</string>
<string name="routing_settings_delete">Clear</string>
<string name="routing_settings_rule_title">Routing Rule Settings</string>
<string name="routing_settings_add_rule">Add rule</string>
<string name="routing_settings_import_predefined_rulesets">Import predefined rulesets</string>
<string name="routing_settings_import_rulesets_tip">Existing rulesets will be deleted, are you sure to continue?</string>
<string name="routing_settings_import_rulesets_from_clipboard">Import ruleset from clipboard</string>
<string name="routing_settings_import_rulesets_from_qrcode">Import ruleset from QRcode</string>
<string name="routing_settings_export_rulesets_to_clipboard">Export ruleset to clipboard</string>
<string name="routing_settings_locked">Locked, keep this rule when import presets</string>
<string
name="routing_settings_import_predefined_rulesets"
>Import predefined rulesets</string>
<string
name="routing_settings_import_rulesets_tip"
>Existing rulesets will be deleted, are you sure to continue?</string>
<string
name="routing_settings_import_rulesets_from_clipboard"
>Import ruleset from clipboard</string>
<string
name="routing_settings_import_rulesets_from_qrcode"
>Import ruleset from QRcode</string>
<string
name="routing_settings_export_rulesets_to_clipboard"
>Export ruleset to clipboard</string>
<string
name="routing_settings_locked"
>Locked, keep this rule when import presets</string>
<string name="routing_settings_domain" translatable="false">domain</string>
<string name="routing_settings_ip" translatable="false">ip</string>
<string name="routing_settings_port" translatable="false">port</string>
<string name="routing_settings_protocol" translatable="false">protocol</string>
<string name="routing_settings_protocol_tip" translatable="false">[http,tls,bittorrent]</string>
<string name="routing_settings_network" translatable="false">network</string>
<string name="routing_settings_network_tip" translatable="false">[udp|tcp]</string>
<string name="routing_settings_outbound_tag" translatable="false">outboundTag</string>
<string
name="routing_settings_protocol"
translatable="false"
>protocol</string>
<string
name="routing_settings_protocol_tip"
translatable="false"
>[http,tls,bittorrent]</string>
<string
name="routing_settings_network"
translatable="false"
>network</string>
<string
name="routing_settings_network_tip"
translatable="false"
>[udp|tcp]</string>
<string
name="routing_settings_outbound_tag"
translatable="false"
>outboundTag</string>
<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_available">Success: Connection took %dms</string>
<string name="connection_test_error">Fail to detect internet connection: %s</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_connected"
>Connected, tap to check connection</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>
<string
name="import_subscription_success"
>Subscription imported Successfully</string>
<string
name="import_subscription_failure"
>Import subscription failed</string>
<string name="title_fragment_settings">Fragment Settings</string>
<string name="title_pref_fragment_packets">Fragment Packets</string>
<string name="title_pref_fragment_length">Fragment Length (min-max)</string>
<string name="title_pref_fragment_interval">Fragment Interval (min-max)</string>
<string
name="title_pref_fragment_interval"
>Fragment Interval (min-max)</string>
<string name="title_pref_fragment_enabled">Enable Fragment</string>
<string name="update_check_for_update">Check for update</string>
<string name="update_already_latest_version">Already on the latest version</string>
<string
name="update_already_latest_version"
>Already on the latest version</string>
<string name="update_new_version_found">New version found: %s</string>
<string name="update_now">Update now</string>
<string name="update_check_pre_release">Check Pre-release</string>
<string name="update_checking_for_update">Checking for update…</string>
<string name="update_downloading">Downloading update…</string>
<string name="update_download_failed">Download failed</string>
<string name="update_install_permission_required">Allow installing unknown apps to update, then tap Update now again</string>
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string
name="title_policy_group_subscription_id"
>From subscription group</string>
<string
name="title_policy_group_subscription_filter"
>Remarks regular filter</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string
name="title_configuration_backup_restore"
>Backup &amp; Restore</string>
<string name="title_configuration_backup">Backup config</string>
<string name="title_configuration_restore">Restore config</string>
<string name="title_configuration_share">Share config</string>
<string name="title_webdav_config_setting">WebDAV Settings</string>
<string name="title_webdav_config_setting_unknown">Please configure WebDAV first.</string>
<string
name="title_webdav_config_setting_unknown"
>Please configure WebDAV first.</string>
<string name="title_webdav_url">WebDAV server URL</string>
<string name="title_webdav_user">Username</string>
<string name="title_webdav_pass">Password</string>
@@ -442,4 +665,36 @@
<item>WebDAV</item>
</string-array>
<!-- Donate dialog -->
<string name="donate_dialog_title">Поддержите нас</string>
<string name="donate_label_card">Карта</string>
<string name="donate_label_ton">TON</string>
<string name="donate_label_trc20">TRC20</string>
<string name="donate_label_btc">BTC</string>
<string
name="donate_card_url"
translatable="false"
>pay.cloudtips.ru/p/28c476e5</string>
<string
name="donate_addr_ton"
translatable="false"
>UQD_Qc2cxLGe1P4wANi46cKdEvvzyJRrJTYPvGX2KAZDnsDh</string>
<string
name="donate_addr_trc20"
translatable="false"
>TYQqdACH5PrScvsMowSyS8JjaaF5wvFf5Q</string>
<string
name="donate_addr_btc"
translatable="false"
>bc1qvw0ts0jk5e5dfj9fdez76j9ck95lqz04fpf02a</string>
<string name="donate_copy">Копировать</string>
<string name="donate_open_link">Открыть</string>
<string
name="donate_card_link_url"
translatable="false"
>https://pay.cloudtips.ru/p/28c476e5</string>
<string name="donate_btn_dont_show">Не показывать</string>
<string name="donate_btn_postpone">Позже</string>
<string name="donate_toast_copied">Скопировано</string>
</resources>
+85 -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,29 @@
<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>
<!-- Dialog corner shape — flatter than M3 default (28dp) -->
<style name="ShapeAppearance.App.Dialog" parent="ShapeAppearance.Material3.Corner.ExtraLarge">
<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 +97,19 @@
<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>
<!-- Flatter corners -->
<item name="shapeAppearanceCornerExtraLarge">@style/ShapeAppearance.App.Dialog</item>
<!-- Make dialog background match app surface instead of standing out -->
<item name="colorSurfaceContainerHigh">?attr/colorSurface</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 +121,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">14sp</item>
</style>
</resources>
+19 -1
View File
@@ -34,6 +34,12 @@
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"
@@ -50,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">
@@ -298,4 +316,4 @@
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen>
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">olcNG</string>
<string name="app_name" translatable="false">olcng</string>
</resources>
+9 -7
View File
@@ -2,17 +2,17 @@
agp = "9.1.0"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8"
kotlin = "2.1.0"
coreKtx = "1.17.0"
kotlin = "2.2.0"
coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
activity = "1.12.4"
material = "1.14.0"
activity = "1.13.0"
constraintlayout = "2.2.1"
mmkvStatic = "1.3.16"
gson = "2.13.2"
gson = "2.14.0"
okhttp = "5.3.2"
quickieFoss = "1.14.0"
kotlinxCoroutinesAndroid = "1.10.2"
@@ -21,10 +21,11 @@ swiperefreshlayout = "1.2.0"
toasty = "1.5.2"
editorkit = "2.9.0"
core = "3.5.4"
workRuntimeKtx = "2.11.1"
workRuntimeKtx = "2.11.2"
lifecycleViewmodelKtx = "2.10.0"
multidex = "2.0.1"
mockitoMockitoInline = "5.2.0"
mockitoKotlin = "5.4.0"
flexbox = "3.0.0"
preferenceKtx = "1.2.1"
recyclerview = "1.4.0"
@@ -60,12 +61,13 @@ lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx",
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" }
multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }
org-mockito-mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoMockitoInline" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoMockitoInline" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" }
recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
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" }
Binary file not shown.
+5 -2
View File
@@ -1,6 +1,9 @@
#Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+179 -116
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
#!/bin/sh
#
# Copyright 2015 the original author or authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,81 +15,114 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
##
## Gradle start up script for UN*X
##
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
MAX_FD=maximum
warn () {
echo "$*"
}
} >&2
die () {
echo
echo "$*"
echo
exit 1
}
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82 -89
View File
@@ -1,89 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+7 -1
View File
@@ -10,8 +10,14 @@ __base="$(basename "${__file}" .sh)"
trap 'echo -e "Aborted, error $? in command: $BASH_COMMAND"; trap ERR; exit 1' ERR INT
export ANDROID_HOME=${ANDROID_HOME:-/opt/android-sdk}
export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk}
if [[ ! -d $ANDROID_HOME ]]; then
export ANDROID_HOME=$HOME/android-sdk
fi
export NDK_HOME=${NDK_HOME:-$ANDROID_HOME/ndk/25.2.9519653}
if [[ ! -d $NDK_HOME ]]; then
export NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653
fi
if [[ ! -d $NDK_HOME ]]; then
echo "Android NDK: NDK_HOME not found. please set env \$NDK_HOME"