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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ android {
applicationId = "com.whitedns.vpn"
minSdk = 26
targetSdk = 35
versionCode = 78
versionName = "1.6.0"
versionCode = 79
versionName = "1.6.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
22 changes: 3 additions & 19 deletions app/src/main/java/com/whitedns/vpn/ConnectionOrbView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -211,31 +211,15 @@ class ConnectionOrbView(context: Context) : View(context) {
}

override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
setOrbPressed(true)
postDelayed({ if (orbPressed) emitDustBurst() }, 80)
return true
}
MotionEvent.ACTION_UP -> {
if (orbPressed) {
setOrbPressed(false)
performClick()
}
return true
}
MotionEvent.ACTION_CANCEL -> {
setOrbPressed(false)
return true
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
postDelayed({ if (isPressed) emitDustBurst() }, 80)
}
return super.onTouchEvent(event)
}

override fun setPressed(pressed: Boolean) {
val changed = pressed != isPressed
super.setPressed(pressed)
if (isTelevision && changed) setOrbPressed(pressed)
setOrbPressed(pressed)
}

override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
Expand Down
121 changes: 83 additions & 38 deletions app/src/main/java/com/whitedns/vpn/DiagnosticLogger.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.whitedns.vpn

import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
Expand All @@ -14,34 +15,57 @@ object DiagnosticLogger {
private const val MAX_LOG_BYTES = 512 * 1024
private const val TRIM_TO_BYTES = 384 * 1024
private const val MAX_STDERR_READ_BYTES = 128 * 1024
private const val MAX_COPIED_CHARS = 128 * 1024
@Volatile
private var captureEnabled = BuildConfig.DEBUG

fun beginCapture(context: Context) {
if (captureEnabled) return
captureEnabled = true
info(context, "diagnostics.capture.start")
}

fun clear(context: Context) {
if (!BuildConfig.DEBUG) return
synchronized(this) {
logFile(context).writeText("")
runCatching { mihomoStderrFile(context).writeText("") }
runCatching {
synchronized(this) {
logFile(context).writeText("")
mihomoStderrFile(context).writeText("")
}
}.onFailure { error ->
if (BuildConfig.DEBUG) Log.w(TAG, "Unable to clear diagnostics", error)
}
}

fun read(context: Context): String {
if (!BuildConfig.DEBUG) return ""
return synchronized(this) {
val file = logFile(context)
val debugLog = if (!file.exists()) "" else file.readText()
val stderr = mihomoStderrFile(context)
if (!stderr.exists() || stderr.length() == 0L) {
debugLog
} else {
buildString {
append(debugLog)
if (isNotEmpty() && !endsWith("\n")) {
append("\n")
val body = runCatching {
synchronized(this) {
val file = logFile(context)
val debugLog = if (!file.exists()) "" else file.readText()
val stderr = mihomoStderrFile(context)
if (!stderr.exists() || stderr.length() == 0L) {
debugLog
} else {
buildString {
append(debugLog)
if (isNotEmpty() && !endsWith("\n")) {
append("\n")
}
append("\n--- mihomo stderr ---\n")
append(stderr.readText().takeLast(MAX_STDERR_READ_BYTES))
}
append("\n--- mihomo stderr ---\n")
append(stderr.readText().takeLast(MAX_STDERR_READ_BYTES).sanitizeForLog())
}
}
}
}.onFailure { error ->
if (BuildConfig.DEBUG) Log.w(TAG, "Unable to read diagnostics", error)
}.getOrDefault("")

return buildString {
append("WhiteVPN ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})\n")
append("Device ${Build.MANUFACTURER} ${Build.MODEL}; Android ${Build.VERSION.RELEASE} ")
append("(SDK ${Build.VERSION.SDK_INT}); ABIs ${Build.SUPPORTED_ABIS.joinToString()}\n")
append("--- events ---\n")
append(body.takeLast(MAX_COPIED_CHARS))
}.sanitizeForLog()
}

fun info(context: Context, event: String, message: String = "") {
Expand All @@ -63,7 +87,7 @@ object DiagnosticLogger {
message: String,
error: Throwable?,
) {
if (!BuildConfig.DEBUG) return
if (!captureEnabled) return
val line = buildString {
append(timestamp())
append(" ")
Expand All @@ -72,28 +96,34 @@ object DiagnosticLogger {
append(event)
if (message.isNotBlank()) {
append(" - ")
append(message.sanitizeForLog())
append(message)
}
if (error != null) {
append("\n")
append(error.stackTraceToString())
}
}
}.sanitizeForLog()

when (level) {
"ERROR" -> Log.e(TAG, line)
"WARN" -> Log.w(TAG, line)
else -> Log.i(TAG, line)
if (BuildConfig.DEBUG) {
when (level) {
"ERROR" -> Log.e(TAG, line)
"WARN" -> Log.w(TAG, line)
else -> Log.i(TAG, line)
}
}

synchronized(this) {
val file = logFile(context)
file.parentFile?.mkdirs()
if (file.exists() && file.length() > MAX_LOG_BYTES) {
file.writeText(file.readText().takeLast(TRIM_TO_BYTES))
runCatching {
synchronized(this) {
val file = logFile(context)
file.parentFile?.mkdirs()
if (file.exists() && file.length() > MAX_LOG_BYTES) {
file.writeText(file.readText().takeLast(TRIM_TO_BYTES))
}
file.appendText(line)
file.appendText("\n")
}
file.appendText(line)
file.appendText("\n")
}.onFailure { writeError ->
if (BuildConfig.DEBUG) Log.w(TAG, "Unable to persist diagnostics", writeError)
}
}

Expand All @@ -114,13 +144,17 @@ object DiagnosticLogger {
* connect button, so anything that reaches them is likely to end up pasted into a chat. Redact
* the credential-bearing fields a Mihomo profile carries rather than a single known token.
*/
private fun String.sanitizeForLog(): String {
internal fun String.sanitizeForLog(): String {
var sanitized = this
SECRET_VALUES.forEach { secret ->
if (secret.isNotBlank()) {
sanitized = sanitized.replace(secret, "<redacted>")
}
}
sanitized = CREDENTIAL_URI_REGEX.replace(sanitized, "<redacted-uri>")
sanitized = AUTHORIZATION_REGEX.replace(sanitized) { match ->
"${match.groupValues[1]}<redacted>"
}
return SECRET_FIELD_REGEX.replace(sanitized) { match ->
"${match.groupValues[1]}${match.groupValues[2]}<redacted>"
}
Expand All @@ -132,11 +166,22 @@ object DiagnosticLogger {
WhiteDnsConfig.ENCRYPTED_IP_LIST_KEY,
)

// Matches `uuid: abc`, `"password":"abc"`, `secret=abc` and friends in YAML, JSON and query
// strings, keeping the key and separator so the log stays readable.
private val CREDENTIAL_URI_REGEX = Regex(
"\\b(?:ss|ssr|vmess|vless|trojan|tuic|hysteria2?|hy2|wireguard|wg|socks5h?|https?)://[^\\s\\\"'<>]+",
RegexOption.IGNORE_CASE,
)

private val AUTHORIZATION_REGEX = Regex(
"(\\b(?:authorization|proxy-authorization)\\b\\s*[:=]\\s*)[^\\r\\n]+",
RegexOption.IGNORE_CASE,
)

// Keeps field names and separators readable while removing their values.
private val SECRET_FIELD_REGEX = Regex(
"(\"?(?:uuid|password|secret|token|psk|auth[_-]?str|private[_-]?key|short[-_]?id)\"?)" +
"(\\s*[:=]\\s*\"?)([^\\s,\"}&]+)",
"(\"?(?:uuid|password|secret|token|psk|auth[_-]?str|private[_-]?key|" +
"preshared[_-]?key|pre[_-]?shared[_-]?key|client[_-]?(?:id|secret)|api[_-]?key|short[-_]?id)\"?)" +
"(\\s*[:=]\\s*)" +
"(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|[^\\s,\"'}&]+)",
RegexOption.IGNORE_CASE,
)
}
35 changes: 30 additions & 5 deletions app/src/main/java/com/whitedns/vpn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,13 @@ class MainActivity : Activity() {
connectionChainPreferenceStore = ConnectionChainPreferenceStore(this)
installedAppRepository = InstalledAppRepository(this)
userSubscriptionManager = UserSubscriptionManager(this)
DiagnosticLogger.info(this, "activity.onCreate")
connectFlowPending = savedInstanceState?.getBoolean(STATE_CONNECT_FLOW_PENDING) == true
connectFlowAction = savedInstanceState?.getString(STATE_CONNECT_FLOW_ACTION) ?: Actions.CONNECT
DiagnosticLogger.info(
this,
"activity.onCreate",
"restored=${savedInstanceState != null} connectPending=$connectFlowPending action=$connectFlowAction",
)
configureSystemBars()
setContentView(buildAppShell())
renderState(VpnState.Stopped)
Expand Down Expand Up @@ -1169,6 +1175,8 @@ class MainActivity : Activity() {
outState.putBoolean(STATE_CONNECTION_TESTING_PAGE, connectionTestingPageVisible)
outState.putString(STATE_CHAIN_PICKER_SLOT, activeChainPickerSlot?.wireName)
outState.putString(STATE_CHAIN_PICKER_SUBSCRIPTION, activeChainPickerSubscriptionId)
outState.putBoolean(STATE_CONNECT_FLOW_PENDING, connectFlowPending)
outState.putString(STATE_CONNECT_FLOW_ACTION, connectFlowAction)
super.onSaveInstanceState(outState)
}

Expand Down Expand Up @@ -3669,7 +3677,18 @@ class MainActivity : Activity() {
}

private fun handleButtonClick() {
val nextAction = buttonModel.nextAction() ?: return
DiagnosticLogger.beginCapture(this)
val nextAction = buttonModel.nextAction()
if (nextAction == null) {
DiagnosticLogger.info(
this,
"button.click.ignored",
"currentState=${buttonModel.state.wireName} " +
"storedState=${VpnRuntimeStateStore.read(this).wireName} " +
"alwaysOn=$alwaysOnMode lockdown=$lockdownMode",
)
return
}
DiagnosticLogger.info(
this,
"button.click",
Expand Down Expand Up @@ -6468,7 +6487,9 @@ class MainActivity : Activity() {
val presentation = DashboardStatePresenter.forState(state)
val accent = accentFor(presentation.tone)
connectionOrb.setVpnState(state)
connectionOrb.isEnabled = buttonModel.isEnabled()
// Keep long-press diagnostics available even when the connection action is unavailable.
connectionOrb.isEnabled = true
connectionOrb.isClickable = buttonModel.isEnabled()
connectionOrb.contentDescription = getString(buttonModel.labelRes())
// Update status dot color based on state
(statusDot.background as? GradientDrawable)?.setColor(
Expand Down Expand Up @@ -6631,8 +6652,10 @@ class MainActivity : Activity() {
val clipboard = getSystemService(ClipboardManager::class.java)
val clip = ClipData.newPlainText("WhiteVPN diagnostics", diagnostics).apply {
// Keeps the clipboard preview toast from rendering the log on screen (Android 13+).
description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
}
}
}
clipboard.setPrimaryClip(clip)
Expand Down Expand Up @@ -6782,6 +6805,8 @@ class MainActivity : Activity() {
const val STATE_CONNECTION_TESTING_PAGE = "connection_testing_page"
const val STATE_CHAIN_PICKER_SLOT = "chain_picker_slot"
const val STATE_CHAIN_PICKER_SUBSCRIPTION = "chain_picker_subscription"
const val STATE_CONNECT_FLOW_PENDING = "connect_flow_pending"
const val STATE_CONNECT_FLOW_ACTION = "connect_flow_action"
const val HOME_MENU_THEME_ID = 100
const val HOME_MENU_LANGUAGE_ID = 101
const val HOME_MENU_SUBSCRIPTION_ID = 102
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/com/whitedns/vpn/UserSubscriptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ object JsonSubscriptionImporter {
}

when (stream.optString("network")) {
"tcp" -> xrayTcpHttpOptions(stream.optJSONObject("tcpSettings"))?.let { options ->
proxy.put("network", "http").put("http-opts", options)
}
"ws" -> {
val ws = stream.optJSONObject("wsSettings") ?: JSONObject()
val options = JSONObject().put("path", ws.optString("path"))
Expand All @@ -276,6 +279,28 @@ object JsonSubscriptionImporter {
return proxy.takeIf(::isSupportedMihomoProxy)
}

private fun xrayTcpHttpOptions(tcpSettings: JSONObject?): JSONObject? {
val header = tcpSettings?.optJSONObject("header") ?: return null
if (!header.optString("type").equals("http", ignoreCase = true)) return null
val request = header.optJSONObject("request") ?: JSONObject()
val paths = request.optJSONArray("path")?.takeIf { it.length() > 0 }
?: JSONArray().put("/")
val headers = JSONObject().apply {
request.optJSONObject("headers")?.let { source ->
source.keys().forEach { key ->
when (val value = source.opt(key)) {
is JSONArray -> if (value.length() > 0) put(key, value)
is String -> if (value.isNotBlank()) put(key, JSONArray().put(value))
}
}
}
}
return JSONObject()
.apply { request.optString("method").takeIf(String::isNotBlank)?.let { put("method", it) } }
.put("path", paths)
.apply { if (headers.length() > 0) put("headers", headers) }
}

private fun isSupportedMihomoProxy(proxy: JSONObject): Boolean =
proxy.optString("type") in setOf("vless", "vmess", "trojan", "ss", "wireguard") &&
proxy.optString("name").isNotBlank() &&
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/java/com/whitedns/vpn/WhiteDnsVpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ class WhiteDnsVpnService : VpnService() {

override fun onCreate() {
super.onCreate()
DiagnosticLogger.beginCapture(this)
configRepository = ConfigRepository(this)
subscriptionStore = SubscriptionStore(this)
scanStateStore = WhiteDnsScanStateStore(this)
Expand Down Expand Up @@ -319,9 +320,9 @@ class WhiteDnsVpnService : VpnService() {
return
}
lastPostConnectRecoveryElapsedMs = 0L
startForeground(NOTIFICATION_ID, serviceNotification(getString(R.string.notification_starting)))
DiagnosticLogger.info(this, "connect.start")
publishState(VpnState.Starting)
startForeground(NOTIFICATION_ID, serviceNotification(getString(R.string.notification_starting)))
launchConnectionStartup("connect")
}

Expand Down Expand Up @@ -3417,7 +3418,8 @@ class WhiteDnsVpnService : VpnService() {
lockdown = lockdownActive,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
WhiteDnsTileService.requestTileRefresh(this)
runCatching { WhiteDnsTileService.requestTileRefresh(this) }
.onFailure { DiagnosticLogger.warn(this, "tile.refresh.failed", error = it) }
}
DiagnosticLogger.info(this, "state.publish", "state=${newState.wireName} debugFrontingIp=${debugFrontingIp}")
val intent = Intent(Actions.STATE_CHANGED)
Expand Down
Loading
Loading