Initial v1.1.8 Commits
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
const val UPDATE_CHECK_NEVER = -1
|
||||
const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 12
|
||||
const val UPDATE_DIALOG_ACTION_POSTPONED = "postponed"
|
||||
const val UPDATE_DIALOG_ACTION_UPDATE = "update"
|
||||
|
||||
private const val UPDATE_LOG_TAG = "WDTT"
|
||||
private const val GITHUB_RELEASES_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases?per_page=30"
|
||||
private const val GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases/latest"
|
||||
private const val GITHUB_LATEST_RELEASE_WEB_URL = "https://github.com/amurcanov/proxy-turn-vk-android/releases/latest"
|
||||
private const val GITHUB_RELEASE_TAG_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/releases/tag/"
|
||||
private const val GITHUB_TAGS_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/tags?per_page=100"
|
||||
private const val GITHUB_TAG_TREE_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/tree/"
|
||||
private const val GITHUB_API_RATE_LIMIT_FALLBACK_MS = 30L * 60L * 1000L
|
||||
private val VERSION_NUMBER_REGEX = Regex("\\d+(?:\\.\\d+)*")
|
||||
|
||||
@Volatile
|
||||
private var githubApiCooldownUntilMs = 0L
|
||||
|
||||
fun updateIntervalHoursToMillis(hours: Int): Long? = when {
|
||||
hours <= 0 -> null
|
||||
else -> hours * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
data class AppReleaseInfo(
|
||||
val versionTag: String,
|
||||
val releaseUrl: String,
|
||||
val source: RemoteVersionSource
|
||||
)
|
||||
|
||||
enum class RemoteVersionSource {
|
||||
Release,
|
||||
Tag
|
||||
}
|
||||
|
||||
suspend fun fetchLatestReleaseInfo(localVersion: String? = null): AppReleaseInfo? = withContext(Dispatchers.IO) {
|
||||
val latestRelease = fetchReleaseFromLatestWebRedirect()
|
||||
?: fetchReleaseFromLatestEndpoint()
|
||||
?: fetchLatestStableReleaseFromList()
|
||||
val latestTag = fetchLatestTagFromList()
|
||||
|
||||
when {
|
||||
latestRelease == null -> latestTag
|
||||
latestTag == null -> latestRelease
|
||||
isNewerVersion(latestRelease.versionTag, latestTag.versionTag) -> latestTag
|
||||
else -> latestRelease
|
||||
}
|
||||
}
|
||||
|
||||
fun isNewerVersion(local: String, remote: String): Boolean {
|
||||
val localParts = versionParts(local)
|
||||
val remoteParts = versionParts(remote)
|
||||
if (remoteParts.isEmpty()) return false
|
||||
|
||||
val maxLen = maxOf(localParts.size, remoteParts.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val localPart = localParts.getOrElse(i) { 0 }
|
||||
val remotePart = remoteParts.getOrElse(i) { 0 }
|
||||
if (remotePart > localPart) return true
|
||||
if (remotePart < localPart) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun fetchLatestStableReleaseFromList(): AppReleaseInfo? {
|
||||
val response = fetchGitHubApi(GITHUB_RELEASES_URL) ?: return null
|
||||
val releases = try {
|
||||
JSONArray(response)
|
||||
} catch (e: Exception) {
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse releases list", e)
|
||||
return null
|
||||
}
|
||||
|
||||
var bestRelease: AppReleaseInfo? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
val json = releases.optJSONObject(i) ?: continue
|
||||
if (json.optBoolean("draft") || json.optBoolean("prerelease")) continue
|
||||
val release = json.toAppReleaseInfo() ?: continue
|
||||
if (bestRelease == null || isNewerVersion(bestRelease.versionTag, release.versionTag)) {
|
||||
bestRelease = release
|
||||
}
|
||||
}
|
||||
return bestRelease
|
||||
}
|
||||
|
||||
private fun fetchLatestTagFromList(): AppReleaseInfo? {
|
||||
val response = fetchGitHubApi(GITHUB_TAGS_URL) ?: return null
|
||||
val tags = try {
|
||||
JSONArray(response)
|
||||
} catch (e: Exception) {
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse tags list", e)
|
||||
return null
|
||||
}
|
||||
|
||||
var bestTag: AppReleaseInfo? = null
|
||||
for (i in 0 until tags.length()) {
|
||||
val json = tags.optJSONObject(i) ?: continue
|
||||
val tagName = normalizeVersionTag(json.optString("name"))
|
||||
if (tagName.isBlank()) continue
|
||||
val tag = AppReleaseInfo(
|
||||
versionTag = tagName,
|
||||
releaseUrl = "$GITHUB_TAG_TREE_URL_PREFIX$tagName",
|
||||
source = RemoteVersionSource.Tag
|
||||
)
|
||||
if (bestTag == null || isNewerVersion(bestTag.versionTag, tag.versionTag)) {
|
||||
bestTag = tag
|
||||
}
|
||||
}
|
||||
return bestTag
|
||||
}
|
||||
|
||||
private fun fetchReleaseFromLatestEndpoint(): AppReleaseInfo? {
|
||||
val response = fetchGitHubApi(GITHUB_LATEST_RELEASE_URL) ?: return null
|
||||
val json = try {
|
||||
JSONObject(response)
|
||||
} catch (e: Exception) {
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse latest release", e)
|
||||
return null
|
||||
}
|
||||
return json.toAppReleaseInfo()
|
||||
}
|
||||
|
||||
private fun fetchReleaseFromLatestWebRedirect(): AppReleaseInfo? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = URL(GITHUB_LATEST_RELEASE_WEB_URL).openConnection() as HttpURLConnection
|
||||
applyNoCacheHeaders(conn)
|
||||
conn.instanceFollowRedirects = false
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Accept", "text/html,*/*")
|
||||
conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}")
|
||||
conn.connectTimeout = 8_000
|
||||
conn.readTimeout = 8_000
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val location = conn.getHeaderField("Location")
|
||||
if (!location.isNullOrBlank()) {
|
||||
val releaseUrl = URL(URL(GITHUB_LATEST_RELEASE_WEB_URL), location).toString()
|
||||
val versionTag = extractTagFromReleaseUrl(releaseUrl)
|
||||
if (!versionTag.isNullOrBlank()) {
|
||||
return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release)
|
||||
}
|
||||
}
|
||||
|
||||
if (responseCode in 200..299) {
|
||||
val response = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val versionTag = Regex("/releases/tag/([^\"?#<]+)").find(response)?.groupValues?.getOrNull(1)
|
||||
if (!versionTag.isNullOrBlank()) {
|
||||
return AppReleaseInfo(versionTag, "$GITHUB_RELEASE_TAG_URL_PREFIX$versionTag", RemoteVersionSource.Release)
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback returned $responseCode")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback failed", e)
|
||||
null
|
||||
} finally {
|
||||
conn?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchGitHubApi(url: String): String? {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now < githubApiCooldownUntilMs) return null
|
||||
return fetchHttpText(
|
||||
url = url,
|
||||
sourceLabel = "GitHub API",
|
||||
accept = "application/vnd.github+json",
|
||||
isGitHubApi = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchHttpText(
|
||||
url: String,
|
||||
sourceLabel: String,
|
||||
accept: String,
|
||||
isGitHubApi: Boolean = false
|
||||
): String? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = URL(url).openConnection() as HttpURLConnection
|
||||
applyNoCacheHeaders(conn)
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Accept", accept)
|
||||
if (isGitHubApi) {
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
}
|
||||
conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}")
|
||||
conn.connectTimeout = 8_000
|
||||
conn.readTimeout = 8_000
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
|
||||
val response = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
|
||||
if (responseCode in 200..299) {
|
||||
if (isGitHubApi) githubApiCooldownUntilMs = 0L
|
||||
response
|
||||
} else {
|
||||
if (isGitHubApi) noteGitHubApiCooldown(conn, responseCode, response)
|
||||
Log.w(
|
||||
UPDATE_LOG_TAG,
|
||||
"[WARN] Update check: $sourceLabel returned $responseCode ${response.take(300)}"
|
||||
)
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: $sourceLabel request failed", e)
|
||||
null
|
||||
} finally {
|
||||
conn?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyNoCacheHeaders(conn: HttpURLConnection) {
|
||||
conn.useCaches = false
|
||||
conn.setRequestProperty("Cache-Control", "no-cache, no-store, max-age=0")
|
||||
conn.setRequestProperty("Pragma", "no-cache")
|
||||
conn.setRequestProperty("Expires", "0")
|
||||
}
|
||||
|
||||
private fun noteGitHubApiCooldown(conn: HttpURLConnection, responseCode: Int, response: String) {
|
||||
if (responseCode != HttpURLConnection.HTTP_FORBIDDEN && responseCode != 429) return
|
||||
val now = System.currentTimeMillis()
|
||||
val retryAfterUntil = conn.getHeaderField("Retry-After")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { now + it * 1000L }
|
||||
val rateLimitResetUntil = conn.getHeaderField("X-RateLimit-Reset")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { it * 1000L }
|
||||
val fallbackUntil = now + if (response.contains("rate limit", ignoreCase = true)) GITHUB_API_RATE_LIMIT_FALLBACK_MS else 5L * 60L * 1000L
|
||||
val cooldownUntil = listOfNotNull(retryAfterUntil, rateLimitResetUntil).filter { it > now }.minOrNull() ?: fallbackUntil
|
||||
if (cooldownUntil > githubApiCooldownUntilMs) {
|
||||
githubApiCooldownUntilMs = cooldownUntil
|
||||
Log.w(
|
||||
UPDATE_LOG_TAG,
|
||||
"[WARN] Update check: GitHub API cooldown ${(cooldownUntil - now) / 1000}s after HTTP $responseCode"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toAppReleaseInfo(): AppReleaseInfo? {
|
||||
val versionTag = normalizeVersionTag(optString("tag_name"))
|
||||
val releaseUrl = optString("html_url").trim()
|
||||
if (versionTag.isBlank() || releaseUrl.isBlank()) return null
|
||||
return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release)
|
||||
}
|
||||
|
||||
private fun versionParts(version: String): List<Int> {
|
||||
val normalized = VERSION_NUMBER_REGEX.find(version.trim())?.value ?: return emptyList()
|
||||
return normalized.split(".").mapNotNull { it.toIntOrNull() }
|
||||
}
|
||||
|
||||
private fun normalizeVersionTag(version: String): String {
|
||||
val trimmed = version.trim()
|
||||
if (trimmed.isBlank()) return ""
|
||||
return if (trimmed.startsWith("v", ignoreCase = true)) trimmed else "v$trimmed"
|
||||
}
|
||||
|
||||
private fun extractTagFromReleaseUrl(releaseUrl: String): String? {
|
||||
val marker = "/releases/tag/"
|
||||
val index = releaseUrl.indexOf(marker)
|
||||
if (index < 0) return null
|
||||
return releaseUrl.substring(index + marker.length)
|
||||
.substringBefore("?")
|
||||
.substringBefore("#")
|
||||
.substringBefore("/")
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::normalizeVersionTag)
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Управляет «невидимым» WebView для автоматического прохождения VK Smart Captcha.
|
||||
*
|
||||
* Один запрос = один свежий WebView:
|
||||
* 1. Создаёт WebView с рандомизированным fingerprint (UA, viewport)
|
||||
* 2. Загружает redirect_uri, ждёт короткую паузу загрузки
|
||||
* 3. Находит чекбокс "Я не робот" (label.vkc__Checkbox-module__Checkbox)
|
||||
* 4. Кликает в рандомную точку внутри label с коротким human-like таймингом
|
||||
* 5. JS-interceptor перехватывает captchaNotRobot.check → success_token
|
||||
* 6. Уничтожает WebView
|
||||
*/
|
||||
object CaptchaWebViewManager {
|
||||
|
||||
private const val TAG = "CaptchaWV"
|
||||
private const val CAPTCHA_TIMEOUT_MS = 10_000L
|
||||
private const val WV_CREATE_TIMEOUT_MS = 3000L
|
||||
const val ERROR_SLIDER_DETECTED = "slider_detected"
|
||||
|
||||
// Рандомизируемые параметры viewport (чтобы VK не видел одинаковый size)
|
||||
private val VIEWPORT_WIDTHS = intArrayOf(356, 358, 360, 362, 364, 366, 368)
|
||||
private val VIEWPORT_HEIGHTS = intArrayOf(376, 378, 380, 382, 384, 386, 388)
|
||||
|
||||
// Пул Chrome-версий (minor builds) для варьирования
|
||||
private val CHROME_BUILDS = arrayOf(
|
||||
"146.0.0.0", "145.0.6422.60", "145.0.6422.53",
|
||||
"144.0.6367.78", "144.0.6367.61", "143.0.6312.99"
|
||||
)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val captchaMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var isTunnelActive = false
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
private val pendingResult = AtomicReference<CompletableDeferred<Result<String>>?>(null)
|
||||
private val postClickSliderWatcher = AtomicReference<Runnable?>(null)
|
||||
|
||||
@Volatile
|
||||
private var currentWebView: WebView? = null
|
||||
|
||||
// Interceptor: перехватывает ответ captchaNotRobot.check → достаёт success_token
|
||||
private val interceptorJSCode = """
|
||||
(function() {
|
||||
if (window.__wdtt_interceptor_installed) return;
|
||||
window.__wdtt_interceptor_installed = true;
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function() {
|
||||
const args = arguments;
|
||||
const url = args[0] || '';
|
||||
if (typeof url === 'string' && url.includes('captchaNotRobot.check')) {
|
||||
const response = await origFetch.apply(this, args);
|
||||
const clone = response.clone();
|
||||
try {
|
||||
const data = await clone.json();
|
||||
if (data.response && data.response.success_token) {
|
||||
window.WdttCaptcha.onSuccess(data.response.success_token);
|
||||
} else if (
|
||||
data.response &&
|
||||
data.response.show_captcha_type === 'slider'
|
||||
) {
|
||||
window.WdttCaptcha.onSliderDetected('check_response');
|
||||
} else if (data.error) {
|
||||
window.WdttCaptcha.onError(JSON.stringify(data.error));
|
||||
}
|
||||
} catch(e) {}
|
||||
return response;
|
||||
}
|
||||
return origFetch.apply(this, args);
|
||||
};
|
||||
|
||||
const origXHROpen = XMLHttpRequest.prototype.open;
|
||||
const origXHRSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._wdtt_url = url;
|
||||
return origXHROpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
const xhr = this;
|
||||
if (xhr._wdtt_url && xhr._wdtt_url.includes('captchaNotRobot.check')) {
|
||||
xhr.addEventListener('load', function() {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.response && data.response.success_token) {
|
||||
window.WdttCaptcha.onSuccess(data.response.success_token);
|
||||
} else if (
|
||||
data.response &&
|
||||
data.response.show_captcha_type === 'slider'
|
||||
) {
|
||||
window.WdttCaptcha.onSliderDetected('check_response');
|
||||
} else if (data.error) {
|
||||
window.WdttCaptcha.onError(JSON.stringify(data.error));
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
return origXHRSend.apply(this, arguments);
|
||||
};
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
fun onTunnelStart(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
isTunnelActive = true
|
||||
Log.d(TAG, "Туннель активен")
|
||||
}
|
||||
|
||||
fun onTunnelStop() {
|
||||
isTunnelActive = false
|
||||
cancelPendingResult("tunnel stopped")
|
||||
destroyCurrentWebView()
|
||||
appContext = null
|
||||
Log.d(TAG, "Туннель остановлен")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Публичный API
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
suspend fun solveCaptchaAsync(redirectUri: String, sessionToken: String, onStep: (String) -> Unit = {}): String {
|
||||
if (!isTunnelActive) throw IllegalStateException("WV не готов — туннель не активен")
|
||||
val ctx = appContext ?: throw IllegalStateException("WV не готов — контекст null")
|
||||
|
||||
// Используем Mutex вместо AtomicBoolean: если запрашивается вторая капча до закрытия первой,
|
||||
// она просто подождет в очереди (несколько секунд), вместо того чтобы вылетать с ошибкой.
|
||||
return captchaMutex.withLock {
|
||||
try {
|
||||
withTimeout(CAPTCHA_TIMEOUT_MS) {
|
||||
doSolveCaptcha(ctx, redirectUri, onStep)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.set(null)
|
||||
destroyCurrentWebView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Внутренняя логика
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private suspend fun doSolveCaptcha(context: Context, redirectUri: String, onStep: (String) -> Unit): String {
|
||||
val deferred = CompletableDeferred<Result<String>>()
|
||||
pendingResult.set(deferred)
|
||||
|
||||
val webView = createWebViewSync(context, onStep)
|
||||
?: throw IllegalStateException("Не удалось создать WebView")
|
||||
|
||||
Log.d(TAG, "WebView создан ✓")
|
||||
|
||||
// Загружаем страницу капчи
|
||||
withContext(Dispatchers.Main) {
|
||||
webView.evaluateJavascript(interceptorJSCode, null)
|
||||
kotlinx.coroutines.delay(80)
|
||||
webView.loadUrl(redirectUri)
|
||||
}
|
||||
|
||||
// Ждём success_token от JS-bridge
|
||||
try {
|
||||
val token = deferred.await().getOrThrow()
|
||||
Log.d(TAG, "Капча решена ✓")
|
||||
return token
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Ошибка: ${e::class.simpleName} — ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Создание WebView с рандомизированным fingerprint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun createWebViewSync(context: Context, onStep: (String) -> Unit): WebView? {
|
||||
// Рандомизируем параметры для КАЖДОГО запроса
|
||||
val vw = VIEWPORT_WIDTHS[Random.Default.nextInt(VIEWPORT_WIDTHS.size)]
|
||||
val vh = VIEWPORT_HEIGHTS[Random.Default.nextInt(VIEWPORT_HEIGHTS.size)]
|
||||
val chromeBuild = CHROME_BUILDS[Random.Default.nextInt(CHROME_BUILDS.size)]
|
||||
val ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$chromeBuild Safari/537.36"
|
||||
|
||||
Log.d(TAG, "Fingerprint: ${vw}x${vh}, Chrome/$chromeBuild")
|
||||
|
||||
val latch = CountDownLatch(1)
|
||||
var webView: WebView? = null
|
||||
|
||||
val createAction = Runnable {
|
||||
try {
|
||||
val wv = WebView(context.applicationContext)
|
||||
wv.apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
blockNetworkLoads = false
|
||||
cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
|
||||
userAgentString = ua
|
||||
}
|
||||
|
||||
addJavascriptInterface(CaptchaJSBridge(), "WdttCaptcha")
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(
|
||||
view: WebView, url: String?, favicon: android.graphics.Bitmap?
|
||||
) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
view.evaluateJavascript(interceptorJSCode, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
|
||||
val isCaptchaPage = url?.let {
|
||||
it.contains("not_robot_captcha") ||
|
||||
it.contains("id.vk.ru/captcha") ||
|
||||
it.contains("not_robot")
|
||||
} ?: false
|
||||
|
||||
if (isCaptchaPage) {
|
||||
Log.d(TAG, "Страница капчи загружена")
|
||||
view.evaluateJavascript(interceptorJSCode, null)
|
||||
|
||||
if (currentWebView === view && isTunnelActive) {
|
||||
// Быстрый auto-pass: WebView получает такой же короткий темп, как Go v2.
|
||||
val pageLoadDelay = 650L + Random.Default.nextLong(0, 550)
|
||||
mainHandler.postDelayed({
|
||||
if (currentWebView === view && isTunnelActive) {
|
||||
solveCaptchaAutomatedSync(view)
|
||||
}
|
||||
}, pageLoadDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView, request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView,
|
||||
handler: android.webkit.SslErrorHandler,
|
||||
error: android.net.http.SslError
|
||||
) {
|
||||
// Разрешаем только для доверенных доменов VK/OK
|
||||
val url = error.url ?: ""
|
||||
if (url.contains("vk.ru") || url.contains("vk.com") || url.contains("okcdn.ru")) {
|
||||
handler.proceed()
|
||||
} else {
|
||||
handler.cancel()
|
||||
Log.w(TAG, "SSL error rejected for: $url")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = WebChromeClient()
|
||||
|
||||
measure(
|
||||
View.MeasureSpec.makeMeasureSpec(vw, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(vh, View.MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(0, 0, vw, vh)
|
||||
onResume()
|
||||
}
|
||||
webView = wv
|
||||
currentWebView = wv
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Ошибка создания WebView: ${e.message}")
|
||||
webView = null
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
createAction.run()
|
||||
} else {
|
||||
mainHandler.post(createAction)
|
||||
}
|
||||
|
||||
val ok = latch.await(WV_CREATE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
if (!ok) {
|
||||
Log.e(TAG, "Таймаут создания WebView")
|
||||
return null
|
||||
}
|
||||
return webView
|
||||
}
|
||||
|
||||
private fun destroyCurrentWebView() {
|
||||
val wv = currentWebView ?: return
|
||||
currentWebView = null
|
||||
postClickSliderWatcher.getAndSet(null)?.let { mainHandler.removeCallbacks(it) }
|
||||
|
||||
val destroyAction = Runnable {
|
||||
try {
|
||||
wv.stopLoading()
|
||||
wv.loadUrl("about:blank")
|
||||
try { wv.removeJavascriptInterface("WdttCaptcha") } catch (_: Exception) {}
|
||||
wv.webViewClient = WebViewClient()
|
||||
wv.webChromeClient = null
|
||||
wv.onPause()
|
||||
wv.removeAllViews()
|
||||
wv.destroy()
|
||||
Log.d(TAG, "WebView уничтожен ✓")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Ошибка уничтожения: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
destroyAction.run()
|
||||
} else {
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
try { destroyAction.run() } finally { latch.countDown() }
|
||||
}
|
||||
latch.await(2000, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Авто-решение: клик по чекбоксу «Я не робот»
|
||||
//
|
||||
// Структура VK капчи (из HTML):
|
||||
// label.vkc__Checkbox-module__Checkbox ← КЛИКАБЕЛЬНЫЙ label (~200x32px)
|
||||
// input#not-robot-captcha-checkbox ← скрытый checkbox
|
||||
// div.vkc__Checkbox-module__Checkbox__iconBlock ← иконка чекбокса
|
||||
// div.vkc__Checkbox-module__Checkbox__title ← текст "Я не робот"
|
||||
//
|
||||
// Стратегия: находим ВЕСЬ label, получаем его размеры,
|
||||
// кликаем в РАНДОМНУЮ точку внутри него (не в центр).
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private fun solveCaptchaAutomatedSync(webView: WebView) {
|
||||
if (currentWebView !== webView || !isTunnelActive) return
|
||||
|
||||
// Ищем LABEL целиком (он большой, ~200x32px — как человек кликает).
|
||||
// Если вместо checkbox открыт slider/kaleidoscope, скрытый WebView сразу отдаёт fallback ручному WV.
|
||||
val findLabelJS = """
|
||||
(function() {
|
||||
var slider = document.querySelector(
|
||||
'[class*="SliderCaptcha"], [class*="Kaleidoscope"], ' +
|
||||
'.vkc__SliderCaptcha-module__description, ' +
|
||||
'.vkc__KaleidoscopeScreen-module__captchaId'
|
||||
);
|
||||
if (slider) return '${ERROR_SLIDER_DETECTED}';
|
||||
|
||||
// Приоритет: label обёртка (самый большой кликабельный элемент)
|
||||
var el = document.querySelector('label.vkc__Checkbox-module__Checkbox');
|
||||
// Fallback: прямой поиск по ID
|
||||
if (!el) el = document.querySelector('label[for="not-robot-captcha-checkbox"]');
|
||||
// Fallback: сам чекбокс
|
||||
if (!el) el = document.getElementById('not-robot-captcha-checkbox');
|
||||
if (!el) return 'not_found';
|
||||
|
||||
var rect = el.getBoundingClientRect();
|
||||
var style = window.getComputedStyle(el);
|
||||
if (rect.width < 5 || rect.height < 5 ||
|
||||
style.display === 'none' || style.visibility === 'hidden') {
|
||||
return 'not_found';
|
||||
}
|
||||
// Возвращаем left,top,width,height — чтобы кликнуть в РАНДОМНУЮ точку
|
||||
return rect.left + ',' + rect.top + ',' + rect.width + ',' + rect.height;
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
webView.evaluateJavascript(findLabelJS) { rawValue ->
|
||||
val result = rawValue?.replace("\"", "") ?: ""
|
||||
Log.d(TAG, "Label чекбокса: $result")
|
||||
|
||||
if (currentWebView !== webView || !isTunnelActive) return@evaluateJavascript
|
||||
|
||||
if (result == ERROR_SLIDER_DETECTED) {
|
||||
Log.i(TAG, "Обнаружен слайдер — fallback на ручной WebView")
|
||||
notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED)))
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
if (result == "not_found" || result.split(",").size < 4) {
|
||||
// Fallback: JS .click() — не идеально, но лучше чем ничего
|
||||
Log.w(TAG, "Label не найден — JS-клик (fallback)")
|
||||
val jsClick = """
|
||||
(function() {
|
||||
var el = document.querySelector('label.vkc__Checkbox-module__Checkbox');
|
||||
if (!el) el = document.getElementById('not-robot-captcha-checkbox');
|
||||
if (el) { el.click(); return 'clicked'; }
|
||||
return 'nothing';
|
||||
})();
|
||||
""".trimIndent()
|
||||
webView.evaluateJavascript(jsClick) { clickResult ->
|
||||
if ((clickResult ?: "").replace("\"", "") == "clicked") {
|
||||
startPostClickSliderWatcher(webView)
|
||||
}
|
||||
}
|
||||
return@evaluateJavascript
|
||||
}
|
||||
|
||||
val parts = result.split(",")
|
||||
val left = parts[0].toFloatOrNull() ?: return@evaluateJavascript
|
||||
val top = parts[1].toFloatOrNull() ?: return@evaluateJavascript
|
||||
val width = parts[2].toFloatOrNull() ?: return@evaluateJavascript
|
||||
val height = parts[3].toFloatOrNull() ?: return@evaluateJavascript
|
||||
|
||||
// Рандомная точка внутри label (60-90% ширины, 25-75% высоты)
|
||||
// Человек кликает не ровно в центр, а примерно туда
|
||||
val randX = left + width * (0.15f + Random.Default.nextFloat() * 0.7f)
|
||||
val randY = top + height * (0.25f + Random.Default.nextFloat() * 0.5f)
|
||||
|
||||
Log.d(TAG, "Клик: (${randX.toInt()}, ${randY.toInt()}) в зоне ${width.toInt()}x${height.toInt()}")
|
||||
|
||||
val thinkDelay = 420L + Random.Default.nextLong(0, 260)
|
||||
|
||||
mainHandler.postDelayed({
|
||||
if (currentWebView === webView && isTunnelActive) {
|
||||
simulateHumanTouch(webView, randX, randY)
|
||||
startPostClickSliderWatcher(webView)
|
||||
}
|
||||
}, thinkDelay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPostClickSliderWatcher(webView: WebView) {
|
||||
postClickSliderWatcher.getAndSet(null)?.let { mainHandler.removeCallbacks(it) }
|
||||
|
||||
var attemptsLeft = 14
|
||||
val watcher = object : Runnable {
|
||||
override fun run() {
|
||||
if (currentWebView !== webView || !isTunnelActive) return
|
||||
|
||||
val detectSliderJS = """
|
||||
(function() {
|
||||
var slider = document.querySelector(
|
||||
'[class*="SliderCaptcha"], [class*="Kaleidoscope"], ' +
|
||||
'.vkc__SliderCaptcha-module__description, ' +
|
||||
'.vkc__KaleidoscopeScreen-module__captchaId, ' +
|
||||
'.vkc__SwipeButton-module__track'
|
||||
);
|
||||
if (slider) return 'slider';
|
||||
|
||||
var success = document.querySelector(
|
||||
'[class*="success"], [class*="Success"], [class*="passed"], [class*="Passed"]'
|
||||
);
|
||||
if (success) return 'success_ui';
|
||||
|
||||
return 'none';
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
webView.evaluateJavascript(detectSliderJS) { rawValue ->
|
||||
if (currentWebView !== webView || !isTunnelActive) return@evaluateJavascript
|
||||
|
||||
val result = rawValue?.replace("\"", "") ?: "none"
|
||||
when (result) {
|
||||
"slider" -> {
|
||||
Log.i(TAG, "После checkbox появился слайдер — fallback на ручной WebView")
|
||||
notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED)))
|
||||
}
|
||||
"success_ui" -> {
|
||||
postClickSliderWatcher.set(null)
|
||||
}
|
||||
else -> {
|
||||
attemptsLeft--
|
||||
if (attemptsLeft > 0) {
|
||||
mainHandler.postDelayed(this, 350L)
|
||||
} else {
|
||||
postClickSliderWatcher.set(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postClickSliderWatcher.set(watcher)
|
||||
mainHandler.postDelayed(watcher, 450L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Имитирует нативный тач как от пальца:
|
||||
* - ACTION_DOWN с рандомным pressure (0.5-0.9)
|
||||
* - Удержание 80-180мс (как палец на экране)
|
||||
* - ACTION_UP с лёгким смещением (палец дрожит)
|
||||
*/
|
||||
private fun simulateHumanTouch(webView: WebView, cssX: Float, cssY: Float) {
|
||||
if (currentWebView !== webView) return
|
||||
|
||||
val density = webView.resources.displayMetrics.density
|
||||
val physX = cssX * density
|
||||
val physY = cssY * density
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
|
||||
// Рандомный pressure — палец нажимает с разной силой
|
||||
val pressure = 0.5f + Random.Default.nextFloat() * 0.4f
|
||||
|
||||
val downEvent = MotionEvent.obtain(
|
||||
downTime, downTime, MotionEvent.ACTION_DOWN, physX, physY, pressure, 1f, 0, 1f, 1f, 0, 0
|
||||
)
|
||||
downEvent.source = android.view.InputDevice.SOURCE_TOUCHSCREEN
|
||||
webView.dispatchTouchEvent(downEvent)
|
||||
downEvent.recycle()
|
||||
|
||||
// Удержание пальца: 80-180мс
|
||||
val holdTime = 80L + Random.Default.nextLong(0, 100)
|
||||
|
||||
mainHandler.postDelayed({
|
||||
if (currentWebView === webView) {
|
||||
// Лёгкое смещение при отпускании (палец не стоит идеально на месте)
|
||||
val jitterX = physX + (-1f + Random.Default.nextFloat() * 2f) * density
|
||||
val jitterY = physY + (-0.5f + Random.Default.nextFloat() * 1f) * density
|
||||
|
||||
val upEvent = MotionEvent.obtain(
|
||||
downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
|
||||
jitterX, jitterY, 0f, 1f, 0, 1f, 1f, 0, 0
|
||||
)
|
||||
upEvent.source = android.view.InputDevice.SOURCE_TOUCHSCREEN
|
||||
webView.dispatchTouchEvent(upEvent)
|
||||
upEvent.recycle()
|
||||
}
|
||||
}, holdTime)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// JS Bridge — вызывается из JavaScript background thread
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private class CaptchaJSBridge {
|
||||
@JavascriptInterface
|
||||
fun onSuccess(token: String) {
|
||||
Log.d(TAG, "JS: success_token получен (${token.length} символов)")
|
||||
notifyResult(Result.success(token))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onSliderDetected(source: String) {
|
||||
Log.i(TAG, "JS: обнаружен slider после auto-step ($source)")
|
||||
notifyResult(Result.failure(IllegalStateException(ERROR_SLIDER_DETECTED)))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onError(error: String) {
|
||||
Log.e(TAG, "JS: ошибка — $error")
|
||||
notifyResult(Result.failure(Exception("VK: $error")))
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyResult(result: Result<String>) {
|
||||
val deferred = pendingResult.getAndSet(null) ?: return
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelPendingResult(reason: String) {
|
||||
val deferred = pendingResult.getAndSet(null) ?: return
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.failure(CancellationException(reason)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object DeployManager {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val isDeploying = MutableStateFlow(false)
|
||||
val deployProgress = MutableStateFlow(0f)
|
||||
val currentStep = MutableStateFlow("")
|
||||
val lastResult = MutableStateFlow("") // "success", "error: ...", ""
|
||||
|
||||
@Volatile
|
||||
var activeSession: com.jcraft.jsch.Session? = null
|
||||
private var deployStartTime = 0L
|
||||
private var errorsFile: File? = null
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
|
||||
/** Вызвать один раз при старте приложения */
|
||||
fun init(context: Context) {
|
||||
val dir = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
errorsFile = File(dir, "errors.log")
|
||||
}
|
||||
|
||||
fun getErrorsFile(): File? = errorsFile
|
||||
|
||||
/** Записать ошибку в файл (потокобезопасно) */
|
||||
@Synchronized
|
||||
fun writeError(msg: String) {
|
||||
val file = errorsFile ?: return
|
||||
try {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
file.appendText("[$timestamp] $msg\n")
|
||||
// Ротация: если файл > 500 КБ, обрезаем до последних 200 КБ
|
||||
if (file.length() > 500_000) {
|
||||
val text = file.readText()
|
||||
file.writeText(text.takeLast(200_000))
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun startDeploy() {
|
||||
// Автосброс зависшего деплоя > 30 минут
|
||||
if (isDeploying.value && deployStartTime > 0 &&
|
||||
System.currentTimeMillis() - deployStartTime > 30 * 60 * 1000) {
|
||||
writeError("Автосброс: предыдущий деплой завис >30 мин")
|
||||
forceReset()
|
||||
}
|
||||
isDeploying.value = true
|
||||
deployStartTime = System.currentTimeMillis()
|
||||
deployProgress.value = 0f
|
||||
currentStep.value = "Инициализация..."
|
||||
lastResult.value = ""
|
||||
}
|
||||
|
||||
fun stopDeploy(result: String = "") {
|
||||
isDeploying.value = false
|
||||
deployStartTime = 0L
|
||||
if (result.isNotBlank()) lastResult.value = result
|
||||
val session = activeSession
|
||||
activeSession = null
|
||||
try { session?.disconnect() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/** Принудительный сброс — для восстановления из любого состояния */
|
||||
fun forceReset() {
|
||||
val session = activeSession
|
||||
activeSession = null
|
||||
try { session?.disconnect() } catch (_: Exception) {}
|
||||
isDeploying.value = false
|
||||
deployStartTime = 0L
|
||||
deployProgress.value = 0f
|
||||
currentStep.value = ""
|
||||
}
|
||||
|
||||
fun updateProgress(progress: Float, step: String) {
|
||||
deployProgress.value = progress
|
||||
currentStep.value = step
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.GenericShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material.icons.outlined.Cloud
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material.icons.outlined.VpnKey
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.wdtt.client.ui.AppUpdateDialog
|
||||
import com.wdtt.client.ui.FloatingToolbar
|
||||
import com.wdtt.client.ui.LogsTab
|
||||
import com.wdtt.client.ui.SettingsTab
|
||||
import com.wdtt.client.ui.DeployTab
|
||||
import com.wdtt.client.ui.ExceptionsTab
|
||||
import com.wdtt.client.ui.InfoTab
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val vpnLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// VPN permission dialog finished
|
||||
}
|
||||
|
||||
private val batteryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
checkAndRequestVpn()
|
||||
}
|
||||
|
||||
private val notificationLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
checkAndRequestBattery()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var activeActivities = 0
|
||||
var isForeground: Boolean
|
||||
get() = activeActivities > 0
|
||||
set(value) {}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activeActivities++
|
||||
ManlCaptchaWebViewManager.checkAndShowPendingCaptcha(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
activeActivities--
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
checkAndRequestNotifications()
|
||||
|
||||
setContent {
|
||||
val settingsStore = remember { SettingsStore(this) }
|
||||
val themeMode by settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = "system")
|
||||
val isDynamicColor by settingsStore.isDynamicColor.collectAsStateWithLifecycle(initialValue = false)
|
||||
val themePalette by settingsStore.themePalette.collectAsStateWithLifecycle(initialValue = "indigo")
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WDTTTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) {
|
||||
MainScreen(
|
||||
settingsStore = settingsStore,
|
||||
themeMode = themeMode,
|
||||
onThemeChange = { mode ->
|
||||
scope.launch {
|
||||
settingsStore.saveThemeMode(mode)
|
||||
}
|
||||
},
|
||||
isDynamicColor = isDynamicColor,
|
||||
onDynamicColorChange = { enabled ->
|
||||
scope.launch { settingsStore.saveDynamicColor(enabled) }
|
||||
},
|
||||
currentPalette = themePalette,
|
||||
onPaletteChange = { palette ->
|
||||
scope.launch { settingsStore.saveThemePalette(palette) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndRequestNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
checkAndRequestBattery()
|
||||
}
|
||||
} else {
|
||||
checkAndRequestBattery()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndRequestBattery() {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
batteryLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
checkAndRequestVpn()
|
||||
}
|
||||
} else {
|
||||
checkAndRequestVpn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndRequestVpn() {
|
||||
try {
|
||||
val vpnIntent = VpnService.prepare(this)
|
||||
if (vpnIntent != null) {
|
||||
vpnLauncher.launch(vpnIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Навигация ═══
|
||||
|
||||
private data class NavItem(
|
||||
val label: String,
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector,
|
||||
)
|
||||
|
||||
private val navItems = listOf(
|
||||
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
|
||||
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
|
||||
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
|
||||
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
|
||||
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
settingsStore: SettingsStore,
|
||||
themeMode: String = "system",
|
||||
onThemeChange: (String) -> Unit = {},
|
||||
isDynamicColor: Boolean = false,
|
||||
onDynamicColorChange: (Boolean) -> Unit = {},
|
||||
currentPalette: String = "indigo",
|
||||
onPaletteChange: (String) -> Unit = {}
|
||||
) {
|
||||
val unreadErrors by TunnelManager.unreadErrorCount.collectAsStateWithLifecycle()
|
||||
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var dragTargetIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragProgress by remember { mutableFloatStateOf(0f) }
|
||||
val updateCheckIntervalHours by settingsStore.updateCheckIntervalHours.collectAsStateWithLifecycle(
|
||||
initialValue = DEFAULT_UPDATE_CHECK_INTERVAL_HOURS
|
||||
)
|
||||
var pendingRelease by remember { mutableStateOf<AppReleaseInfo?>(null) }
|
||||
val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" }
|
||||
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
|
||||
val navOverlayReserve = safeBottomInset + 96.dp
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
||||
}
|
||||
|
||||
LaunchedEffect(updateCheckIntervalHours) {
|
||||
if (updateCheckIntervalHours == UPDATE_CHECK_NEVER) return@LaunchedEffect
|
||||
|
||||
val intervalMillis = updateIntervalHoursToMillis(updateCheckIntervalHours)
|
||||
?: updateIntervalHoursToMillis(DEFAULT_UPDATE_CHECK_INTERVAL_HOURS)
|
||||
?: 12L * 60L * 60L * 1000L
|
||||
|
||||
suspend fun runUpdateCheck(reason: String) {
|
||||
val checkedAt = System.currentTimeMillis()
|
||||
val release = fetchLatestReleaseInfo(currentVersion)
|
||||
settingsStore.saveUpdateState(
|
||||
lastCheckAt = checkedAt,
|
||||
latestVersion = release?.versionTag ?: "",
|
||||
error = if (release == null) "Не удалось проверить" else ""
|
||||
)
|
||||
|
||||
if (release == null) {
|
||||
Log.w("WDTT", "[WARN] Update check: no release info, local=$currentVersion reason=$reason")
|
||||
return
|
||||
}
|
||||
|
||||
val hasUpdate = isNewerVersion(currentVersion, release.versionTag)
|
||||
val postponeVer = settingsStore.updatePostponeVersion.first()
|
||||
val postponeUntil = settingsStore.updatePostponeUntil.first()
|
||||
val isPostponed = postponeVer == release.versionTag && checkedAt < postponeUntil
|
||||
Log.i(
|
||||
"WDTT",
|
||||
"Update check: local=$currentVersion remote=${release.versionTag} newer=$hasUpdate postponed=$isPostponed reason=$reason"
|
||||
)
|
||||
|
||||
if (hasUpdate && !isPostponed) {
|
||||
settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt)
|
||||
pendingRelease = release
|
||||
}
|
||||
}
|
||||
|
||||
runUpdateCheck("startup")
|
||||
|
||||
while (isActive) {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastCheck = settingsStore.updateLastCheckAt.first()
|
||||
val nextCheckAt = lastCheck + intervalMillis
|
||||
val waitMs = (nextCheckAt - now).coerceAtLeast(intervalMillis)
|
||||
delay(waitMs)
|
||||
if (isActive) {
|
||||
runUpdateCheck("periodic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AppBackdrop(modifier = Modifier.matchParentSize())
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
containerColor = Color.Transparent,
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.pointerInput(selectedTab) {
|
||||
var totalDrag = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = {
|
||||
totalDrag = 0f
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
},
|
||||
onDragEnd = {
|
||||
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) {
|
||||
selectedTab = dragTargetIndex
|
||||
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
||||
}
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
}
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
totalDrag += dragAmount
|
||||
if (abs(totalDrag) < 12f) {
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
return@detectHorizontalDragGestures
|
||||
}
|
||||
|
||||
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1
|
||||
if (candidate !in navItems.indices) {
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
return@detectHorizontalDragGestures
|
||||
}
|
||||
|
||||
dragTargetIndex = candidate
|
||||
dragProgress = (abs(totalDrag) / 180f).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = selectedTab,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(300)) togetherWith fadeOut(tween(225))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = navOverlayReserve),
|
||||
label = "tab_content"
|
||||
) { tab ->
|
||||
when (tab) {
|
||||
0 -> SettingsTab()
|
||||
1 -> DeployTab()
|
||||
2 -> ExceptionsTab()
|
||||
3 -> LogsTab()
|
||||
4 -> InfoTab()
|
||||
}
|
||||
}
|
||||
|
||||
ProxyNavigationBar(
|
||||
navItems = navItems,
|
||||
selectedTab = selectedTab,
|
||||
dragTargetIndex = dragTargetIndex,
|
||||
dragProgress = dragProgress,
|
||||
unreadErrors = unreadErrors,
|
||||
tunnelRunning = tunnelRunning,
|
||||
onTabSelected = { index ->
|
||||
if (selectedTab != index) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
selectedTab = index
|
||||
if (index == 3) TunnelManager.clearUnreadErrors()
|
||||
}
|
||||
dragTargetIndex = -1
|
||||
dragProgress = 0f
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating theme toolbar overlay
|
||||
FloatingToolbar(
|
||||
currentTheme = themeMode,
|
||||
onThemeChange = onThemeChange,
|
||||
isDynamicColor = isDynamicColor,
|
||||
onDynamicColorChange = onDynamicColorChange,
|
||||
currentPalette = currentPalette,
|
||||
onPaletteChange = onPaletteChange
|
||||
)
|
||||
}
|
||||
|
||||
pendingRelease?.let { release ->
|
||||
AppUpdateDialog(
|
||||
release = release,
|
||||
onPostpone = {
|
||||
pendingRelease = null
|
||||
Toast.makeText(context, "Обновление отложено на 24 часа.", Toast.LENGTH_SHORT).show()
|
||||
scope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
settingsStore.saveUpdatePostpone(
|
||||
version = release.versionTag,
|
||||
until = now + 24L * 60L * 60L * 1000L
|
||||
)
|
||||
settingsStore.saveUpdateDialogAction(
|
||||
version = release.versionTag,
|
||||
action = UPDATE_DIALOG_ACTION_POSTPONED,
|
||||
actedAt = now
|
||||
)
|
||||
}
|
||||
},
|
||||
onUpdate = {
|
||||
pendingRelease = null
|
||||
scope.launch {
|
||||
settingsStore.saveUpdateDialogAction(
|
||||
version = release.versionTag,
|
||||
action = UPDATE_DIALOG_ACTION_UPDATE,
|
||||
actedAt = System.currentTimeMillis()
|
||||
)
|
||||
openReleaseUrl(context, release.releaseUrl)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyNavigationBar(
|
||||
navItems: List<NavItem>,
|
||||
selectedTab: Int,
|
||||
dragTargetIndex: Int,
|
||||
dragProgress: Float,
|
||||
unreadErrors: Int,
|
||||
tunnelRunning: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val isDark = colors.background.luminance() < 0.22f
|
||||
val selectedColor = colors.primary
|
||||
val unselectedColor = colors.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
val shellColor = if (isDark) {
|
||||
colors.surface.copy(alpha = 0.78f)
|
||||
} else {
|
||||
lerp(colors.surface, colors.surfaceVariant, 0.48f).copy(alpha = 0.95f)
|
||||
}
|
||||
val shellBorder = if (isDark) {
|
||||
colors.outlineVariant.copy(alpha = 0.42f)
|
||||
} else {
|
||||
colors.outline.copy(alpha = 0.16f)
|
||||
}
|
||||
val indicatorColor = if (isDark) {
|
||||
colors.primaryContainer.copy(alpha = 0.84f)
|
||||
} else {
|
||||
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
|
||||
}
|
||||
val indicatorIndex = remember { Animatable(selectedTab.toFloat()) }
|
||||
val dragVisualIndex = indicatorIndex.value
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (dragTargetIndex !in navItems.indices) {
|
||||
indicatorIndex.animateTo(
|
||||
targetValue = selectedTab.toFloat(),
|
||||
animationSpec = tween(
|
||||
durationMillis = 720,
|
||||
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) {
|
||||
if (dragTargetIndex in navItems.indices) {
|
||||
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress
|
||||
indicatorIndex.snapTo(target)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom))
|
||||
.padding(horizontal = 22.dp, vertical = 12.dp)
|
||||
) {
|
||||
val trackPadding = 8.dp
|
||||
val itemWidth = (maxWidth - trackPadding * 2) / navItems.size
|
||||
val indicatorOffset = trackPadding + itemWidth * dragVisualIndex
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = shellColor,
|
||||
border = BorderStroke(1.dp, shellBorder),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = if (isDark) 10.dp else 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = indicatorColor,
|
||||
modifier = Modifier
|
||||
.offset(x = indicatorOffset)
|
||||
.padding(vertical = 6.dp)
|
||||
.width(itemWidth)
|
||||
.fillMaxHeight()
|
||||
) {}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = trackPadding, vertical = 6.dp)
|
||||
) {
|
||||
navItems.forEachIndexed { index, item ->
|
||||
val emphasis = (1f - abs(index - dragVisualIndex)).coerceIn(0f, 1f)
|
||||
val iconColor = lerp(unselectedColor, selectedColor, emphasis)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clickable { onTabSelected(index) },
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Icon(
|
||||
imageVector = if (emphasis > 0.55f) item.selectedIcon else item.unselectedIcon,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = iconColor
|
||||
)
|
||||
if (index == 3 && unreadErrors > 0) {
|
||||
Badge(
|
||||
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
|
||||
contentColor = colors.onPrimary,
|
||||
modifier = Modifier.offset(x = 12.dp, y = (-8).dp)
|
||||
) {
|
||||
Text("$unreadErrors")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (emphasis > 0.55f) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = iconColor,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openReleaseUrl(context: Context, url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(context, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun android16OrbShape(points: Int, innerRatio: Float): Shape = GenericShape { size, _ ->
|
||||
val centerX = size.width / 2f
|
||||
val centerY = size.height / 2f
|
||||
val outerRadius = min(size.width, size.height) / 2f
|
||||
val innerRadius = outerRadius * innerRatio
|
||||
|
||||
for (i in 0 until points * 2) {
|
||||
val angle = (-PI / 2.0) + (i * PI / points)
|
||||
val radius = if (i % 2 == 0) outerRadius else innerRadius
|
||||
val x = centerX + (radius * cos(angle)).toFloat()
|
||||
val y = centerY + (radius * sin(angle)).toFloat()
|
||||
if (i == 0) moveTo(x, y) else lineTo(x, y)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
private val Android16OrbLarge: Shape = android16OrbShape(points = 18, innerRatio = 0.90f)
|
||||
private val Android16OrbMedium: Shape = android16OrbShape(points = 20, innerRatio = 0.92f)
|
||||
private val Android16OrbSmall: Shape = android16OrbShape(points = 16, innerRatio = 0.88f)
|
||||
|
||||
@Composable
|
||||
private fun AppBackdrop(modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val isDark = colors.background.luminance() < 0.22f
|
||||
val baseBrush = remember(colors.background, colors.surface, colors.surfaceVariant) {
|
||||
Brush.verticalGradient(
|
||||
colors = if (isDark) {
|
||||
listOf(
|
||||
lerp(colors.background, colors.surface, 0.18f),
|
||||
colors.background,
|
||||
lerp(colors.surfaceVariant, colors.background, 0.72f)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
lerp(colors.background, colors.surface, 0.78f),
|
||||
colors.background,
|
||||
lerp(colors.surfaceVariant, colors.background, 0.30f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val topGlow = colors.primary.copy(alpha = if (isDark) 0.055f else 0.09f)
|
||||
val leftGlow = if (isDark) {
|
||||
colors.tertiary.copy(alpha = 0.045f)
|
||||
} else {
|
||||
lerp(colors.tertiary, colors.secondaryContainer, 0.74f).copy(alpha = 0.24f)
|
||||
}
|
||||
val bottomGlow = if (isDark) {
|
||||
colors.primary.copy(alpha = 0.04f)
|
||||
} else {
|
||||
lerp(colors.secondary, colors.primaryContainer, 0.70f).copy(alpha = 0.22f)
|
||||
}
|
||||
val lightOrbOutline = colors.outlineVariant.copy(alpha = 0.26f)
|
||||
val topOrbGlow = if (isDark) {
|
||||
topGlow
|
||||
} else {
|
||||
lerp(colors.primary, colors.primaryContainer, 0.72f).copy(alpha = 0.32f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(baseBrush)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset(x = (-86).dp, y = (-126).dp)
|
||||
.size(258.dp)
|
||||
.clip(Android16OrbLarge)
|
||||
.background(topOrbGlow)
|
||||
.then(
|
||||
if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline, Android16OrbLarge)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = (-44).dp, y = 28.dp)
|
||||
.size(146.dp)
|
||||
.clip(Android16OrbSmall)
|
||||
.background(leftGlow)
|
||||
.then(
|
||||
if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.22f), Android16OrbSmall)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 62.dp, y = (-208).dp)
|
||||
.size(198.dp)
|
||||
.clip(Android16OrbMedium)
|
||||
.background(bottomGlow)
|
||||
.then(
|
||||
if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.20f), Android16OrbMedium)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.random.Random
|
||||
|
||||
object ManlCaptchaWebViewManager {
|
||||
private const val TAG = "ManlCaptchaWV"
|
||||
private const val CAPTCHA_TIMEOUT_MS = 60_000L
|
||||
|
||||
val captchaMutex = Mutex()
|
||||
val pendingResult = AtomicReference<CompletableDeferred<Result<String>>?>(null)
|
||||
var activeActivity: ManlCaptchaActivity? = null
|
||||
var pendingIntentToStart: Intent? = null
|
||||
var isCaptchaPending = false
|
||||
|
||||
fun checkAndShowPendingCaptcha(context: Context) {
|
||||
val intent = pendingIntentToStart
|
||||
if (intent != null && activeActivity == null) {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelCaptcha() {
|
||||
pendingResult.get()?.completeExceptionally(kotlin.coroutines.cancellation.CancellationException("Cancelled by system"))
|
||||
}
|
||||
|
||||
private const val NOTIFICATION_ID = 9001
|
||||
private const val CHANNEL_ID = "captcha_channel"
|
||||
|
||||
private fun showCaptchaNotification(context: Context, redirectUri: String) {
|
||||
if (MainActivity.isForeground) return // Если юзер уже в приложении — не спамим пушом
|
||||
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Уведомления защиты (Капча)",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val openIntent = Intent(context, ManlCaptchaActivity::class.java).apply {
|
||||
putExtra("redirectUri", redirectUri)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
}
|
||||
|
||||
val openPendingIntent = PendingIntent.getActivity(
|
||||
context, 0, openIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val cancelIntent = Intent(context, CaptchaCancelReceiver::class.java)
|
||||
val cancelPendingIntent = PendingIntent.getBroadcast(
|
||||
context, 1, cancelIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setContentTitle("Требуется подтверждение капчи")
|
||||
.setContentText("ВК запросил проверку безопасности. Нажмите для решения.")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(openPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, "Отменить и выключить", cancelPendingIntent)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun clearCaptchaNotification(context: Context) {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
suspend fun solveCaptchaAsync(context: Context, redirectUri: String, sessionToken: String): String {
|
||||
return captchaMutex.withLock {
|
||||
isCaptchaPending = true
|
||||
val deferred = CompletableDeferred<Result<String>>()
|
||||
// Если предыдущий вызов завис, отменяем его
|
||||
pendingResult.getAndSet(deferred)?.cancel()
|
||||
|
||||
showCaptchaNotification(context, redirectUri)
|
||||
|
||||
val intent = Intent(context, ManlCaptchaActivity::class.java).apply {
|
||||
putExtra("redirectUri", redirectUri)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
}
|
||||
pendingIntentToStart = intent
|
||||
|
||||
if (MainActivity.isForeground) {
|
||||
// Запускаем окно только если интерфейс приложения активен (иначе Android блокирует старт)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
try {
|
||||
withTimeout(CAPTCHA_TIMEOUT_MS) {
|
||||
deferred.await().getOrThrow()
|
||||
}
|
||||
} finally {
|
||||
isCaptchaPending = false
|
||||
pendingResult.set(null)
|
||||
pendingIntentToStart = null
|
||||
clearCaptchaNotification(context)
|
||||
try {
|
||||
activeActivity?.finish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error finishing activity: ${e.message}")
|
||||
}
|
||||
activeActivity = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyResult(result: Result<String>) {
|
||||
val deferred = pendingResult.getAndSet(null) ?: return
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManlCaptchaActivity : ComponentActivity() {
|
||||
private val interceptorJSCode = """
|
||||
(function() {
|
||||
if (window.__wdtt_interceptor_installed) return;
|
||||
window.__wdtt_interceptor_installed = true;
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function() {
|
||||
const args = arguments;
|
||||
const url = args[0] || '';
|
||||
if (typeof url === 'string' && url.includes('captchaNotRobot.check')) {
|
||||
const response = await origFetch.apply(this, args);
|
||||
const clone = response.clone();
|
||||
try {
|
||||
const data = await clone.json();
|
||||
if (data.response && data.response.success_token) {
|
||||
window.WdttCaptcha.onSuccess(data.response.success_token);
|
||||
} else if (data.error) {
|
||||
window.WdttCaptcha.onError(JSON.stringify(data.error));
|
||||
}
|
||||
} catch(e) {}
|
||||
return response;
|
||||
}
|
||||
return origFetch.apply(this, args);
|
||||
};
|
||||
|
||||
const origXHROpen = XMLHttpRequest.prototype.open;
|
||||
const origXHRSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._wdtt_url = url;
|
||||
return origXHROpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
const xhr = this;
|
||||
if (xhr._wdtt_url && xhr._wdtt_url.includes('captchaNotRobot.check')) {
|
||||
xhr.addEventListener('load', function() {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.response && data.response.success_token) {
|
||||
window.WdttCaptcha.onSuccess(data.response.success_token);
|
||||
} else if (data.error) {
|
||||
window.WdttCaptcha.onError(JSON.stringify(data.error));
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
return origXHRSend.apply(this, arguments);
|
||||
};
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
private val hideElementsJSCode = """
|
||||
(function() {
|
||||
// Перехватываем клик по нативному крестику ВК, чтобы закрывать Android Activity и останавливать туннель
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.vkc__ModalCardBase-module__dismiss')) {
|
||||
window.WdttCaptcha.onCancelAndStop();
|
||||
}
|
||||
});
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
/* Скрываем серый фон, лого, id, ссылку и кнопку АУДИО (крестик ВК оставляем!) */
|
||||
.vkc__VisuallyHiddenModalOverlay-module__host,
|
||||
.vkc__ModalOverlay-module__host,
|
||||
.vkc__KaleidoscopeScreen-module__logoBlock,
|
||||
.vkc__KaleidoscopeScreen-module__captchaId,
|
||||
.vkc__SliderCaptcha-module__descriptionLink,
|
||||
.vkc__SliderCaptcha-module__changeTypeButton {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Основной фон вокруг окна - прозрачный прозрачным, убираем тени */
|
||||
body, html, .vkc__ModalCard-module__host, .vkc__AppRoot-module__host, .vkui__root {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Сама карточка (окно) - фон чёрный */
|
||||
.vkc__ModalCardBase-module__container {
|
||||
background: #000000 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Стилизуем крестик ВК: левее, меньше, красный */
|
||||
.vkc__ModalCardBase-module__dismiss {
|
||||
color: #ef4444 !important;
|
||||
transform: scale(0.8) translateX(-12px) !important;
|
||||
}
|
||||
.vkc__ModalCardBase-module__dismiss svg {
|
||||
fill: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Текст "Обновить" и описание капчи делаем белыми */
|
||||
.vkc__RefreshButton-module__text,
|
||||
.vkc__SliderCaptcha-module__description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Поле (трек), где нужно потянуть вправо - делаем белым */
|
||||
.vkc__SwipeButton-module__track {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Текст "Потяните вправо" внутри трека - делаем синим */
|
||||
.vkc__SwipeButton-module__track span {
|
||||
color: #0000FF !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ManlCaptchaWebViewManager.activeActivity = this
|
||||
MainActivity.isForeground = true // Если появилось само окно капчи, мы тоже считаемся в фореграунде
|
||||
val redirectUri = intent.getStringExtra("redirectUri") ?: return finish()
|
||||
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
var isLoading by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
blockNetworkLoads = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT // Включаем кэш для моментальной загрузки!
|
||||
userAgentString = "Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
}
|
||||
|
||||
addJavascriptInterface(object {
|
||||
@JavascriptInterface
|
||||
fun onSuccess(token: String) {
|
||||
Log.d("ManlCaptchaWV", "Token received")
|
||||
ManlCaptchaWebViewManager.notifyResult(Result.success(token))
|
||||
finish()
|
||||
}
|
||||
@JavascriptInterface
|
||||
fun onError(err: String) {
|
||||
Log.e("ManlCaptchaWV", "Error: $err")
|
||||
ManlCaptchaWebViewManager.notifyResult(Result.failure(Exception("VK Captcha error: ${'$'}err")))
|
||||
finish()
|
||||
}
|
||||
@JavascriptInterface
|
||||
fun onCancelAndStop() {
|
||||
Log.d("ManlCaptchaWV", "User clicked VK Close. Stopping tunnel.")
|
||||
TunnelManager.stop()
|
||||
ManlCaptchaWebViewManager.notifyResult(Result.failure(Exception("Cancelled and stopped by user")))
|
||||
finish()
|
||||
}
|
||||
}, "WdttCaptcha")
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
view?.evaluateJavascript(interceptorJSCode, null)
|
||||
}
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
view?.evaluateJavascript(interceptorJSCode, null)
|
||||
view?.evaluateJavascript(hideElementsJSCode, null)
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
loadUrl(redirectUri)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Индикатор загрузки, пока страница белая/прозрачная
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center).size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
MainActivity.isForeground = false
|
||||
if (ManlCaptchaWebViewManager.activeActivity === this) {
|
||||
ManlCaptchaWebViewManager.activeActivity = null
|
||||
}
|
||||
// Мы НЕ отправляем ошибку здесь!
|
||||
// Если юзер смахнул окно (нажал назад), капча останется висеть в памяти (через пуш).
|
||||
// Ошибка или Успех отправляются только по явным действиям (крестик, решение, или таймаут 5 мин).
|
||||
}
|
||||
}
|
||||
|
||||
class CaptchaCancelReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
TunnelManager.stop()
|
||||
ManlCaptchaWebViewManager.activeActivity?.finish()
|
||||
val notifMgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notifMgr.cancel(9001) // NOTIFICATION_ID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class SecureStringStore(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALIAS = "wdtt.settings.secrets"
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_BITS = 128
|
||||
private const val VERSION_PREFIX = "v1:"
|
||||
}
|
||||
|
||||
private val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
}
|
||||
|
||||
fun encrypt(value: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
return VERSION_PREFIX +
|
||||
Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
|
||||
":" +
|
||||
Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun decrypt(value: String?): String? {
|
||||
if (value.isNullOrBlank() || !value.startsWith(VERSION_PREFIX)) return null
|
||||
val payload = value.removePrefix(VERSION_PREFIX)
|
||||
val parts = payload.split(":", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return runCatching {
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||
cipher.doFinal(encrypted).toString(Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
synchronized(appContext) {
|
||||
val existing = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
|
||||
if (existing != null) return existing
|
||||
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
generator.init(spec)
|
||||
return generator.generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsStore(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
companion object {
|
||||
private val Context.dataStore by preferencesDataStore("settings")
|
||||
private val PEER = stringPreferencesKey("peer")
|
||||
private val VK_HASHES = stringPreferencesKey("vk_hashes")
|
||||
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
|
||||
private val WORKERS_PER_HASH = intPreferencesKey("workers_per_hash")
|
||||
private val PROTOCOL = stringPreferencesKey("protocol")
|
||||
private val LISTEN_PORT = intPreferencesKey("listen_port")
|
||||
private val MANUAL_PORTS_ENABLED = booleanPreferencesKey("manual_ports_enabled")
|
||||
private val SERVER_DTLS_PORT = intPreferencesKey("server_dtls_port")
|
||||
private val SERVER_WG_PORT = intPreferencesKey("server_wg_port")
|
||||
private val SNI = stringPreferencesKey("sni")
|
||||
private val NO_DTLS = booleanPreferencesKey("no_dtls")
|
||||
private val NO_DNS = booleanPreferencesKey("no_dns")
|
||||
|
||||
private val USER_AGENT = stringPreferencesKey("user_agent")
|
||||
|
||||
private val DEPLOY_IP = stringPreferencesKey("deploy_ip")
|
||||
private val DEPLOY_LOGIN = stringPreferencesKey("deploy_login")
|
||||
private val DEPLOY_PASSWORD = stringPreferencesKey("deploy_password")
|
||||
private val DEPLOY_PASSWORD_ENCRYPTED = stringPreferencesKey("deploy_password_encrypted")
|
||||
private val DEPLOY_SSH_PORT = stringPreferencesKey("deploy_ssh_port")
|
||||
private val EXCLUDED_APPS = stringPreferencesKey("excluded_apps")
|
||||
|
||||
private val DETAILED_LOGS = booleanPreferencesKey("detailed_logs")
|
||||
|
||||
// ═══ Пароли и Управление ═══
|
||||
private val CONNECTION_PASSWORD = stringPreferencesKey("connection_password")
|
||||
private val CONNECTION_PASSWORD_ENCRYPTED = stringPreferencesKey("connection_password_encrypted")
|
||||
private val DEPLOY_MAIN_PASSWORD = stringPreferencesKey("deploy_main_password")
|
||||
private val DEPLOY_MAIN_PASSWORD_ENCRYPTED = stringPreferencesKey("deploy_main_password_encrypted")
|
||||
private val DEPLOY_ADMIN_ID = stringPreferencesKey("deploy_admin_id")
|
||||
private val DEPLOY_ADMIN_ID_ENCRYPTED = stringPreferencesKey("deploy_admin_id_encrypted")
|
||||
private val DEPLOY_BOT_TOKEN = stringPreferencesKey("deploy_bot_token")
|
||||
private val DEPLOY_BOT_TOKEN_ENCRYPTED = stringPreferencesKey("deploy_bot_token_encrypted")
|
||||
|
||||
// ═══ Proxy Mode ═══
|
||||
private val PROXY_MODE = stringPreferencesKey("proxy_mode") // "tun" or "socks5"
|
||||
private val PROXY_HOST = stringPreferencesKey("proxy_host")
|
||||
private val PROXY_PORT = intPreferencesKey("proxy_port")
|
||||
|
||||
// ═══ Captcha Solve Mode ═══
|
||||
private val CAPTCHA_MODE = stringPreferencesKey("captcha_mode") // "auto", "wv", or "rjs"
|
||||
private val CAPTCHA_SOLVE_METHOD = stringPreferencesKey("captcha_solve_method") // "manual" or "auto"
|
||||
private val CAPTCHA_WBV_SOLVE_METHOD = stringPreferencesKey("captcha_wbv_solve_method") // "manual" or "auto"
|
||||
|
||||
// ═══ VPN Exclusions Mode ═══
|
||||
private val IS_WHITELIST = booleanPreferencesKey("is_whitelist")
|
||||
|
||||
// ═══ Theme Mode ═══
|
||||
private val THEME_MODE = stringPreferencesKey("theme_mode") // "system", "light", "dark"
|
||||
private val IS_DYNAMIC_COLOR = booleanPreferencesKey("is_dynamic_color")
|
||||
private val THEME_PALETTE = stringPreferencesKey("theme_palette")
|
||||
|
||||
private val UPDATE_LAST_CHECK_AT = longPreferencesKey("update_last_check_at")
|
||||
private val UPDATE_LATEST_VERSION = stringPreferencesKey("update_latest_version")
|
||||
private val UPDATE_LAST_ERROR = stringPreferencesKey("update_last_error")
|
||||
private val UPDATE_CHECK_INTERVAL_HOURS = intPreferencesKey("update_check_interval_hours")
|
||||
private val UPDATE_POSTPONE_UNTIL = longPreferencesKey("update_postpone_until")
|
||||
private val UPDATE_POSTPONE_VERSION = stringPreferencesKey("update_postpone_version")
|
||||
private val UPDATE_DIALOG_LAST_SHOWN_VERSION = stringPreferencesKey("update_dialog_last_shown_version")
|
||||
private val UPDATE_DIALOG_LAST_SHOWN_AT = longPreferencesKey("update_dialog_last_shown_at")
|
||||
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
|
||||
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
|
||||
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
|
||||
}
|
||||
|
||||
private val dataStore = appContext.dataStore
|
||||
private val secureStore = SecureStringStore(appContext)
|
||||
|
||||
init {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
migrateSecretsToKeystore()
|
||||
}
|
||||
}
|
||||
|
||||
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" }
|
||||
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" }
|
||||
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" }
|
||||
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 }
|
||||
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" }
|
||||
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 }
|
||||
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false }
|
||||
val serverDtlsPort: Flow<Int> = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 }
|
||||
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 }
|
||||
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" }
|
||||
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false }
|
||||
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
|
||||
|
||||
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" }
|
||||
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" }
|
||||
val deployPassword: Flow<String> = dataStore.data.map {
|
||||
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
|
||||
}
|
||||
val deploySshPort: Flow<String> = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" }
|
||||
val excludedApps: Flow<String> = dataStore.data.map { it[EXCLUDED_APPS] ?: "" }
|
||||
|
||||
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
|
||||
|
||||
// ═══ Пароли и Управление ═══
|
||||
val connectionPassword: Flow<String> = dataStore.data.map {
|
||||
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
|
||||
}
|
||||
val deployMainPassword: Flow<String> = dataStore.data.map {
|
||||
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
|
||||
}
|
||||
val deployAdminId: Flow<String> = dataStore.data.map {
|
||||
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
|
||||
}
|
||||
val deployBotToken: Flow<String> = dataStore.data.map {
|
||||
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
|
||||
}
|
||||
|
||||
// ═══ Proxy Mode ═══
|
||||
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" }
|
||||
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" }
|
||||
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 }
|
||||
|
||||
// ═══ Captcha Solve Mode ═══
|
||||
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" }
|
||||
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" }
|
||||
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" }
|
||||
|
||||
// ═══ VPN Exclusions Mode ═══
|
||||
val isWhitelist: Flow<Boolean> = dataStore.data.map { it[IS_WHITELIST] ?: false }
|
||||
|
||||
// ═══ Theme Mode ═══
|
||||
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
|
||||
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false }
|
||||
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
|
||||
|
||||
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
|
||||
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
|
||||
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
|
||||
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS }
|
||||
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
|
||||
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
|
||||
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
|
||||
val updateDialogLastShownAt: Flow<Long> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_AT] ?: 0L }
|
||||
val updateDialogLastActionVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION_VERSION] ?: "" }
|
||||
val updateDialogLastAction: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION] ?: "" }
|
||||
val updateDialogLastActionAt: Flow<Long> = dataStore.data.map { it[UPDATE_DIALOG_LAST_ACTION_AT] ?: 0L }
|
||||
|
||||
suspend fun saveThemeMode(mode: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[THEME_MODE] = mode
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveDynamicColor(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[IS_DYNAMIC_COLOR] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveThemePalette(palette: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[THEME_PALETTE] = palette
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUpdateState(lastCheckAt: Long, latestVersion: String, error: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[UPDATE_LAST_CHECK_AT] = lastCheckAt
|
||||
prefs[UPDATE_LATEST_VERSION] = latestVersion
|
||||
prefs[UPDATE_LAST_ERROR] = error
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUpdateCheckIntervalHours(hours: Int) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[UPDATE_CHECK_INTERVAL_HOURS] = hours
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUpdatePostpone(version: String, until: Long) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[UPDATE_POSTPONE_VERSION] = version
|
||||
prefs[UPDATE_POSTPONE_UNTIL] = until
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUpdateDialogShown(version: String, shownAt: Long) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[UPDATE_DIALOG_LAST_SHOWN_VERSION] = version
|
||||
prefs[UPDATE_DIALOG_LAST_SHOWN_AT] = shownAt
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUpdateDialogAction(version: String, action: String, actedAt: Long) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[UPDATE_DIALOG_LAST_ACTION_VERSION] = version
|
||||
prefs[UPDATE_DIALOG_LAST_ACTION] = action
|
||||
prefs[UPDATE_DIALOG_LAST_ACTION_AT] = actedAt
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
peer: String,
|
||||
vkHashes: String,
|
||||
secondaryVkHash: String,
|
||||
workersPerHash: Int,
|
||||
protocol: String,
|
||||
listenPort: Int,
|
||||
sni: String = "",
|
||||
noDns: Boolean = false
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PEER] = peer
|
||||
prefs[VK_HASHES] = vkHashes
|
||||
prefs[SECONDARY_VK_HASH] = secondaryVkHash
|
||||
prefs[WORKERS_PER_HASH] = workersPerHash
|
||||
prefs[PROTOCOL] = protocol
|
||||
prefs[LISTEN_PORT] = listenPort
|
||||
prefs[SNI] = sni
|
||||
prefs[NO_DNS] = noDns
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveManualPortsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[MANUAL_PORTS_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[SERVER_DTLS_PORT] = serverDtlsPort
|
||||
prefs[SERVER_WG_PORT] = serverWgPort
|
||||
prefs[LISTEN_PORT] = listenPort
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUserAgent(ua: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[USER_AGENT] = ua
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[DEPLOY_IP] = ip
|
||||
prefs[DEPLOY_LOGIN] = login
|
||||
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass)
|
||||
prefs[DEPLOY_SSH_PORT] = sshPort
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveExcludedApps(packages: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXCLUDED_APPS] = packages
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveDetailedLogs(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[DETAILED_LOGS] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Сохранение пароля подключения ═══
|
||||
suspend fun saveConnectionPassword(password: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Сохранение секретов деплоя ═══
|
||||
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass)
|
||||
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId)
|
||||
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken)
|
||||
prefs[DEPLOY_SSH_PORT] = sshPort
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Сохранение proxy mode ═══
|
||||
suspend fun saveProxyMode(mode: String, host: String, port: Int) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PROXY_MODE] = mode
|
||||
prefs[PROXY_HOST] = host
|
||||
prefs[PROXY_PORT] = port
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Сохранение режима обхода капчи ═══
|
||||
suspend fun saveCaptchaMode(mode: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[CAPTCHA_MODE] = mode
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCaptchaSolveMethod(method: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[CAPTCHA_SOLVE_METHOD] = method
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveWbvCaptchaSolveMethod(method: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method
|
||||
if (prefs[CAPTCHA_MODE] == "wv") {
|
||||
prefs[CAPTCHA_SOLVE_METHOD] = method
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Сохранение режима списка (ЧС/БС) ═══
|
||||
suspend fun saveIsWhitelist(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[IS_WHITELIST] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
|
||||
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXCLUDED_APPS] = packages
|
||||
prefs[IS_WHITELIST] = isWhitelist
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateSecretsToKeystore() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
|
||||
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
|
||||
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
|
||||
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
|
||||
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSecret(
|
||||
prefs: Preferences,
|
||||
encryptedKey: Preferences.Key<String>,
|
||||
legacyKey: Preferences.Key<String>
|
||||
): String {
|
||||
return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: ""
|
||||
}
|
||||
|
||||
private fun MutablePreferences.putSecret(
|
||||
encryptedKey: Preferences.Key<String>,
|
||||
legacyKey: Preferences.Key<String>,
|
||||
value: String
|
||||
) {
|
||||
if (value.isBlank()) {
|
||||
remove(encryptedKey)
|
||||
remove(legacyKey)
|
||||
} else {
|
||||
this[encryptedKey] = secureStore.encrypt(value)
|
||||
remove(legacyKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutablePreferences.migrateSecret(
|
||||
encryptedKey: Preferences.Key<String>,
|
||||
legacyKey: Preferences.Key<String>
|
||||
) {
|
||||
val legacyValue = this[legacyKey]
|
||||
val encryptedValue = this[encryptedKey]
|
||||
if (!encryptedValue.isNullOrBlank()) {
|
||||
remove(legacyKey)
|
||||
return
|
||||
}
|
||||
if (!legacyValue.isNullOrBlank()) {
|
||||
runCatching {
|
||||
this[encryptedKey] = secureStore.encrypt(legacyValue)
|
||||
remove(legacyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.os.Build
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
// ═══ Inter Font Family ═══
|
||||
val InterFontFamily = FontFamily(
|
||||
Font(R.font.inter_regular, FontWeight.Normal),
|
||||
Font(R.font.inter_medium, FontWeight.Medium),
|
||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.inter_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
// ═══ Типография на Inter ═══
|
||||
val WDTTTypography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp),
|
||||
displayMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp),
|
||||
displaySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp),
|
||||
headlineLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp),
|
||||
headlineMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp),
|
||||
headlineSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp),
|
||||
titleLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
|
||||
titleMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp),
|
||||
titleSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
|
||||
bodyLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp),
|
||||
bodyMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp),
|
||||
bodySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp),
|
||||
labelLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
|
||||
labelMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
|
||||
labelSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
|
||||
)
|
||||
|
||||
// ═══ Светлая палитра — «Раф на кокосовом молоке» ═══
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF6D4C41),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD7CCC8),
|
||||
onPrimaryContainer = Color(0xFF3E2723),
|
||||
secondary = Color(0xFF8D6E63),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFEFEBE9),
|
||||
onSecondaryContainer = Color(0xFF4E342E),
|
||||
tertiary = Color(0xFF795548),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFBCAAA4),
|
||||
onTertiaryContainer = Color(0xFF3E2723),
|
||||
background = Color(0xFFF2F0EC),
|
||||
onBackground = Color(0xFF1C1B1A),
|
||||
surface = Color(0xFFFAF8F4),
|
||||
onSurface = Color(0xFF1C1B1A),
|
||||
surfaceVariant = Color(0xFFEFEBE9),
|
||||
onSurfaceVariant = Color(0xFF5D4037),
|
||||
outline = Color(0xFFBCAAA4),
|
||||
outlineVariant = Color(0xFFD7CCC8),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
inverseSurface = Color(0xFF322F2D),
|
||||
inverseOnSurface = Color(0xFFF5F0EB),
|
||||
inversePrimary = Color(0xFFD7CCC8),
|
||||
surfaceTint = Color(0xFF6D4C41),
|
||||
)
|
||||
|
||||
// ═══ Тёмная палитра — «Эспрессо» ═══
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFD7CCC8),
|
||||
onPrimary = Color(0xFF3E2723),
|
||||
primaryContainer = Color(0xFF5D4037),
|
||||
onPrimaryContainer = Color(0xFFEFEBE9),
|
||||
secondary = Color(0xFFBCAAA4),
|
||||
onSecondary = Color(0xFF3E2723),
|
||||
secondaryContainer = Color(0xFF4E342E),
|
||||
onSecondaryContainer = Color(0xFFEFEBE9),
|
||||
tertiary = Color(0xFFA1887F),
|
||||
onTertiary = Color(0xFF3E2723),
|
||||
tertiaryContainer = Color(0xFF5D4037),
|
||||
onTertiaryContainer = Color(0xFFEFEBE9),
|
||||
background = Color(0xFF1A1614),
|
||||
onBackground = Color(0xFFEDE0D4),
|
||||
surface = Color(0xFF211D1B),
|
||||
onSurface = Color(0xFFEDE0D4),
|
||||
surfaceVariant = Color(0xFF2C2624),
|
||||
onSurfaceVariant = Color(0xFFD7CCC8),
|
||||
outline = Color(0xFF8D6E63),
|
||||
outlineVariant = Color(0xFF4E342E),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
inverseSurface = Color(0xFFEDE0D4),
|
||||
inverseOnSurface = Color(0xFF322F2D),
|
||||
inversePrimary = Color(0xFF6D4C41),
|
||||
surfaceTint = Color(0xFFD7CCC8),
|
||||
)
|
||||
|
||||
private val IndigoLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5B588D),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFE2DFFF),
|
||||
onPrimaryContainer = Color(0xFF1A1744),
|
||||
secondary = Color(0xFF5B588D),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFE2DFFF),
|
||||
onSecondaryContainer = Color(0xFF1A1744),
|
||||
background = Color(0xFFFBF8FF),
|
||||
onBackground = Color(0xFF1B1B1F),
|
||||
surface = Color(0xFFF6F3FA),
|
||||
onSurface = Color(0xFF1B1B1F),
|
||||
surfaceVariant = Color(0xFFE4E1EC),
|
||||
onSurfaceVariant = Color(0xFF47464F),
|
||||
outline = Color(0xFF787680),
|
||||
outlineVariant = Color(0xFFC8C5D0),
|
||||
)
|
||||
|
||||
private val IndigoDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFC4C0FF),
|
||||
onPrimary = Color(0xFF2D2A5B),
|
||||
primaryContainer = Color(0xFF434073),
|
||||
onPrimaryContainer = Color(0xFFE2DFFF),
|
||||
secondary = Color(0xFFC4C0FF),
|
||||
onSecondary = Color(0xFF2D2A5B),
|
||||
secondaryContainer = Color(0xFF434073),
|
||||
onSecondaryContainer = Color(0xFFE2DFFF),
|
||||
background = Color(0xFF131316),
|
||||
onBackground = Color(0xFFE4E1E6),
|
||||
surface = Color(0xFF1B1B1F),
|
||||
onSurface = Color(0xFFC8C5D0),
|
||||
surfaceVariant = Color(0xFF47464F),
|
||||
onSurfaceVariant = Color(0xFFC8C5D0),
|
||||
outline = Color(0xFF918F9A),
|
||||
outlineVariant = Color(0xFF47464F),
|
||||
)
|
||||
|
||||
private val ForestLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5F5D68),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFE5E0F0),
|
||||
onPrimaryContainer = Color(0xFF1C1A23),
|
||||
secondary = Color(0xFF5F5D68),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFE5E0F0),
|
||||
onSecondaryContainer = Color(0xFF1C1A23),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1D1B20),
|
||||
surface = Color(0xFFF7F2FA),
|
||||
onSurface = Color(0xFF1D1B20),
|
||||
surfaceVariant = Color(0xFFE6E0E9),
|
||||
onSurfaceVariant = Color(0xFF48454E),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCAC4D0),
|
||||
)
|
||||
|
||||
private val ForestDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFC8C4D3),
|
||||
onPrimary = Color(0xFF312F38),
|
||||
primaryContainer = Color(0xFF474550),
|
||||
onPrimaryContainer = Color(0xFFE5E0F0),
|
||||
secondary = Color(0xFFC8C4D3),
|
||||
onSecondary = Color(0xFF312F38),
|
||||
secondaryContainer = Color(0xFF474550),
|
||||
onSecondaryContainer = Color(0xFFE5E0F0),
|
||||
background = Color(0xFF141318),
|
||||
onBackground = Color(0xFFE6E1E5),
|
||||
surface = Color(0xFF1D1B20),
|
||||
onSurface = Color(0xFFCAC4D0),
|
||||
surfaceVariant = Color(0xFF48454E),
|
||||
onSurfaceVariant = Color(0xFFCAC4D0),
|
||||
outline = Color(0xFF938F99),
|
||||
outlineVariant = Color(0xFF48454E),
|
||||
)
|
||||
|
||||
private fun getAppColorScheme(palette: String, isDark: Boolean): androidx.compose.material3.ColorScheme {
|
||||
return when (palette) {
|
||||
"espresso" -> if (isDark) DarkColorScheme else LightColorScheme
|
||||
"forest" -> if (isDark) ForestDarkColorScheme else ForestLightColorScheme
|
||||
else -> if (isDark) IndigoDarkColorScheme else IndigoLightColorScheme
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Расширенные цвета для кастомных элементов ═══
|
||||
object WDTTColors {
|
||||
// Статус: подключено
|
||||
val connected = Color(0xFF4CAF50)
|
||||
val connectedContainer = Color(0xFF4CAF50).copy(alpha = 0.12f)
|
||||
val onConnected = Color(0xFF1B5E20)
|
||||
|
||||
val connectedDark = Color(0xFF81C784)
|
||||
val connectedContainerDark = Color(0xFF81C784).copy(alpha = 0.15f)
|
||||
val onConnectedDark = Color(0xFFC8E6C9)
|
||||
|
||||
// Статус: предупреждение
|
||||
val warning = Color(0xFFFFA726)
|
||||
val warningDark = Color(0xFFFFCC80)
|
||||
|
||||
// Терминал (логи)
|
||||
val terminalBg = Color(0xFF1A1A2E)
|
||||
val terminalBgDark = Color(0xFF0D0D1A)
|
||||
val terminalText = Color(0xFFE0E0E0)
|
||||
val terminalGreen = Color(0xFF4CAF50)
|
||||
val terminalBlue = Color(0xFF42A5F5)
|
||||
val terminalRed = Color(0xFFEF5350)
|
||||
val terminalYellow = Color(0xFFFFC107)
|
||||
val terminalCounter = Color(0xFF1E88E5)
|
||||
|
||||
// GitHub
|
||||
val github = Color(0xFF24292E)
|
||||
val githubDark = Color(0xFF333C47)
|
||||
|
||||
// Donate
|
||||
val donate = Color(0xFF8B3FFD)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WDTTTheme(
|
||||
themeMode: String = "system",
|
||||
dynamicColor: Boolean = false,
|
||||
themePalette: String = "indigo",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && !darkTheme && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
dynamicLightColorScheme(context)
|
||||
}
|
||||
else -> getAppColorScheme(themePalette, darkTheme)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
val navigationBarColor = if (darkTheme) {
|
||||
Color.Transparent
|
||||
} else {
|
||||
lerp(colorScheme.background, colorScheme.surface, 0.55f)
|
||||
}
|
||||
window.statusBarColor = Color.Transparent.toArgb()
|
||||
window.navigationBarColor = navigationBarColor.toArgb()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
window.isStatusBarContrastEnforced = false
|
||||
}
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = WDTTTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
@Stable
|
||||
data class LogEntry(
|
||||
val key: String,
|
||||
val message: String,
|
||||
val count: Int = 1,
|
||||
val priority: Int = 99, // 0 - Creds, 1 - DTLS, 2 - Ready, 3 - Stats, 99 - Errors/Other
|
||||
val isError: Boolean = false
|
||||
)
|
||||
|
||||
object TunnelManager {
|
||||
// 100% защита от утечек: единый управляемый глобальный Scope
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private var process: Process? = null
|
||||
private var readerJob: Job? = null
|
||||
private var watchdogJob: Job? = null
|
||||
private var wgHelper: WireGuardHelper? = null
|
||||
|
||||
// Error counters for circuit breaker
|
||||
private var floodCount = 0
|
||||
private var mismatchCount = 0
|
||||
private var refusedCount = 0
|
||||
private var currentHashErrorCount = 0
|
||||
private var wrapAuthTimeoutCount = 0
|
||||
private var processStartedAtMs = 0L
|
||||
private var lastActiveAtMs = 0L
|
||||
private var activeHashIndex = 0 // 0: primary, 1: secondary
|
||||
private var currentParams: TunnelParams? = null
|
||||
private var lastContext: Context? = null
|
||||
private var forceRegenerateUA = false // принудительная перегенерация UA при ошибках
|
||||
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
|
||||
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
|
||||
|
||||
val running = MutableStateFlow(false)
|
||||
val logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val unreadErrorCount = MutableStateFlow(0)
|
||||
val config = MutableStateFlow<String?>(null)
|
||||
val stats = MutableStateFlow("Ожидание данных...")
|
||||
val activeWorkers = MutableStateFlow(0)
|
||||
|
||||
val cooldownSeconds = MutableStateFlow(0)
|
||||
private var cooldownJob: Job? = null
|
||||
|
||||
fun clearUnreadErrors() {
|
||||
unreadErrorCount.value = 0
|
||||
}
|
||||
|
||||
// Добавляем лог с Деплоя
|
||||
fun addDeployErrorLog(message: String) {
|
||||
val hash = message.hashCode().toString()
|
||||
updateLog("deploy_err_$hash", "[ДЕПЛОЙ] $message", 99, true)
|
||||
}
|
||||
|
||||
fun addDeploySuccessLog(message: String) {
|
||||
val hash = message.hashCode().toString() + System.currentTimeMillis()
|
||||
updateLog("deploy_ok_$hash", message, 2, false)
|
||||
}
|
||||
|
||||
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
|
||||
if (isError) {
|
||||
val list = logs.value
|
||||
if (list.none { it.key == key }) {
|
||||
unreadErrorCount.value++
|
||||
}
|
||||
}
|
||||
logs.update { currentList ->
|
||||
val current = currentList.toMutableList()
|
||||
val index = current.indexOfFirst { it.key == key }
|
||||
|
||||
if (index != -1) {
|
||||
// Обновляем текст и счётчик НА МЕСТЕ
|
||||
val entry = current[index]
|
||||
current[index] = entry.copy(count = entry.count + 1, message = message, priority = priority, isError = isError)
|
||||
} else {
|
||||
// Новая запись
|
||||
current.add(LogEntry(key, message, 1, priority, isError))
|
||||
}
|
||||
|
||||
// Сортировка: по приоритету (наименьший сверху), затем ошибки
|
||||
// Приоритеты: Основной=1, Капча=5, Готов=10, Статы=100, Ошибки=200
|
||||
val sorted = current.sortedWith(compareBy({ it.priority }, { if (it.isError) 1 else 0 }, { it.key }))
|
||||
|
||||
// Лимит 100 записей
|
||||
if (sorted.size > 100) sorted.takeLast(100) else sorted
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: Context, params: TunnelParams, isSwitching: Boolean = false) {
|
||||
if (running.value && !isSwitching) return
|
||||
|
||||
val appContext = context.applicationContext // Защита от Memory Leak
|
||||
|
||||
if (!isSwitching) {
|
||||
clearLogs()
|
||||
config.value = null
|
||||
stats.value = "Ожидание данных..."
|
||||
floodCount = 0
|
||||
mismatchCount = 0
|
||||
refusedCount = 0
|
||||
currentHashErrorCount = 0
|
||||
wrapAuthTimeoutCount = 0
|
||||
processStartedAtMs = 0L
|
||||
lastActiveAtMs = 0L
|
||||
activeHashIndex = 0
|
||||
currentParams = params
|
||||
lastContext = appContext
|
||||
forceRegenerateUA = false
|
||||
currentCaptchaMode = params.captchaMode
|
||||
currentCaptchaSolveMethod = params.captchaSolveMethod
|
||||
}
|
||||
|
||||
wgHelper = WireGuardHelper(appContext)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val targetHash = if (activeHashIndex == 0) params.vkHashes else params.secondaryVkHash
|
||||
|
||||
// Robust hash parsing: split by comma, newline, or whitespace
|
||||
val hashList = targetHash
|
||||
.split(Regex("[,\\s\\n]+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(3)
|
||||
|
||||
if (hashList.isEmpty()) {
|
||||
updateLog("hash_error", "Ошибка: Хеш не указан", 99, true)
|
||||
running.value = false
|
||||
return@launch
|
||||
}
|
||||
if (params.connectionPassword.isBlank()) {
|
||||
updateLog("password_error", "Ошибка: пароль подключения не указан", 99, true)
|
||||
running.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val hashCount = hashList.size.coerceIn(1, 3)
|
||||
val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса
|
||||
|
||||
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
|
||||
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
|
||||
|
||||
|
||||
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
|
||||
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
|
||||
val binaryFile = File(binaryPath)
|
||||
|
||||
if (!binaryFile.exists()) {
|
||||
updateLog("binary_error", "Ошибка: Бинарный файл не найден", 99, true)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val cmd = mutableListOf(
|
||||
binaryPath,
|
||||
"-peer", params.peer,
|
||||
"-vk", hashList.joinToString(","),
|
||||
"-n", totalWorkers.toString(),
|
||||
"-listen", "127.0.0.1:${params.port}"
|
||||
)
|
||||
|
||||
val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID) ?: "unknown"
|
||||
cmd.add("-device-id")
|
||||
cmd.add(androidId)
|
||||
|
||||
cmd.add("-password")
|
||||
cmd.add(params.connectionPassword)
|
||||
|
||||
// Captcha mode: wv или rjs
|
||||
cmd.add("-captcha-mode")
|
||||
cmd.add(params.captchaMode)
|
||||
|
||||
val pb = ProcessBuilder(cmd)
|
||||
pb.directory(context.filesDir) // Устанавливаем рабочую директорию
|
||||
pb.redirectErrorStream(true)
|
||||
|
||||
// Set LD_LIBRARY_PATH
|
||||
val env = pb.environment()
|
||||
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
|
||||
|
||||
process = pb.start()
|
||||
processStartedAtMs = System.currentTimeMillis()
|
||||
wrapAuthTimeoutCount = 0
|
||||
lastActiveAtMs = 0L
|
||||
running.value = true
|
||||
startLogReader()
|
||||
startWatchdog(appContext, params)
|
||||
|
||||
} catch (e: Exception) {
|
||||
updateLog("critical_start_error", "Критическая ошибка запуска: ${e.message}", 99, true)
|
||||
e.printStackTrace()
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLogReader() {
|
||||
readerJob = scope.launch {
|
||||
val reader = process?.inputStream?.bufferedReader() ?: return@launch
|
||||
var collectingConfig = false
|
||||
val configBuilder = StringBuilder()
|
||||
|
||||
try {
|
||||
var lastResetTime = System.currentTimeMillis()
|
||||
|
||||
reader.forEachLine { line ->
|
||||
// Периодический сброс счетчиков ошибок (раз в 60 сек)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastResetTime > 60000) {
|
||||
refusedCount = 0
|
||||
floodCount = 0
|
||||
mismatchCount = 0
|
||||
currentHashErrorCount = 0
|
||||
lastResetTime = now
|
||||
}
|
||||
|
||||
// Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...")
|
||||
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
|
||||
val lineTrim = msgPrefixReplaced.trim()
|
||||
|
||||
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
|
||||
|
||||
// 0. FATAL AUTH — мгновенная остановка
|
||||
if (lineTrim.contains("FATAL_AUTH")) {
|
||||
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
|
||||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
|
||||
if (isWrapHandshakeTimeout) {
|
||||
if (activeWorkers.value > 0) {
|
||||
wrapAuthTimeoutCount = 0
|
||||
updateLog(
|
||||
"wrap_timeout_recovered",
|
||||
"[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем",
|
||||
50,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
wrapAuthTimeoutCount++
|
||||
updateLog(
|
||||
"wrap_timeout_wait",
|
||||
"[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)",
|
||||
50,
|
||||
true
|
||||
)
|
||||
}
|
||||
return@forEachLine
|
||||
}
|
||||
|
||||
val reason = when {
|
||||
lineTrim.contains("неверный пароль") -> "Неверный пароль подключения"
|
||||
lineTrim.contains("истёк") -> "Срок действия пароля истёк"
|
||||
lineTrim.contains("другому устройству") -> "Пароль привязан к другому устройству"
|
||||
else -> "Ошибка авторизации"
|
||||
}
|
||||
handleCriticalError("\uD83D\uDD12 $reason. Воркеры остановлены.")
|
||||
return@forEachLine
|
||||
}
|
||||
|
||||
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
|
||||
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
|
||||
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
|
||||
if (activeWorkers.value > 0) {
|
||||
wrapAuthTimeoutCount = 0
|
||||
updateLog(
|
||||
"wrap_timeout_recovered",
|
||||
"[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем",
|
||||
50,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
wrapAuthTimeoutCount++
|
||||
updateLog(
|
||||
"wrap_timeout_wait",
|
||||
"[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)",
|
||||
50,
|
||||
true
|
||||
)
|
||||
}
|
||||
return@forEachLine
|
||||
}
|
||||
|
||||
// 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима.
|
||||
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
|
||||
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
|
||||
val parts = payload.split("|", limit = 3)
|
||||
when (parts.size) {
|
||||
3 -> {
|
||||
val requestMode = parts[0]
|
||||
val redirectUri = parts[1]
|
||||
val sessionToken = parts[2]
|
||||
scope.launch {
|
||||
handleCaptchaSolve(requestMode, redirectUri, sessionToken)
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
val redirectUri = parts[0]
|
||||
val sessionToken = parts[1]
|
||||
scope.launch {
|
||||
handleCaptchaSolve("selected", redirectUri, sessionToken)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
writeCaptchaResult("error:invalid CAPTCHA_SOLVE format")
|
||||
}
|
||||
}
|
||||
return@forEachLine
|
||||
}
|
||||
|
||||
// 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker)
|
||||
if (isError) {
|
||||
when {
|
||||
lineTrim.contains("Flood control", true) -> {
|
||||
floodCount++
|
||||
if (floodCount >= 5) {
|
||||
handleCriticalError("Flood Control (ВК ограничил ваш IP). Попробуйте позже.")
|
||||
return@forEachLine
|
||||
}
|
||||
}
|
||||
lineTrim.contains("ip mismatch", true) -> {
|
||||
mismatchCount++
|
||||
if (mismatchCount >= 5) {
|
||||
handleCriticalError("IP Mismatch (IP утерян). Попробуйте переподключиться.")
|
||||
return@forEachLine
|
||||
}
|
||||
}
|
||||
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
|
||||
// Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети
|
||||
refusedCount++
|
||||
if (refusedCount >= 400) {
|
||||
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
|
||||
return@forEachLine
|
||||
}
|
||||
}
|
||||
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
|
||||
currentHashErrorCount++
|
||||
// Нужно больше попыток, так как 1 воркер может спамить
|
||||
if (currentHashErrorCount >= 10) {
|
||||
handleHashError()
|
||||
return@forEachLine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Статистика (Обновляемая строка)
|
||||
if (lineTrim.contains("[СТАТИСТИКА]")) {
|
||||
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
|
||||
stats.value = msg
|
||||
|
||||
val match = Regex("Активных:\\s*(\\d+)").find(msg)
|
||||
if (match != null) {
|
||||
val active = match.groupValues[1].toIntOrNull() ?: 0
|
||||
activeWorkers.value = active
|
||||
if (active > 0) {
|
||||
lastActiveAtMs = now
|
||||
wrapAuthTimeoutCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
updateLog("stats", "[СТАТИСТИКА] $msg", 3, false)
|
||||
return@forEachLine
|
||||
}
|
||||
|
||||
// 2. Этапы подключения и Ошибки
|
||||
when {
|
||||
|
||||
// ═══ Авто-оркестратор капчи ═══
|
||||
lineTrim.contains("[КАПЧА] AUTO:") -> {
|
||||
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
|
||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||
|
||||
val isErr = text.contains("ошибка", true) ||
|
||||
text.contains("timeout", true) ||
|
||||
text.contains("не решил", true)
|
||||
val stableKey = when {
|
||||
text.contains("старт") -> "captcha_auto_1"
|
||||
text.contains("Go v2") && text.contains("2 попыт") -> "captcha_auto_2"
|
||||
text.contains("WBV Auto попытка") -> "captcha_auto_3"
|
||||
text.contains("финальная") -> "captcha_auto_4"
|
||||
text.contains("ручной WebView") -> "captcha_auto_5"
|
||||
text.contains("решил") || text.contains("решила") -> "captcha_auto_done"
|
||||
else -> "captcha_auto_${text.take(18).hashCode()}"
|
||||
}
|
||||
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
|
||||
}
|
||||
|
||||
// ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══
|
||||
lineTrim.contains("[КАПЧА] RJS:") -> {
|
||||
// Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...)
|
||||
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
|
||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||
|
||||
val stableKey = when {
|
||||
text.contains("Загрузка") || text.contains("fetch") -> "captcha_rjs_1"
|
||||
text.contains("PoW") -> "captcha_rjs_2"
|
||||
text.contains("осматривает") || text.contains("человек") -> "captcha_rjs_3"
|
||||
text.contains("captchaNotRobot") || text.contains("Отправка") -> "captcha_rjs_4"
|
||||
text.contains("endSession") -> "captcha_rjs_5"
|
||||
text.contains("решена") -> "captcha_rjs_6"
|
||||
else -> "captcha_rjs_${text.take(15).hashCode()}"
|
||||
}
|
||||
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
|
||||
}
|
||||
|
||||
// ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══
|
||||
lineTrim.contains("[КАПЧА] WBV:") -> {
|
||||
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
|
||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||
|
||||
val isErr = text.contains("Ошибка")
|
||||
val stableKey = when {
|
||||
text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV)
|
||||
text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением)
|
||||
isErr -> "captcha_wv_err"
|
||||
else -> "captcha_wv_go_other"
|
||||
}
|
||||
updateLog(stableKey, "[КАПЧА WBV] $text", 5, isErr)
|
||||
}
|
||||
|
||||
lineTrim.contains("Старт") || lineTrim.contains("Ожидайте") ->
|
||||
updateLog("creds_start", "[ВК] Получение учетных данных...", 2, false)
|
||||
lineTrim.contains("Креды получены") ->
|
||||
updateLog("creds_lifetime", lineTrim, 2, false)
|
||||
lineTrim.contains("Креды OK") || lineTrim.contains("Первые креды") ->
|
||||
updateLog("creds_ok", "[ВК] Учетные данные проверены ✓", 2, false)
|
||||
lineTrim.contains("Решаю VK Smart Captcha") ->
|
||||
updateLog("captcha_start", "[КАПЧА] Решение капчи...", 5, false)
|
||||
lineTrim.contains("Smart Captcha решена") ->
|
||||
updateLog("captcha_done", "[КАПЧА] Капча решена ✓", 5, false)
|
||||
lineTrim.contains("капча не решена") || lineTrim.contains("ошибка решения капчи") ->
|
||||
updateLog("captcha_failed", "[КАПЧА] Ошибка решения капчи", 5, true)
|
||||
lineTrim.contains("[WRAP]") -> {
|
||||
val text = lineTrim.substringAfter("[WRAP]").trim()
|
||||
updateLog("wrap_status", "[WRAP] $text", 1, false)
|
||||
}
|
||||
lineTrim.contains("[TURN]") -> {
|
||||
val text = lineTrim.substringAfter("[TURN]").trim()
|
||||
val turnError = text.contains("Ошибка", true) ||
|
||||
text.contains("не удалось", true) ||
|
||||
text.contains("неполный ответ", true)
|
||||
updateLog("turn_${text.take(32).hashCode()}", "[TURN] $text", 2, turnError)
|
||||
}
|
||||
lineTrim.contains("Relay:") ->
|
||||
updateLog("dtls_start", "[DTLS] Рукопожатие (Handshake)...", 1, false)
|
||||
lineTrim.contains("DTLS ОК") ->
|
||||
updateLog("dtls_ok", "[DTLS] Соединение установлено ✓", 1, false)
|
||||
lineTrim.contains("Активна ✓") ->
|
||||
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
|
||||
|
||||
// Ошибки (в конец)
|
||||
isError -> {
|
||||
// Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки)
|
||||
val errorKey = when {
|
||||
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
|
||||
lineTrim.contains("connection refused") -> "err_conn_refused"
|
||||
lineTrim.contains("timeout") -> "err_timeout"
|
||||
lineTrim.contains("кредов") -> "err_creds"
|
||||
lineTrim.contains("DTLS") -> "err_dtls"
|
||||
else -> "general_error_" + lineTrim.take(15).hashCode()
|
||||
}
|
||||
val errorMessage = if (errorKey == "err_vk_dns") {
|
||||
"[СЕТЬ] DNS до VK недоступен: login.vk.ru"
|
||||
} else {
|
||||
lineTrim
|
||||
}
|
||||
updateLog(errorKey, errorMessage, 99, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Обработка конфига (Скрываем от пользователя)
|
||||
if (line.contains("╔") && line.contains("WireGuard")) {
|
||||
collectingConfig = true
|
||||
configBuilder.clear()
|
||||
return@forEachLine
|
||||
} else if (collectingConfig) {
|
||||
if (line.contains("╚")) {
|
||||
collectingConfig = false
|
||||
val configStr = configBuilder.toString().trim()
|
||||
config.value = configStr
|
||||
|
||||
scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
wgHelper?.startTunnel(configStr)
|
||||
} catch (e: Exception) {
|
||||
updateLog("vpn_start_error", "Ошибка запуска VPN: ${e.readableMessage()}", 99, true)
|
||||
}
|
||||
}
|
||||
} else if (line.contains("║")) {
|
||||
val content = line.replace("║", "").trim()
|
||||
if (content.isNotEmpty()) {
|
||||
configBuilder.appendLine(content)
|
||||
}
|
||||
}
|
||||
return@forEachLine
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
updateLog("sys_error", "Процесс остановлен: ${e.message}", -1, true)
|
||||
} finally {
|
||||
running.value = false
|
||||
process = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCriticalError(message: String) {
|
||||
updateLog("circuit_breaker", "[СТОП] $message", -1, true)
|
||||
stop()
|
||||
}
|
||||
|
||||
private fun handleHashError() {
|
||||
val params = currentParams ?: return
|
||||
val context = lastContext ?: return
|
||||
|
||||
currentHashErrorCount = 0
|
||||
forceRegenerateUA = true // Перегенерируем UA при следующих ошибках
|
||||
|
||||
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
|
||||
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
|
||||
activeHashIndex = 1
|
||||
stopOnlyProcess()
|
||||
start(context, params, isSwitching = true)
|
||||
} else {
|
||||
val msg = if (activeHashIndex == 1) "Запасной хеш тоже мертв. Отключение." else "Хеш умер, запасного нет. Отключение."
|
||||
handleCriticalError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WATCHDOG ====================
|
||||
// Проверяет, жив ли Go-процесс. Если умер — перезапускает.
|
||||
// Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби).
|
||||
private fun startWatchdog(context: Context, params: TunnelParams) {
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = scope.launch {
|
||||
var zeroWorkersSince = 0L
|
||||
delay(10_000) // Даём 10 сек на старт
|
||||
while (isActive && running.value) {
|
||||
val proc = process
|
||||
if (proc == null || !proc.isAlive) {
|
||||
// Go-процесс мёртв!
|
||||
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
|
||||
activeWorkers.value = 0
|
||||
forceRegenerateUA = true
|
||||
killProcess()
|
||||
delay(2000)
|
||||
if (running.value) {
|
||||
start(context, params, isSwitching = true)
|
||||
}
|
||||
return@launch // startWatchdog будет перезапущен из start()
|
||||
}
|
||||
|
||||
// Детекция зомби: процесс жив, но 0 воркеров
|
||||
val workers = activeWorkers.value
|
||||
if (workers <= 0) {
|
||||
if (zeroWorkersSince == 0L) {
|
||||
zeroWorkersSince = System.currentTimeMillis()
|
||||
} else if (
|
||||
wrapAuthTimeoutCount >= 3 &&
|
||||
processStartedAtMs > 0L &&
|
||||
System.currentTimeMillis() - processStartedAtMs > 30_000 &&
|
||||
lastActiveAtMs == 0L &&
|
||||
!ManlCaptchaWebViewManager.isCaptchaPending
|
||||
) {
|
||||
handleCriticalError("\uD83D\uDD12 Неверный пароль подключения или несовместимый WRAP. Воркеры остановлены.")
|
||||
return@launch
|
||||
} else if (System.currentTimeMillis() - zeroWorkersSince > 90_000 && !ManlCaptchaWebViewManager.isCaptchaPending) {
|
||||
updateLog("watchdog", "⚠ Зомби-процесс (0 воркеров 90с). Перезапуск...", 50, true)
|
||||
forceRegenerateUA = true
|
||||
killProcess()
|
||||
delay(2000)
|
||||
if (running.value) {
|
||||
start(context, params, isSwitching = true)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
} else {
|
||||
zeroWorkersSince = 0L
|
||||
}
|
||||
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restartTransport() {
|
||||
val params = currentParams ?: return
|
||||
val context = lastContext ?: return
|
||||
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
|
||||
killProcess() // Только убиваем процесс, running не трогаем!
|
||||
scope.launch {
|
||||
delay(1500)
|
||||
start(context, params, isSwitching = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if (!running.value) return
|
||||
killProcess() // Не ставим running=false, чтоб сервис не умер
|
||||
activeWorkers.value = 0
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
if (currentParams != null && lastContext != null) {
|
||||
scope.launch {
|
||||
start(lastContext!!, currentParams!!, isSwitching = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Убивает процесс без изменения running
|
||||
private fun killProcess() {
|
||||
watchdogJob?.cancel()
|
||||
readerJob?.cancel()
|
||||
val proc = process
|
||||
process = null
|
||||
if (proc != null) {
|
||||
try { proc.destroy() } catch (_: Exception) {}
|
||||
// Даём 500мс на graceful shutdown
|
||||
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
|
||||
if (proc.isAlive) {
|
||||
try { proc.destroyForcibly() } catch (_: Exception) {}
|
||||
try { proc.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopOnlyProcess() {
|
||||
killProcess()
|
||||
running.value = false
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
updateLog("internal_${message.hashCode()}", message, 50, false)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
wgHelper?.stopTunnel()
|
||||
}
|
||||
killProcess()
|
||||
running.value = false
|
||||
activeWorkers.value = 0
|
||||
currentParams = null
|
||||
ManlCaptchaWebViewManager.cancelCaptcha()
|
||||
}
|
||||
|
||||
// Suspend-версия: гарантирует что процесс мёртв и порт свободен
|
||||
suspend fun stopAndWait() {
|
||||
// Сначала останавливаем WireGuard и ждём завершения
|
||||
withContext(Dispatchers.Main) {
|
||||
wgHelper?.stopTunnel()
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
killProcess()
|
||||
running.value = false
|
||||
activeWorkers.value = 0
|
||||
currentParams = null
|
||||
ManlCaptchaWebViewManager.cancelCaptcha()
|
||||
// Ждём освобождения порта 9000 (до 3 секунд)
|
||||
repeat(30) {
|
||||
try {
|
||||
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
|
||||
return@withContext // Порт свободен!
|
||||
} catch (_: Exception) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadWireGuard() {
|
||||
if (running.value) {
|
||||
scope.launch {
|
||||
wgHelper?.reloadTunnel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CAPTCHA SOLVER (WebView Mode) ====================
|
||||
|
||||
/**
|
||||
* Вызывается при получении CAPTCHA_SOLVE от Go-процесса.
|
||||
* auto: одна короткая скрытая попытка для Go-оркестратора.
|
||||
* manual: сразу видимый WebView.
|
||||
* selected: старое поведение из UI, когда пользователь сам выбрал режим.
|
||||
* Результат ВСЕГДА отправляется обратно в Go через writeCaptchaResult.
|
||||
*/
|
||||
private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
|
||||
val ctx = lastContext ?: run {
|
||||
writeCaptchaResult("error:context is null")
|
||||
return
|
||||
}
|
||||
val mode = requestMode.lowercase()
|
||||
|
||||
try {
|
||||
val token = when (mode) {
|
||||
"auto" -> solveSingleAutoWebViewCaptcha(redirectUri, sessionToken)
|
||||
"manual" -> {
|
||||
updateLog("captcha_wv_step_1", "[КАПЧА WBV] Создание ручного WebView...", 5, false)
|
||||
ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||
}
|
||||
else -> {
|
||||
if (currentCaptchaSolveMethod == "auto") {
|
||||
solveAutoWebViewCaptcha(ctx, redirectUri, sessionToken)
|
||||
} else {
|
||||
updateLog("captcha_wv_step_1", "[КАПЧА WBV] Создание ручного WebView...", 5, false)
|
||||
ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLog("captcha_wv_step_4", "[КАПЧА WBV] Капча решена ✓", 5, false)
|
||||
writeCaptchaResult(token)
|
||||
} catch (e: IllegalStateException) {
|
||||
val errorMsg = e.message ?: "WV state error"
|
||||
updateLog("captcha_wv_err", "[КАПЧА WBV] $errorMsg", 5, true)
|
||||
writeCaptchaResult("error:$errorMsg")
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
updateLog("captcha_wv_err", "[КАПЧА WBV] Таймаут WebView", 5, true)
|
||||
writeCaptchaResult("error:timeout")
|
||||
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
|
||||
updateLog("captcha_wv_err", "[КАПЧА WBV] Отменено", 5, true)
|
||||
writeCaptchaResult("error:cancelled")
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = e.message ?: "${e::class.simpleName}"
|
||||
if (errorMsg != "tunnel stopped") {
|
||||
updateLog("captcha_wv_err", "[КАПЧА WBV] Ошибка — $errorMsg", 5, true)
|
||||
}
|
||||
writeCaptchaResult("error:$errorMsg")
|
||||
}
|
||||
|
||||
// WebView уничтожен в finally блоке соответствующего менеджера.
|
||||
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
|
||||
}
|
||||
|
||||
private suspend fun solveSingleAutoWebViewCaptcha(
|
||||
redirectUri: String,
|
||||
sessionToken: String
|
||||
): String {
|
||||
updateLog("captcha_wv_step_1", "[КАПЧА WBV] Авто WebView попытка 10с...", 5, false)
|
||||
return CaptchaWebViewManager.solveCaptchaAsync(redirectUri, sessionToken) { step ->
|
||||
updateLog("captcha_wv_auto_step", "[КАПЧА WBV] $step", 5, false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun solveAutoWebViewCaptcha(
|
||||
ctx: Context,
|
||||
redirectUri: String,
|
||||
sessionToken: String
|
||||
): String {
|
||||
for (attempt in 1..2) {
|
||||
updateLog("captcha_wv_step_1", "[КАПЧА WBV] Авто WebView попытка $attempt/2...", 5, false)
|
||||
try {
|
||||
return CaptchaWebViewManager.solveCaptchaAsync(redirectUri, sessionToken) { step ->
|
||||
updateLog("captcha_wv_auto_step", "[КАПЧА WBV] $step", 5, false)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
updateLog("captcha_wv_timeout_$attempt", "[КАПЧА WBV] Авто таймаут 10с ($attempt/2)", 5, attempt == 2)
|
||||
if (attempt == 2) {
|
||||
updateLog("captcha_wv_fallback", "[КАПЧА WBV] 2 таймаута авто, открыт ручной WebView", 5, false)
|
||||
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e.message == CaptchaWebViewManager.ERROR_SLIDER_DETECTED) {
|
||||
updateLog("captcha_wv_fallback", "[КАПЧА WBV] Обнаружен слайдер, открыт ручной WebView", 5, false)
|
||||
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает результат решения капчи в stdin Go-процесса.
|
||||
*/
|
||||
private fun writeCaptchaResult(result: String) {
|
||||
val proc = process
|
||||
if (proc == null || !proc.isAlive) return
|
||||
try {
|
||||
val line = "CAPTCHA_RESULT|$result\n"
|
||||
proc.outputStream.write(line.toByteArray(Charsets.UTF_8))
|
||||
proc.outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
updateLog("captcha_write_err", "[КАПЧА] Ошибка записи: ${e.message}", 200, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
logs.value = emptyList()
|
||||
activeWorkers.value = 0
|
||||
}
|
||||
|
||||
fun startCooldown(seconds: Int) {
|
||||
cooldownJob?.cancel()
|
||||
cooldownSeconds.value = seconds
|
||||
cooldownJob = scope.launch(Dispatchers.Main) {
|
||||
while (cooldownSeconds.value > 0) {
|
||||
delay(1000)
|
||||
cooldownSeconds.update { it - 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.readableMessage(): String {
|
||||
val text = message ?: localizedMessage
|
||||
return if (text.isNullOrBlank()) this::class.java.simpleName else "${this::class.java.simpleName}: $text"
|
||||
}
|
||||
}
|
||||
|
||||
data class TunnelParams(
|
||||
val peer: String,
|
||||
val vkHashes: String,
|
||||
val secondaryVkHash: String = "",
|
||||
val workersPerHash: Int,
|
||||
val port: Int,
|
||||
val sni: String = "",
|
||||
val connectionPassword: String = "",
|
||||
val protocol: String = "udp",
|
||||
val captchaMode: String = "auto", // "auto", "wv" или "rjs"
|
||||
val captchaSolveMethod: String = "auto" // "manual" или "auto"
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TUNNEL_NOTIFICATION_CHANNEL_ID = "wdtt_tunnel_v4"
|
||||
private const val TUNNEL_NOTIFICATION_ID = 1
|
||||
|
||||
class TunnelService : Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var updateJob: Job? = null
|
||||
private var lastNotificationText: String? = null
|
||||
|
||||
// Network Monitoring
|
||||
private var connectivityManager: ConnectivityManager? = null
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var lastNetworkChangeTime = 0L
|
||||
private val activeNetworks = mutableSetOf<Network>()
|
||||
private var isTunnelPaused = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
// Сразу берем лок при создании
|
||||
acquireWakeLock()
|
||||
setupNetworkCallback()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) {
|
||||
restoreTunnel()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
"START" -> {
|
||||
val notification = createNotification("Запуск...")
|
||||
startPersistentForeground(notification)
|
||||
|
||||
val params = TunnelParams(
|
||||
peer = intent.getStringExtra("peer") ?: "",
|
||||
vkHashes = intent.getStringExtra("vk_hashes") ?: "",
|
||||
secondaryVkHash = intent.getStringExtra("secondary_vk_hash") ?: "",
|
||||
workersPerHash = intent.getIntExtra("workers_per_hash", 16),
|
||||
port = intent.getIntExtra("port", 9000),
|
||||
sni = intent.getStringExtra("sni") ?: "",
|
||||
connectionPassword = intent.getStringExtra("connection_password") ?: "",
|
||||
protocol = intent.getStringExtra("protocol") ?: "udp",
|
||||
captchaMode = sanitizeCaptchaMode(intent.getStringExtra("captcha_mode")),
|
||||
captchaSolveMethod = intent.getStringExtra("captcha_solve_method") ?: "auto"
|
||||
)
|
||||
startTunnel(params)
|
||||
}
|
||||
"STOP" -> stopTunnel()
|
||||
"DEPLOY_START" -> {
|
||||
val notification = createNotification("Установка на сервер...", "DEPLOY_CANCEL", "Отменить")
|
||||
startPersistentForeground(notification)
|
||||
acquireWakeLock()
|
||||
}
|
||||
"DEPLOY_CANCEL" -> {
|
||||
com.wdtt.client.DeployManager.writeError("[!] ❌ Установка отменена пользователем")
|
||||
com.wdtt.client.DeployManager.stopDeploy("error: Отменена пользователем")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
"DEPLOY_STOP" -> {
|
||||
if (!TunnelManager.running.value) {
|
||||
stopTunnel()
|
||||
} else {
|
||||
updateNotification("Туннель активен")
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun restoreTunnel() {
|
||||
val notification = createNotification("Восстановление соединения...")
|
||||
startPersistentForeground(notification)
|
||||
|
||||
val appContext = applicationContext
|
||||
TunnelManager.scope.launch {
|
||||
try {
|
||||
val store = SettingsStore(appContext)
|
||||
val basePeer = store.peer.first()
|
||||
val manualPortsEnabled = store.manualPortsEnabled.first()
|
||||
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
|
||||
val peerWithPort = if (basePeer.isBlank() || basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
|
||||
val params = TunnelParams(
|
||||
peer = peerWithPort,
|
||||
vkHashes = store.vkHashes.first(),
|
||||
secondaryVkHash = store.secondaryVkHash.first(),
|
||||
workersPerHash = store.workersPerHash.first(),
|
||||
port = store.listenPort.first(),
|
||||
sni = store.sni.first(),
|
||||
connectionPassword = store.connectionPassword.first(),
|
||||
captchaMode = sanitizeCaptchaMode(store.captchaMode.first()),
|
||||
captchaSolveMethod = store.captchaSolveMethod.first()
|
||||
)
|
||||
if (params.peer.isNotEmpty() && params.vkHashes.isNotEmpty()) {
|
||||
launch(Dispatchers.Main) {
|
||||
startTunnel(params)
|
||||
}
|
||||
} else {
|
||||
launch(Dispatchers.Main) {
|
||||
stopTunnel()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
stopTunnel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTunnel(params: TunnelParams) {
|
||||
updateNotification("Подключение...")
|
||||
acquireWakeLock()
|
||||
acquireWifiLock()
|
||||
|
||||
// Подготавливаем CaptchaWebViewManager (не создаёт WebView — просто сохраняет контекст)
|
||||
// Вызываем всегда — дёшево, а WebView создаётся на лету при каждом запросе капчи
|
||||
CaptchaWebViewManager.onTunnelStart(applicationContext)
|
||||
|
||||
TunnelManager.start(this, params)
|
||||
startStatsUpdater()
|
||||
}
|
||||
|
||||
private fun stopTunnel() {
|
||||
updateJob?.cancel()
|
||||
|
||||
// Уничтожаем текущий WebView (если капча решается) и чистим контекст
|
||||
CaptchaWebViewManager.onTunnelStop()
|
||||
|
||||
TunnelManager.stop()
|
||||
releaseWakeLock()
|
||||
releaseWifiLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun setupNetworkCallback() {
|
||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
activeNetworks.clear()
|
||||
|
||||
networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
val wasEmpty = activeNetworks.isEmpty()
|
||||
activeNetworks.add(network)
|
||||
if (wasEmpty) {
|
||||
if (isTunnelPaused) {
|
||||
isTunnelPaused = false
|
||||
Log.d("TunnelService", "Сеть появилась, возобновляем туннель")
|
||||
TunnelManager.resume()
|
||||
updateNotification("Подключение...")
|
||||
} else {
|
||||
handleNetworkChange()
|
||||
}
|
||||
} else {
|
||||
handleNetworkChange()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
activeNetworks.remove(network)
|
||||
if (activeNetworks.isEmpty() && TunnelManager.running.value && !isTunnelPaused) {
|
||||
isTunnelPaused = true
|
||||
Log.d("TunnelService", "Сеть потеряна, приостанавливаем туннель")
|
||||
TunnelManager.pause()
|
||||
updateNotification("Ожидание сети (Фоновый сон)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ВАЖНО: Слушаем только реальные (не VPN) сети с доступом в интернет.
|
||||
// Иначе интерфейс VPN (tun0) считается активной сетью, и при "Режиме полёта" activeNetworks не падает до 0.
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
|
||||
connectivityManager?.registerNetworkCallback(request, networkCallback!!)
|
||||
}
|
||||
|
||||
private fun handleNetworkChange() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastNetworkChangeTime < 5000) return
|
||||
lastNetworkChangeTime = now
|
||||
|
||||
if (TunnelManager.running.value && !isTunnelPaused) {
|
||||
Log.d("TunnelService", "Сеть изменилась, мягкий перезапуск Go-клиента")
|
||||
TunnelManager.restartTransport()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeCaptchaMode(mode: String?): String {
|
||||
return when (mode?.lowercase()) {
|
||||
"auto" -> "auto"
|
||||
"rjs" -> "rjs"
|
||||
"wv" -> "wv"
|
||||
else -> "auto"
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"wdtt:tunnel_cpu"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWifiLock() {
|
||||
if (wifiLock?.isHeld == true) return
|
||||
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
|
||||
// Используем WIFI_MODE_FULL_LOW_LATENCY для Android 10+,
|
||||
// это предотвращает отключение радиомодуля при выключенном экране
|
||||
val mode = if (Build.VERSION.SDK_INT >= 29) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
|
||||
wifiLock = wm.createWifiLock(mode, "wdtt:wifi_perf").apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
if (wakeLock?.isHeld == true) {
|
||||
wakeLock?.release()
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun releaseWifiLock() {
|
||||
if (wifiLock?.isHeld == true) {
|
||||
wifiLock?.release()
|
||||
}
|
||||
wifiLock = null
|
||||
}
|
||||
|
||||
private fun startStatsUpdater() {
|
||||
updateJob?.cancel()
|
||||
updateJob = TunnelManager.scope.launch(Dispatchers.Main) {
|
||||
delay(1000)
|
||||
while (isActive) {
|
||||
if (!TunnelManager.running.value && !isTunnelPaused) {
|
||||
// Туннель полностью остановлен (не на паузе) — убиваем сервис
|
||||
stopSelf()
|
||||
break
|
||||
}
|
||||
if (!isTunnelPaused) {
|
||||
updateNotification(buildTunnelNotificationText())
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTunnelNotificationText(): String {
|
||||
val statsText = TunnelManager.stats.value.trim()
|
||||
return when {
|
||||
statsText.isEmpty() -> "Туннель активен"
|
||||
statsText == "Ожидание данных..." -> "Туннель активен"
|
||||
else -> statsText
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
TUNNEL_NOTIFICATION_CHANNEL_ID,
|
||||
"WDTT Туннель",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Уведомление о работе туннеля"
|
||||
setShowBadge(false)
|
||||
// ВАЖНО: Разрешаем показывать на экране блокировки
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun createNotification(text: String, actionName: String = "STOP", actionTitle: String = "Отключить"): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this, if (actionName == "STOP") 1 else 2,
|
||||
Intent(this, TunnelService::class.java).apply { action = actionName },
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, TUNNEL_NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("WDTT")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_stat_connected)
|
||||
.setOngoing(true)
|
||||
.setLocalOnly(true)
|
||||
.setContentIntent(openIntent)
|
||||
.addAction(R.drawable.ic_stop, actionTitle, stopIntent)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFAULT)
|
||||
// ВАЖНО: Делаем уведомление публичным (видимым на локскрине)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
// Категория SERVICE помогает системе понять важность
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOnlyAlertOnce(true) // Не издавать звук и не будить экран при обновлении статистики!
|
||||
.setSilent(true) // Делаем тихим само уведомление
|
||||
.setShowWhen(false)
|
||||
.setUsesChronometer(false)
|
||||
.setWhen(0L)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun startPersistentForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
startForeground(TUNNEL_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(TUNNEL_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
if (lastNotificationText == text) return
|
||||
lastNotificationText = text
|
||||
val notification = createNotification(text)
|
||||
getSystemService(NotificationManager::class.java).notify(TUNNEL_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
networkCallback?.let {
|
||||
connectivityManager?.unregisterNetworkCallback(it)
|
||||
}
|
||||
stopTunnel()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.os.Build
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Генерирует реалистичный User-Agent на основе информации об устройстве.
|
||||
* Включает случайные вариации версий браузеров и мелкие опечатки для уникальности.
|
||||
*/
|
||||
object UserAgentGenerator {
|
||||
|
||||
private val chromeVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131)
|
||||
private val firefoxVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130)
|
||||
private val edgeVersions = listOf(120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130)
|
||||
private val yaBrowserVersions = listOf("24.1", "24.2", "24.3", "24.4", "24.5", "24.6", "24.7", "24.8", "24.9", "24.10", "24.11", "24.12")
|
||||
private val operaVersions = listOf(106, 107, 108, 109, 110, 111, 112, 113, 114, 115)
|
||||
|
||||
/**
|
||||
* Генерирует User-Agent для текущего устройства.
|
||||
* @param seed опциональный seed для детерминированной генерации (на основе device ID)
|
||||
*/
|
||||
fun generate(seed: Long? = null): String {
|
||||
val rng = seed?.let { Random(it) } ?: Random.Default
|
||||
|
||||
val androidVersion = Build.VERSION.RELEASE
|
||||
val deviceModel = Build.MODEL ?: "Unknown"
|
||||
|
||||
val androidPlatform = "Linux; Android $androidVersion; $deviceModel"
|
||||
|
||||
val chromeVersion = chromeVersions[rng.nextInt(chromeVersions.size)]
|
||||
val patchVersion = rng.nextInt(0, 99)
|
||||
val fullChromeVersion = "$chromeVersion.0.$patchVersion.0"
|
||||
|
||||
val browserType = rng.nextInt(100)
|
||||
|
||||
return when {
|
||||
// 60% — обычный Chrome на Android
|
||||
browserType < 60 -> {
|
||||
"Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36"
|
||||
}
|
||||
// 15% — Chrome на десктопе (имитация)
|
||||
browserType < 75 -> {
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Safari/537.36"
|
||||
}
|
||||
// 8% — Yandex Browser
|
||||
browserType < 83 -> {
|
||||
val yaVer = yaBrowserVersions[rng.nextInt(yaBrowserVersions.size)]
|
||||
val yaPatch = rng.nextInt(0, 9)
|
||||
"Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$chromeVersion.0.$patchVersion.0 YaBrowser/$yaVer.$yaPatch Yowser/2.5 Mobile Safari/537.36"
|
||||
}
|
||||
// 7% — Firefox
|
||||
browserType < 90 -> {
|
||||
val ffVersion = firefoxVersions[rng.nextInt(firefoxVersions.size)]
|
||||
"Mozilla/5.0 ($androidPlatform; rv:$ffVersion.0) Gecko/20100101 Firefox/$ffVersion.0"
|
||||
}
|
||||
// 5% — Edge
|
||||
browserType < 95 -> {
|
||||
val edgeVersion = edgeVersions[rng.nextInt(edgeVersions.size)]
|
||||
"Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36 EdgA/$edgeVersion.0.${rng.nextInt(0, 99)}.${rng.nextInt(0, 99)}"
|
||||
}
|
||||
// 5% — Opera
|
||||
else -> {
|
||||
val operaVersion = operaVersions[rng.nextInt(operaVersions.size)]
|
||||
"Mozilla/5.0 ($androidPlatform) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$fullChromeVersion Mobile Safari/537.36 OPR/$operaVersion.0.${rng.nextInt(0, 99)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует детерминированный UA на основе device ID.
|
||||
* Один и тот же device ID всегда даёт одинаковый UA.
|
||||
*/
|
||||
fun generateForDevice(deviceId: String): String {
|
||||
val seed = deviceId.hashCode().toLong() and 0xFFFFFFFFL
|
||||
return generate(seed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.wireguard.android.backend.GoBackend
|
||||
|
||||
class WdttApplication : Application() {
|
||||
@Volatile
|
||||
private var backendInstance: GoBackend? = null
|
||||
|
||||
val backend: GoBackend
|
||||
get() = getBackend(this)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DeployManager.init(this)
|
||||
}
|
||||
|
||||
fun getBackend(context: Context): GoBackend {
|
||||
return backendInstance ?: synchronized(this) {
|
||||
backendInstance ?: GoBackend(context.applicationContext).also { backendInstance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.wdtt.client
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import com.wireguard.android.backend.GoBackend
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.wireguard.config.Config
|
||||
import com.wireguard.config.Interface
|
||||
import com.wireguard.config.Peer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class WireGuardHelper(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val backend = (appContext as WdttApplication).getBackend(context)
|
||||
|
||||
private companion object {
|
||||
val wgMutex = Mutex()
|
||||
var sharedTunnel: WgTunnel? = null
|
||||
}
|
||||
|
||||
class WgTunnel : Tunnel {
|
||||
override fun getName() = "wdtt"
|
||||
override fun onStateChange(newState: Tunnel.State) {}
|
||||
}
|
||||
|
||||
suspend fun startTunnel(configString: String) = wgMutex.withLock {
|
||||
startTunnelLocked(configString)
|
||||
}
|
||||
|
||||
private suspend fun startTunnelLocked(configString: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (VpnService.prepare(appContext) != null) {
|
||||
throw IllegalStateException("VPN-разрешение не выдано")
|
||||
}
|
||||
|
||||
ensureGoBackendServiceStarted()
|
||||
|
||||
sharedTunnel?.let { existingTunnel ->
|
||||
try {
|
||||
backend.setState(existingTunnel, Tunnel.State.DOWN, null)
|
||||
} catch (e: Exception) {
|
||||
Log.w("WG", "Failed to stop previous tunnel before restart: ${e.readableMessage()}")
|
||||
}
|
||||
sharedTunnel = null
|
||||
delay(150)
|
||||
}
|
||||
|
||||
val parsedConfig = Config.parse(ByteArrayInputStream(configString.toByteArray(Charsets.UTF_8)))
|
||||
|
||||
val builder = Interface.Builder()
|
||||
.parseAddresses(parsedConfig.`interface`.addresses.joinToString(", ") { it.toString() })
|
||||
|
||||
if (parsedConfig.`interface`.dnsServers.isNotEmpty()) {
|
||||
builder.parseDnsServers(parsedConfig.`interface`.dnsServers.joinToString(", ") { it.hostAddress ?: "" })
|
||||
}
|
||||
if (parsedConfig.`interface`.listenPort.isPresent) {
|
||||
builder.parseListenPort(parsedConfig.`interface`.listenPort.get().toString())
|
||||
}
|
||||
if (parsedConfig.`interface`.mtu.isPresent) {
|
||||
val serverMtu = parsedConfig.`interface`.mtu.get()
|
||||
// Используем серверное значение, но не менее 1280 для мобильных сетей
|
||||
builder.parseMtu(serverMtu.coerceAtLeast(1280).toString())
|
||||
} else {
|
||||
builder.parseMtu("1280")
|
||||
}
|
||||
builder.parsePrivateKey(parsedConfig.`interface`.keyPair.privateKey.toBase64())
|
||||
|
||||
// 1. Пакеты, которые всегда исключаются (наше приложение, ВК)
|
||||
// 2. Получаю настройки пользователя
|
||||
val settingsStore = SettingsStore(appContext)
|
||||
val savedExcluded = settingsStore.excludedApps.first()
|
||||
|
||||
val userSelected = savedExcluded.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
|
||||
// В обоих режимах (ЧС и БС) мы технически используем Blacklist (Checked = Excluded),
|
||||
// так как пользователю удобнее логика "снимите галочку, чтобы приложение пошло в туннель".
|
||||
// Разница только в описании и начальном состоянии списка (пустой/полный).
|
||||
val excluded = mutableSetOf(appContext.packageName, "com.vkontakte.android", "com.vk.calls")
|
||||
excluded.addAll(userSelected)
|
||||
val installedExcluded = excluded.filter { it.isInstalledPackage() }.toSet()
|
||||
if (installedExcluded.isNotEmpty()) {
|
||||
builder.excludeApplications(installedExcluded)
|
||||
}
|
||||
|
||||
val newInterface = builder.build()
|
||||
|
||||
val peerBuilder = Peer.Builder()
|
||||
val firstPeer = parsedConfig.peers.firstOrNull()
|
||||
?: throw IllegalStateException("WireGuard config has no peer")
|
||||
firstPeer.let { peer ->
|
||||
peerBuilder.parsePublicKey(peer.publicKey.toBase64())
|
||||
if (peer.preSharedKey.isPresent) peerBuilder.parsePreSharedKey(peer.preSharedKey.get().toBase64())
|
||||
if (peer.endpoint.isPresent) peerBuilder.parseEndpoint(peer.endpoint.get().toString())
|
||||
if (peer.persistentKeepalive.isPresent) peerBuilder.parsePersistentKeepalive(peer.persistentKeepalive.get().toString())
|
||||
}
|
||||
// Override AllowedIPs
|
||||
peerBuilder.parseAllowedIPs("0.0.0.0/0")
|
||||
|
||||
val finalConfig = Config.Builder()
|
||||
.setInterface(newInterface)
|
||||
.addPeer(peerBuilder.build())
|
||||
.build()
|
||||
|
||||
val nextTunnel = WgTunnel()
|
||||
setTunnelUpWithRetry(nextTunnel, finalConfig)
|
||||
sharedTunnel = nextTunnel
|
||||
Log.d("WG", "WireGuard tunnel started successfully")
|
||||
} catch (e: Exception) {
|
||||
val detailed = "WireGuard start failed: ${e.readableMessage()}; ${configString.describeWireGuardConfig()}"
|
||||
Log.e("WG", detailed)
|
||||
e.printStackTrace()
|
||||
throw IllegalStateException(detailed, e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reloadTunnel() = wgMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val currentTunnel = sharedTunnel ?: return@withContext
|
||||
try {
|
||||
val configFlow = TunnelManager.config.first() ?: return@withContext
|
||||
backend.setState(currentTunnel, Tunnel.State.DOWN, null)
|
||||
sharedTunnel = null
|
||||
delay(150)
|
||||
startTunnelLocked(configFlow)
|
||||
Log.d("WG", "WireGuard tunnel reloaded for new exceptions")
|
||||
} catch (e: Exception) {
|
||||
Log.e("WG", "Failed to reload WireGuard: ${e.readableMessage()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopTunnel() = wgMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
sharedTunnel?.let {
|
||||
backend.setState(it, Tunnel.State.DOWN, null)
|
||||
sharedTunnel = null
|
||||
Log.d("WG", "WireGuard tunnel stopped")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("WG", "Failed to stop WireGuard: ${e.readableMessage()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureGoBackendServiceStarted() {
|
||||
withContext(Dispatchers.Main) {
|
||||
runCatching {
|
||||
val intent = Intent(appContext, GoBackend.VpnService::class.java)
|
||||
appContext.startService(intent)
|
||||
}.onFailure {
|
||||
Log.w("WG", "GoBackend service warmup failed: ${it.readableMessage()}")
|
||||
}
|
||||
}
|
||||
delay(300)
|
||||
}
|
||||
|
||||
private suspend fun setTunnelUpWithRetry(nextTunnel: WgTunnel, finalConfig: Config) {
|
||||
var lastError: Exception? = null
|
||||
repeat(3) { attempt ->
|
||||
try {
|
||||
backend.setState(nextTunnel, Tunnel.State.UP, finalConfig)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
Log.w("WG", "WireGuard UP attempt ${attempt + 1}/3 failed: ${e.readableMessage()}")
|
||||
runCatching { backend.setState(nextTunnel, Tunnel.State.DOWN, null) }
|
||||
ensureGoBackendServiceStarted()
|
||||
delay(250L * (attempt + 1))
|
||||
}
|
||||
}
|
||||
throw lastError ?: IllegalStateException("WireGuard UP failed")
|
||||
}
|
||||
|
||||
private fun Throwable.readableMessage(): String {
|
||||
val text = message ?: localizedMessage
|
||||
return if (text.isNullOrBlank()) this::class.java.simpleName else "${this::class.java.simpleName}: $text"
|
||||
}
|
||||
|
||||
private fun String.isInstalledPackage(): Boolean {
|
||||
return runCatching {
|
||||
appContext.packageManager.getPackageInfo(this, 0)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun String.describeWireGuardConfig(): String {
|
||||
val lines = lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList()
|
||||
val hasInterface = lines.any { it.equals("[Interface]", ignoreCase = true) }
|
||||
val hasPeer = lines.any { it.equals("[Peer]", ignoreCase = true) }
|
||||
val hasPrivateKey = lines.any { it.startsWith("PrivateKey", ignoreCase = true) }
|
||||
val hasPublicKey = lines.any { it.startsWith("PublicKey", ignoreCase = true) }
|
||||
val hasAddress = lines.any { it.startsWith("Address", ignoreCase = true) }
|
||||
val endpoint = lines.firstOrNull { it.startsWith("Endpoint", ignoreCase = true) }
|
||||
?.substringAfter("=", "")
|
||||
?.trim()
|
||||
?.take(80)
|
||||
?: "none"
|
||||
return "config lines=${lines.size}, interface=$hasInterface, peer=$hasPeer, privateKey=$hasPrivateKey, publicKey=$hasPublicKey, address=$hasAddress, endpoint=$endpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
private fun appSectionCardColor(): Color {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val isDark = colors.background.luminance() < 0.22f
|
||||
return if (isDark) {
|
||||
lerp(colors.surface, colors.surfaceVariant, 0.10f)
|
||||
} else {
|
||||
lerp(colors.surface, colors.surfaceVariant, 0.28f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun appSectionCardBorderColor(): Color {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val isDark = colors.background.luminance() < 0.22f
|
||||
return if (isDark) {
|
||||
colors.outlineVariant.copy(alpha = 0.26f)
|
||||
} else {
|
||||
colors.outlineVariant.copy(alpha = 0.24f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppSectionCard(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp),
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = appSectionCardColor(),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, appSectionCardBorderColor()),
|
||||
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp,
|
||||
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = verticalArrangement,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Update
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.wdtt.client.AppReleaseInfo
|
||||
import com.wdtt.client.RemoteVersionSource
|
||||
|
||||
@Composable
|
||||
fun AppUpdateDialog(
|
||||
release: AppReleaseInfo,
|
||||
onPostpone: () -> Unit,
|
||||
onUpdate: () -> Unit
|
||||
) {
|
||||
val isTagOnly = release.source == RemoteVersionSource.Tag
|
||||
val title = if (isTagOnly) "Найден новый tag" else "Доступно обновление"
|
||||
val description = if (isTagOnly) {
|
||||
"На GitHub обнаружен более новый tag ${release.versionTag}. Похоже, опубликованный release ещё не догнал его."
|
||||
} else {
|
||||
"Вышла новая версия приложения ${release.versionTag}. Можно открыть страницу релиза и обновиться вручную."
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = false,
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth(0.92f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Update,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = release.versionTag,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onPostpone,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Text("Позже", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onUpdate,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Text("Обновить", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.wdtt.client.TunnelService
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jcraft.jsch.ChannelExec
|
||||
import com.jcraft.jsch.ChannelSftp
|
||||
import com.jcraft.jsch.JSch
|
||||
import com.jcraft.jsch.Session
|
||||
import com.wdtt.client.DeployManager
|
||||
import com.wdtt.client.SettingsStore
|
||||
import com.wdtt.client.TunnelManager
|
||||
import com.wdtt.client.WDTTColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Properties
|
||||
|
||||
private const val CMD_TIMEOUT = 900000L // 15 minutes
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeployTab() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
|
||||
LaunchedEffect(Unit) { DeployManager.init(context) }
|
||||
|
||||
val savedIp by settingsStore.deployIp.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedLogin by settingsStore.deployLogin.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedPassword by settingsStore.deployPassword.collectAsStateWithLifecycle(initialValue = "")
|
||||
|
||||
var ip by remember { mutableStateOf("") }
|
||||
var login by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
val savedMainPass by settingsStore.deployMainPassword.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedAdminId by settingsStore.deployAdminId.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedBotToken by settingsStore.deployBotToken.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedSshPort by settingsStore.deploySshPort.collectAsStateWithLifecycle(initialValue = "22")
|
||||
val savedManualPorts by settingsStore.manualPortsEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val savedServerDtlsPort by settingsStore.serverDtlsPort.collectAsStateWithLifecycle(initialValue = 56000)
|
||||
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
|
||||
|
||||
var showSecretsDialog by remember { mutableStateOf(false) }
|
||||
var showUninstallDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var showSuccessBanner by rememberSaveable { mutableStateOf(false) }
|
||||
var successCountdown by rememberSaveable { mutableIntStateOf(5) }
|
||||
|
||||
LaunchedEffect(showSuccessBanner) {
|
||||
if (showSuccessBanner) {
|
||||
while (successCountdown > 0) {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
successCountdown--
|
||||
}
|
||||
showSuccessBanner = false
|
||||
}
|
||||
}
|
||||
|
||||
val isDeploying by DeployManager.isDeploying.collectAsStateWithLifecycle()
|
||||
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
|
||||
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp }
|
||||
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin }
|
||||
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword }
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = deployProgress,
|
||||
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Настройки сервера",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// ═══ Поля ввода в Card ═══
|
||||
AppSectionCard(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = ip,
|
||||
onValueChange = {
|
||||
ip = it.filter { c -> !c.isWhitespace() }
|
||||
scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) }
|
||||
},
|
||||
label = { Text("IP сервера или домен (без порта)") },
|
||||
placeholder = { Text("1.2.3.4 (без порта)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
enabled = !isDeploying,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = login,
|
||||
onValueChange = {
|
||||
login = it.filter { c -> !c.isWhitespace() }
|
||||
scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) }
|
||||
},
|
||||
label = { Text("Логин") },
|
||||
placeholder = { Text("root") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
enabled = !isDeploying,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it.filter { c -> !c.isWhitespace() }
|
||||
scope.launch { settingsStore.saveDeploy(ip, login, password, savedSshPort) }
|
||||
},
|
||||
label = { Text("Пароль SSH") },
|
||||
placeholder = { Text("password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
enabled = !isDeploying,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Ручное управление портами",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Switch(
|
||||
checked = savedManualPorts,
|
||||
enabled = !isDeploying,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { settingsStore.saveManualPortsEnabled(enabled) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSecretsDialog) {
|
||||
DeploySecretsDialog(
|
||||
settingsStore = settingsStore,
|
||||
initialMainPass = savedMainPass,
|
||||
initialAdminId = savedAdminId,
|
||||
initialBotToken = savedBotToken,
|
||||
initialSshPort = savedSshPort,
|
||||
manualPortsEnabled = savedManualPorts,
|
||||
initialServerDtlsPort = savedServerDtlsPort.toString(),
|
||||
initialServerWgPort = savedServerWgPort.toString(),
|
||||
onSaved = { _, _ -> },
|
||||
onDismiss = { showSecretsDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// ═══ Прогресс ═══
|
||||
if (isDeploying) {
|
||||
AppSectionCard(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = currentStep,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "${(animatedProgress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Кнопки ═══
|
||||
val deploySecretsMissing = savedMainPass.isBlank()
|
||||
OutlinedButton(
|
||||
onClick = { showSecretsDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (deploySecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (deploySecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (deploySecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Key, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (savedManualPorts) "Секреты (BOT, Пароли, Порты)" else "Секреты (BOT, Пароли)",
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (ip.isBlank() || password.isBlank() || savedMainPass.isBlank()) return@Button
|
||||
val effectiveLogin = if (login.isBlank()) "root" else login
|
||||
val effectiveDtlsPort = if (savedManualPorts) savedServerDtlsPort.coerceIn(1, 65535) else 56000
|
||||
val effectiveWgPort = if (savedManualPorts) savedServerWgPort.coerceIn(1, 65535) else 56001
|
||||
val appContext = context.applicationContext
|
||||
DeployManager.scope.launch {
|
||||
try {
|
||||
DeployManager.startDeploy()
|
||||
val intent = Intent(appContext, TunnelService::class.java).apply { action = "DEPLOY_START" }
|
||||
if (Build.VERSION.SDK_INT >= 26) appContext.startForegroundService(intent)
|
||||
else appContext.startService(intent)
|
||||
|
||||
val success = performDeploy(
|
||||
context = appContext,
|
||||
host = ip, user = effectiveLogin, pass = password, port = savedSshPort.toIntOrNull() ?: 22,
|
||||
mainPass = savedMainPass, adminId = savedAdminId, botToken = savedBotToken,
|
||||
dtlsPort = effectiveDtlsPort, wgPort = effectiveWgPort,
|
||||
onProgress = { p, s -> DeployManager.updateProgress(p, s) }
|
||||
)
|
||||
if (success) {
|
||||
successCountdown = 5
|
||||
showSuccessBanner = true
|
||||
}
|
||||
} finally {
|
||||
try { appContext.startService(Intent(appContext, TunnelService::class.java).apply { action = "DEPLOY_STOP" }) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary),
|
||||
enabled = !isDeploying && ip.isNotBlank() && password.isNotBlank() && savedMainPass.isNotBlank()
|
||||
) {
|
||||
if (isDeploying) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.CloudUpload, null, Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isDeploying) "Установка..." else "Установить", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (ip.isBlank() || password.isBlank()) return@Button
|
||||
showUninstallDialog = true
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
),
|
||||
enabled = !isDeploying && ip.isNotBlank() && password.isNotBlank()
|
||||
) {
|
||||
Icon(Icons.Default.Delete, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Удалить", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
if (showUninstallDialog) {
|
||||
UninstallConfirmDialog(
|
||||
onDismiss = { showUninstallDialog = false },
|
||||
onConfirm = {
|
||||
showUninstallDialog = false
|
||||
val effectiveLogin = if (login.isBlank()) "root" else login
|
||||
val effectiveDtlsPort = if (savedManualPorts) savedServerDtlsPort.coerceIn(1, 65535) else 56000
|
||||
val effectiveWgPort = if (savedManualPorts) savedServerWgPort.coerceIn(1, 65535) else 56001
|
||||
DeployManager.scope.launch {
|
||||
try {
|
||||
DeployManager.startDeploy()
|
||||
performUninstall(
|
||||
host = ip, user = effectiveLogin, pass = password, port = savedSshPort.toIntOrNull() ?: 22,
|
||||
dtlsPort = effectiveDtlsPort, wgPort = effectiveWgPort,
|
||||
onProgress = { p, s -> DeployManager.updateProgress(p, s) }
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// ═══ Success Banner ═══
|
||||
if (showSuccessBanner) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = WDTTColors.connected.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, WDTTColors.connected.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = WDTTColors.connected)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Деплой успешно завершен ($successCountdown)",
|
||||
color = WDTTColors.connected,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SSH ====================
|
||||
|
||||
private class SSHClient(private val session: Session, private val pass: String) {
|
||||
|
||||
fun exec(command: String, timeout: Long = CMD_TIMEOUT): String {
|
||||
if (!session.isConnected) {
|
||||
DeployManager.writeError("SSH exec: сессия разорвана перед командой: ${command.take(80)}")
|
||||
return "error: session is down"
|
||||
}
|
||||
|
||||
var channel: ChannelExec? = null
|
||||
val result = StringBuilder()
|
||||
|
||||
return try {
|
||||
channel = session.openChannel("exec") as ChannelExec
|
||||
val cmd = if (command.contains("sudo") && !command.contains("sudo -S")) {
|
||||
command.replace("sudo ", "sudo -S ")
|
||||
} else command
|
||||
|
||||
channel.setCommand(cmd)
|
||||
val outStream = channel.outputStream
|
||||
val input = channel.inputStream
|
||||
val err = channel.errStream
|
||||
channel.connect(15000)
|
||||
|
||||
if (cmd.contains("sudo -S")) {
|
||||
outStream.write("$pass\n".toByteArray())
|
||||
outStream.flush()
|
||||
}
|
||||
|
||||
val reader = input.bufferedReader()
|
||||
val errReader = err.bufferedReader()
|
||||
val startTime = System.currentTimeMillis()
|
||||
val progressRegex = Regex("^WDTT_PROGRESS\\|(\\d+\\.?\\d*)\\|(.+)$")
|
||||
|
||||
while (!channel.isClosed || reader.ready() || errReader.ready()) {
|
||||
if (System.currentTimeMillis() - startTime > timeout) {
|
||||
DeployManager.writeError("SSH timeout (${timeout/1000}s): ${command.take(80)}")
|
||||
try { channel.disconnect() } catch (_: Exception) {}
|
||||
return "error: timeout"
|
||||
}
|
||||
|
||||
if (reader.ready()) {
|
||||
val line = reader.readLine()
|
||||
if (line != null) {
|
||||
val match = progressRegex.find(line.trim())
|
||||
if (match != null) {
|
||||
val p = match.groupValues[1].toFloatOrNull() ?: 0f
|
||||
DeployManager.updateProgress(p, match.groupValues[2])
|
||||
} else if (!line.contains("WDTT_PROGRESS")) {
|
||||
val clean = line.replace(Regex("\u001B\\[[;\\d]*m"), "")
|
||||
result.appendLine(clean)
|
||||
if (clean.contains("[✗]") || clean.contains("FAIL") ||
|
||||
(clean.contains("error", true) && !clean.contains("2>/dev/null"))) {
|
||||
DeployManager.writeError("REMOTE: $clean")
|
||||
TunnelManager.addDeployErrorLog("REMOTE: $clean")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errReader.ready()) {
|
||||
val line = errReader.readLine()
|
||||
if (line != null && !line.contains("password for")) {
|
||||
val clean = line.replace(Regex("\u001B\\[[;\\d]*m"), "")
|
||||
result.appendLine(clean)
|
||||
if (clean.isNotBlank() && !clean.startsWith("Warning:")) {
|
||||
DeployManager.writeError("STDERR: $clean")
|
||||
TunnelManager.addDeployErrorLog("STDERR: $clean")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reader.ready() && !errReader.ready()) Thread.sleep(100)
|
||||
}
|
||||
|
||||
result.toString()
|
||||
} catch (e: Exception) {
|
||||
DeployManager.writeError("SSH exec error: ${e.message} | cmd: ${command.take(80)}")
|
||||
TunnelManager.addDeployErrorLog("SSH exec error: ${e.message}")
|
||||
"error: ${e.message}"
|
||||
} finally {
|
||||
try { channel?.disconnect() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(localFile: File, remotePath: String) {
|
||||
if (!session.isConnected) {
|
||||
DeployManager.writeError("SSH upload: сессия разорвана")
|
||||
throw Exception("Session is down")
|
||||
}
|
||||
var sftp: ChannelSftp? = null
|
||||
try {
|
||||
sftp = session.openChannel("sftp") as ChannelSftp
|
||||
sftp.connect(15000)
|
||||
sftp.put(localFile.absolutePath, remotePath)
|
||||
} catch (e: Exception) {
|
||||
DeployManager.writeError("SFTP upload error: ${e.message} | file: ${localFile.name}")
|
||||
throw e
|
||||
} finally {
|
||||
try { sftp?.disconnect() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSSHSession(host: String, user: String, pass: String, port: Int = 22): Session {
|
||||
val jsch = JSch()
|
||||
val session = jsch.getSession(user, host, port)
|
||||
session.setPassword(pass)
|
||||
session.setConfig(Properties().apply {
|
||||
put("StrictHostKeyChecking", "no")
|
||||
put("ServerAliveInterval", "10")
|
||||
put("ServerAliveCountMax", "6")
|
||||
put("ConnectTimeout", "15000")
|
||||
put("PreferredAuthentications", "password,keyboard-interactive")
|
||||
})
|
||||
session.connect(20000)
|
||||
return session
|
||||
}
|
||||
|
||||
private fun shellQuote(value: String): String {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private fun rootCommand(command: String): String {
|
||||
val quoted = shellQuote(command)
|
||||
return "if command -v sudo >/dev/null 2>&1; then sudo bash -c $quoted; " +
|
||||
"elif [ \"\$(id -u)\" = \"0\" ]; then bash -c $quoted; " +
|
||||
"else echo 'error: root privileges required and sudo not found'; exit 1; fi"
|
||||
}
|
||||
|
||||
private fun File.containsBinaryToken(token: String): Boolean {
|
||||
val data = readBytes()
|
||||
val needle = token.toByteArray()
|
||||
if (needle.isEmpty() || data.size < needle.size) return false
|
||||
for (i in 0..data.size - needle.size) {
|
||||
var matched = true
|
||||
for (j in needle.indices) {
|
||||
if (data[i + j] != needle[j]) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matched) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isUnsafeLegacyServerAsset(serverFile: File): Boolean {
|
||||
return serverFile.containsBinaryToken("/etc/wireguard") ||
|
||||
(serverFile.containsBinaryToken("wg0") && !serverFile.containsBinaryToken("wdtt0"))
|
||||
}
|
||||
|
||||
// ==================== Deploy ====================
|
||||
|
||||
private suspend fun performDeploy(
|
||||
context: Context,
|
||||
host: String, user: String, pass: String, port: Int,
|
||||
mainPass: String, adminId: String, botToken: String,
|
||||
dtlsPort: Int, wgPort: Int,
|
||||
onProgress: (Float, String) -> Unit
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
var session: Session? = null
|
||||
try {
|
||||
onProgress(0.02f, "Подключение...")
|
||||
session = createSSHSession(host, user, pass, port)
|
||||
DeployManager.activeSession = session
|
||||
val ssh = SSHClient(session, pass)
|
||||
|
||||
onProgress(0.05f, "Подготовка файлов...")
|
||||
val passArg = if (mainPass.isNotBlank()) "-password \"$mainPass\" " else ""
|
||||
val adminArg = if (adminId.isNotBlank()) "-admin \"$adminId\" " else ""
|
||||
val botArg = if (botToken.isNotBlank()) "-bot-token \"$botToken\" " else ""
|
||||
val args = "$passArg$adminArg$botArg".trim()
|
||||
|
||||
val scriptFile = File(context.cacheDir, "deploy.sh")
|
||||
val serverFile = File(context.cacheDir, "server")
|
||||
try {
|
||||
context.assets.open("deploy.sh").use { inp -> FileOutputStream(scriptFile).use { out -> inp.copyTo(out) } }
|
||||
context.assets.open("server").use { inp -> FileOutputStream(serverFile).use { out -> inp.copyTo(out) } }
|
||||
} catch (e: Exception) {
|
||||
DeployManager.writeError("Assets extraction failed: ${e.message}")
|
||||
DeployManager.stopDeploy("Ошибка: файлы не найдены в assets")
|
||||
return@withContext false
|
||||
}
|
||||
if (isUnsafeLegacyServerAsset(serverFile)) {
|
||||
scriptFile.delete()
|
||||
serverFile.delete()
|
||||
DeployManager.writeError("Unsafe legacy server asset: найдено wg0 или /etc/wireguard. Нужна пересборка server под wdtt0 и /etc/wdtt.")
|
||||
DeployManager.stopDeploy("Нужна пересборка server asset")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
onProgress(0.06f, "Загрузка на сервер...")
|
||||
ssh.upload(scriptFile, "/tmp/deploy.sh")
|
||||
ssh.upload(serverFile, "/tmp/wdtt-server")
|
||||
scriptFile.delete()
|
||||
serverFile.delete()
|
||||
|
||||
onProgress(0.08f, "Установка...")
|
||||
val output = ssh.exec(
|
||||
rootCommand("env WDTT_ARGS=${shellQuote(args)} WDTT_DTLS_PORT=$dtlsPort WDTT_WG_PORT=$wgPort WDTT_SSH_PORT=$port bash /tmp/deploy.sh"),
|
||||
timeout = CMD_TIMEOUT
|
||||
)
|
||||
|
||||
if (output.contains("✅") || output.contains("Деплой успешно") || output.contains("active")) {
|
||||
DeployManager.stopDeploy("success")
|
||||
TunnelManager.addDeploySuccessLog("Деплой успешно завершен. Сервис активен.")
|
||||
return@withContext true
|
||||
} else if (output.contains("error:")) {
|
||||
DeployManager.writeError("Deploy script output contains error")
|
||||
DeployManager.stopDeploy("Ошибка выполнения скрипта (см. errors.log)")
|
||||
return@withContext false
|
||||
} else {
|
||||
DeployManager.stopDeploy("success")
|
||||
TunnelManager.addDeploySuccessLog("Деплой завершён. (Проверьте подключение)")
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
DeployManager.writeError("Deploy critical: ${e.message}\n${e.stackTraceToString().take(500)}")
|
||||
DeployManager.stopDeploy("Ошибка: ${e.message?.take(100)}")
|
||||
return@withContext false
|
||||
} finally {
|
||||
try { session?.disconnect() } catch (_: Exception) {}
|
||||
DeployManager.activeSession = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== Uninstall ====================
|
||||
|
||||
private suspend fun performUninstall(
|
||||
host: String, user: String, pass: String, port: Int,
|
||||
dtlsPort: Int, wgPort: Int,
|
||||
onProgress: (Float, String) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
var session: Session? = null
|
||||
try {
|
||||
onProgress(0.05f, "Подключение...")
|
||||
session = createSSHSession(host, user, pass, port)
|
||||
DeployManager.activeSession = session
|
||||
val ssh = SSHClient(session, pass)
|
||||
|
||||
onProgress(0.15f, "Остановка сервиса...")
|
||||
ssh.exec(
|
||||
rootCommand(
|
||||
"systemctl unmask wdtt 2>/dev/null || true; " +
|
||||
"systemctl stop wdtt 2>/dev/null || true; " +
|
||||
"systemctl disable wdtt 2>/dev/null || true; " +
|
||||
"rm -f /etc/systemd/system/wdtt.service; " +
|
||||
"systemctl daemon-reload 2>/dev/null || true"
|
||||
),
|
||||
timeout = 15000L
|
||||
)
|
||||
|
||||
onProgress(0.30f, "Удаление через deploy.sh...")
|
||||
ssh.exec(rootCommand("[ -f /tmp/deploy.sh ] && env WDTT_DTLS_PORT=$dtlsPort WDTT_WG_PORT=$wgPort WDTT_SSH_PORT=$port bash /tmp/deploy.sh uninstall 2>/dev/null || true"), timeout = 30000L)
|
||||
|
||||
onProgress(0.45f, "Удаление бинарника...")
|
||||
ssh.exec(rootCommand("pkill -x wdtt-server 2>/dev/null || true; rm -f /usr/local/bin/wdtt-server"), timeout = 10000L)
|
||||
|
||||
onProgress(0.60f, "Очистка firewall...")
|
||||
ssh.exec(
|
||||
rootCommand(
|
||||
"if command -v iptables >/dev/null 2>&1; then " +
|
||||
"for i in 1 2 3 4 5; do " +
|
||||
"for iface in $(ls /sys/class/net 2>/dev/null || true); do " +
|
||||
"iptables -t nat -D POSTROUTING -s 10.66.66.0/24 -o \"${'$'}iface\" -m comment --comment WDTT_MANAGED -j MASQUERADE 2>/dev/null || true; " +
|
||||
"done; " +
|
||||
"iptables -D INPUT -p udp --dport $dtlsPort -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D INPUT -p udp --dport $wgPort -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D INPUT -p udp --dport 56000 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D INPUT -p udp --dport 56001 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D INPUT -p tcp --dport $port -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D INPUT -p tcp --dport 22 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D FORWARD -i wdtt0 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"iptables -D FORWARD -o wdtt0 -m comment --comment WDTT_MANAGED -j ACCEPT 2>/dev/null || true; " +
|
||||
"done; fi; " +
|
||||
"if command -v nft >/dev/null 2>&1; then " +
|
||||
"nft delete table ip wdtt 2>/dev/null || true; " +
|
||||
"nft delete table inet wdtt 2>/dev/null || true; " +
|
||||
"nft delete table inet wdtt_mangle 2>/dev/null || true; " +
|
||||
"fi"
|
||||
),
|
||||
timeout = 15000L
|
||||
)
|
||||
|
||||
onProgress(0.75f, "Удаление WDTT-интерфейса...")
|
||||
ssh.exec(
|
||||
rootCommand(
|
||||
"ip link show wdtt0 >/dev/null 2>&1 && ip link del wdtt0 2>/dev/null || true; " +
|
||||
"[ -d /etc/wdtt ] && find /etc/wdtt -mindepth 1 -maxdepth 1 ! -name passwords.json -exec rm -rf {} + 2>/dev/null || true; " +
|
||||
"[ -f /etc/wdtt/passwords.json ] && chmod 600 /etc/wdtt/passwords.json 2>/dev/null || true"
|
||||
),
|
||||
timeout = 10000L
|
||||
)
|
||||
|
||||
onProgress(0.90f, "Очистка sysctl...")
|
||||
ssh.exec(rootCommand("rm -f /etc/sysctl.d/99-wdtt.conf; sysctl --system >/dev/null 2>&1 || true"), timeout = 15000L)
|
||||
|
||||
onProgress(1.0f, "Готово!")
|
||||
DeployManager.stopDeploy("success")
|
||||
|
||||
} catch (e: Exception) {
|
||||
DeployManager.writeError("Uninstall error: ${e.message}")
|
||||
DeployManager.stopDeploy("Ошибка: ${e.message?.take(100)}")
|
||||
} finally {
|
||||
try { session?.disconnect() } catch (_: Exception) {}
|
||||
DeployManager.activeSession = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Dialogs ====================
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeploySecretsDialog(
|
||||
settingsStore: SettingsStore,
|
||||
initialMainPass: String,
|
||||
initialAdminId: String,
|
||||
initialBotToken: String,
|
||||
initialSshPort: String,
|
||||
manualPortsEnabled: Boolean,
|
||||
initialServerDtlsPort: String,
|
||||
initialServerWgPort: String,
|
||||
onSaved: (String, String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var passInput by rememberSaveable { mutableStateOf(initialMainPass) }
|
||||
var adminIdInput by rememberSaveable { mutableStateOf(initialAdminId) }
|
||||
var botTokenInput by rememberSaveable { mutableStateOf(initialBotToken) }
|
||||
var sshPortInput by rememberSaveable { mutableStateOf(if (initialSshPort.isBlank()) "22" else initialSshPort) }
|
||||
var dtlsPortInput by rememberSaveable { mutableStateOf(initialServerDtlsPort.ifBlank { "56000" }) }
|
||||
var wgPortInput by rememberSaveable { mutableStateOf(initialServerWgPort.ifBlank { "56001" }) }
|
||||
|
||||
fun normalizePort(value: String, fallback: String): String {
|
||||
return value.toIntOrNull()?.takeIf { it in 1..65535 }?.toString() ?: fallback
|
||||
}
|
||||
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp).fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Секреты Деплоя", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = passInput,
|
||||
onValueChange = { passInput = it },
|
||||
label = { Text("Задайте пароль туннеля (любой)") },
|
||||
placeholder = { Text("Придумайте надежный пароль") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Телеграм бот для управления", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = adminIdInput,
|
||||
onValueChange = { adminIdInput = it },
|
||||
label = { Text("ID Админа (Опционально)") },
|
||||
placeholder = { Text("ID из @getmyid_bot") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = botTokenInput,
|
||||
onValueChange = { botTokenInput = it },
|
||||
label = { Text("Токен Бота (Опционально)") },
|
||||
placeholder = { Text("Токен от BotFather") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("SSH Порт", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = sshPortInput,
|
||||
onValueChange = { sshPortInput = it.filter(Char::isDigit).take(5) },
|
||||
label = { Text("Порт для деплоя SSH") },
|
||||
placeholder = { Text("22") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
)
|
||||
)
|
||||
|
||||
if (manualPortsEnabled) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Порты сервера", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = dtlsPortInput,
|
||||
onValueChange = { dtlsPortInput = it.filter(Char::isDigit).take(5) },
|
||||
label = { Text("Порт DTLS сервера") },
|
||||
placeholder = { Text("56000") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = wgPortInput,
|
||||
onValueChange = { wgPortInput = it.filter(Char::isDigit).take(5) },
|
||||
label = { Text("Порт WireGuard сервера") },
|
||||
placeholder = { Text("56001") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val finalPort = if (sshPortInput.isBlank()) "22" else sshPortInput
|
||||
val finalDtls = normalizePort(dtlsPortInput, "56000")
|
||||
val finalWg = normalizePort(wgPortInput, "56001")
|
||||
scope.launch {
|
||||
settingsStore.saveDeploySecrets(passInput, adminIdInput, botTokenInput, finalPort)
|
||||
settingsStore.savePorts(finalDtls.toInt(), finalWg.toInt(), settingsStore.listenPort.first())
|
||||
onSaved(finalDtls, finalWg)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
enabled = passInput.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
|
||||
) { Text("Сохранить", fontWeight = FontWeight.SemiBold) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UninstallConfirmDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
var confirmText by remember { mutableStateOf("") }
|
||||
val isConfirmed = confirmText.trim().lowercase() == "да"
|
||||
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Удаление WDTT с сервера",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
"Будут удалены: бинарник, systemd-сервис, бот, конфигурация WDTT и только помеченные правила firewall/NAT для WDTT.\n\nЭто действие необратимо.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = confirmText,
|
||||
onValueChange = { confirmText = it },
|
||||
label = { Text("Введите «да» для подтверждения") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.error,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss, modifier = Modifier.weight(1f).height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
|
||||
) { Text("Отмена") }
|
||||
Button(
|
||||
onClick = onConfirm, modifier = Modifier.weight(1f).height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp), enabled = isConfirmed,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Удалить", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.wdtt.client.SettingsStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
@Stable
|
||||
data class AppItem(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val icon: ImageBitmap?
|
||||
)
|
||||
|
||||
object AppCache {
|
||||
var cachedList: List<AppItem>? = null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExceptionsTab() {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
|
||||
val savedExcluded by settingsStore.excludedApps.collectAsStateWithLifecycle(initialValue = "")
|
||||
val selectedPackages = remember(savedExcluded) {
|
||||
savedExcluded.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
}
|
||||
|
||||
var appsList by remember { mutableStateOf<List<AppItem>>(AppCache.cachedList ?: emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
||||
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
// Load Apps
|
||||
LaunchedEffect(Unit) {
|
||||
if (AppCache.cachedList != null) return@LaunchedEffect
|
||||
isLoading = true
|
||||
withContext(Dispatchers.IO) {
|
||||
val list = mutableListOf<AppItem>()
|
||||
val pm = context.packageManager
|
||||
val installedApps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
|
||||
installedApps.forEach { app ->
|
||||
if (app.packageName != context.packageName &&
|
||||
!app.packageName.contains("vkontakte") &&
|
||||
!app.packageName.contains("vk.calls")) {
|
||||
list.add(AppItem(
|
||||
name = app.loadLabel(pm).toString(),
|
||||
packageName = app.packageName,
|
||||
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap()
|
||||
))
|
||||
}
|
||||
}
|
||||
appsList = list.sortedBy { it.name.lowercase() }
|
||||
AppCache.cachedList = appsList
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
val filteredApps by remember {
|
||||
derivedStateOf {
|
||||
if (searchQuery.isBlank()) appsList
|
||||
else appsList.filter {
|
||||
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||
it.packageName.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
// Header
|
||||
Text(
|
||||
"Исключения приложений",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
// Search Bar
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
placeholder = { Text("Поиск приложений...", fontSize = 14.sp) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).height(52.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, modifier = Modifier.size(20.dp)) },
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
// Mode Toggle
|
||||
AppSectionCard(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
"Режим исключений",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
if (isWhitelist) "БС: Неотмеченные приложения добавляются в туннель"
|
||||
else "ЧС: Выбранные приложения исключаются из туннеля",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ModeChip("ЧС", !isWhitelist) {
|
||||
if (isWhitelist) {
|
||||
scope.launch {
|
||||
val all = appsList.map { it.packageName }.toSet()
|
||||
val inverted = all - selectedPackages
|
||||
settingsStore.saveExceptionsMode(inverted.joinToString(","), false)
|
||||
delay(300)
|
||||
com.wdtt.client.TunnelManager.reloadWireGuard()
|
||||
}
|
||||
}
|
||||
}
|
||||
ModeChip("БС", isWhitelist) {
|
||||
if (!isWhitelist) {
|
||||
scope.launch {
|
||||
val all = appsList.map { it.packageName }.toSet()
|
||||
val inverted = all - selectedPackages
|
||||
settingsStore.saveExceptionsMode(inverted.joinToString(","), true)
|
||||
delay(300)
|
||||
com.wdtt.client.TunnelManager.reloadWireGuard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredApps, key = { it.packageName }) { app ->
|
||||
val isSelected = selectedPackages.contains(app.packageName)
|
||||
|
||||
AppRow(
|
||||
app = app,
|
||||
isSelected = isSelected,
|
||||
onClick = {
|
||||
val newList = if (isSelected) {
|
||||
selectedPackages - app.packageName
|
||||
} else {
|
||||
selectedPackages + app.packageName
|
||||
}
|
||||
scope.launch {
|
||||
settingsStore.saveExcludedApps(newList.joinToString(","))
|
||||
com.wdtt.client.TunnelManager.reloadWireGuard()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
label = {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
label,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(64.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
labelColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = selected,
|
||||
borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f),
|
||||
selectedBorderColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppRow(app: AppItem, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = if (isSelected) 4.dp else 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (app.icon != null) {
|
||||
Image(
|
||||
bitmap = app.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.size(40.dp).background(Color.Gray, RoundedCornerShape(8.dp)))
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = app.packageName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = null,
|
||||
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wdtt.client.R
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun FloatingToolbar(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit,
|
||||
isDynamicColor: Boolean,
|
||||
onDynamicColorChange: (Boolean) -> Unit,
|
||||
currentPalette: String,
|
||||
onPaletteChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val screenHeightPx = remember(configuration.screenHeightDp, density) {
|
||||
with(density) { configuration.screenHeightDp.dp.toPx() }
|
||||
}
|
||||
val screenWidthPx = remember(configuration.screenWidthDp, density) {
|
||||
with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
}
|
||||
|
||||
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
|
||||
var isRightSide by rememberSaveable { mutableStateOf(true) }
|
||||
var isExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var tabHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var panelHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val tabWidthDp = 42.dp
|
||||
val tabHeightDp = 52.dp
|
||||
val panelWidthDp = 220.dp
|
||||
|
||||
val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } }
|
||||
val fallbackTabHeightPx = remember(density) { with(density) { tabHeightDp.toPx() } }
|
||||
val edgePaddingPx = remember(density) { with(density) { 8.dp.toPx() } }
|
||||
val safeTopPx = WindowInsets.safeDrawing.getTop(density).toFloat()
|
||||
val safeBottomPx = WindowInsets.safeDrawing.getBottom(density).toFloat()
|
||||
val effectiveTabHeightPx = maxOf(tabHeightPx, fallbackTabHeightPx)
|
||||
val floatingHeightPx = if (isExpanded && panelHeightPx > 0f) {
|
||||
maxOf(effectiveTabHeightPx, panelHeightPx)
|
||||
} else {
|
||||
effectiveTabHeightPx
|
||||
}
|
||||
val minOffsetY = safeTopPx + edgePaddingPx
|
||||
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx)
|
||||
.coerceAtLeast(minOffsetY)
|
||||
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
|
||||
|
||||
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f
|
||||
|
||||
val animatedTabXPx by animateFloatAsState(
|
||||
targetValue = targetXPx,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow),
|
||||
label = "tab_shift"
|
||||
)
|
||||
|
||||
LaunchedEffect(minOffsetY, maxOffsetY) {
|
||||
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Surface(
|
||||
onClick = { isExpanded = !isExpanded },
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(animatedTabXPx.roundToInt(), offsetY.roundToInt()) }
|
||||
.onGloballyPositioned { coordinates ->
|
||||
tabHeightPx = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(minOffsetY, maxOffsetY) {
|
||||
detectDragGestures(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetY = (offsetY + dragAmount.y).coerceIn(minOffsetY, maxOffsetY)
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = if (isRightSide)
|
||||
RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp)
|
||||
else
|
||||
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
|
||||
shadowElevation = 6.dp,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(tabWidthDp, tabHeightDp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_palette),
|
||||
contentDescription = "Тема",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.offset {
|
||||
val panelWidthPx = with(density) { panelWidthDp.toPx() }
|
||||
val gap = with(density) { 8.dp.toPx() }
|
||||
val panelX = if (isRightSide) {
|
||||
(targetXPx - panelWidthPx - gap).roundToInt()
|
||||
} else {
|
||||
(tabWidthPx + gap).roundToInt()
|
||||
}
|
||||
IntOffset(panelX, offsetY.roundToInt())
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
panelHeightPx = coordinates.size.height.toFloat()
|
||||
},
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
"Тема",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
ThemeOption(
|
||||
icon = R.drawable.ic_auto,
|
||||
label = "Системная",
|
||||
selected = currentTheme == "system",
|
||||
onClick = { onThemeChange("system"); isExpanded = false }
|
||||
)
|
||||
ThemeOption(
|
||||
icon = R.drawable.ic_light_mode,
|
||||
label = "Светлая",
|
||||
selected = currentTheme == "light",
|
||||
onClick = { onThemeChange("light"); isExpanded = false }
|
||||
)
|
||||
ThemeOption(
|
||||
icon = R.drawable.ic_dark_mode,
|
||||
label = "Тёмная",
|
||||
selected = currentTheme == "dark",
|
||||
onClick = { onThemeChange("dark"); isExpanded = false }
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
|
||||
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val showDynamicColorOn = isDynamicColor && supportsDynamicColor
|
||||
val showPalettes = !showDynamicColorOn
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Динамические",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (supportsDynamicColor) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
)
|
||||
Switch(
|
||||
checked = showDynamicColorOn,
|
||||
onCheckedChange = { onDynamicColorChange(it) },
|
||||
enabled = supportsDynamicColor,
|
||||
modifier = Modifier.scale(0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showPalettes) {
|
||||
Column {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
"Палитра",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
PaletteCircle("indigo", 0xFF5B588D, currentPalette, onPaletteChange)
|
||||
PaletteCircle("forest", 0xFF5F5D68, currentPalette, onPaletteChange)
|
||||
PaletteCircle("espresso", 0xFF6D4C41, currentPalette, onPaletteChange)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeOption(
|
||||
icon: Int,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaletteCircle(
|
||||
paletteId: String,
|
||||
colorHex: Long,
|
||||
selectedId: String,
|
||||
onClick: (String) -> Unit
|
||||
) {
|
||||
val isSelected = paletteId == selectedId
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(colorHex))
|
||||
.clickable { onClick(paletteId) }
|
||||
.then(
|
||||
if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||
else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.GenericShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Update
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.wdtt.client.BuildConfig
|
||||
import com.wdtt.client.R
|
||||
import com.wdtt.client.SettingsStore
|
||||
import com.wdtt.client.UPDATE_DIALOG_ACTION_POSTPONED
|
||||
import com.wdtt.client.UPDATE_DIALOG_ACTION_UPDATE
|
||||
import com.wdtt.client.WDTTColors
|
||||
import com.wdtt.client.fetchLatestReleaseInfo
|
||||
import com.wdtt.client.isNewerVersion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val ReleasesUrl = "https://github.com/amurcanov/proxy-turn-vk-android/releases"
|
||||
private const val IssuesUrl = "https://github.com/amurcanov/proxy-turn-vk-android/issues/new"
|
||||
private const val DeveloperProfileUrl = "https://github.com/amurcanov"
|
||||
private const val RepositoryUrl = "https://github.com/amurcanov/proxy-turn-vk-android"
|
||||
private const val DonateUrl = ""
|
||||
private val DonateActionButtonColor = Color(0xFF00AEA5)
|
||||
|
||||
private val browserPackages = listOf(
|
||||
"com.android.chrome",
|
||||
"com.google.android.googlequicksearchbox",
|
||||
"org.mozilla.firefox",
|
||||
"com.yandex.browser",
|
||||
"ru.yandex.searchplugin",
|
||||
"com.yandex.browser.lite",
|
||||
"com.opera.browser",
|
||||
"com.opera.mini.native",
|
||||
"com.microsoft.emmx",
|
||||
"com.brave.browser",
|
||||
"com.duckduckgo.mobile.android",
|
||||
"com.sec.android.app.sbrowser",
|
||||
"com.vivaldi.browser",
|
||||
"com.kiwibrowser.browser",
|
||||
)
|
||||
|
||||
private val Android16BlobShape: Shape = GenericShape { size, _ ->
|
||||
val centerX = size.width / 2f
|
||||
val centerY = size.height / 2f
|
||||
val outerRadius = min(size.width, size.height) / 2f
|
||||
val innerRadius = outerRadius * 0.92f
|
||||
val points = 14
|
||||
|
||||
for (i in 0 until points * 2) {
|
||||
val angle = (-PI / 2.0) + (i * PI / points)
|
||||
val radius = if (i % 2 == 0) outerRadius else innerRadius
|
||||
val x = centerX + (radius * cos(angle)).toFloat()
|
||||
val y = centerY + (radius * sin(angle)).toFloat()
|
||||
if (i == 0) moveTo(x, y) else lineTo(x, y)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
private fun openUrlInBrowser(context: Context, url: String) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val uri = Uri.parse(url)
|
||||
for (pkg in browserPackages) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
setPackage(pkg)
|
||||
}
|
||||
if (intent.resolveActivity(pm) != null) {
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply { addCategory(Intent.CATEGORY_BROWSABLE) }
|
||||
if (intent.resolveActivity(pm) != null) context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoTab() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" }
|
||||
var isCheckingUpdates by remember { mutableStateOf(false) }
|
||||
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
|
||||
var showHelpDialog by remember { mutableStateOf(false) }
|
||||
var showDonateDialog by remember { mutableStateOf(false) }
|
||||
var actionsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
var projectExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
|
||||
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
|
||||
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
|
||||
when {
|
||||
isCheckingUpdates -> "Проверяем GitHub releases..."
|
||||
updateLatestVersion.isNotBlank() && isNewerVersion(currentVersion, updateLatestVersion) ->
|
||||
"На GitHub доступна версия $updateLatestVersion"
|
||||
updateLatestVersion.isNotBlank() -> "Последняя версия: $updateLatestVersion"
|
||||
updateLastError.isNotBlank() -> "Последняя проверка завершилась ошибкой"
|
||||
else -> "Проверить GitHub вручную"
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 28.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Информация",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
InfoHeroCard(currentVersion = currentVersion, onSupportClick = { showDonateDialog = true })
|
||||
|
||||
ExpandableSectionCard(
|
||||
title = "Действия",
|
||||
itemCount = "4 пункта",
|
||||
expanded = actionsExpanded,
|
||||
onToggle = { actionsExpanded = !actionsExpanded },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
InfoActionTile(
|
||||
title = "Поднять вопрос",
|
||||
subtitle = "Открыть GitHub issue",
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { openUrlInBrowser(context, IssuesUrl) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
InfoActionTile(
|
||||
title = "Собрать отчёт",
|
||||
subtitle = "Android, ABI, версия, устройство",
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java)
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("WDTT Report", buildSupportReport()))
|
||||
Toast.makeText(context, "Отчёт сформирован и скопирован", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
WideActionTile(
|
||||
title = "Справка",
|
||||
subtitle = "Коротко про VPN, исключения, капчу и запуск",
|
||||
onClick = { showHelpDialog = true },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
WideActionTile(
|
||||
title = "Проверить обновления",
|
||||
subtitle = updateStatus,
|
||||
onClick = {
|
||||
if (isCheckingUpdates) return@WideActionTile
|
||||
isCheckingUpdates = true
|
||||
scope.launch {
|
||||
val checkedAt = System.currentTimeMillis()
|
||||
val release = fetchLatestReleaseInfo(currentVersion)
|
||||
val latest = release?.versionTag
|
||||
settingsStore.saveUpdateState(
|
||||
lastCheckAt = checkedAt,
|
||||
latestVersion = latest ?: "",
|
||||
error = if (release == null) "Не удалось проверить" else ""
|
||||
)
|
||||
isCheckingUpdates = false
|
||||
|
||||
if (release == null) {
|
||||
val message = if (updateLatestVersion.isNotBlank()) {
|
||||
"Не удалось проверить. Последняя известная версия: $updateLatestVersion"
|
||||
} else {
|
||||
"Не удалось проверить обновления"
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isNewerVersion(currentVersion, release.versionTag)) {
|
||||
settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt)
|
||||
pendingManualRelease = release
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"У вас уже последняя версия: ${release.versionTag}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Update,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingManualRelease?.let { release ->
|
||||
AppUpdateDialog(
|
||||
release = release,
|
||||
onPostpone = {
|
||||
pendingManualRelease = null
|
||||
Toast.makeText(context, "Обновление отложено на 24 часа.", Toast.LENGTH_SHORT).show()
|
||||
scope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
settingsStore.saveUpdatePostpone(
|
||||
version = release.versionTag,
|
||||
until = now + 24L * 60L * 60L * 1000L
|
||||
)
|
||||
settingsStore.saveUpdateDialogAction(
|
||||
version = release.versionTag,
|
||||
action = UPDATE_DIALOG_ACTION_POSTPONED,
|
||||
actedAt = now
|
||||
)
|
||||
}
|
||||
},
|
||||
onUpdate = {
|
||||
pendingManualRelease = null
|
||||
scope.launch {
|
||||
settingsStore.saveUpdateDialogAction(
|
||||
version = release.versionTag,
|
||||
action = UPDATE_DIALOG_ACTION_UPDATE,
|
||||
actedAt = System.currentTimeMillis()
|
||||
)
|
||||
openUrlInBrowser(context, release.releaseUrl)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ExpandableSectionCard(
|
||||
title = "О проекте",
|
||||
itemCount = "3 ссылки",
|
||||
expanded = projectExpanded,
|
||||
onToggle = { projectExpanded = !projectExpanded },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Code,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ProjectLinkRow(
|
||||
title = "Автор Android-версии",
|
||||
subtitle = "GitHub профиль amurcanov",
|
||||
onClick = { openUrlInBrowser(context, DeveloperProfileUrl) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ProjectLinkRow(
|
||||
title = "Репозиторий WDTT",
|
||||
subtitle = "Исходники и релизы приложения",
|
||||
onClick = { openUrlInBrowser(context, RepositoryUrl) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ProjectLinkRow(
|
||||
title = "Актуальные релизы",
|
||||
subtitle = "Страница загрузки APK",
|
||||
onClick = { openUrlInBrowser(context, ReleasesUrl) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Update,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (showHelpDialog) ImportantInfoDialog(onDismiss = { showHelpDialog = false })
|
||||
if (showDonateDialog) DonateDialog(onDismiss = { showDonateDialog = false })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoHeroCard(currentVersion: String, onSupportClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val isDark = colors.background.luminance() < 0.22f
|
||||
val heroBrush = remember(colors.primaryContainer, colors.secondaryContainer, colors.surfaceVariant) {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
colors.primaryContainer,
|
||||
colors.secondaryContainer,
|
||||
colors.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
val glassColor = if (isDark) colors.surface.copy(alpha = 0.46f) else Color.White.copy(alpha = 0.54f)
|
||||
val glassBorder = colors.outlineVariant.copy(alpha = if (isDark) 0.50f else 0.32f)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shadowElevation = 10.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(heroBrush)
|
||||
.padding(22.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 30.dp, y = (-34).dp)
|
||||
.size(138.dp)
|
||||
.clip(Android16BlobShape)
|
||||
.background(colors.primary.copy(alpha = 0.10f))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 26.dp, y = 30.dp)
|
||||
.size(112.dp)
|
||||
.clip(Android16BlobShape)
|
||||
.background(colors.secondary.copy(alpha = 0.12f))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HeroMetaPill(
|
||||
text = "WDTT",
|
||||
containerColor = glassColor,
|
||||
borderColor = glassBorder,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
HeroMetaPill(
|
||||
text = currentVersion,
|
||||
containerColor = colors.primary.copy(alpha = if (isDark) 0.18f else 0.10f),
|
||||
borderColor = colors.primary.copy(alpha = if (isDark) 0.22f else 0.14f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "WDTT VPN Tunnel",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 34.sp
|
||||
),
|
||||
color = colors.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Android-клиент для TURN/VK туннеля с WireGuard, капчей и управлением сервером.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.onSurfaceVariant,
|
||||
lineHeight = 21.sp
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSupportClick,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = DonateActionButtonColor,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Поддержать проект", fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroMetaPill(
|
||||
text: String,
|
||||
containerColor: Color,
|
||||
borderColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = containerColor,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandableSectionCard(
|
||||
title: String,
|
||||
itemCount: String,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val arrowRotation by animateFloatAsState(
|
||||
targetValue = if (expanded) 180f else 0f,
|
||||
label = "section_arrow_rotation"
|
||||
)
|
||||
|
||||
AppSectionCard(
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
MetaChip(text = itemCount)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(arrowRotation)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.30f))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaChip(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoActionTile(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 116.dp)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WideActionTile(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { icon() }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectLinkRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.64f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Box(
|
||||
modifier = Modifier.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DonateDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 10.dp,
|
||||
shadowElevation = 14.dp,
|
||||
modifier = Modifier.fillMaxWidth(0.92f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Поддержка проекта",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
FilledTonalIconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Если приложение реально помогает, можно поддержать Android-версию проекта.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { openUrlInBrowser(context, DonateUrl) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(62.dp),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = WDTTColors.donate,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_yoomoney),
|
||||
contentDescription = "ЮMoney",
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier
|
||||
.width(126.dp)
|
||||
.height(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSupportReport(): String {
|
||||
val androidVersion = Build.VERSION.RELEASE ?: "?"
|
||||
val sdkInt = Build.VERSION.SDK_INT
|
||||
val primaryAbi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty().ifBlank { "unknown" }
|
||||
val supportedAbis = Build.SUPPORTED_ABIS.joinToString().ifBlank { "unknown" }
|
||||
val manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "unknown" }
|
||||
val brand = Build.BRAND.orEmpty().ifBlank { "unknown" }
|
||||
val model = Build.MODEL.orEmpty().ifBlank { "unknown" }
|
||||
val device = Build.DEVICE.orEmpty().ifBlank { "unknown" }
|
||||
val product = Build.PRODUCT.orEmpty().ifBlank { "unknown" }
|
||||
val hardware = Build.HARDWARE.orEmpty().ifBlank { "unknown" }
|
||||
val board = Build.BOARD.orEmpty().ifBlank { "unknown" }
|
||||
val romDisplay = Build.DISPLAY.orEmpty().ifBlank { "unknown" }
|
||||
val buildId = Build.ID.orEmpty().ifBlank { "unknown" }
|
||||
val buildFingerprint = Build.FINGERPRINT.orEmpty().ifBlank { "unknown" }
|
||||
val buildType = Build.TYPE.orEmpty().ifBlank { "unknown" }
|
||||
val socManufacturer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Build.SOC_MANUFACTURER.orEmpty().ifBlank { "unknown" }
|
||||
} else {
|
||||
"n/a"
|
||||
}
|
||||
val socModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Build.SOC_MODEL.orEmpty().ifBlank { "unknown" }
|
||||
} else {
|
||||
"n/a"
|
||||
}
|
||||
|
||||
return buildString {
|
||||
appendLine("Версия приложения: ${BuildConfig.VERSION_NAME}")
|
||||
appendLine("Андроид: $androidVersion (SDK $sdkInt)")
|
||||
appendLine("Устройство: $manufacturer / $brand / $model")
|
||||
appendLine("Код устройства: $device")
|
||||
appendLine("Продукт: $product")
|
||||
appendLine("ABI: $primaryAbi")
|
||||
appendLine("Все ABI: $supportedAbis")
|
||||
appendLine("SoC: $socManufacturer / $socModel")
|
||||
appendLine("Hardware: $hardware")
|
||||
appendLine("Board: $board")
|
||||
appendLine("ROM: $romDisplay")
|
||||
appendLine("Build ID: $buildId")
|
||||
appendLine("Build type: $buildType")
|
||||
appendLine("Fingerprint: $buildFingerprint")
|
||||
}.trim()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.wdtt.client.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.wdtt.client.LogEntry
|
||||
import com.wdtt.client.TunnelManager
|
||||
import com.wdtt.client.WDTTColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogsTab() {
|
||||
val context = LocalContext.current
|
||||
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Лог событий",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row {
|
||||
IconButton(onClick = { TunnelManager.clearLogs() }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Clear", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val text = currentLogs.joinToString("\n") { "${it.message} (x${it.count})" }
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("WDTT Logs", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logs container — адаптивный к теме
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
colors = CardDefaults.cardColors(containerColor = terminalBg),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
contentPadding = PaddingValues(bottom = 12.dp)
|
||||
) {
|
||||
items(currentLogs, key = { it.key }) { entry ->
|
||||
LogLine(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogLine(entry: LogEntry) {
|
||||
val color = when {
|
||||
entry.isError -> WDTTColors.terminalRed
|
||||
entry.priority <= 2 -> WDTTColors.terminalGreen
|
||||
entry.priority == 3 -> WDTTColors.terminalBlue
|
||||
else -> WDTTColors.terminalText
|
||||
}
|
||||
|
||||
var trigger by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(entry.count) { trigger++ }
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = if (trigger > 0) 1.15f else 1.0f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "scale",
|
||||
finishedListener = { trigger = 0 }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
color = WDTTColors.terminalCounter.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 24.dp, minHeight = 24.dp)
|
||||
.graphicsLayer(scaleX = animatedScale, scaleY = animatedScale)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${entry.count}",
|
||||
color = WDTTColors.terminalBlue,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = entry.message,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = if (entry.isError) FontWeight.Bold else FontWeight.Normal,
|
||||
lineHeight = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user