Add app source for F-Droid release (v1.0.6)
Adds the full Android source tree, Gradle wrapper, fastlane store metadata and the fdroiddata build recipe so F-Droid can build from source. README gains build instructions and an updated structure tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cc.skysparkle.sparkledns"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cc.skysparkle.sparkledns"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
// Single source of truth: the About screen reads these through BuildConfig.
|
||||
// Kept as plain literals so F-Droid can detect new versions. Code = major * 10000 + minor * 100 + patch.
|
||||
versionCode = 10006
|
||||
versionName = "1.0.6"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Builds from a source tarball must match builds from git (reproducible builds)
|
||||
vcsInfo.include = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// F-Droid rejects the Google-encrypted dependency metadata block in the APK signature
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2025.09.00"))
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Readable stack traces in crash reports
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-dontobfuscate
|
||||
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- HttpEngine (Cronet) tracks network changes, required for HTTP/3 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- Lists launcher apps for per-app DNS settings without QUERY_ALL_PACKAGES -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".SparkleApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SparkleDNS">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".DohVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||
android:value="true" />
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Local DNS-over-HTTPS VPN that keeps encrypted DNS active while the user has it enabled" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".DnsTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<!-- Announced as an on/off switch by TalkBack and shown as one in the panel -->
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,272 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val PRIVACY_POLICY = "https://skysparkle.cc/privacy?lang=en"
|
||||
private const val SOURCE_CODE = "https://git.skysparkle.cc/kalarina/Sparkledns"
|
||||
private const val APP_LICENSE = "GPL-3.0-or-later"
|
||||
|
||||
private const val APACHE_2 = "Apache License 2.0"
|
||||
private const val APACHE_2_URL = "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
|
||||
private class OpenSourceItem(
|
||||
val name: String,
|
||||
val author: String,
|
||||
val license: String,
|
||||
val licenseUrl: String,
|
||||
val projectUrl: String,
|
||||
)
|
||||
|
||||
private val Frameworks = listOf(
|
||||
OpenSourceItem("Kotlin", "JetBrains", APACHE_2, APACHE_2_URL, "https://kotlinlang.org"),
|
||||
OpenSourceItem("Kotlin Coroutines", "JetBrains", APACHE_2, APACHE_2_URL, "https://github.com/Kotlin/kotlinx.coroutines"),
|
||||
OpenSourceItem("Jetpack Compose", "The Android Open Source Project", APACHE_2, APACHE_2_URL, "https://developer.android.com/jetpack/compose"),
|
||||
OpenSourceItem("Material 3 for Compose", "The Android Open Source Project", APACHE_2, APACHE_2_URL, "https://developer.android.com/jetpack/androidx/releases/compose-material3"),
|
||||
OpenSourceItem("AndroidX Activity", "The Android Open Source Project", APACHE_2, APACHE_2_URL, "https://developer.android.com/jetpack/androidx/releases/activity"),
|
||||
OpenSourceItem("OkHttp", "Square, Inc.", APACHE_2, APACHE_2_URL, "https://square.github.io/okhttp/"),
|
||||
OpenSourceItem("Okio", "Square, Inc.", APACHE_2, APACHE_2_URL, "https://square.github.io/okio/"),
|
||||
OpenSourceItem(
|
||||
"Android HttpEngine (Cronet)", "The Chromium Authors", "BSD 3-Clause",
|
||||
"https://chromium.googlesource.com/chromium/src/+/main/LICENSE", "https://developer.android.com/reference/android/net/http/HttpEngine",
|
||||
),
|
||||
)
|
||||
|
||||
// Not bundled: downloaded only when the user adds them
|
||||
private val DataSources = listOf(
|
||||
OpenSourceItem(
|
||||
"HaGeZi DNS Blocklists", "HaGeZi", "GPL-3.0",
|
||||
"https://www.gnu.org/licenses/gpl-3.0.html", "https://github.com/hagezi/dns-blocklists",
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(modifier: Modifier = Modifier) {
|
||||
var showLicenses by rememberSaveable { mutableStateOf(false) }
|
||||
// Licenses slide in from the side like a pushed page
|
||||
AnimatedContent(
|
||||
targetState = showLicenses,
|
||||
transitionSpec = {
|
||||
val forward = targetState
|
||||
(slideInHorizontally(Motion.slide) { if (forward) it / 3 else -it / 3 } + fadeIn(Motion.enterFade)) togetherWith
|
||||
(slideOutHorizontally(Motion.slide) { if (forward) -it / 3 else it / 3 } + fadeOut(Motion.exitFade))
|
||||
},
|
||||
modifier = modifier,
|
||||
label = "about",
|
||||
) { licenses ->
|
||||
if (licenses) {
|
||||
LicensesScreen(Modifier.fillMaxSize(), onBack = { showLicenses = false })
|
||||
} else {
|
||||
AboutContent(Modifier.fillMaxSize(), onOpenLicenses = { showLicenses = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutContent(modifier: Modifier, onOpenLicenses: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val http3 = stringResource(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) R.string.about_http3_yes
|
||||
else R.string.about_http3_no
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Image(
|
||||
painterResource(R.drawable.app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(104.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.about_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Comes straight from versionName in app/build.gradle.kts
|
||||
InfoRow(stringResource(R.string.about_version), BuildConfig.VERSION_NAME)
|
||||
InfoRow(stringResource(R.string.about_protocol), "DNS-over-HTTPS (RFC 8484)")
|
||||
InfoRow("HTTP/3", http3)
|
||||
InfoRow(stringResource(R.string.about_license), APP_LICENSE)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = { openUrl(context, PRIVACY_POLICY) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(painterResource(R.drawable.ic_shield_search), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.about_privacy_policy))
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = { openUrl(context, SOURCE_CODE) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(painterResource(R.drawable.ic_android), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.about_source))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.clickable(onClick = onOpenLicenses)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_copyright),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.licenses_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LicensesScreen(modifier: Modifier, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 4.dp, end = 16.dp, top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(painterResource(R.drawable.ic_back), stringResource(R.string.back))
|
||||
}
|
||||
Text(stringResource(R.string.licenses_title), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.licenses_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(Frameworks, key = { it.name }) { LicenseCard(context, it) }
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.licenses_data),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
items(DataSources, key = { it.name }) { LicenseCard(context, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LicenseCard(context: Context, item: OpenSourceItem) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openUrl(context, item.projectUrl) },
|
||||
) {
|
||||
Column(Modifier.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 4.dp)) {
|
||||
Text(item.name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${item.author} · ${item.license}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { openUrl(context, item.licenseUrl) }) {
|
||||
Text(stringResource(R.string.license_view))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.End, modifier = Modifier.weight(1.4f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
private enum class Tab(@StringRes val label: Int, @DrawableRes val icon: Int) {
|
||||
HOME(R.string.tab_home, R.drawable.ic_tab_home),
|
||||
MONITOR(R.string.tab_monitor, R.drawable.ic_tab_monitor),
|
||||
APPS(R.string.tab_apps, R.drawable.ic_tab_apps),
|
||||
BLOCKLIST(R.string.tab_blocklist, R.drawable.ic_tab_block),
|
||||
ABOUT(R.string.tab_about, R.drawable.ic_tab_info),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppShell() {
|
||||
var tab by rememberSaveable { mutableStateOf(Tab.HOME) }
|
||||
|
||||
// Back leads to Home first; only Home closes the app
|
||||
BackHandler(enabled = tab != Tab.HOME) { tab = Tab.HOME }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
Tab.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = tab == item,
|
||||
onClick = { tab = item },
|
||||
icon = { Icon(painterResource(item.icon), contentDescription = null) },
|
||||
label = {
|
||||
Text(stringResource(item.label), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
// Screens drift a little in the direction of the tab that was picked and cross-fade
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
val forward = targetState.ordinal > initialState.ordinal
|
||||
val shift = { width: Int -> if (forward) width / 8 else -width / 8 }
|
||||
(fadeIn(Motion.enterFade) + slideInHorizontally(Motion.slide) { shift(it) }) togetherWith
|
||||
(fadeOut(Motion.exitFade) + slideOutHorizontally(Motion.slide) { -shift(it) })
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
label = "tabs",
|
||||
) { current ->
|
||||
val modifier = Modifier.fillMaxSize()
|
||||
when (current) {
|
||||
Tab.HOME -> HomeScreen(modifier)
|
||||
Tab.MONITOR -> MonitorScreen(modifier)
|
||||
Tab.APPS -> AppsScreen(modifier)
|
||||
Tab.BLOCKLIST -> BlocklistScreen(modifier)
|
||||
Tab.ABOUT -> AboutScreen(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.LruCache
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.Image
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private enum class AppGroup(@StringRes val label: Int, @DrawableRes val icon: Int) {
|
||||
SOCIAL(R.string.group_social, R.drawable.ic_group_social),
|
||||
VIDEO(R.string.group_video, R.drawable.ic_group_video),
|
||||
AUDIO(R.string.group_audio, R.drawable.ic_group_audio),
|
||||
GAMES(R.string.group_games, R.drawable.ic_group_games),
|
||||
IMAGES(R.string.group_images, R.drawable.ic_group_images),
|
||||
NEWS(R.string.group_news, R.drawable.ic_group_news),
|
||||
MAPS(R.string.group_maps, R.drawable.ic_group_maps),
|
||||
PRODUCTIVITY(R.string.group_productivity, R.drawable.ic_group_productivity),
|
||||
OTHER(R.string.group_other, R.drawable.ic_tab_apps),
|
||||
SYSTEM(R.string.group_system, R.drawable.ic_group_system),
|
||||
}
|
||||
|
||||
private class AppItem(val label: String, val packageName: String, val group: AppGroup)
|
||||
|
||||
// Decoded icons survive scrolling and tab switches instead of being rendered again every time
|
||||
private val IconCache = LruCache<String, ImageBitmap>(256)
|
||||
|
||||
@Composable
|
||||
fun AppsScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { Prefs(context) }
|
||||
val active by DohVpnService.active.collectAsState()
|
||||
val activeExcluded by DohVpnService.activeExcluded.collectAsState()
|
||||
|
||||
var apps by remember { mutableStateOf<List<AppItem>?>(null) }
|
||||
var excluded by remember { mutableStateOf(prefs.excludedApps) }
|
||||
var search by rememberSaveable { mutableStateOf("") }
|
||||
var collapsed by remember { mutableStateOf(setOf(AppGroup.SYSTEM)) }
|
||||
val pending = active != null && excluded != activeExcluded
|
||||
// Uninstalled apps may still be in the saved set; count only the ones on the device
|
||||
val excludedCount = apps?.count { it.packageName in excluded } ?: excluded.size
|
||||
val searching = search.isNotBlank()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
apps = withContext(Dispatchers.IO) { loadApps(context) }
|
||||
}
|
||||
|
||||
val groups = remember(apps, search) {
|
||||
val term = search.trim()
|
||||
apps.orEmpty()
|
||||
.filter {
|
||||
term.isEmpty() || it.label.contains(term, ignoreCase = true) ||
|
||||
it.packageName.contains(term, ignoreCase = true)
|
||||
}
|
||||
.groupBy { it.group }
|
||||
.toSortedMap()
|
||||
}
|
||||
|
||||
fun update(newExcluded: Set<String>) {
|
||||
excluded = newExcluded
|
||||
prefs.excludedApps = newExcluded
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HintCard(stringResource(R.string.apps_hint), icon = R.drawable.ic_tab_info)
|
||||
AnimatedVisibility(visible = pending, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
HintCard(
|
||||
text = stringResource(R.string.apps_pending),
|
||||
icon = R.drawable.ic_refresh,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
action = {
|
||||
TextButton(onClick = { sendAction(context, DohVpnService.ACTION_START) }) {
|
||||
Text(stringResource(R.string.reconnect))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
placeholder = { Text(stringResource(R.string.apps_search)) },
|
||||
leadingIcon = { Icon(painterResource(R.drawable.ic_search), contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (excludedCount > 0) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.apps_count, excludedCount),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (apps == null) {
|
||||
item(key = "loading") {
|
||||
Box(Modifier.animateItem()) { EmptyState(R.drawable.ic_tab_apps, stringResource(R.string.apps_loading)) }
|
||||
}
|
||||
}
|
||||
|
||||
groups.forEach { (group, items) ->
|
||||
val expanded = searching || group !in collapsed
|
||||
item(key = "group:${group.name}") {
|
||||
GroupHeader(
|
||||
modifier = Modifier.animateItem(),
|
||||
group = group,
|
||||
apps = items,
|
||||
excluded = excluded,
|
||||
expanded = expanded,
|
||||
onToggleExpand = {
|
||||
collapsed = if (group in collapsed) collapsed - group else collapsed + group
|
||||
},
|
||||
onSetAll = { on ->
|
||||
val packages = items.map { it.packageName }.toSet()
|
||||
update(if (on) excluded - packages else excluded + packages)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
itemsIndexed(items, key = { _, app -> app.packageName }) { index, app ->
|
||||
AppRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
app = app,
|
||||
enabled = app.packageName !in excluded,
|
||||
last = index == items.lastIndex,
|
||||
) { on ->
|
||||
update(if (on) excluded - app.packageName else excluded + app.packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(
|
||||
modifier: Modifier,
|
||||
group: AppGroup,
|
||||
apps: List<AppItem>,
|
||||
excluded: Set<String>,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onSetAll: (Boolean) -> Unit,
|
||||
) {
|
||||
val through = apps.count { it.packageName !in excluded }
|
||||
val state = when (through) {
|
||||
apps.size -> ToggleableState.On
|
||||
0 -> ToggleableState.Off
|
||||
else -> ToggleableState.Indeterminate
|
||||
}
|
||||
|
||||
val chevron by animateFloatAsState(if (expanded) 180f else 0f, Motion.rotation, label = "chevron")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.background(groupedBackground, groupedShape(first = true, last = !expanded))
|
||||
.clickable(onClick = onToggleExpand)
|
||||
.padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconBadge(group.icon, size = 40.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(group.label), style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
stringResource(R.string.group_count, through, apps.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Checked: whole group goes through SparkleDNS
|
||||
TriStateCheckbox(state = state, onClick = { onSetAll(state != ToggleableState.On) })
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_expand),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(22.dp)
|
||||
.rotate(chevron),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(modifier: Modifier, app: AppItem, enabled: Boolean, last: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState<ImageBitmap?>(IconCache.get(app.packageName), app.packageName) {
|
||||
if (value != null) return@produceState
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching { context.packageManager.getApplicationIcon(app.packageName).toImageBitmap(96) }
|
||||
.getOrNull()
|
||||
?.also { IconCache.put(app.packageName, it) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(groupedBackground, groupedShape(first = false, last = last))
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 64.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(40.dp)) {
|
||||
icon?.let {
|
||||
Image(
|
||||
it,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(app.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
app.packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Switch(checked = enabled, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadApps(context: Context): List<AppItem> {
|
||||
val pm = context.packageManager
|
||||
val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
@Suppress("DEPRECATION")
|
||||
return pm.queryIntentActivities(launcher, 0)
|
||||
.map { it.activityInfo.applicationInfo }
|
||||
.distinctBy { it.packageName }
|
||||
.filter { it.packageName != context.packageName }
|
||||
.map { AppItem(pm.getApplicationLabel(it).toString(), it.packageName, groupOf(it)) }
|
||||
.sortedBy { it.label.lowercase() }
|
||||
}
|
||||
|
||||
// Uses the category the developer declared in the manifest; undeclared apps fall back to System/Other
|
||||
@Suppress("DEPRECATION")
|
||||
private fun groupOf(info: ApplicationInfo): AppGroup = when {
|
||||
info.category == ApplicationInfo.CATEGORY_GAME || info.flags and ApplicationInfo.FLAG_IS_GAME != 0 -> AppGroup.GAMES
|
||||
info.category == ApplicationInfo.CATEGORY_SOCIAL -> AppGroup.SOCIAL
|
||||
info.category == ApplicationInfo.CATEGORY_VIDEO -> AppGroup.VIDEO
|
||||
info.category == ApplicationInfo.CATEGORY_AUDIO -> AppGroup.AUDIO
|
||||
info.category == ApplicationInfo.CATEGORY_IMAGE -> AppGroup.IMAGES
|
||||
info.category == ApplicationInfo.CATEGORY_NEWS -> AppGroup.NEWS
|
||||
info.category == ApplicationInfo.CATEGORY_MAPS -> AppGroup.MAPS
|
||||
info.category == ApplicationInfo.CATEGORY_PRODUCTIVITY -> AppGroup.PRODUCTIVITY
|
||||
info.flags and ApplicationInfo.FLAG_SYSTEM != 0 -> AppGroup.SYSTEM
|
||||
else -> AppGroup.OTHER
|
||||
}
|
||||
|
||||
private fun Drawable.toImageBitmap(size: Int): ImageBitmap {
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
setBounds(0, 0, size, size)
|
||||
draw(canvas)
|
||||
return bitmap.asImageBitmap()
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.channels.FileChannel
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class RemoteList(val url: String, val count: Int, val updated: Long)
|
||||
|
||||
// Blocks a domain together with all its subdomains. Changes apply instantly, no reconnect needed.
|
||||
// Domains are kept as sorted 64-bit hashes: about 8 bytes per entry, so million-entry lists fit easily.
|
||||
object Blocklist {
|
||||
const val ALL_LISTS = "*"
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val lock = Any()
|
||||
|
||||
private val _enabled = MutableStateFlow(true)
|
||||
val enabled: StateFlow<Boolean> = _enabled
|
||||
|
||||
private val _custom = MutableStateFlow<List<String>>(emptyList())
|
||||
val custom: StateFlow<List<String>> = _custom
|
||||
|
||||
private val _lists = MutableStateFlow<List<RemoteList>>(emptyList())
|
||||
val lists: StateFlow<List<RemoteList>> = _lists
|
||||
|
||||
private val _total = MutableStateFlow(0)
|
||||
val total: StateFlow<Int> = _total
|
||||
|
||||
// URL being downloaded, ALL_LISTS while updating everything, null when idle
|
||||
private val _busy = MutableStateFlow<String?>(null)
|
||||
val busy: StateFlow<String?> = _busy
|
||||
|
||||
// Reason of the last failed download, null after a success
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
@Volatile
|
||||
private var hashes = LongArray(0)
|
||||
private var listHashes = LongArray(0)
|
||||
private var loaded = false
|
||||
|
||||
fun isBlocked(name: String): Boolean {
|
||||
if (!_enabled.value) return false
|
||||
val set = hashes
|
||||
if (set.isEmpty()) return false
|
||||
val domain = name.trimEnd('.')
|
||||
var start = 0
|
||||
while (true) {
|
||||
if (set.binarySearch(DomainHash.of(domain, start)) >= 0) return true
|
||||
val dot = domain.indexOf('.', start)
|
||||
if (dot < 0) return false
|
||||
start = dot + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun reload(context: Context) = synchronized(lock) { load(BlocklistStore(context)) }
|
||||
|
||||
fun setEnabled(context: Context, value: Boolean) {
|
||||
BlocklistStore(context).enabled = value
|
||||
_enabled.value = value
|
||||
}
|
||||
|
||||
fun addCustom(context: Context, input: String): Boolean = synchronized(lock) {
|
||||
val domain = BlocklistParser.normalize(input) ?: return false
|
||||
val store = BlocklistStore(context)
|
||||
ensureLoaded(store)
|
||||
val updated = (_custom.value + domain).distinct().sorted()
|
||||
store.saveCustom(updated)
|
||||
_custom.value = updated
|
||||
combine()
|
||||
true
|
||||
}
|
||||
|
||||
fun removeCustom(context: Context, domain: String) = synchronized(lock) {
|
||||
val store = BlocklistStore(context)
|
||||
ensureLoaded(store)
|
||||
val updated = _custom.value - domain
|
||||
store.saveCustom(updated)
|
||||
_custom.value = updated
|
||||
combine()
|
||||
}
|
||||
|
||||
fun removeList(context: Context, url: String) = synchronized(lock) {
|
||||
val store = BlocklistStore(context)
|
||||
ensureLoaded(store)
|
||||
store.deleteListFile(url)
|
||||
val updated = _lists.value.filter { it.url != url }
|
||||
store.saveLists(updated)
|
||||
_lists.value = updated
|
||||
reloadListHashes(store)
|
||||
}
|
||||
|
||||
// Downloads run in the background, outside the lock, so editing own domains never waits for them.
|
||||
// Returns false when another download is already running.
|
||||
fun download(context: Context, url: String): Boolean {
|
||||
val target = url.trim()
|
||||
if (target.isEmpty() || !_busy.compareAndSet(null, target)) return false
|
||||
_error.value = null
|
||||
val store = BlocklistStore(context.applicationContext)
|
||||
scope.launch {
|
||||
try {
|
||||
val list = store.download(target)
|
||||
commit(store, listOf(list))
|
||||
} catch (e: Exception) {
|
||||
_error.value = e.message ?: e.javaClass.simpleName
|
||||
} finally {
|
||||
_busy.value = null
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun updateAll(context: Context): Boolean {
|
||||
if (_lists.value.isEmpty() || !_busy.compareAndSet(null, ALL_LISTS)) return false
|
||||
_error.value = null
|
||||
val store = BlocklistStore(context.applicationContext)
|
||||
val urls = _lists.value.map { it.url }
|
||||
scope.launch {
|
||||
try {
|
||||
val fresh = urls.mapNotNull { url ->
|
||||
try {
|
||||
store.download(url)
|
||||
} catch (e: Exception) {
|
||||
if (_error.value == null) _error.value = e.message ?: e.javaClass.simpleName
|
||||
null
|
||||
}
|
||||
}
|
||||
commit(store, fresh)
|
||||
} finally {
|
||||
_busy.value = null
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun commit(store: BlocklistStore, fresh: List<RemoteList>) = synchronized(lock) {
|
||||
ensureLoaded(store)
|
||||
val byUrl = fresh.associateBy { it.url }
|
||||
val kept = _lists.value.map { byUrl[it.url] ?: it }
|
||||
val added = fresh.filter { new -> kept.none { it.url == new.url } }
|
||||
val updated = kept + added
|
||||
store.saveLists(updated)
|
||||
_lists.value = updated
|
||||
reloadListHashes(store)
|
||||
}
|
||||
|
||||
private fun ensureLoaded(store: BlocklistStore) {
|
||||
if (!loaded) load(store)
|
||||
}
|
||||
|
||||
private fun load(store: BlocklistStore) {
|
||||
_enabled.value = store.enabled
|
||||
_custom.value = store.loadCustom()
|
||||
_lists.value = store.loadLists()
|
||||
reloadListHashes(store)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
private fun reloadListHashes(store: BlocklistStore) {
|
||||
listHashes = merge(_lists.value.map { store.loadListHashes(it.url) })
|
||||
combine()
|
||||
}
|
||||
|
||||
private fun combine() {
|
||||
val custom = _custom.value
|
||||
hashes = if (custom.isEmpty()) {
|
||||
listHashes
|
||||
} else {
|
||||
merge(listOf(listHashes, LongArray(custom.size) { DomainHash.of(custom[it]) }))
|
||||
}
|
||||
_total.value = hashes.size
|
||||
}
|
||||
|
||||
private fun merge(parts: List<LongArray>): LongArray {
|
||||
val nonEmpty = parts.filter { it.isNotEmpty() }
|
||||
if (nonEmpty.size <= 1) return nonEmpty.firstOrNull() ?: LongArray(0) // stored lists are already sorted
|
||||
val all = LongArray(nonEmpty.sumOf { it.size })
|
||||
var pos = 0
|
||||
nonEmpty.forEach { it.copyInto(all, pos); pos += it.size }
|
||||
return all.sortUnique()
|
||||
}
|
||||
}
|
||||
|
||||
internal object DomainHash {
|
||||
private const val FNV_OFFSET = -3750763034362895579L // 0xcbf29ce484222325
|
||||
private const val FNV_PRIME = 1099511628211L
|
||||
|
||||
// FNV-1a over the domain starting at [start], so parent domains are hashed without substrings
|
||||
fun of(s: String, start: Int = 0): Long {
|
||||
var h = FNV_OFFSET
|
||||
for (i in start until s.length) {
|
||||
h = h xor s[i].code.toLong()
|
||||
h *= FNV_PRIME
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
||||
|
||||
private class LongList {
|
||||
private var data = LongArray(4096)
|
||||
var size = 0
|
||||
private set
|
||||
|
||||
fun add(value: Long) {
|
||||
if (size == data.size) data = data.copyOf(size * 2)
|
||||
data[size++] = value
|
||||
}
|
||||
|
||||
fun toArray(): LongArray = data.copyOf(size)
|
||||
}
|
||||
|
||||
// Sorts in place and drops duplicates
|
||||
private fun LongArray.sortUnique(): LongArray {
|
||||
if (size < 2) return this
|
||||
sort()
|
||||
var n = 1
|
||||
for (i in 1 until size) if (this[i] != this[n - 1]) this[n++] = this[i]
|
||||
return if (n == size) this else copyOf(n)
|
||||
}
|
||||
|
||||
private class BlocklistStore(context: Context) {
|
||||
private val dir = File(context.filesDir, "blocklist").apply { mkdirs() }
|
||||
private val sp = context.getSharedPreferences("blocklist", Context.MODE_PRIVATE)
|
||||
|
||||
var enabled: Boolean
|
||||
get() = sp.getBoolean("enabled", true)
|
||||
set(value) = sp.edit().putBoolean("enabled", value).apply()
|
||||
|
||||
fun loadCustom(): List<String> {
|
||||
val file = File(dir, "custom.txt")
|
||||
return if (file.exists()) file.readLines().map { it.trim() }.filter { it.isNotEmpty() } else emptyList()
|
||||
}
|
||||
|
||||
fun saveCustom(domains: List<String>) {
|
||||
val tmp = File(dir, "custom.txt.tmp")
|
||||
tmp.writeText(domains.joinToString("\n"))
|
||||
if (!tmp.renameTo(File(dir, "custom.txt"))) throw IOException("Cannot save domains")
|
||||
}
|
||||
|
||||
fun loadLists(): List<RemoteList> {
|
||||
val raw = sp.getString("lists", null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
(0 until array.length()).map {
|
||||
val o = array.getJSONObject(it)
|
||||
RemoteList(o.getString("url"), o.getInt("count"), o.getLong("updated"))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun saveLists(lists: List<RemoteList>) {
|
||||
val array = JSONArray()
|
||||
lists.forEach {
|
||||
array.put(JSONObject().put("url", it.url).put("count", it.count).put("updated", it.updated))
|
||||
}
|
||||
sp.edit().putString("lists", array.toString()).commit()
|
||||
}
|
||||
|
||||
fun loadListHashes(url: String): LongArray {
|
||||
val file = binFile(url)
|
||||
if (!file.exists()) migrateText(url)?.let { return it }
|
||||
if (!file.exists()) return LongArray(0)
|
||||
return runCatching {
|
||||
FileInputStream(file).use { stream ->
|
||||
val channel = stream.channel
|
||||
val result = LongArray((channel.size() / 8).toInt())
|
||||
channel.map(FileChannel.MapMode.READ_ONLY, 0, result.size * 8L).asLongBuffer().get(result)
|
||||
result
|
||||
}
|
||||
}.getOrDefault(LongArray(0))
|
||||
}
|
||||
|
||||
fun deleteListFile(url: String) {
|
||||
binFile(url).delete()
|
||||
textFile(url).delete()
|
||||
}
|
||||
|
||||
fun download(url: String): RemoteList {
|
||||
val request = runCatching { Request.Builder().url(url).build() }
|
||||
.getOrElse { throw IOException("Invalid URL") }
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
|
||||
val body = response.body ?: throw IOException("Empty response")
|
||||
val parsed = LongList()
|
||||
body.charStream().buffered().useLines { lines ->
|
||||
lines.forEach { line -> BlocklistParser.parseLine(line)?.let { parsed.add(DomainHash.of(it)) } }
|
||||
}
|
||||
val result = parsed.toArray().sortUnique()
|
||||
if (result.isEmpty()) throw IOException("No domains found")
|
||||
write(url, result)
|
||||
return RemoteList(url, result.size, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(url: String, data: LongArray) {
|
||||
val tmp = File.createTempFile("list_", ".tmp", dir)
|
||||
try {
|
||||
DataOutputStream(BufferedOutputStream(FileOutputStream(tmp), 64 * 1024)).use { out ->
|
||||
data.forEach { out.writeLong(it) }
|
||||
}
|
||||
if (!tmp.renameTo(binFile(url))) throw IOException("Cannot save list")
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// Lists saved by older versions as plain text are converted once
|
||||
private fun migrateText(url: String): LongArray? {
|
||||
val text = textFile(url)
|
||||
if (!text.exists()) return null
|
||||
val result = text.readLines().map { DomainHash.of(it) }.toLongArray().sortUnique()
|
||||
runCatching { write(url, result) }
|
||||
text.delete()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun binFile(url: String) = File(dir, "list_${hash(url)}.bin")
|
||||
private fun textFile(url: String) = File(dir, "list_${hash(url)}.txt")
|
||||
|
||||
private fun hash(url: String) = MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
|
||||
companion object {
|
||||
val client: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Understands hosts files, plain domain lists, AdBlock rules (||domain^) and dnsmasq / unbound syntax
|
||||
object BlocklistParser {
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
private val IGNORED = setOf(
|
||||
"localhost", "localhost.localdomain", "local", "broadcasthost",
|
||||
"ip6-localhost", "ip6-loopback", "ip6-localnet", "ip6-mcastprefix",
|
||||
"ip6-allnodes", "ip6-allrouters", "ip6-allhosts", "0.0.0.0",
|
||||
)
|
||||
private val DNSMASQ = listOf("address=/", "server=/", "local=/")
|
||||
|
||||
fun parseLine(raw: String): String? {
|
||||
val line = raw.trim()
|
||||
if (line.isEmpty() || line[0] == '#' || line[0] == '!' || line[0] == '[') return null
|
||||
|
||||
// Fast path for AdBlock-style lists such as HaGeZi: ||domain^
|
||||
if (line.startsWith("||")) {
|
||||
val end = line.indexOfAny(charArrayOf('^', '$', '/'), 2)
|
||||
return normalize(if (end >= 0) line.substring(2, end) else line.substring(2))
|
||||
}
|
||||
if (DNSMASQ.any { line.startsWith(it) }) return normalize(line.substringAfter("=/").substringBefore('/'))
|
||||
if (line.startsWith("local-zone:")) return normalize(line.substringAfter('"').substringBefore('"'))
|
||||
if (line.startsWith("@@") || "##" in line || "#@#" in line || "#?#" in line) return null
|
||||
|
||||
val parts = line.substringBefore('#').trim().split(WHITESPACE)
|
||||
val candidate = if (parts.size >= 2 && looksLikeIp(parts[0])) parts[1] else parts[0]
|
||||
if (candidate.lowercase() in IGNORED) return null
|
||||
return normalize(candidate)
|
||||
}
|
||||
|
||||
fun normalize(input: String): String? {
|
||||
var s = input.trim().lowercase()
|
||||
s = s.substringAfter("://")
|
||||
s = s.substringBefore('/').substringBefore(':')
|
||||
s = s.removePrefix("||").removeSuffix("^").removePrefix("*.").removePrefix(".").trimEnd('.')
|
||||
return s.takeIf { isValidDomain(it) && !looksLikeIp(it) }
|
||||
}
|
||||
|
||||
private fun isValidDomain(s: String): Boolean {
|
||||
if (s.isEmpty() || s.length > 253 || s[0] == '.' || s[0] == '-' || s.last() == '.') return false
|
||||
var hasDot = false
|
||||
var prev = ' '
|
||||
for (c in s) {
|
||||
val ok = c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '_' || c == '.'
|
||||
if (!ok) return false
|
||||
if (c == '.') {
|
||||
if (prev == '.') return false
|
||||
hasDot = true
|
||||
}
|
||||
prev = c
|
||||
}
|
||||
return hasDot
|
||||
}
|
||||
|
||||
private fun looksLikeIp(s: String): Boolean =
|
||||
':' in s || s.all { it.isDigit() || it == '.' }
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private val Presets = listOf(
|
||||
"HaGeZi Pro++" to "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.plus.txt",
|
||||
"HaGeZi TIF" to "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/tif.txt",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BlocklistScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val enabled by Blocklist.enabled.collectAsState()
|
||||
val total by Blocklist.total.collectAsState()
|
||||
val custom by Blocklist.custom.collectAsState()
|
||||
val lists by Blocklist.lists.collectAsState()
|
||||
|
||||
var domainInput by rememberSaveable { mutableStateOf("") }
|
||||
var domainError by remember { mutableStateOf(false) }
|
||||
var urlInput by rememberSaveable { mutableStateOf("") }
|
||||
// Downloads live in Blocklist, so they keep running and stay visible across tab switches
|
||||
val busy by Blocklist.busy.collectAsState()
|
||||
val error by Blocklist.error.collectAsState()
|
||||
val lastError = remember { arrayOf("") } // kept while the card animates out
|
||||
error?.let { lastError[0] = it }
|
||||
|
||||
fun addDomain() {
|
||||
val input = domainInput
|
||||
if (input.isBlank()) return
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { Blocklist.addCustom(context, input) }
|
||||
domainError = !ok
|
||||
if (ok) domainInput = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun addList() {
|
||||
val url = urlInput.trim()
|
||||
if (url.isNotEmpty() && Blocklist.download(context, url)) urlInput = ""
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconBadge(
|
||||
R.drawable.ic_tab_block,
|
||||
tint = if (enabled) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
|
||||
size = 52.dp,
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.bl_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
formatCount(total.toLong()),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.bl_total_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = enabled, onCheckedChange = { Blocklist.setEnabled(context, it) })
|
||||
}
|
||||
}
|
||||
|
||||
HintCard(stringResource(R.string.bl_note), icon = R.drawable.ic_tab_info)
|
||||
|
||||
SectionHeader(stringResource(R.string.bl_my), icon = R.drawable.ic_tab_block)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier
|
||||
.animateContentSize(Motion.size)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = domainInput,
|
||||
onValueChange = { domainInput = it; domainError = false },
|
||||
placeholder = { Text(stringResource(R.string.bl_domain_hint), maxLines = 1) },
|
||||
singleLine = true,
|
||||
isError = domainError,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { addDomain() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(onClick = ::addDomain, enabled = domainInput.isNotBlank()) {
|
||||
Icon(painterResource(R.drawable.ic_add_domain), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = domainError, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
Text(
|
||||
stringResource(R.string.bl_invalid),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (custom.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.bl_empty_custom),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
custom.forEach { domain ->
|
||||
InsetDivider(16)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(domain, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
IconButton(onClick = { scope.launch(Dispatchers.IO) { Blocklist.removeCustom(context, domain) } }) {
|
||||
Icon(painterResource(R.drawable.ic_delete), stringResource(R.string.delete), Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader(
|
||||
stringResource(R.string.bl_lists),
|
||||
icon = R.drawable.ic_list,
|
||||
action = {
|
||||
TextButton(
|
||||
onClick = { Blocklist.updateAll(context) },
|
||||
enabled = lists.isNotEmpty() && busy == null,
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_refresh), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(if (busy == Blocklist.ALL_LISTS) R.string.bl_updating else R.string.bl_update_all))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier
|
||||
.animateContentSize(Motion.size)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
lists.forEachIndexed { index, list ->
|
||||
if (index > 0) InsetDivider(68)
|
||||
ListRow(
|
||||
list = list,
|
||||
updating = busy == list.url || busy == Blocklist.ALL_LISTS,
|
||||
actionsEnabled = busy == null,
|
||||
onRefresh = { Blocklist.download(context, list.url) },
|
||||
onDelete = { scope.launch(Dispatchers.IO) { Blocklist.removeList(context, list.url) } },
|
||||
)
|
||||
}
|
||||
if (lists.isNotEmpty()) InsetDivider(16)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = urlInput,
|
||||
onValueChange = { urlInput = it },
|
||||
placeholder = { Text(stringResource(R.string.bl_list_url), maxLines = 1) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (busy == null) addList() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = ::addList,
|
||||
enabled = urlInput.isNotBlank() && busy == null,
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_add), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
// A new list being added has no row yet, so its progress shows under the input
|
||||
val addingNew = busy != null && busy != Blocklist.ALL_LISTS && lists.none { it.url == busy }
|
||||
AnimatedVisibility(visible = addingNew, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val missingPresets = Presets.filter { (_, url) -> lists.none { it.url == url } }
|
||||
AnimatedVisibility(visible = missingPresets.isNotEmpty(), enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SectionHeader(stringResource(R.string.bl_presets))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
missingPresets.forEach { (name, url) ->
|
||||
AssistChip(
|
||||
onClick = { Blocklist.download(context, url) },
|
||||
enabled = busy == null,
|
||||
leadingIcon = {
|
||||
Icon(painterResource(R.drawable.ic_add), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
label = { Text(if (busy == url) stringResource(R.string.bl_updating) else name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = error != null, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
HintCard(
|
||||
stringResource(R.string.bl_download_failed, lastError[0]),
|
||||
icon = R.drawable.ic_alert,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InsetDivider(start: Int) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = start.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListRow(
|
||||
list: RemoteList,
|
||||
updating: Boolean,
|
||||
actionsEnabled: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val uri = remember(list.url) { Uri.parse(list.url) }
|
||||
val title = uri.lastPathSegment ?: list.url
|
||||
val updated = remember(list.updated) {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(list.updated))
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconBadge(R.drawable.ic_list, size = 40.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
uri.host ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (updating) stringResource(R.string.bl_updating)
|
||||
else stringResource(R.string.bl_list_info, formatCount(list.count.toLong()), updated),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = actionsEnabled) {
|
||||
Icon(painterResource(R.drawable.ic_refresh), stringResource(R.string.bl_update_all), Modifier.size(20.dp))
|
||||
}
|
||||
IconButton(onClick = onDelete, enabled = actionsEnabled) {
|
||||
Icon(painterResource(R.drawable.ic_delete), stringResource(R.string.delete), Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = updating, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 68.dp, end = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
class SparkleApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashLog.install(this)
|
||||
Thread({ runCatching { Blocklist.reload(this) } }, "blocklist-load").start()
|
||||
}
|
||||
}
|
||||
|
||||
// Saves the last crash so it can be shown and copied on the next launch
|
||||
object CrashLog {
|
||||
private const val FILE_NAME = "last_crash.txt"
|
||||
|
||||
fun install(context: Context) {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||
runCatching {
|
||||
val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
||||
file.writeText(
|
||||
"Thread: ${thread.name}\n" +
|
||||
"Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT}), " +
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}\n\n$trace"
|
||||
)
|
||||
}
|
||||
previous?.uncaughtException(thread, error)
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): String? =
|
||||
File(context.filesDir, FILE_NAME).takeIf { it.exists() }?.readText()
|
||||
|
||||
fun clear(context: Context) {
|
||||
File(context.filesDir, FILE_NAME).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
// TTL-aware LRU cache of DNS responses, keyed by the question section
|
||||
class DnsCache(private val maxEntries: Int = 2000) {
|
||||
|
||||
private class Entry(
|
||||
val response: ByteArray,
|
||||
val ttlOffsets: IntArray,
|
||||
val ttls: IntArray,
|
||||
val storedAt: Long,
|
||||
val expiresAt: Long,
|
||||
)
|
||||
|
||||
private val map = object : LinkedHashMap<String, Entry>(256, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Entry>?) = size > maxEntries
|
||||
}
|
||||
|
||||
fun get(query: ByteArray): ByteArray? {
|
||||
val key = keyOf(query) ?: return null
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val entry = synchronized(map) {
|
||||
val e = map[key] ?: return null
|
||||
if (now >= e.expiresAt) {
|
||||
map.remove(key)
|
||||
return null
|
||||
}
|
||||
e
|
||||
}
|
||||
val out = entry.response.copyOf()
|
||||
out[0] = query[0]
|
||||
out[1] = query[1]
|
||||
val elapsed = ((now - entry.storedAt) / 1000).toInt()
|
||||
entry.ttlOffsets.forEachIndexed { i, off -> put32(out, off, maxOf(entry.ttls[i] - elapsed, 0)) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun put(query: ByteArray, response: ByteArray) {
|
||||
val key = keyOf(query) ?: return
|
||||
if (response.size < 12 || (response[2].toInt() and 0x02) != 0) return // truncated
|
||||
val rcode = response[3].toInt() and 0x0F
|
||||
if (rcode != 0 && rcode != 3) return // only NOERROR and NXDOMAIN
|
||||
|
||||
val records = parseTtls(response) ?: return
|
||||
val minTtl = if (records.first.isEmpty()) NO_RECORDS_TTL else records.second.min()
|
||||
val ttl = minOf(minTtl, MAX_TTL)
|
||||
if (ttl <= 0) return
|
||||
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val entry = Entry(response.copyOf(), records.first, records.second, now, now + ttl * 1000L)
|
||||
synchronized(map) { map[key] = entry }
|
||||
}
|
||||
|
||||
// Offsets and values of every TTL field except the EDNS OPT pseudo-record
|
||||
private fun parseTtls(msg: ByteArray): Pair<IntArray, IntArray>? {
|
||||
var pos = 12
|
||||
repeat(u16(msg, 4)) {
|
||||
pos = skipName(msg, pos) ?: return null
|
||||
pos += 4
|
||||
}
|
||||
val total = u16(msg, 6) + u16(msg, 8) + u16(msg, 10)
|
||||
val offsets = ArrayList<Int>(total)
|
||||
val values = ArrayList<Int>(total)
|
||||
repeat(total) {
|
||||
pos = skipName(msg, pos) ?: return null
|
||||
if (pos + 10 > msg.size) return null
|
||||
val type = u16(msg, pos)
|
||||
val ttlOffset = pos + 4
|
||||
val rdLength = u16(msg, pos + 8)
|
||||
if (type != TYPE_OPT) {
|
||||
offsets += ttlOffset
|
||||
values += minOf(u32(msg, ttlOffset), Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
pos += 10 + rdLength
|
||||
if (pos > msg.size) return null
|
||||
}
|
||||
return offsets.toIntArray() to values.toIntArray()
|
||||
}
|
||||
|
||||
private fun keyOf(query: ByteArray): String? {
|
||||
if (query.size < 12 || u16(query, 4) != 1) return null
|
||||
val end = (skipName(query, 12) ?: return null) + 4
|
||||
if (end > query.size) return null
|
||||
val key = query.copyOfRange(12, end)
|
||||
// DNS names are case-insensitive; label length bytes never fall in A..Z
|
||||
for (i in key.indices) if (key[i] in 'A'.code.toByte()..'Z'.code.toByte()) key[i] = (key[i] + 32).toByte()
|
||||
return String(key, Charsets.ISO_8859_1)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TYPE_OPT = 41
|
||||
const val MAX_TTL = 3600
|
||||
const val NO_RECORDS_TTL = 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
class DnsTileService : TileService() {
|
||||
|
||||
companion object {
|
||||
fun refresh(context: Context) {
|
||||
runCatching {
|
||||
TileService.requestListeningState(context, ComponentName(context, DnsTileService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var listening: CoroutineScope? = null
|
||||
|
||||
// Follows the VPN state while the panel is open, so the tile flips as soon as the tunnel is up
|
||||
override fun onStartListening() {
|
||||
listening?.cancel()
|
||||
listening = MainScope().also { scope ->
|
||||
scope.launch { DohVpnService.active.collect { updateTile() } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
listening?.cancel()
|
||||
listening = null
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
val running = DohVpnService.active.value != null
|
||||
if (!running) {
|
||||
val provider = Prefs(this).resolve()
|
||||
val urlValid = provider.url.toHttpUrlOrNull()?.isHttps == true
|
||||
// VPN consent or a broken custom URL can only be handled in the app
|
||||
if (VpnService.prepare(this) != null || !urlValid) {
|
||||
openApp()
|
||||
return
|
||||
}
|
||||
}
|
||||
val action = if (running) DohVpnService.ACTION_STOP else DohVpnService.ACTION_START
|
||||
try {
|
||||
startService(Intent(this, DohVpnService::class.java).setAction(action))
|
||||
} catch (e: Exception) {
|
||||
Log.w("SparkleDNS", "Tile could not reach the service", e)
|
||||
openApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
val active = DohVpnService.active.value
|
||||
tile.state = if (active != null) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
||||
tile.label = getString(R.string.app_name)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
tile.subtitle = (active ?: Prefs(this).resolve()).displayName(this)
|
||||
}
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun openApp() {
|
||||
val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(
|
||||
PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
import android.net.http.HttpEngine
|
||||
import android.net.http.HttpException
|
||||
import android.net.http.UploadDataProvider
|
||||
import android.net.http.UploadDataSink
|
||||
import android.net.http.UrlRequest
|
||||
import android.net.http.UrlResponseInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
private const val DNS_MESSAGE = "application/dns-message"
|
||||
private const val MAX_DNS_SIZE = 65535
|
||||
private const val TAG = "SparkleDNS"
|
||||
|
||||
// RFC 8484 client. HTTP/3 goes through the platform HttpEngine (Android 14+, no extra libraries),
|
||||
// everything else and older devices use OkHttp with HTTP/2.
|
||||
class DohClient(context: Context, provider: DohProvider) {
|
||||
|
||||
private val transport: Transport = createTransport(context.applicationContext, provider)
|
||||
|
||||
val lastProtocol: String? get() = transport.lastProtocol
|
||||
|
||||
fun query(message: ByteArray): ByteArray {
|
||||
val body = transport.query(message)
|
||||
if (!DnsMessage.isResponse(body)) throw IOException("Invalid DNS response")
|
||||
return body
|
||||
}
|
||||
|
||||
fun shutdown() = transport.shutdown()
|
||||
|
||||
private fun createTransport(context: Context, provider: DohProvider): Transport {
|
||||
if (provider.http3 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
try {
|
||||
return Http3Transport(context, provider)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "HttpEngine unavailable, falling back to HTTP/2", e)
|
||||
}
|
||||
}
|
||||
return OkHttpTransport(provider)
|
||||
}
|
||||
}
|
||||
|
||||
private interface Transport {
|
||||
val lastProtocol: String?
|
||||
fun query(message: ByteArray): ByteArray
|
||||
fun shutdown()
|
||||
}
|
||||
|
||||
private class OkHttpTransport(private val provider: DohProvider) : Transport {
|
||||
|
||||
private val url by lazy { provider.url.toHttpUrl() }
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.dns(BootstrapDns(provider.bootstrap))
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.callTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
override var lastProtocol: String? = null
|
||||
|
||||
override fun query(message: ByteArray): ByteArray {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", DNS_MESSAGE)
|
||||
.post(message.toRequestBody(MEDIA_TYPE))
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
lastProtocol = response.protocol.toString()
|
||||
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
|
||||
val body = response.body ?: throw IOException("Empty response")
|
||||
if (body.contentLength() > MAX_DNS_SIZE) throw IOException("Response too large")
|
||||
return body.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
override fun shutdown() {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val MEDIA_TYPE = DNS_MESSAGE.toMediaType()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private class Http3Transport(context: Context, private val provider: DohProvider) : Transport {
|
||||
|
||||
private val engine: HttpEngine = HttpEngine.Builder(context)
|
||||
.setEnableQuic(true)
|
||||
.setEnableHttp2(true)
|
||||
.apply {
|
||||
// QUIC hint lets the very first request go straight over HTTP/3 instead of waiting for Alt-Svc
|
||||
val uri = runCatching { URI(provider.url) }.getOrNull()
|
||||
val host = uri?.host
|
||||
if (host != null) {
|
||||
val port = if (uri.port > 0) uri.port else 443
|
||||
addQuicHint(host, port, port)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
override var lastProtocol: String? = null
|
||||
|
||||
override fun query(message: ByteArray): ByteArray {
|
||||
val result = CompletableFuture<ByteArray>()
|
||||
val body = ByteArrayOutputStream()
|
||||
|
||||
val callback = object : UrlRequest.Callback {
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
request.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||
lastProtocol = info.negotiatedProtocol
|
||||
if (info.httpStatusCode != 200) {
|
||||
result.completeExceptionally(IOException("HTTP ${info.httpStatusCode}"))
|
||||
request.cancel()
|
||||
return
|
||||
}
|
||||
readNext(request, ByteBuffer.allocateDirect(4096))
|
||||
}
|
||||
|
||||
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, buffer: ByteBuffer) {
|
||||
buffer.flip()
|
||||
val chunk = ByteArray(buffer.remaining())
|
||||
buffer.get(chunk)
|
||||
body.write(chunk, 0, chunk.size)
|
||||
if (body.size() > MAX_DNS_SIZE) {
|
||||
result.completeExceptionally(IOException("Response too large"))
|
||||
request.cancel()
|
||||
return
|
||||
}
|
||||
buffer.clear()
|
||||
readNext(request, buffer)
|
||||
}
|
||||
|
||||
private fun readNext(request: UrlRequest, buffer: ByteBuffer) {
|
||||
try {
|
||||
request.read(buffer)
|
||||
} catch (e: Exception) {
|
||||
result.completeExceptionally(IOException(e))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
result.complete(body.toByteArray())
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) {
|
||||
result.completeExceptionally(IOException(error.message, error))
|
||||
}
|
||||
|
||||
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
|
||||
result.completeExceptionally(IOException("Canceled"))
|
||||
}
|
||||
}
|
||||
|
||||
val request = engine.newUrlRequestBuilder(provider.url, executor, callback)
|
||||
.setHttpMethod("POST")
|
||||
.addHeader("Content-Type", DNS_MESSAGE)
|
||||
.addHeader("Accept", DNS_MESSAGE)
|
||||
.setUploadDataProvider(BytesUploadProvider(message), executor)
|
||||
.build()
|
||||
try {
|
||||
request.start()
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e)
|
||||
}
|
||||
|
||||
return try {
|
||||
result.get(8, TimeUnit.SECONDS)
|
||||
} catch (_: TimeoutException) {
|
||||
request.cancel()
|
||||
throw IOException("Timeout")
|
||||
} catch (e: ExecutionException) {
|
||||
throw e.cause as? IOException ?: IOException(e.cause)
|
||||
}
|
||||
}
|
||||
|
||||
// HttpEngine refuses to shut down while requests are in flight, so retry for a few seconds
|
||||
override fun shutdown() {
|
||||
executor.execute {
|
||||
repeat(20) {
|
||||
if (runCatching { engine.shutdown() }.isSuccess) return@execute
|
||||
Thread.sleep(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Shared and never shut down: a late callback must not hit a rejected executor
|
||||
val executor: Executor = Executors.newCachedThreadPool { r ->
|
||||
Thread(r, "doh-h3").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The platform API has no UploadDataProviders helper (that one exists only in Cronet)
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private class BytesUploadProvider(private val data: ByteArray) : UploadDataProvider() {
|
||||
private var position = 0
|
||||
|
||||
override fun getLength(): Long = data.size.toLong()
|
||||
|
||||
override fun read(sink: UploadDataSink, buffer: ByteBuffer) {
|
||||
val count = minOf(buffer.remaining(), data.size - position)
|
||||
buffer.put(data, position, count)
|
||||
position += count
|
||||
sink.onReadSucceeded(false)
|
||||
}
|
||||
|
||||
override fun rewind(sink: UploadDataSink) {
|
||||
position = 0
|
||||
sink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
|
||||
// Uses fixed IPs for the DoH host when given, so the server is reachable even if system DNS blocks it
|
||||
private class BootstrapDns(ips: List<String>) : Dns {
|
||||
private val addresses by lazy {
|
||||
ips.mapNotNull { runCatching { InetAddress.getByName(it) }.getOrNull() }
|
||||
}
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> =
|
||||
addresses.ifEmpty { Dns.SYSTEM.lookup(hostname) }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.SystemClock
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.system.StructPollfd
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val TUN_ADDRESS = "10.111.222.1"
|
||||
private const val DNS_ADDRESS = "10.111.222.2"
|
||||
private const val MTU = 4096
|
||||
private const val WORKERS = 16
|
||||
private const val TAG = "SparkleDNS"
|
||||
private const val CHANNEL_ID = "vpn_status"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
// Local VPN that routes only the fake DNS address into the tunnel; all other traffic is untouched
|
||||
class DohVpnService : VpnService() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "cc.skysparkle.sparkledns.START"
|
||||
const val ACTION_STOP = "cc.skysparkle.sparkledns.STOP"
|
||||
|
||||
// Provider the tunnel is actually using, null when disconnected
|
||||
private val _active = MutableStateFlow<DohProvider?>(null)
|
||||
val active: StateFlow<DohProvider?> = _active
|
||||
|
||||
// Apps that bypass the tunnel in the running session
|
||||
private val _activeExcluded = MutableStateFlow<Set<String>>(emptySet())
|
||||
val activeExcluded: StateFlow<Set<String>> = _activeExcluded
|
||||
}
|
||||
|
||||
private var session: Session? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSession()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Explicit start, always-on VPN (android.net.VpnService) or a sticky restart (null intent)
|
||||
try {
|
||||
startSession()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start VPN", e)
|
||||
stopSession()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return if (session != null) START_STICKY else START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopSession()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopSession()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startSession() {
|
||||
val prefs = Prefs(this)
|
||||
val provider = prefs.resolve()
|
||||
val excluded = prefs.excludedApps
|
||||
|
||||
val builder = Builder()
|
||||
.setSession(provider.displayName(this))
|
||||
.addAddress(TUN_ADDRESS, 32)
|
||||
.addRoute(DNS_ADDRESS, 32)
|
||||
.addDnsServer(DNS_ADDRESS)
|
||||
.setMtu(MTU)
|
||||
.setBlocking(true)
|
||||
// Without this Android blocks every family the tunnel has no address for, i.e. all IPv6
|
||||
// traffic of all apps, and stops asking for AAAA records
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6)
|
||||
// The app itself bypasses the VPN so it can resolve and reach the DoH server
|
||||
runCatching { builder.addDisallowedApplication(packageName) }
|
||||
excluded.forEach { runCatching { builder.addDisallowedApplication(it) } }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false)
|
||||
|
||||
// The new interface replaces the old one in place, so DNS never leaks during a server switch
|
||||
val tun = runCatching { builder.establish() }.getOrNull()
|
||||
val previous = session
|
||||
if (tun == null) {
|
||||
stopSession()
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
session = Session(tun, DohClient(this, provider)).also { it.start() }
|
||||
runCatching { previous?.stop() }
|
||||
|
||||
_active.value = provider
|
||||
_activeExcluded.value = excluded
|
||||
showNotification(provider)
|
||||
DnsTileService.refresh(this)
|
||||
}
|
||||
|
||||
private fun stopSession() {
|
||||
runCatching { session?.stop() }
|
||||
session = null
|
||||
_active.value = null
|
||||
_activeExcluded.value = emptySet()
|
||||
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
|
||||
getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
DnsTileService.refresh(this)
|
||||
}
|
||||
|
||||
private fun showNotification(provider: DohProvider) {
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, getString(R.string.notif_channel), NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val stop = PendingIntent.getService(
|
||||
this, 1,
|
||||
Intent(this, DohVpnService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(getString(R.string.notif_title, provider.displayName(this)))
|
||||
.setContentText(getString(R.string.notif_text))
|
||||
.setContentIntent(openApp)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.addAction(
|
||||
Notification.Action.Builder(
|
||||
Icon.createWithResource(this, R.drawable.ic_notification),
|
||||
getString(R.string.disconnect),
|
||||
stop,
|
||||
).build()
|
||||
)
|
||||
.build()
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Started from the background (e.g. always-on VPN): keep running with a plain notification
|
||||
Log.w(TAG, "startForeground not allowed", e)
|
||||
runCatching { manager.notify(NOTIFICATION_ID, notification) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Session(
|
||||
private val tun: ParcelFileDescriptor,
|
||||
private val doh: DohClient,
|
||||
) {
|
||||
private val alive = AtomicBoolean(true)
|
||||
private val pool = Executors.newFixedThreadPool(WORKERS)
|
||||
private val cache = DnsCache()
|
||||
private val output = FileOutputStream(tun.fileDescriptor)
|
||||
private val reader = Thread(::readLoop, "tun-reader")
|
||||
|
||||
fun start() = reader.start()
|
||||
|
||||
fun stop() {
|
||||
if (!alive.getAndSet(false)) return
|
||||
pool.shutdownNow()
|
||||
doh.shutdown()
|
||||
runCatching { tun.close() }
|
||||
}
|
||||
|
||||
private fun readLoop() {
|
||||
val input = FileInputStream(tun.fileDescriptor)
|
||||
val buf = ByteArray(MTU)
|
||||
val pollFd = StructPollfd().apply {
|
||||
fd = tun.fileDescriptor
|
||||
events = OsConstants.POLLIN.toShort()
|
||||
}
|
||||
|
||||
while (alive.get()) {
|
||||
try {
|
||||
if (Os.poll(arrayOf(pollFd), 500) <= 0) continue
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
val query = Packet.parseDnsQuery(buf, n) ?: continue
|
||||
dispatch(query)
|
||||
} catch (e: ErrnoException) {
|
||||
if (e.errno != OsConstants.EINTR) break
|
||||
} catch (_: IOException) {
|
||||
break
|
||||
} catch (_: RejectedExecutionException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Tunnel reader failed", e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocklist and cache are answered right here; only real lookups go to the worker pool
|
||||
private fun dispatch(query: UdpPacket) {
|
||||
val question = DnsMessage.question(query.payload)
|
||||
if (question != null && Blocklist.isBlocked(question.name)) {
|
||||
DnsMessage.errorReply(query.payload, DnsMessage.RCODE_NXDOMAIN)?.let { write(query, it) }
|
||||
QueryLog.add(question, QueryStatus.BLOCKED, 0)
|
||||
return
|
||||
}
|
||||
val cached = cache.get(query.payload)
|
||||
if (cached != null) {
|
||||
write(query, cached)
|
||||
QueryLog.add(question, QueryStatus.CACHED, 0)
|
||||
return
|
||||
}
|
||||
pool.execute { handle(query, question) }
|
||||
}
|
||||
|
||||
private fun handle(query: UdpPacket, question: DnsMessage.Question?) {
|
||||
try {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
var status = QueryStatus.OK
|
||||
val answer = try {
|
||||
doh.query(query.payload).also { cache.put(query.payload, it) }
|
||||
} catch (_: Exception) {
|
||||
status = QueryStatus.FAILED
|
||||
DnsMessage.servfail(query.payload)
|
||||
}
|
||||
if (!alive.get()) return
|
||||
QueryLog.add(question, status, (SystemClock.elapsedRealtime() - start).toInt())
|
||||
answer?.let { write(query, it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Query failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(query: UdpPacket, answer: ByteArray) {
|
||||
if (!alive.get()) return
|
||||
// Larger answers do not fit into one IPv4 datagram; SERVFAIL lets the client move on
|
||||
val payload = if (answer.size <= Packet.MAX_UDP_PAYLOAD) answer else DnsMessage.servfail(query.payload) ?: return
|
||||
val packet = Packet.buildReply(query, DnsMessage.withId(payload, query.payload))
|
||||
try {
|
||||
synchronized(output) { output.write(packet) }
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = AppColors) { AppShell() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val AppColors = darkColorScheme(
|
||||
primary = Color(0xFF7FB2FF),
|
||||
onPrimary = Color(0xFF00285C),
|
||||
background = Color(0xFF0F1115),
|
||||
surface = Color(0xFF0F1115),
|
||||
)
|
||||
|
||||
private val ProtectedContainer = Color(0xFF16233A)
|
||||
|
||||
private class TestResult(val ok: Boolean, val text: String)
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { Prefs(context) }
|
||||
val active by DohVpnService.active.collectAsState()
|
||||
val running = active != null
|
||||
val scope = rememberCoroutineScope()
|
||||
val stats = rememberLiveStats()
|
||||
|
||||
var selected by remember { mutableStateOf(prefs.providerId) }
|
||||
var customUrl by remember { mutableStateOf(prefs.customUrl) }
|
||||
var customBootstrap by remember { mutableStateOf(prefs.customBootstrap) }
|
||||
var customHttp3 by remember { mutableStateOf(prefs.customHttp3) }
|
||||
var testResult by remember { mutableStateOf<TestResult?>(null) }
|
||||
var shownResult by remember { mutableStateOf<TestResult?>(null) } // kept while the card animates out
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val crashReport = remember { CrashLog.read(context) }
|
||||
// Strict Private DNS makes Android ignore the VPN's DNS server; re-checked while Home is open
|
||||
val privateDnsHost by produceState<String?>(null) {
|
||||
while (true) {
|
||||
value = readPrivateDnsHost(context)
|
||||
delay(3000)
|
||||
}
|
||||
}
|
||||
var crashVisible by remember { mutableStateOf(crashReport != null) }
|
||||
|
||||
val isCustom = selected == Providers.CUSTOM_ID
|
||||
val customValid = customUrl.trim().toHttpUrlOrNull()?.isHttps == true
|
||||
val canConnect = !isCustom || customValid
|
||||
val selectedProvider = remember(selected, customUrl, customBootstrap, customHttp3) { prefs.resolve() }
|
||||
val customName = stringResource(R.string.custom)
|
||||
fun nameOf(p: DohProvider) = if (p.id == Providers.CUSTOM_ID) customName else p.name
|
||||
val shownProvider = active ?: selectedProvider
|
||||
val usesHttp3 = shownProvider.http3 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
|
||||
|
||||
// Picking a server never touches a live connection; switching is an explicit action
|
||||
val pendingSwitch = active?.let { it != selectedProvider && canConnect } == true
|
||||
|
||||
val vpnPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { if (it.resultCode == Activity.RESULT_OK) sendAction(context, DohVpnService.ACTION_START) }
|
||||
|
||||
fun connectVpn() {
|
||||
val permission = VpnService.prepare(context)
|
||||
if (permission != null) vpnPermission.launch(permission)
|
||||
else sendAction(context, DohVpnService.ACTION_START)
|
||||
}
|
||||
|
||||
// The status notification is optional: connect whatever the user answers
|
||||
val notificationPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { connectVpn() }
|
||||
|
||||
fun toggle() {
|
||||
if (running) {
|
||||
sendAction(context, DohVpnService.ACTION_STOP)
|
||||
return
|
||||
}
|
||||
val needsNotificationPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
if (needsNotificationPermission) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
else connectVpn()
|
||||
}
|
||||
|
||||
fun select(id: String) {
|
||||
selected = id
|
||||
prefs.providerId = id
|
||||
testResult = null
|
||||
}
|
||||
|
||||
fun runTest() {
|
||||
val provider = prefs.resolve()
|
||||
testing = true
|
||||
testResult = null
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { testProvider(context, provider) }
|
||||
shownResult = result
|
||||
testResult = result
|
||||
testing = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AnimatedVisibility(visible = crashVisible, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
val report = crashReport.orEmpty()
|
||||
CrashCard(
|
||||
report = report,
|
||||
onCopy = {
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
.setPrimaryClip(ClipData.newPlainText("SparkleDNS crash", report))
|
||||
},
|
||||
onDismiss = { CrashLog.clear(context); crashVisible = false },
|
||||
)
|
||||
}
|
||||
|
||||
StatusHero(
|
||||
running = running,
|
||||
providerName = nameOf(shownProvider),
|
||||
http3 = usesHttp3,
|
||||
stats = stats,
|
||||
canConnect = canConnect,
|
||||
switchTo = if (pendingSwitch) nameOf(selectedProvider) else null,
|
||||
onToggle = ::toggle,
|
||||
onSwitch = { sendAction(context, DohVpnService.ACTION_START) },
|
||||
)
|
||||
|
||||
SectionHeader(stringResource(R.string.servers))
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(vertical = 6.dp)) {
|
||||
Providers.list.forEachIndexed { index, p ->
|
||||
if (index > 0) RowDivider()
|
||||
ProviderRow(p.name, hostOf(p.url), p.http3, selected == p.id) { select(p.id) }
|
||||
}
|
||||
RowDivider()
|
||||
ProviderRow(
|
||||
title = stringResource(R.string.custom),
|
||||
subtitle = hostOf(customUrl),
|
||||
http3 = customHttp3,
|
||||
selected = isCustom,
|
||||
) { select(Providers.CUSTOM_ID) }
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isCustom, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = customUrl,
|
||||
onValueChange = { customUrl = it; prefs.customUrl = it },
|
||||
label = { Text(stringResource(R.string.custom_url)) },
|
||||
singleLine = true,
|
||||
isError = !customValid,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (!customValid) {
|
||||
Text(
|
||||
stringResource(R.string.invalid_url),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = customBootstrap,
|
||||
onValueChange = { customBootstrap = it; prefs.customBootstrap = it },
|
||||
label = { Text(stringResource(R.string.custom_bootstrap)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = customHttp3,
|
||||
role = Role.Switch,
|
||||
onValueChange = { customHttp3 = it; prefs.customHttp3 = it },
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.http3), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(R.string.http3_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Switch(checked = customHttp3, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = ::runTest,
|
||||
enabled = !testing && canConnect,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
) { Text(stringResource(if (testing) R.string.testing else R.string.test)) }
|
||||
|
||||
AnimatedVisibility(visible = testResult != null, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
shownResult?.let { result ->
|
||||
HintCard(
|
||||
text = result.text,
|
||||
icon = if (result.ok) R.drawable.ic_check else R.drawable.ic_alert,
|
||||
tint = if (result.ok) CachedColor else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val lastHost = remember { arrayOf("") }
|
||||
privateDnsHost?.let { lastHost[0] = it }
|
||||
AnimatedVisibility(visible = privateDnsHost != null, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
HintCard(
|
||||
text = stringResource(R.string.hint, lastHost[0]),
|
||||
icon = R.drawable.ic_alert,
|
||||
tint = WarningColor,
|
||||
action = {
|
||||
TextButton(onClick = { openNetworkSettings(context) }) { Text(stringResource(R.string.open_settings)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusHero(
|
||||
running: Boolean,
|
||||
providerName: String,
|
||||
http3: Boolean,
|
||||
stats: QueryStats,
|
||||
canConnect: Boolean,
|
||||
switchTo: String?,
|
||||
onToggle: () -> Unit,
|
||||
onSwitch: () -> Unit,
|
||||
) {
|
||||
val accent by animateColorAsState(
|
||||
if (running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
animationSpec = Motion.color,
|
||||
label = "accent",
|
||||
)
|
||||
val container by animateColorAsState(
|
||||
if (running) ProtectedContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
animationSpec = Motion.color,
|
||||
label = "container",
|
||||
)
|
||||
|
||||
val ringAlpha by animateFloatAsState(if (running) 1f else 0f, Motion.rotation, label = "ring")
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = container),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.size(124.dp), contentAlignment = Alignment.Center) {
|
||||
if (ringAlpha > 0f) PulseRing(accent, ringAlpha)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(92.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accent.copy(alpha = 0.18f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_privacy),
|
||||
contentDescription = null,
|
||||
tint = accent,
|
||||
modifier = Modifier.size(52.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = running,
|
||||
transitionSpec = { fadeIn(Motion.enterFade) togetherWith fadeOut(Motion.exitFade) },
|
||||
label = "status",
|
||||
) { on ->
|
||||
Text(
|
||||
stringResource(if (on) R.string.status_on else R.string.status_off),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.via, providerName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (http3) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Pill("HTTP/3", accent)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = running, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatColumn(formatCount(stats.total), stringResource(R.string.mon_queries), Modifier.weight(1f))
|
||||
StatDivider()
|
||||
StatColumn(formatCount(stats.blocked), stringResource(R.string.mon_blocked), Modifier.weight(1f))
|
||||
StatDivider()
|
||||
StatColumn(cachePercent(stats), stringResource(R.string.mon_cached), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onToggle,
|
||||
enabled = running || canConnect,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = running,
|
||||
transitionSpec = { fadeIn(Motion.enterFade) togetherWith fadeOut(Motion.exitFade) },
|
||||
label = "toggle",
|
||||
) { on -> Text(stringResource(if (on) R.string.disconnect else R.string.connect)) }
|
||||
}
|
||||
|
||||
// Remembers the last name so the button keeps its label while it animates out
|
||||
val lastSwitch = remember { arrayOf("") }
|
||||
if (switchTo != null) lastSwitch[0] = switchTo
|
||||
AnimatedVisibility(visible = switchTo != null, enter = Motion.expandIn, exit = Motion.collapseOut) {
|
||||
OutlinedButton(onClick = onSwitch, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(painterResource(R.drawable.ic_switch), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.switch_to, lastSwitch[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulseRing(color: Color, visibility: Float) {
|
||||
val transition = rememberInfiniteTransition(label = "pulse")
|
||||
val progress by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1800, easing = LinearEasing)),
|
||||
label = "progress",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(124.dp)
|
||||
.graphicsLayer {
|
||||
val scale = 0.74f + 0.26f * progress
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = (1f - progress) * visibility
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 0.28f))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashCard(report: String, onCopy: () -> Unit, onDismiss: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(painterResource(R.drawable.ic_alert), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.crash_title), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Text(
|
||||
report,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 12,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = onCopy) { Text(stringResource(R.string.copy)) }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dismiss)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderRow(title: String, subtitle: String, http3: Boolean, selected: Boolean, onClick: () -> Unit) {
|
||||
val highlight by animateColorAsState(
|
||||
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) else Color.Transparent,
|
||||
animationSpec = Motion.color,
|
||||
label = "highlight",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(highlight)
|
||||
.selectable(selected = selected, onClick = onClick, role = Role.RadioButton)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (http3) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Pill("HTTP/3", MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowDivider() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 60.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
// Hostname of strict Private DNS (DNS-over-TLS) on the current network, null when it is Automatic or Off
|
||||
private fun readPrivateDnsHost(context: Context): String? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return null
|
||||
return runCatching { cm.getLinkProperties(cm.activeNetwork)?.privateDnsServerName }.getOrNull()
|
||||
}
|
||||
|
||||
private fun openNetworkSettings(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
|
||||
private fun hostOf(url: String): String =
|
||||
runCatching { Uri.parse(url.trim()).host }.getOrNull()?.takeIf { it.isNotEmpty() } ?: url
|
||||
|
||||
internal fun sendAction(context: Context, action: String) {
|
||||
context.startService(Intent(context, DohVpnService::class.java).setAction(action))
|
||||
}
|
||||
|
||||
// Runs from the app process, which is excluded from the VPN, so it tests the server directly
|
||||
private fun testProvider(context: Context, provider: DohProvider): TestResult {
|
||||
val client = DohClient(context, provider)
|
||||
return try {
|
||||
val t0 = SystemClock.elapsedRealtime()
|
||||
client.query(DnsMessage.buildQuery("example.com"))
|
||||
val t1 = SystemClock.elapsedRealtime()
|
||||
val response = client.query(DnsMessage.buildQuery("example.com"))
|
||||
val t2 = SystemClock.elapsedRealtime()
|
||||
val rcode = DnsMessage.rcode(response)
|
||||
if (rcode == 0) TestResult(true, context.getString(R.string.test_ok, client.lastProtocol ?: "?", t1 - t0, t2 - t1))
|
||||
else TestResult(false, context.getString(R.string.test_rcode, rcode))
|
||||
} catch (e: Exception) {
|
||||
TestResult(false, context.getString(R.string.test_fail, e.message ?: e.javaClass.simpleName))
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private enum class MonitorFilter { ALL, BLOCKED, ERRORS }
|
||||
|
||||
private val TimeFormat = SimpleDateFormat("HH:mm:ss", Locale.ROOT)
|
||||
|
||||
@Composable
|
||||
fun MonitorScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val custom by Blocklist.custom.collectAsState()
|
||||
|
||||
var entries by remember { mutableStateOf(QueryLog.snapshot()) }
|
||||
var stats by remember { mutableStateOf(QueryLog.stats()) }
|
||||
var filter by rememberSaveable { mutableStateOf(MonitorFilter.ALL) }
|
||||
var search by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
entries = QueryLog.snapshot()
|
||||
stats = QueryLog.stats()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val visible = remember(entries, filter, search) {
|
||||
val term = search.trim()
|
||||
entries.filter { e ->
|
||||
val matchesFilter = when (filter) {
|
||||
MonitorFilter.ALL -> true
|
||||
MonitorFilter.BLOCKED -> e.status == QueryStatus.BLOCKED
|
||||
MonitorFilter.ERRORS -> e.status == QueryStatus.FAILED
|
||||
}
|
||||
matchesFilter && (term.isEmpty() || e.domain.contains(term, ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleBlock(domain: String, unblock: Boolean) {
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
if (unblock) {
|
||||
Blocklist.removeCustom(context, domain)
|
||||
true
|
||||
} else {
|
||||
Blocklist.addCustom(context, domain)
|
||||
}
|
||||
}
|
||||
val message = when {
|
||||
!ok -> context.getString(R.string.bl_invalid)
|
||||
unblock -> context.getString(R.string.mon_unblocked, domain)
|
||||
else -> context.getString(R.string.mon_blocked_added, domain)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
item {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatColumn(formatCount(stats.total), stringResource(R.string.mon_queries), Modifier.weight(1f))
|
||||
StatDivider()
|
||||
StatColumn(cachePercent(stats), stringResource(R.string.mon_cached), Modifier.weight(1f))
|
||||
StatDivider()
|
||||
StatColumn(formatCount(stats.blocked), stringResource(R.string.mon_blocked), Modifier.weight(1f))
|
||||
StatDivider()
|
||||
StatColumn(
|
||||
stringResource(R.string.mon_ms, stats.avgLatencyMs),
|
||||
stringResource(R.string.mon_latency),
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Chips scroll sideways on narrow screens instead of squeezing the clear button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = filter == MonitorFilter.ALL,
|
||||
onClick = { filter = MonitorFilter.ALL },
|
||||
label = { Text(stringResource(R.string.mon_all)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = filter == MonitorFilter.BLOCKED,
|
||||
onClick = { filter = MonitorFilter.BLOCKED },
|
||||
label = { Text(stringResource(R.string.status_blocked)) },
|
||||
leadingIcon = { ChipIcon(R.drawable.ic_tab_block) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = filter == MonitorFilter.ERRORS,
|
||||
onClick = { filter = MonitorFilter.ERRORS },
|
||||
label = { Text(stringResource(R.string.mon_errors)) },
|
||||
leadingIcon = { ChipIcon(R.drawable.ic_alert) },
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
QueryLog.clear()
|
||||
entries = emptyList()
|
||||
stats = QueryLog.stats()
|
||||
},
|
||||
enabled = entries.isNotEmpty() || stats.total > 0,
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_clear), stringResource(R.string.mon_clear), Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
placeholder = { Text(stringResource(R.string.mon_search)) },
|
||||
leadingIcon = { Icon(painterResource(R.drawable.ic_search), contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
val text = stringResource(if (entries.isEmpty()) R.string.mon_empty else R.string.mon_no_match)
|
||||
Box(Modifier.animateItem()) { EmptyState(R.drawable.ic_tab_monitor, text) }
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(visible, key = { _, e -> e.id }) { index, entry ->
|
||||
val inCustom = entry.domain in custom
|
||||
QueryRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
entry = entry,
|
||||
first = index == 0,
|
||||
last = index == visible.lastIndex,
|
||||
showBlock = !inCustom && entry.status != QueryStatus.BLOCKED,
|
||||
showUnblock = inCustom,
|
||||
onBlock = { toggleBlock(entry.domain, unblock = false) },
|
||||
onUnblock = { toggleBlock(entry.domain, unblock = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChipIcon(icon: Int) {
|
||||
Icon(painterResource(icon), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(icon: Int, text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
IconBadge(icon, MaterialTheme.colorScheme.onSurfaceVariant, size = 64.dp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueryRow(
|
||||
modifier: Modifier,
|
||||
entry: QueryEntry,
|
||||
first: Boolean,
|
||||
last: Boolean,
|
||||
showBlock: Boolean,
|
||||
showUnblock: Boolean,
|
||||
onBlock: () -> Unit,
|
||||
onUnblock: () -> Unit,
|
||||
) {
|
||||
val (icon, color: Color) = when (entry.status) {
|
||||
QueryStatus.OK -> R.drawable.ic_check to MaterialTheme.colorScheme.primary
|
||||
QueryStatus.CACHED -> R.drawable.ic_check to CachedColor
|
||||
QueryStatus.BLOCKED -> R.drawable.ic_tab_block to MaterialTheme.colorScheme.error
|
||||
QueryStatus.FAILED -> R.drawable.ic_alert to WarningColor
|
||||
}
|
||||
val label = when (entry.status) {
|
||||
QueryStatus.OK -> stringResource(R.string.mon_ms, entry.latencyMs)
|
||||
QueryStatus.CACHED -> stringResource(R.string.status_cached)
|
||||
QueryStatus.BLOCKED -> stringResource(R.string.status_blocked)
|
||||
QueryStatus.FAILED -> stringResource(R.string.status_failed)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(groupedBackground, groupedShape(first, last))
|
||||
) {
|
||||
if (!first) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 60.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconBadge(icon, color, size = 36.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.domain, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
"${TimeFormat.format(Date(entry.time))} · ${DnsMessage.typeName(entry.type)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Pill(label, color)
|
||||
when {
|
||||
showBlock -> IconButton(onClick = onBlock) {
|
||||
Icon(painterResource(R.drawable.ic_tab_block), stringResource(R.string.mon_block), Modifier.size(20.dp))
|
||||
}
|
||||
showUnblock -> IconButton(onClick = onUnblock) {
|
||||
Icon(painterResource(R.drawable.ic_check), stringResource(R.string.mon_unblock), Modifier.size(20.dp))
|
||||
}
|
||||
else -> Spacer(Modifier.width(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
// One set of timings for the whole app, based on Material 3 emphasized motion
|
||||
object Motion {
|
||||
val Emphasized = CubicBezierEasing(0.2f, 0f, 0f, 1f)
|
||||
val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
|
||||
|
||||
val enterFade = tween<Float>(durationMillis = 280, delayMillis = 60, easing = Emphasized)
|
||||
val exitFade = tween<Float>(durationMillis = 140, easing = EmphasizedAccelerate)
|
||||
val slide: FiniteAnimationSpec<IntOffset> = tween(durationMillis = 380, easing = Emphasized)
|
||||
val size: FiniteAnimationSpec<IntSize> = tween(durationMillis = 320, easing = Emphasized)
|
||||
val color = tween<androidx.compose.ui.graphics.Color>(durationMillis = 400, easing = Emphasized)
|
||||
val rotation = tween<Float>(durationMillis = 260, easing = Emphasized)
|
||||
|
||||
val expandIn = fadeIn(enterFade) + expandVertically(size)
|
||||
val collapseOut = fadeOut(exitFade) + shrinkVertically(size)
|
||||
|
||||
fun sizeTransform() = SizeTransform(clip = false) { _, _ -> size }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
class UdpPacket(
|
||||
val srcIp: ByteArray,
|
||||
val dstIp: ByteArray,
|
||||
val srcPort: Int,
|
||||
val dstPort: Int,
|
||||
val payload: ByteArray,
|
||||
)
|
||||
|
||||
object Packet {
|
||||
|
||||
// IPv4 total length is 16 bits: 65535 minus the 20-byte IP and 8-byte UDP headers
|
||||
const val MAX_UDP_PAYLOAD = 65507
|
||||
|
||||
// Returns the DNS payload of an IPv4/UDP packet addressed to port 53, null for anything else
|
||||
fun parseDnsQuery(buf: ByteArray, len: Int): UdpPacket? {
|
||||
if (len < 28 || ((buf[0].toInt() shr 4) and 0x0F) != 4) return null
|
||||
val ihl = (buf[0].toInt() and 0x0F) * 4
|
||||
if (ihl < 20 || buf[9].toInt() != 17 || len < ihl + 8) return null
|
||||
if ((u16(buf, 6) and 0x3FFF) != 0) return null // fragmented
|
||||
|
||||
val dstPort = u16(buf, ihl + 2)
|
||||
if (dstPort != 53) return null
|
||||
val end = minOf(len, ihl + u16(buf, ihl + 4))
|
||||
if (end <= ihl + 8) return null
|
||||
|
||||
return UdpPacket(
|
||||
srcIp = buf.copyOfRange(12, 16),
|
||||
dstIp = buf.copyOfRange(16, 20),
|
||||
srcPort = u16(buf, ihl),
|
||||
dstPort = dstPort,
|
||||
payload = buf.copyOfRange(ihl + 8, end),
|
||||
)
|
||||
}
|
||||
|
||||
fun buildReply(query: UdpPacket, payload: ByteArray): ByteArray {
|
||||
val udpLen = 8 + payload.size
|
||||
val total = 20 + udpLen
|
||||
val p = ByteArray(total)
|
||||
|
||||
p[0] = 0x45
|
||||
put16(p, 2, total)
|
||||
p[6] = 0x40 // don't fragment
|
||||
p[8] = 64
|
||||
p[9] = 17
|
||||
query.dstIp.copyInto(p, 12)
|
||||
query.srcIp.copyInto(p, 16)
|
||||
put16(p, 10, checksum(p, 0, 20, 0))
|
||||
|
||||
put16(p, 20, query.dstPort)
|
||||
put16(p, 22, query.srcPort)
|
||||
put16(p, 24, udpLen)
|
||||
payload.copyInto(p, 28)
|
||||
|
||||
val pseudo = sum16(p, 12, 8) + 17 + udpLen
|
||||
val udpSum = checksum(p, 20, udpLen, pseudo)
|
||||
put16(p, 26, if (udpSum == 0) 0xFFFF else udpSum)
|
||||
return p
|
||||
}
|
||||
|
||||
private fun sum16(b: ByteArray, off: Int, len: Int): Long {
|
||||
var sum = 0L
|
||||
var i = off
|
||||
val end = off + len
|
||||
while (i + 1 < end) {
|
||||
sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)
|
||||
i += 2
|
||||
}
|
||||
if (i < end) sum += (b[i].toInt() and 0xFF) shl 8
|
||||
return sum
|
||||
}
|
||||
|
||||
private fun checksum(b: ByteArray, off: Int, len: Int, initial: Long): Int {
|
||||
var sum = initial + sum16(b, off, len)
|
||||
while ((sum shr 16) != 0L) sum = (sum and 0xFFFF) + (sum shr 16)
|
||||
return (sum.inv() and 0xFFFF).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
object DnsMessage {
|
||||
|
||||
fun buildQuery(name: String, type: Int = 1): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val id = Random.nextInt(0x10000)
|
||||
out.write(id shr 8); out.write(id and 0xFF)
|
||||
out.write(0x01); out.write(0x00) // recursion desired
|
||||
out.write(0x00); out.write(0x01) // one question
|
||||
repeat(6) { out.write(0) }
|
||||
name.trimEnd('.').split('.').forEach { label ->
|
||||
val bytes = label.toByteArray()
|
||||
out.write(bytes.size)
|
||||
out.write(bytes, 0, bytes.size)
|
||||
}
|
||||
out.write(0)
|
||||
out.write(type shr 8); out.write(type and 0xFF)
|
||||
out.write(0x00); out.write(0x01) // class IN
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun rcode(message: ByteArray): Int = if (message.size >= 4) message[3].toInt() and 0x0F else -1
|
||||
|
||||
fun isResponse(message: ByteArray): Boolean = message.size >= 12 && (message[2].toInt() and 0x80) != 0
|
||||
|
||||
// Copies the transaction ID of the query, so the client always matches the answer
|
||||
fun withId(response: ByteArray, query: ByteArray): ByteArray {
|
||||
if (response.size < 2 || query.size < 2) return response
|
||||
if (response[0] == query[0] && response[1] == query[1]) return response
|
||||
val out = response.copyOf()
|
||||
out[0] = query[0]
|
||||
out[1] = query[1]
|
||||
return out
|
||||
}
|
||||
|
||||
// SERVFAIL lets the system resolver fail fast instead of waiting for a timeout
|
||||
const val RCODE_SERVFAIL = 2
|
||||
const val RCODE_NXDOMAIN = 3
|
||||
|
||||
class Question(val name: String, val type: Int)
|
||||
|
||||
fun question(msg: ByteArray): Question? {
|
||||
if (msg.size < 12 || u16(msg, 4) < 1) return null
|
||||
val name = StringBuilder()
|
||||
var p = 12
|
||||
while (true) {
|
||||
if (p >= msg.size) return null
|
||||
val len = msg[p].toInt() and 0xFF
|
||||
if (len == 0) { p++; break }
|
||||
if ((len and 0xC0) != 0 || p + 1 + len > msg.size) return null
|
||||
if (name.isNotEmpty()) name.append('.')
|
||||
for (i in p + 1..p + len) name.append((msg[i].toInt() and 0xFF).toChar().lowercaseChar())
|
||||
p += len + 1
|
||||
}
|
||||
if (p + 2 > msg.size) return null
|
||||
return Question(name.toString(), u16(msg, p))
|
||||
}
|
||||
|
||||
fun typeName(type: Int): String = when (type) {
|
||||
1 -> "A"; 2 -> "NS"; 5 -> "CNAME"; 6 -> "SOA"; 12 -> "PTR"; 15 -> "MX"; 16 -> "TXT"
|
||||
28 -> "AAAA"; 33 -> "SRV"; 64 -> "SVCB"; 65 -> "HTTPS"
|
||||
else -> "TYPE$type"
|
||||
}
|
||||
|
||||
fun servfail(query: ByteArray): ByteArray? = errorReply(query, RCODE_SERVFAIL)
|
||||
|
||||
fun errorReply(query: ByteArray, rcode: Int): ByteArray? {
|
||||
if (query.size < 12) return null
|
||||
var pos = 12
|
||||
repeat(u16(query, 4)) {
|
||||
pos = skipName(query, pos) ?: return null
|
||||
pos += 4
|
||||
if (pos > query.size) return null
|
||||
}
|
||||
val out = query.copyOf(pos)
|
||||
out[2] = (out[2].toInt() or 0x80).toByte()
|
||||
out[3] = (0x80 or rcode).toByte()
|
||||
for (i in 6 until 12) out[i] = 0
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
internal fun u16(b: ByteArray, off: Int): Int =
|
||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
||||
|
||||
internal fun put16(b: ByteArray, off: Int, value: Int) {
|
||||
b[off] = (value shr 8).toByte()
|
||||
b[off + 1] = value.toByte()
|
||||
}
|
||||
|
||||
internal fun put32(b: ByteArray, off: Int, value: Int) {
|
||||
b[off] = (value ushr 24).toByte()
|
||||
b[off + 1] = (value ushr 16).toByte()
|
||||
b[off + 2] = (value ushr 8).toByte()
|
||||
b[off + 3] = value.toByte()
|
||||
}
|
||||
|
||||
internal fun u32(b: ByteArray, off: Int): Long =
|
||||
((u16(b, off).toLong()) shl 16) or u16(b, off + 2).toLong()
|
||||
|
||||
// Returns the offset right after a (possibly compressed) DNS name
|
||||
internal fun skipName(b: ByteArray, start: Int): Int? {
|
||||
var p = start
|
||||
while (p < b.size) {
|
||||
val len = b[p].toInt() and 0xFF
|
||||
if (len == 0) return p + 1
|
||||
if ((len and 0xC0) == 0xC0) return if (p + 2 <= b.size) p + 2 else null
|
||||
p += len + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class Prefs(context: Context) {
|
||||
private val sp = context.applicationContext.getSharedPreferences("doh", Context.MODE_PRIVATE)
|
||||
|
||||
var providerId: String
|
||||
get() = sp.getString(KEY_PROVIDER, null) ?: Providers.default.id
|
||||
set(value) = sp.edit().putString(KEY_PROVIDER, value).apply()
|
||||
|
||||
var customUrl: String
|
||||
get() = sp.getString(KEY_URL, null) ?: "https://"
|
||||
set(value) = sp.edit().putString(KEY_URL, value).apply()
|
||||
|
||||
var customBootstrap: String
|
||||
get() = sp.getString(KEY_BOOTSTRAP, null) ?: ""
|
||||
set(value) = sp.edit().putString(KEY_BOOTSTRAP, value).apply()
|
||||
|
||||
var customHttp3: Boolean
|
||||
get() = sp.getBoolean(KEY_HTTP3, false)
|
||||
set(value) = sp.edit().putBoolean(KEY_HTTP3, value).apply()
|
||||
|
||||
var excludedApps: Set<String>
|
||||
get() = sp.getStringSet(KEY_EXCLUDED, null)?.toSet() ?: emptySet()
|
||||
set(value) = sp.edit().putStringSet(KEY_EXCLUDED, value).apply()
|
||||
|
||||
fun resolve(): DohProvider =
|
||||
if (providerId == Providers.CUSTOM_ID) {
|
||||
DohProvider(Providers.CUSTOM_ID, "Custom", customUrl.trim(), parseIps(customBootstrap), customHttp3)
|
||||
} else {
|
||||
Providers.byId(providerId)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_PROVIDER = "provider"
|
||||
const val KEY_URL = "custom_url"
|
||||
const val KEY_BOOTSTRAP = "custom_bootstrap"
|
||||
const val KEY_HTTP3 = "custom_http3"
|
||||
const val KEY_EXCLUDED = "excluded_apps"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import android.content.Context
|
||||
|
||||
data class DohProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val bootstrap: List<String> = emptyList(),
|
||||
val http3: Boolean = false,
|
||||
)
|
||||
|
||||
object Providers {
|
||||
const val CUSTOM_ID = "custom"
|
||||
|
||||
val list = listOf(
|
||||
DohProvider("skysparkle", "SkySparkle", "https://blog.skysparkle.cc/dns-query", http3 = true),
|
||||
DohProvider("cloudflare", "Cloudflare", "https://cloudflare-dns.com/dns-query", listOf("1.1.1.1", "1.0.0.1")),
|
||||
DohProvider("google", "Google", "https://dns.google/dns-query", listOf("8.8.8.8", "8.8.4.4")),
|
||||
DohProvider("quad9", "Quad9", "https://dns.quad9.net/dns-query", listOf("9.9.9.9", "149.112.112.112")),
|
||||
DohProvider("adguard", "AdGuard", "https://dns.adguard-dns.com/dns-query", listOf("94.140.14.14", "94.140.15.15")),
|
||||
DohProvider("mullvad", "Mullvad", "https://dns.mullvad.net/dns-query", listOf("194.242.2.2")),
|
||||
)
|
||||
|
||||
val default: DohProvider get() = list.first()
|
||||
|
||||
fun byId(id: String): DohProvider = list.firstOrNull { it.id == id } ?: default
|
||||
}
|
||||
|
||||
fun parseIps(raw: String): List<String> =
|
||||
raw.split(',', ';', ' ').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
fun DohProvider.displayName(context: Context): String =
|
||||
if (id == Providers.CUSTOM_ID) context.getString(R.string.custom) else name
|
||||
@@ -0,0 +1,71 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
enum class QueryStatus { OK, CACHED, BLOCKED, FAILED }
|
||||
|
||||
class QueryEntry(
|
||||
val id: Long,
|
||||
val time: Long,
|
||||
val domain: String,
|
||||
val type: Int,
|
||||
val status: QueryStatus,
|
||||
val latencyMs: Int,
|
||||
)
|
||||
|
||||
data class QueryStats(
|
||||
val total: Long,
|
||||
val cached: Long,
|
||||
val blocked: Long,
|
||||
val failed: Long,
|
||||
val avgLatencyMs: Int,
|
||||
)
|
||||
|
||||
// In-memory only: nothing is written to disk or sent anywhere
|
||||
object QueryLog {
|
||||
private const val MAX_ENTRIES = 500
|
||||
|
||||
private val entries = ArrayDeque<QueryEntry>()
|
||||
private var nextId = 0L
|
||||
private var total = 0L
|
||||
private var cached = 0L
|
||||
private var blocked = 0L
|
||||
private var failed = 0L
|
||||
private var latencySum = 0L
|
||||
private var latencyCount = 0L
|
||||
|
||||
fun add(question: DnsMessage.Question?, status: QueryStatus, latencyMs: Int) {
|
||||
if (question == null) return
|
||||
synchronized(this) {
|
||||
if (entries.size >= MAX_ENTRIES) entries.removeFirst()
|
||||
entries.addLast(
|
||||
QueryEntry(nextId++, System.currentTimeMillis(), question.name.ifEmpty { "." }, question.type, status, latencyMs)
|
||||
)
|
||||
total++
|
||||
when (status) {
|
||||
QueryStatus.OK -> { latencySum += latencyMs; latencyCount++ }
|
||||
QueryStatus.CACHED -> cached++
|
||||
QueryStatus.BLOCKED -> blocked++
|
||||
QueryStatus.FAILED -> failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newest first. Builds a real copy: on API 35+ List.reversed() resolves to a live JDK view,
|
||||
// and on older devices that method does not exist at all
|
||||
@Synchronized
|
||||
fun snapshot(): List<QueryEntry> = List(entries.size) { entries[entries.size - 1 - it] }
|
||||
|
||||
@Synchronized
|
||||
fun stats() = QueryStats(
|
||||
total = total,
|
||||
cached = cached,
|
||||
blocked = blocked,
|
||||
failed = failed,
|
||||
avgLatencyMs = if (latencyCount == 0L) 0 else (latencySum / latencyCount).toInt(),
|
||||
)
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
total = 0; cached = 0; blocked = 0; failed = 0; latencySum = 0; latencyCount = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package cc.skysparkle.sparkledns
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.NumberFormat
|
||||
|
||||
val CachedColor = Color(0xFF7FD1AE)
|
||||
val WarningColor = Color(0xFFFFB86B)
|
||||
|
||||
// Icon on a soft circle of its own color
|
||||
@Composable
|
||||
fun IconBadge(@DrawableRes icon: Int, tint: Color = MaterialTheme.colorScheme.primary, size: Dp = 40.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(tint.copy(alpha = 0.16f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(painterResource(icon), contentDescription = null, tint = tint, modifier = Modifier.size(size * 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(
|
||||
title: String,
|
||||
@DrawableRes icon: Int? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Pill(text: String, color: Color) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color.copy(alpha = 0.14f))
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HintCard(
|
||||
text: String,
|
||||
@DrawableRes icon: Int = R.drawable.ic_tab_info,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = tint.copy(alpha = 0.10f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 14.dp, end = if (action != null) 6.dp else 14.dp, top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(painterResource(icon), contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatColumn(value: String, label: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Numbers roll up when they change
|
||||
AnimatedContent(
|
||||
targetState = value,
|
||||
transitionSpec = {
|
||||
(slideInVertically(Motion.slide) { it / 2 } + fadeIn(Motion.enterFade)) togetherWith
|
||||
(slideOutVertically(Motion.slide) { -it / 2 } + fadeOut(Motion.exitFade)) using
|
||||
Motion.sizeTransform()
|
||||
},
|
||||
label = "stat",
|
||||
) { shown ->
|
||||
Text(shown, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 1.dp, height = 32.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
}
|
||||
|
||||
// Polls the in-memory query log once a second while the screen is visible
|
||||
@Composable
|
||||
fun rememberLiveStats(): QueryStats {
|
||||
var stats by remember { mutableStateOf(QueryLog.stats()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
stats = QueryLog.stats()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
fun formatCount(value: Long): String = NumberFormat.getIntegerInstance().format(value)
|
||||
|
||||
fun cachePercent(stats: QueryStats): String =
|
||||
"${if (stats.total == 0L) 0 else stats.cached * 100 / stats.total}%"
|
||||
|
||||
// Rows of one visual group share a background; only the outer corners are rounded
|
||||
fun groupedShape(first: Boolean, last: Boolean, radius: Dp = 16.dp) = RoundedCornerShape(
|
||||
topStart = if (first) radius else 0.dp,
|
||||
topEnd = if (first) radius else 0.dp,
|
||||
bottomStart = if (last) radius else 0.dp,
|
||||
bottomEnd = if (last) radius else 0.dp,
|
||||
)
|
||||
|
||||
val groupedBackground: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M132.0,191.5L123.0,191.5L122.3,191.0L122.0,133.0L64.0,132.7L63.5,132.0L63.5,123.0L64.0,122.3L122.0,122.0L122.3,64.0L123.0,63.5L132.0,63.5L132.7,64.0L133.0,122.0L191.0,122.3L191.5,123.0L191.5,132.0L191.0,132.7L133.0,133.0L132.7,191.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M175.0,214.5L29.0,214.5L28.5,214.0L28.5,34.0L29.0,33.3L124.0,33.3L124.5,34.0L124.5,75.0L125.0,75.7L226.3,76.0L226.5,164.0L226.0,164.5L217.0,164.5L215.7,164.0L215.7,87.0L215.0,86.5L172.0,86.5L125.0,86.5L124.5,87.0L124.5,118.0L125.0,118.5L149.0,118.5L150.0,119.0L150.0,129.0L126.0,129.3L124.7,130.0L125.0,161.0L149.0,161.3L150.0,162.0L150.0,171.0L149.0,171.7L125.0,172.0L124.7,203.0L126.0,203.7L175.0,203.7L175.5,205.0L175.5,214.0ZM71.3,75.0L71.0,44.0L39.5,44.0L39.3,75.0L40.0,75.7L70.0,75.7ZM114.0,75.0L114.0,45.0L113.5,44.0L82.0,44.0L82.0,75.0L83.0,75.7L113.0,75.7ZM71.3,118.0L71.0,86.7L40.0,86.5L39.3,87.0L39.3,118.0L40.0,118.5L70.0,118.5ZM114.0,118.0L114.0,87.0L113.0,86.5L83.0,86.5L82.0,87.0L82.0,118.0L83.0,118.5L113.0,118.5ZM186.0,129.0L175.7,129.0L175.5,119.0L176.0,118.5L185.0,118.5L186.3,119.0ZM71.0,161.0L71.0,129.5L40.0,129.3L39.3,130.0L39.5,161.0ZM114.0,161.0L114.0,130.0L113.0,129.3L83.0,129.3L82.0,130.0L82.0,160.0L82.5,161.0ZM186.0,171.5L176.0,171.7L175.5,171.0L175.5,162.0L176.0,161.3L186.0,161.5ZM71.3,203.0L71.0,172.0L39.5,172.0L39.3,203.0L40.0,203.7L70.0,203.7ZM114.0,203.0L114.0,173.0L113.5,172.0L82.0,172.0L82.0,203.0L83.0,203.7L113.0,203.7ZM226.0,235.7L217.0,235.7L215.7,235.0L215.7,215.0L215.0,214.5L195.0,214.5L194.5,214.0L194.5,205.0L195.0,203.7L215.0,203.7L215.7,203.0L216.0,182.7L226.0,182.5L226.5,183.0L226.5,203.0L227.0,203.7L247.5,204.0L247.7,214.0L247.0,214.5L227.0,214.5L226.5,215.0L226.5,235.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M137.0,223.0L130.0,223.5L117.0,223.0L105.0,221.0L98.0,219.0L90.0,216.0L80.0,211.0L68.0,203.0L60.0,196.0L53.7,189.0L47.7,181.0L43.5,174.0L37.7,162.0L34.7,153.0L31.7,135.0L31.5,125.0L32.0,117.0L34.0,105.0L36.0,98.0L39.0,90.0L44.0,80.0L52.0,68.0L59.0,60.0L66.0,53.7L74.0,47.7L81.0,43.5L93.0,37.7L102.0,34.7L120.0,31.7L130.0,31.5L138.0,32.0L150.0,34.0L157.0,36.0L165.0,39.0L175.0,44.0L187.0,52.0L195.0,59.0L201.3,66.0L207.3,74.0L211.5,81.0L217.3,93.0L220.3,102.0L223.3,120.0L223.5,130.0L223.0,138.0L221.0,150.0L219.0,157.0L216.0,165.0L211.0,175.0L203.0,187.0L196.0,195.0L189.0,201.3L181.0,207.3L174.0,211.5L162.0,217.3L153.0,220.3ZM139.0,212.0L145.0,211.3L159.0,207.3L167.0,203.7L174.0,199.5L185.0,191.0L193.7,182.0L199.0,175.0L207.0,160.0L209.3,153.0L211.5,144.0L212.5,136.0L212.7,123.0L211.3,110.0L207.3,96.0L203.7,88.0L199.5,81.0L191.0,70.0L182.0,61.3L175.0,56.0L160.0,48.0L153.0,45.7L144.0,43.5L136.0,42.5L123.0,42.3L110.0,43.7L96.0,47.7L88.0,51.3L81.0,55.5L70.0,64.0L61.3,73.0L56.0,80.0L48.0,95.0L45.7,102.0L43.5,111.0L42.5,119.0L42.3,132.0L43.7,145.0L47.7,159.0L51.3,167.0L55.5,174.0L64.0,185.0L73.0,193.7L80.0,199.0L95.0,207.0L102.0,209.3L111.0,211.5L123.0,212.7L132.0,212.7ZM132.0,139.7L123.0,139.7L122.3,139.0L122.3,77.0L123.0,75.7L132.0,75.7L132.7,77.0L132.7,139.0ZM128.0,175.0L126.0,175.0L123.0,173.3L121.3,171.0L121.0,169.0L122.0,165.0L124.0,163.0L128.0,162.0L131.0,163.0L132.0,164.0L133.0,165.0L134.0,168.0L133.5,171.0L132.3,173.0L130.0,174.7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M226.0,181.0L29.0,181.0L28.5,180.0L28.7,177.0L32.0,161.0L36.0,150.0L44.0,135.0L49.0,128.0L59.0,117.3L67.0,110.7L76.0,105.0L76.7,104.0L59.7,74.0L60.0,70.0L62.0,68.0L65.0,67.0L66.0,67.0L69.3,69.0L87.0,98.7L99.0,94.7L102.0,94.3L107.0,92.7L113.0,91.7L126.0,90.7L140.0,91.5L148.0,92.7L159.0,95.5L165.0,98.0L168.0,98.7L173.0,91.0L173.0,90.0L177.0,84.0L184.0,71.0L186.0,68.7L188.0,67.3L191.0,67.0L193.0,68.0L195.3,71.0L195.0,75.0L186.0,90.0L186.0,91.0L182.0,97.0L179.0,103.0L178.0,104.0L187.0,110.0L196.7,118.0L205.0,127.0L212.3,137.0L217.7,147.0L223.3,162.0L223.7,165.0L225.3,170.0L225.7,175.0L226.3,177.0L226.7,180.0ZM83.5,156.0L87.0,155.3L90.3,153.0L93.0,149.0L93.5,146.0L93.3,142.0L92.5,140.0L90.0,136.7L86.0,134.3L81.0,133.7L76.0,135.7L74.3,137.0L72.3,140.0L71.3,144.0L71.7,149.0L74.3,153.0L77.0,155.0L83.0,156.3ZM174.0,156.0L177.0,155.3L180.0,153.3L182.0,151.0L183.3,148.0L183.7,144.0L183.3,141.0L180.7,137.0L177.0,134.5L172.0,133.7L168.0,134.7L164.7,137.0L162.0,141.0L161.3,145.0L161.7,148.0L162.7,150.0L166.0,154.0L169.0,155.5L173.0,156.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M128.0,201.7L127.0,201.7L53.3,128.0L53.3,127.0L127.0,53.3L128.0,53.3L134.7,60.0L134.3,61.0L74.3,121.0L73.7,122.0L74.0,122.3L201.0,122.3L202.3,123.0L202.3,132.0L201.0,132.7L73.5,133.0L134.3,194.0L134.7,195.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M101.0,184.0L49.0,132.0L48.5,131.0L49.0,130.3L56.0,123.7L101.0,168.7L102.0,168.3L199.0,71.3L200.0,72.0L206.0,78.0L206.3,79.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M127.0,202.3L49.0,202.3L48.0,202.0L32.0,186.0L30.0,183.0L28.3,179.0L27.7,176.0L28.0,171.0L30.0,166.0L35.0,160.0L142.0,49.0L147.0,45.7L152.0,44.7L157.0,45.0L160.0,46.0L165.0,49.7L209.3,94.0L213.3,100.0L214.0,104.0L214.0,109.0L211.0,116.0ZM227.0,202.3L167.7,202.0L178.0,191.5L227.0,191.5L227.5,192.0L227.5,201.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M137.0,223.0L130.0,223.5L117.0,223.0L105.0,221.0L98.0,219.0L90.0,216.0L80.0,211.0L68.0,203.0L60.0,196.0L53.7,189.0L47.7,181.0L43.5,174.0L37.7,162.0L34.7,153.0L31.7,135.0L31.5,125.0L32.0,117.0L34.0,105.0L36.0,98.0L39.0,90.0L44.0,80.0L52.0,68.0L59.0,60.0L66.0,53.7L74.0,47.7L81.0,43.5L93.0,37.7L102.0,34.7L120.0,31.7L130.0,31.5L138.0,32.0L150.0,34.0L157.0,36.0L165.0,39.0L175.0,44.0L187.0,52.0L195.0,59.0L201.3,66.0L207.3,74.0L211.5,81.0L217.3,93.0L220.3,102.0L223.3,120.0L223.5,130.0L223.0,138.0L221.0,150.0L219.0,157.0L216.0,165.0L211.0,175.0L203.0,187.0L196.0,195.0L189.0,201.3L181.0,207.3L174.0,211.5L162.0,217.3L153.0,220.3ZM139.0,212.0L145.0,211.3L159.0,207.3L167.0,203.7L174.0,199.5L185.0,191.0L193.7,182.0L199.0,175.0L207.0,160.0L209.3,153.0L211.5,144.0L212.5,136.0L212.7,123.0L211.3,110.0L207.3,96.0L203.7,88.0L199.5,81.0L191.0,70.0L182.0,61.3L175.0,56.0L160.0,48.0L153.0,45.7L144.0,43.5L136.0,42.5L123.0,42.3L110.0,43.7L96.0,47.7L88.0,51.3L81.0,55.5L70.0,64.0L61.3,73.0L56.0,80.0L48.0,95.0L45.7,102.0L43.5,111.0L42.5,119.0L42.3,132.0L43.7,145.0L47.7,159.0L51.3,167.0L55.5,174.0L64.0,185.0L73.0,193.7L80.0,199.0L95.0,207.0L102.0,209.3L111.0,211.5L123.0,212.7L132.0,212.7ZM151.0,164.3L106.0,164.7L103.0,164.0L101.5,162.0L100.7,160.0L100.7,96.0L102.0,92.0L104.0,90.7L149.0,90.3L152.0,91.0L153.5,93.0L154.3,95.0L154.0,111.3L144.0,111.5L143.5,111.0L143.5,102.0L143.0,100.7L112.0,100.7L111.5,102.0L111.7,154.0L143.0,154.3L143.5,153.0L143.5,144.0L144.0,143.5L154.0,143.7L154.3,159.0L153.0,163.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M179.0,212.3L79.0,212.7L75.0,212.0L71.0,210.0L67.7,207.0L65.0,203.0L63.7,199.0L63.5,196.0L63.5,64.0L63.0,63.5L54.0,63.5L52.7,63.0L53.0,53.0L95.0,52.7L95.5,52.0L95.5,45.0L96.0,44.5L159.0,44.5L159.5,45.0L159.5,52.0L160.0,52.7L202.0,53.0L202.3,63.0L201.0,63.5L192.0,63.5L191.5,64.0L191.5,197.0L190.5,202.0L189.0,205.0L185.0,209.3L182.0,211.3ZM114.7,180.0L114.7,86.0L114.0,84.7L105.0,84.7L104.0,85.0L104.0,180.0L105.0,180.7L114.0,180.7ZM151.0,180.0L151.0,86.0L150.0,84.7L141.0,84.7L140.3,86.0L140.3,180.0L141.0,180.7L150.0,180.7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M128.0,201.7L127.0,201.7L53.3,128.0L53.3,127.0L60.0,120.3L61.0,120.7L121.0,180.7L122.0,181.3L122.3,181.0L122.3,54.0L123.0,52.7L132.0,52.7L132.7,54.0L133.0,181.5L194.0,120.7L195.0,120.3L201.7,127.0L201.7,128.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M206.0,180.3L84.0,180.7L80.0,180.0L76.0,178.0L73.0,175.0L69.7,170.0L68.7,164.0L68.7,49.0L70.0,42.0L71.7,39.0L75.0,35.3L79.0,32.7L82.0,31.7L85.0,31.5L202.0,31.5L207.0,32.5L213.0,36.0L215.3,39.0L217.3,43.0L218.3,48.0L218.3,163.0L218.0,167.0L215.0,174.0L212.0,177.0ZM131.0,156.0L138.0,154.7L142.0,152.3L148.0,146.0L150.3,140.0L151.0,135.0L151.0,74.0L172.0,73.7L175.0,73.0L177.0,71.7L178.0,71.0L180.0,66.0L179.0,62.0L177.7,60.0L175.0,58.0L171.0,57.3L151.0,57.3L148.0,57.7L145.0,60.0L143.0,63.0L142.7,65.0L142.7,118.0L142.0,118.7L139.0,116.0L138.0,116.0L137.0,115.0L132.0,113.7L126.0,113.7L122.0,114.7L118.0,116.7L114.0,120.0L111.0,124.0L109.5,127.0L108.5,131.0L108.5,139.0L109.7,143.0L112.7,148.0L118.0,153.0L122.0,155.0L129.0,156.3ZM177.0,212.0L175.0,212.7L52.0,212.7L48.0,212.0L44.0,210.0L41.0,207.0L37.7,202.0L36.7,196.0L36.7,76.0L38.0,72.0L40.0,70.5L44.0,70.5L46.3,72.0L47.5,75.0L47.5,196.0L49.3,200.0L52.0,201.7L55.0,202.3L175.0,202.3L178.0,203.7L179.3,206.0L179.0,210.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M131.0,222.0L128.0,222.5L123.0,222.0L113.0,217.0L100.0,209.0L99.0,209.0L93.0,205.0L80.0,198.0L74.0,194.0L73.0,194.0L67.0,190.0L54.0,183.0L48.0,179.0L44.7,175.0L42.7,170.0L42.3,167.0L42.7,149.0L44.0,145.0L47.0,141.0L53.0,136.7L122.0,97.0L122.0,84.0L119.0,83.5L112.0,80.0L106.7,75.0L102.7,68.0L100.7,59.0L101.5,52.0L103.0,47.0L107.0,41.0L112.0,36.3L117.0,33.5L123.0,31.7L127.0,31.5L131.0,31.7L137.0,33.0L144.0,37.0L150.3,44.0L152.3,48.0L153.3,51.0L154.3,58.0L154.0,62.0L152.5,68.0L149.0,74.0L145.0,78.3L142.0,80.7L137.0,83.3L134.0,83.7L132.7,85.0L133.0,97.0L148.0,106.0L149.0,106.0L155.0,110.0L161.0,113.0L162.0,114.0L168.0,117.0L169.0,118.0L175.0,121.0L181.0,125.0L182.0,125.0L188.0,129.0L201.0,136.0L207.0,140.0L210.3,144.0L211.3,146.0L212.7,152.0L212.3,170.0L211.0,174.0L208.0,178.0L202.0,182.3L173.0,198.7L164.0,204.3L136.0,220.3ZM129.5,182.0L193.7,145.0L190.0,142.0L177.0,135.0L171.0,131.0L170.0,131.0L164.0,127.0L151.0,120.0L138.0,112.0L133.0,109.7L132.7,143.0L132.0,143.5L123.0,143.5L122.3,143.0L122.0,109.5L62.0,143.7L61.0,145.0L84.0,158.0L90.0,162.0L91.0,162.0L104.0,170.0L110.0,173.0L116.0,177.0L117.0,177.0L123.0,181.0L127.0,182.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M206.0,180.3L84.0,180.7L80.0,180.0L76.0,178.0L73.0,175.0L69.7,170.0L68.7,164.0L68.7,49.0L70.0,42.0L71.7,39.0L75.0,35.3L79.0,32.7L82.0,31.7L85.0,31.5L202.0,31.5L207.0,32.5L213.0,36.0L215.3,39.0L217.3,43.0L218.3,48.0L218.3,163.0L218.0,167.0L215.0,174.0L212.0,177.0ZM179.0,212.7L52.0,212.7L48.0,212.0L44.0,210.0L41.0,207.0L37.7,202.0L36.7,196.0L37.0,70.5L38.0,70.0L47.0,70.0L47.5,71.0L47.5,196.0L49.3,200.0L52.0,201.7L55.0,202.3L179.0,202.3L179.5,203.0L179.5,212.0ZM189.3,145.0L164.7,112.0L161.0,107.3L160.0,107.0L135.0,138.5L134.0,139.0L133.0,138.0L118.0,120.0L117.0,120.0L97.5,145.0L98.0,145.5L188.0,145.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M159.0,212.0L103.0,192.0L95.0,189.7L89.0,192.3L87.0,192.7L84.0,194.3L82.0,194.7L76.0,197.3L74.0,197.7L71.0,199.3L69.0,199.7L52.0,206.3L49.0,206.5L44.3,204.0L42.3,200.0L42.3,67.0L42.5,65.0L45.0,61.0L49.0,58.7L96.0,43.0L152.0,63.0L160.0,65.3L166.0,62.7L168.0,62.3L171.0,60.7L173.0,60.3L179.0,57.7L181.0,57.3L184.0,55.7L186.0,55.3L203.0,48.7L206.0,48.5L208.0,49.0L211.0,51.0L212.7,54.0L212.5,191.0L209.0,195.3L205.0,197.3L199.0,199.3L196.0,199.7ZM154.3,198.0L154.0,74.0L150.0,73.0L140.0,69.0L130.0,66.0L101.0,55.7L100.7,180.0L102.0,181.0L109.0,183.0L142.0,195.0L153.0,198.5L154.0,198.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M201.0,212.0L198.0,212.7L58.0,212.7L55.0,212.3L51.0,211.0L47.0,208.0L43.7,203.0L42.3,198.0L42.3,58.0L42.7,55.0L44.0,51.0L48.0,46.3L52.0,43.7L57.0,42.3L165.0,42.3L166.0,42.7L212.3,89.0L212.5,199.0L211.0,204.0L208.0,208.0L205.0,210.3ZM201.7,95.0L160.0,53.3L159.5,54.0L159.5,95.0L160.0,95.5L201.0,95.5ZM127.5,95.0L127.5,86.0L127.0,84.7L79.7,85.0L79.5,95.0L80.0,95.5L127.0,95.5ZM175.5,132.0L175.5,123.0L175.0,122.3L80.0,122.3L79.5,123.0L79.5,132.0L80.0,132.7L175.0,132.7ZM175.3,170.0L175.5,160.0L175.0,159.5L80.0,159.5L79.5,160.0L79.5,169.0L80.0,170.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M211.0,212.3L47.0,212.7L43.0,212.0L39.0,210.0L35.7,207.0L33.0,203.0L31.7,199.0L31.5,196.0L31.5,90.0L32.5,85.0L34.0,82.0L39.0,77.0L41.0,75.7L45.0,74.5L95.0,74.3L95.5,73.0L95.5,58.0L97.0,52.0L99.7,48.0L103.0,45.0L106.0,43.3L111.0,42.3L144.0,42.3L148.0,43.0L152.0,45.0L156.0,49.0L158.3,53.0L159.3,56.0L159.5,73.0L160.0,74.3L208.0,74.3L212.0,75.0L216.0,77.0L220.0,81.0L222.3,85.0L223.3,88.0L223.5,197.0L222.5,202.0L221.0,205.0L216.0,210.0L214.0,211.3ZM148.5,74.0L148.3,57.0L147.0,55.0L145.0,53.7L142.0,52.7L113.0,52.7L110.0,53.7L108.0,55.0L106.5,58.0L106.3,73.0L107.0,74.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M219.0,228.0L218.0,228.7L40.0,228.7L39.0,228.0L39.0,187.0L38.0,186.3L32.0,186.3L31.0,186.0L30.7,185.0L31.0,146.0L32.0,144.0L34.0,142.7L36.0,142.3L61.0,142.3L64.0,143.5L65.3,145.0L66.0,148.0L66.0,186.0L59.0,186.3L57.7,187.0L58.0,218.0L155.0,218.0L155.0,154.0L154.0,153.0L130.0,153.0L125.0,152.5L119.0,151.0L111.0,147.0L106.3,143.0L102.0,138.0L98.7,132.0L96.7,125.0L96.7,114.0L99.0,106.0L103.0,99.0L112.0,90.0L122.0,84.0L123.5,75.0L125.5,69.0L129.0,63.0L135.0,56.3L143.0,50.7L147.0,48.7L153.0,47.3L154.0,46.7L166.0,46.7L173.0,48.5L180.0,52.0L186.0,56.7L191.0,62.0L196.3,72.0L198.3,84.0L206.0,88.0L210.0,91.0L216.7,98.0L220.0,103.0L222.7,109.0L224.3,117.0L224.3,121.0L223.0,129.0L222.0,132.0L220.0,135.0L220.0,136.0L214.3,143.0L209.0,147.3L203.0,150.5L197.0,152.3L190.0,153.0L167.0,153.0L165.7,154.0L165.7,217.0L166.0,218.0L167.0,218.3L218.0,218.3L219.0,219.0ZM52.0,131.0L49.0,131.5L44.0,131.0L40.0,128.3L37.7,125.0L36.5,120.0L37.0,116.0L39.0,112.0L44.0,108.5L48.0,107.7L54.0,109.3L57.0,111.5L59.3,115.0L59.7,118.0L60.3,119.0L59.5,124.0L57.0,128.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M147.0,223.5L108.0,223.5L107.5,223.0L106.7,218.0L106.3,212.0L105.7,210.0L105.3,204.0L104.7,202.0L103.7,193.0L91.0,187.0L83.0,181.0L75.0,183.7L70.0,186.3L68.0,186.7L56.0,192.3L54.0,192.3L34.7,159.0L34.7,158.0L37.0,156.0L58.0,140.3L58.7,139.0L57.7,130.0L58.7,116.0L56.0,113.0L54.0,112.0L52.0,110.0L34.5,97.0L38.0,90.0L42.0,84.0L42.0,83.0L54.0,63.0L58.0,64.0L82.0,74.5L92.0,67.7L103.0,62.5L104.0,60.0L107.0,35.0L108.0,31.5L147.0,31.5L147.5,32.0L148.3,38.0L150.7,59.0L152.0,63.0L153.0,63.0L163.0,68.0L172.0,74.3L178.0,71.7L180.0,71.3L183.0,69.7L185.0,69.3L195.0,64.7L201.0,62.7L220.3,96.0L220.3,97.0L218.0,99.0L195.5,116.0L196.3,121.0L196.5,128.0L196.3,133.0L195.3,139.0L196.0,140.0L220.0,158.0L220.0,159.0L219.0,160.0L204.0,187.0L201.0,192.0L200.0,192.5L172.0,180.5L161.0,188.3L153.0,191.7L151.3,193.0L151.0,194.0L148.0,220.0ZM129.0,154.0L134.0,153.3L138.0,152.0L143.0,149.0L146.5,146.0L149.0,143.0L152.0,138.0L153.5,133.0L153.7,125.0L153.3,121.0L150.3,114.0L145.0,107.7L142.0,105.0L137.0,102.5L127.0,100.7L120.0,101.7L117.0,102.7L113.0,104.7L107.0,110.0L104.0,114.0L102.5,117.0L101.0,122.0L100.5,128.0L101.7,136.0L105.3,143.0L110.0,148.0L116.0,152.0L119.0,153.0L124.0,154.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M206.0,180.3L84.0,180.7L80.0,180.0L76.0,178.0L73.0,175.0L69.7,170.0L68.7,164.0L68.7,49.0L70.0,42.0L71.7,39.0L75.0,35.3L79.0,32.7L82.0,31.7L85.0,31.5L202.0,31.5L207.0,32.5L213.0,36.0L215.3,39.0L217.3,43.0L218.3,48.0L218.3,163.0L218.0,167.0L215.0,174.0L212.0,177.0ZM177.0,212.0L175.0,212.7L52.0,212.7L48.0,212.0L44.0,210.0L41.0,207.0L37.7,202.0L36.7,196.0L36.7,76.0L38.0,72.0L40.0,70.5L44.0,70.5L46.3,72.0L47.5,75.0L47.5,196.0L49.3,200.0L52.0,201.7L55.0,202.3L175.0,202.3L178.0,203.7L179.3,206.0L179.0,210.0ZM129.5,139.0L131.0,138.7L135.0,136.3L173.0,111.7L175.5,109.0L176.3,106.0L175.3,103.0L173.0,100.7L160.0,92.0L156.0,90.0L132.0,74.0L127.0,73.3L124.0,75.0L122.3,79.0L122.3,134.0L125.0,138.0L127.0,139.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M122.0,212.5L56.0,212.5L54.0,212.0L49.0,209.3L47.3,208.0L43.7,203.0L42.3,198.0L42.3,58.0L43.0,54.0L47.0,47.3L52.0,43.7L57.0,42.3L197.0,42.3L201.0,43.0L207.7,47.0L211.3,52.0L212.7,57.0L212.7,122.0L212.0,122.5L201.0,120.0L191.0,119.3L182.0,120.0L171.0,122.5L80.0,122.3L79.5,123.0L79.5,132.0L80.0,132.7L149.0,133.0L148.0,134.3L142.0,139.0L136.0,145.3L131.0,152.0L128.0,157.0L128.0,158.0L127.0,159.0L127.0,160.0L125.0,162.3L80.0,162.3L79.5,163.0L79.5,172.0L80.0,173.0L121.0,173.0L121.5,174.0L119.7,184.0L119.3,194.0L119.7,202.0L120.7,208.0L122.3,212.0ZM175.5,92.0L175.5,83.0L175.0,82.0L80.0,82.0L79.5,83.0L79.5,92.0L80.0,92.7L175.0,92.7ZM196.0,235.0L189.0,235.3L182.0,234.5L174.0,232.0L173.0,231.0L167.0,228.0L160.3,222.0L155.0,215.0L151.7,209.0L149.5,201.0L149.3,196.0L148.7,193.0L149.5,184.0L152.0,176.0L155.0,171.0L155.0,170.0L159.0,165.0L167.0,157.7L176.0,152.7L182.0,151.3L183.0,150.7L192.0,150.0L201.0,151.0L208.0,153.0L209.0,154.0L210.0,154.0L215.0,157.0L219.0,160.0L226.3,168.0L231.3,177.0L233.3,183.0L234.3,192.0L233.5,201.0L231.5,208.0L227.5,216.0L221.0,223.7L212.0,230.3L208.0,232.3L202.0,234.3ZM171.5,199.0L174.0,197.0L175.3,194.0L175.3,191.0L174.3,189.0L171.0,186.0L167.0,185.7L165.0,186.3L163.0,188.0L161.5,191.0L161.3,194.0L162.0,196.0L163.5,198.0L166.0,199.5L168.0,200.0ZM195.0,199.0L198.0,196.0L198.5,194.0L198.3,190.0L196.0,187.0L192.0,185.5L190.0,185.7L186.0,188.0L184.5,191.0L184.7,195.0L185.7,197.0L189.0,199.5L191.0,200.0ZM218.0,199.0L220.0,197.5L221.5,195.0L221.3,190.0L219.0,187.0L216.0,185.7L213.0,185.7L210.0,187.3L208.0,190.0L207.5,192.0L207.7,195.0L209.0,197.0L212.0,199.5L216.0,199.7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M128.0,242.3L124.0,241.5L112.0,236.0L111.0,236.0L110.0,235.0L109.0,235.0L108.0,234.0L107.0,234.0L102.0,231.0L99.0,230.0L86.0,222.0L79.0,217.0L68.0,208.0L58.3,198.0L49.7,187.0L42.7,176.0L37.7,166.0L34.7,159.0L30.7,147.0L28.3,136.0L27.7,135.0L27.3,131.0L26.7,130.0L25.3,118.0L24.7,116.0L23.5,95.0L23.7,79.0L25.0,62.0L26.0,58.0L28.0,54.0L30.7,50.0L35.0,45.7L41.0,41.7L43.0,41.3L46.0,39.7L105.0,20.3L108.0,18.7L114.0,16.7L122.0,14.7L127.0,14.3L133.0,14.7L140.0,16.5L164.0,25.0L211.0,40.5L218.0,44.0L224.3,50.0L227.0,54.0L229.3,59.0L229.7,62.0L230.3,63.0L231.5,83.0L231.3,102.0L230.5,114.0L228.5,129.0L226.0,141.0L221.0,157.0L219.0,161.0L217.0,167.0L216.0,168.0L216.0,169.0L215.0,170.0L210.0,180.0L204.0,189.0L196.0,198.7L188.0,207.0L178.0,215.3L167.0,223.3L155.0,230.3L141.0,237.3L139.0,237.7L134.0,240.3ZM128.5,225.0L139.0,220.5L151.0,214.3L161.0,208.0L170.0,201.3L185.0,187.0L191.3,179.0L198.0,169.0L199.0,166.0L200.0,165.0L203.0,159.0L203.0,158.0L207.0,149.0L209.0,141.0L210.0,139.0L212.0,130.0L214.5,113.0L215.5,98.0L215.5,83.0L214.3,65.0L213.7,63.0L211.0,59.0L209.0,57.5L204.0,55.0L185.0,49.0L133.0,31.0L127.0,30.3L123.0,30.7L87.0,42.7L84.0,44.3L49.0,55.7L46.0,57.3L43.0,60.0L40.5,66.0L39.5,83.0L39.5,98.0L40.7,115.0L41.3,117.0L41.7,122.0L43.7,133.0L44.3,134.0L44.7,138.0L47.3,145.0L47.7,148.0L51.7,158.0L57.7,170.0L64.3,180.0L70.0,187.0L81.0,198.0L91.0,206.0L105.0,215.0L106.0,215.0L110.0,217.5L127.0,225.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M196.0,182.0L186.0,182.0L174.0,180.0L157.0,174.0L152.0,171.0L145.3,165.0L140.7,159.0L133.7,147.0L130.0,143.0L128.0,142.7L125.0,143.3L122.0,146.0L113.0,161.0L106.7,168.0L101.0,172.3L87.0,178.3L84.0,178.7L80.0,180.3L76.0,180.7L75.0,181.3L65.0,182.3L58.0,182.0L46.0,180.0L29.0,174.0L27.5,172.0L24.7,164.0L24.3,161.0L22.7,157.0L20.7,147.0L19.5,137.0L19.7,118.0L19.0,117.0L14.0,117.0L13.7,116.0L21.3,86.0L22.0,85.3L27.0,85.0L28.0,82.0L30.0,80.7L56.0,77.3L57.0,76.7L58.0,77.0L76.0,75.3L78.0,74.7L89.0,74.3L94.0,73.7L123.0,72.7L153.0,73.3L176.0,74.7L206.0,78.0L227.0,81.5L227.7,84.0L228.5,85.0L233.0,85.3L233.7,86.0L241.3,116.0L241.0,117.0L234.0,117.0L233.5,118.0L234.0,131.0L233.3,142.0L231.0,157.0L228.0,171.0L227.0,173.3L220.0,176.3L218.0,176.7L215.0,178.3L212.0,178.7L208.0,180.3L204.0,180.7L203.0,181.3ZM198.0,173.0L207.0,171.3L216.0,168.3L219.0,166.7L220.3,164.0L224.0,144.0L225.0,126.0L223.3,106.0L220.0,90.0L219.0,89.0L198.0,86.0L173.0,83.5L131.0,81.7L109.0,82.0L107.0,85.0L103.0,95.0L97.0,113.0L96.0,118.0L94.0,124.0L89.5,147.0L87.7,168.0L88.0,168.3L91.0,166.7L93.0,166.3L98.0,163.3L104.0,158.0L113.0,143.0L115.0,141.0L116.0,139.0L118.3,137.0L122.0,134.7L128.0,133.7L134.0,135.0L138.0,138.0L144.3,147.0L149.7,156.0L155.0,162.0L158.0,164.0L169.0,169.0L179.0,172.0L190.0,173.5ZM56.3,172.0L59.0,151.0L63.0,131.0L63.0,129.0L69.0,105.0L75.3,86.0L75.5,85.0L75.0,84.3L64.0,85.3L63.0,86.0L58.5,96.0L54.5,107.0L49.0,127.0L45.5,148.0L44.0,169.0L45.0,170.3L51.0,172.0L55.0,172.7ZM35.3,164.0L36.7,146.0L40.0,126.0L45.0,107.0L50.0,93.0L52.0,89.0L52.0,86.7L50.0,87.3L38.0,88.7L35.0,90.0L31.0,104.0L28.5,120.0L28.5,137.0L29.7,146.0L32.7,159.0L35.0,165.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M139.0,212.3L138.3,211.0L138.3,202.0L139.0,201.3L149.0,198.3L161.0,192.3L169.0,186.3L176.3,179.0L183.0,170.0L188.5,158.0L190.5,150.0L191.5,141.0L191.3,131.0L189.3,120.0L187.3,114.0L184.3,108.0L181.3,103.0L175.0,95.0L170.0,90.0L165.0,86.0L159.0,82.0L155.0,80.0L154.0,80.0L153.0,79.0L146.0,76.7L135.0,74.5L122.0,74.5L121.7,75.0L137.7,91.0L130.0,98.5L100.3,69.0L130.0,39.3L131.0,39.7L137.3,46.0L137.0,47.0L121.0,63.0L122.0,63.5L135.0,63.7L148.0,66.0L157.0,69.0L158.0,70.0L159.0,70.0L163.0,72.0L171.0,77.0L176.0,81.0L185.0,90.0L187.3,93.0L190.3,97.0L196.3,108.0L200.3,120.0L202.3,137.0L201.5,149.0L198.5,162.0L192.0,176.0L184.0,187.0L173.0,197.3L164.0,203.3L151.0,209.3L148.0,209.7L144.0,211.3L140.0,211.7ZM116.0,211.7L107.0,210.0L98.0,207.0L88.0,202.0L81.0,197.0L71.7,188.0L64.7,179.0L58.7,168.0L58.3,166.0L55.7,160.0L53.7,151.0L52.7,139.0L53.5,128.0L55.7,118.0L58.0,111.0L63.0,101.0L69.0,92.0L75.0,85.5L82.5,93.0L76.0,100.0L72.0,106.0L68.0,114.0L66.0,120.0L64.5,126.0L63.5,136.0L63.7,145.0L65.7,156.0L68.7,164.0L74.3,174.0L79.0,180.0L87.0,188.0L94.0,193.0L102.0,197.0L109.0,199.5L116.0,201.0L116.7,202.0L116.7,211.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M208.0,214.0L207.0,214.0L141.0,148.0L140.0,148.0L131.0,154.3L122.0,158.3L116.0,159.7L115.0,160.3L112.0,160.7L111.0,161.3L99.0,162.0L89.0,161.0L76.0,157.0L67.0,152.0L63.0,149.0L56.7,143.0L52.3,138.0L47.7,131.0L42.7,120.0L40.7,111.0L40.3,96.0L41.0,89.0L44.5,77.0L48.0,70.0L52.0,64.0L59.0,56.3L64.0,52.0L70.0,48.0L78.0,43.7L89.0,40.7L97.0,39.7L104.0,39.7L115.0,41.0L125.0,44.0L136.0,50.0L141.0,54.0L148.0,61.0L154.3,70.0L158.3,78.0L161.3,88.0L162.3,96.0L162.3,105.0L160.0,118.0L154.0,132.0L148.3,140.0L214.0,206.0L214.7,207.0L214.0,208.0ZM106.0,151.0L116.0,149.3L124.0,146.3L130.0,142.7L135.0,138.7L142.0,131.0L146.5,124.0L150.0,115.0L151.5,106.0L151.3,93.0L149.3,84.0L144.3,74.0L140.0,68.0L134.0,62.0L130.0,59.0L121.0,54.0L113.0,51.5L106.0,50.5L94.0,50.7L85.0,52.7L76.0,56.7L66.0,64.3L60.0,71.0L54.0,82.0L52.0,89.0L50.7,100.0L51.7,112.0L52.3,113.0L53.7,119.0L59.7,130.0L69.0,140.0L73.0,143.0L80.0,147.0L85.0,149.0L91.0,150.5L99.0,151.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M127.0,223.0L116.0,219.0L108.0,215.0L107.0,214.0L106.0,214.0L92.0,204.0L82.0,194.0L74.7,185.0L68.7,176.0L63.7,167.0L57.7,152.0L57.3,149.0L55.7,145.0L53.7,133.0L52.7,119.0L52.7,61.0L54.0,59.7L56.0,59.3L59.0,57.7L64.0,56.3L67.0,54.7L72.0,53.3L75.0,51.7L80.0,50.3L83.0,48.7L88.0,47.3L91.0,45.7L96.0,44.3L99.0,42.7L104.0,41.3L107.0,39.7L112.0,38.3L126.0,32.7L128.0,32.5L202.0,60.0L202.3,118.0L201.0,135.0L199.0,145.0L195.0,158.0L191.0,167.0L188.0,172.0L188.0,173.0L181.0,184.0L179.0,186.0L146.0,153.5L137.0,158.3L132.0,159.3L121.0,159.0L116.0,157.5L111.0,155.0L107.0,152.0L103.0,148.0L100.7,145.0L98.0,140.0L95.7,131.0L95.7,124.0L97.5,116.0L100.0,111.0L103.0,107.0L107.0,103.0L110.0,100.7L115.0,98.0L124.0,95.7L131.0,95.7L139.0,97.5L144.0,100.0L148.0,103.0L154.3,110.0L157.0,115.0L158.3,119.0L159.3,124.0L159.3,132.0L157.0,140.0L154.3,146.0L177.0,168.7L178.0,169.0L182.0,161.0L182.0,160.0L183.0,159.0L187.0,148.0L189.0,141.0L190.5,133.0L191.5,121.0L191.5,68.0L191.0,67.3L127.0,43.7L124.0,45.3L108.0,50.7L105.0,52.3L100.0,53.7L97.0,55.3L92.0,56.7L89.0,58.3L84.0,59.7L78.0,62.3L73.0,63.7L70.0,65.3L65.0,66.7L63.5,68.0L63.5,121.0L64.7,135.0L65.3,136.0L65.7,140.0L67.3,145.0L67.7,148.0L69.7,154.0L71.3,157.0L71.7,159.0L76.7,169.0L83.3,179.0L90.7,188.0L100.0,197.0L110.0,204.0L122.0,210.0L128.0,211.7L132.0,210.3L141.0,206.3L153.0,198.5L159.5,206.0L159.0,207.0L146.0,215.3L136.0,220.3ZM133.5,148.0L136.0,147.3L139.7,145.0L145.5,139.0L148.5,132.0L148.3,122.0L147.3,119.0L145.5,116.0L144.0,114.0L139.0,109.5L132.0,106.5L122.0,106.7L119.0,107.7L116.0,109.5L114.0,111.0L109.5,116.0L106.5,123.0L106.7,133.0L107.7,136.0L109.5,139.0L111.0,141.0L116.0,145.5L123.0,148.5L129.0,148.7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M178.0,144.3L177.0,144.0L170.3,137.0L202.0,105.0L201.0,104.0L125.0,104.0L124.3,103.0L124.3,94.0L125.0,93.5L202.0,93.5L202.7,93.0L170.7,61.0L170.3,60.0L177.0,53.3L178.0,53.3L223.3,99.0ZM78.0,201.3L77.0,201.7L31.7,156.0L77.0,110.7L78.0,110.7L84.3,117.0L84.7,118.0L52.7,150.0L53.0,150.5L130.0,150.7L130.7,152.0L130.7,160.0L130.0,161.3L53.0,161.5L52.7,162.0L84.7,194.0L84.3,195.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M67.0,76.3L61.0,76.5L57.0,75.0L52.7,71.0L50.7,67.0L50.5,61.0L52.0,57.0L56.0,52.7L60.0,50.7L66.0,50.5L70.0,52.0L74.3,56.0L76.3,60.0L76.5,66.0L75.0,70.0L71.0,74.3ZM131.0,76.3L125.0,76.5L121.0,75.0L116.7,71.0L114.7,67.0L114.5,61.0L116.0,57.0L120.0,52.7L124.0,50.7L130.0,50.5L134.0,52.0L138.3,56.0L140.3,60.0L140.5,66.0L139.0,70.0L135.0,74.3ZM195.0,76.3L189.0,76.5L185.0,75.0L180.7,71.0L178.7,67.0L178.5,61.0L180.0,57.0L184.0,52.7L188.0,50.7L194.0,50.5L198.0,52.0L202.3,56.0L204.3,60.0L204.5,66.0L203.0,70.0L199.0,74.3ZM67.0,140.3L61.0,140.5L57.0,139.0L52.7,135.0L50.7,131.0L50.5,125.0L52.0,121.0L56.0,116.7L60.0,114.7L66.0,114.5L70.0,116.0L74.3,120.0L76.3,124.0L76.5,130.0L75.0,134.0L71.0,138.3ZM131.0,140.3L125.0,140.5L121.0,139.0L116.7,135.0L114.7,131.0L114.5,125.0L116.0,121.0L120.0,116.7L124.0,114.7L130.0,114.5L134.0,116.0L138.3,120.0L140.3,124.0L140.5,130.0L139.0,134.0L135.0,138.3ZM195.0,140.3L189.0,140.5L185.0,139.0L180.7,135.0L178.7,131.0L178.5,125.0L180.0,121.0L184.0,116.7L188.0,114.7L194.0,114.5L198.0,116.0L202.3,120.0L204.3,124.0L204.5,130.0L203.0,134.0L199.0,138.3ZM67.0,204.3L61.0,204.5L57.0,203.0L52.7,199.0L50.7,195.0L50.5,189.0L52.0,185.0L56.0,180.7L60.0,178.7L66.0,178.5L70.0,180.0L74.3,184.0L76.3,188.0L76.5,194.0L75.0,198.0L71.0,202.3ZM131.0,204.3L125.0,204.5L121.0,203.0L116.7,199.0L114.7,195.0L114.5,189.0L116.0,185.0L120.0,180.7L124.0,178.7L130.0,178.5L134.0,180.0L138.3,184.0L140.3,188.0L140.5,194.0L139.0,198.0L135.0,202.3ZM195.0,204.3L189.0,204.5L185.0,203.0L180.7,199.0L178.7,195.0L178.5,189.0L180.0,185.0L184.0,180.7L188.0,178.7L194.0,178.5L198.0,180.0L202.3,184.0L204.3,188.0L204.5,194.0L203.0,198.0L199.0,202.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M137.0,223.0L130.0,223.5L117.0,223.0L105.0,221.0L98.0,219.0L90.0,216.0L80.0,211.0L68.0,203.0L60.0,196.0L53.7,189.0L47.7,181.0L43.5,174.0L37.7,162.0L34.7,153.0L31.7,135.0L31.5,125.0L32.0,117.0L34.0,105.0L36.0,98.0L39.0,90.0L44.0,80.0L52.0,68.0L59.0,60.0L66.0,53.7L74.0,47.7L81.0,43.5L93.0,37.7L102.0,34.7L120.0,31.7L130.0,31.5L138.0,32.0L150.0,34.0L157.0,36.0L165.0,39.0L175.0,44.0L187.0,52.0L195.0,59.0L201.3,66.0L207.3,74.0L211.5,81.0L217.3,93.0L220.3,102.0L223.3,120.0L223.5,130.0L223.0,138.0L221.0,150.0L219.0,157.0L216.0,165.0L211.0,175.0L203.0,187.0L196.0,195.0L189.0,201.3L181.0,207.3L174.0,211.5L162.0,217.3L153.0,220.3ZM192.0,183.0L194.0,181.0L201.0,171.0L207.0,159.0L210.0,150.0L212.0,140.0L212.7,132.0L212.7,123.0L211.3,110.0L207.3,96.0L203.7,88.0L199.5,81.0L191.0,70.0L182.0,61.3L175.0,56.0L169.0,52.5L160.0,48.0L153.0,45.7L144.0,43.5L136.0,42.5L119.0,42.5L106.0,44.7L94.0,48.7L86.0,52.7L77.0,58.7L73.0,62.0L71.7,64.0L191.0,183.3ZM139.0,212.0L149.0,210.3L158.0,207.3L165.0,204.3L175.0,198.3L182.0,193.0L183.3,191.0L64.0,71.7L63.0,72.0L58.0,78.0L54.0,84.0L54.0,85.0L53.0,86.0L49.0,94.0L44.7,107.0L42.5,119.0L42.5,136.0L43.7,145.0L47.7,159.0L51.3,167.0L59.0,179.0L64.0,185.0L73.0,193.7L80.0,199.0L85.0,202.0L95.0,207.0L102.0,209.3L111.0,211.5L123.0,212.7L132.0,212.7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M128.0,242.3L124.0,241.5L112.0,236.0L111.0,236.0L110.0,235.0L109.0,235.0L108.0,234.0L107.0,234.0L102.0,231.0L99.0,230.0L86.0,222.0L79.0,217.0L68.0,208.0L58.3,198.0L49.7,187.0L42.7,176.0L37.7,166.0L34.7,159.0L30.7,147.0L28.3,136.0L27.7,135.0L27.3,131.0L26.7,130.0L25.3,118.0L24.7,116.0L23.5,95.0L23.7,79.0L25.0,62.0L26.0,58.0L28.0,54.0L30.7,50.0L35.0,45.7L41.0,41.7L43.0,41.3L46.0,39.7L105.0,20.3L108.0,18.7L114.0,16.7L122.0,14.7L127.0,14.3L133.0,14.7L140.0,16.5L164.0,25.0L211.0,40.5L218.0,44.0L224.3,50.0L227.0,54.0L229.3,59.0L229.7,62.0L230.3,63.0L231.5,83.0L231.3,102.0L230.5,114.0L228.5,129.0L226.0,141.0L221.0,157.0L219.0,161.0L217.0,167.0L216.0,168.0L216.0,169.0L215.0,170.0L210.0,180.0L204.0,189.0L196.0,198.7L188.0,207.0L178.0,215.3L167.0,223.3L155.0,230.3L141.0,237.3L139.0,237.7L134.0,240.3ZM128.5,225.0L139.0,220.5L151.0,214.3L161.0,208.0L170.0,201.3L185.0,187.0L191.3,179.0L198.0,169.0L199.0,166.0L200.0,165.0L203.0,159.0L203.0,158.0L207.0,149.0L209.0,141.0L210.0,139.0L212.0,130.0L214.5,113.0L215.5,98.0L215.5,83.0L214.3,65.0L213.7,63.0L211.0,59.0L209.0,57.5L204.0,55.0L185.0,49.0L133.0,31.0L127.0,30.3L123.0,30.7L87.0,42.7L84.0,44.3L49.0,55.7L46.0,57.3L43.0,60.0L40.5,66.0L39.5,83.0L39.5,98.0L40.7,115.0L41.3,117.0L41.7,122.0L43.7,133.0L44.3,134.0L44.7,138.0L47.3,145.0L47.7,148.0L51.7,158.0L57.7,170.0L64.3,180.0L70.0,187.0L81.0,198.0L91.0,206.0L105.0,215.0L106.0,215.0L110.0,217.5L127.0,225.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M131.0,74.0L127.0,74.3L122.0,73.3L120.0,72.3L115.7,69.0L112.5,64.0L111.7,61.0L111.5,57.0L112.7,52.0L115.0,48.0L118.0,45.0L122.0,43.0L129.0,42.3L132.0,42.7L134.0,43.5L138.0,46.0L140.0,48.0L142.5,52.0L143.5,57.0L143.3,62.0L141.0,67.0L136.0,72.0ZM138.0,212.5L118.0,212.7L116.7,212.0L116.7,105.0L117.0,104.0L118.0,103.7L137.0,103.7L138.0,104.0L138.3,105.0Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M223.0,128.7L214.0,128.7L212.7,128.0L212.7,69.0L211.0,66.0L209.0,64.0L206.0,63.5L47.0,63.7L45.0,65.0L42.5,68.0L42.3,96.0L41.0,96.7L32.0,96.7L31.5,96.0L31.5,69.0L32.5,64.0L36.0,58.0L39.0,55.7L43.0,53.7L48.0,52.7L206.0,52.7L213.0,54.0L216.0,55.7L219.7,59.0L222.3,63.0L223.3,66.0L223.5,128.0ZM76.0,175.3L74.0,175.5L71.0,174.7L68.7,172.0L49.3,133.0L32.0,132.7L31.5,132.0L31.5,123.0L32.0,122.3L53.0,122.3L56.0,123.3L59.5,129.0L74.0,157.7L75.0,157.0L76.0,154.0L77.0,153.0L77.0,152.0L79.0,149.0L79.0,148.0L80.0,147.0L80.0,146.0L81.0,145.0L81.0,144.0L82.0,143.0L82.0,142.0L83.0,141.0L83.0,140.0L84.0,139.0L84.0,138.0L85.0,137.0L85.0,136.0L86.0,135.0L86.0,134.0L87.0,133.0L88.0,130.0L89.0,129.0L89.0,128.0L90.0,127.0L90.0,126.0L91.0,125.0L91.0,124.0L92.0,123.0L92.0,122.0L93.0,121.0L93.0,120.0L94.0,119.0L94.0,118.0L95.0,117.0L96.0,114.0L97.0,113.0L97.0,112.0L98.0,111.0L99.0,108.0L100.0,107.0L100.0,106.0L101.0,105.0L102.0,102.0L103.0,101.0L103.0,100.0L104.0,99.0L104.0,98.0L105.0,97.0L106.0,94.0L107.0,93.0L107.0,92.0L109.0,89.0L109.0,88.0L110.0,87.0L110.0,86.0L111.0,85.0L111.0,84.0L114.0,80.7L117.0,80.0L119.0,80.5L121.0,82.0L122.3,84.0L142.3,124.0L142.0,125.0L135.0,130.7L134.0,131.3L133.7,131.0L117.3,98.0L117.0,97.5L116.0,98.0L78.5,173.0ZM187.0,223.0L180.0,223.5L171.0,222.5L166.0,221.0L155.0,215.0L149.7,210.0L144.7,204.0L140.7,196.0L139.3,190.0L138.7,189.0L138.3,179.0L139.0,172.0L141.0,165.0L145.0,157.0L148.0,154.0L150.0,151.0L158.0,144.7L166.0,140.7L173.0,138.7L183.0,138.3L190.0,139.0L197.0,141.0L205.0,145.5L212.3,152.0L217.0,158.0L220.3,164.0L222.7,172.0L223.5,180.0L223.0,188.0L221.0,196.0L217.0,204.0L209.0,213.0L203.0,217.3L195.0,221.3ZM116.0,202.0L49.0,202.3L42.0,201.0L40.0,200.0L35.3,196.0L32.7,192.0L31.7,189.0L31.5,159.0L32.0,158.3L41.0,158.3L42.3,159.0L42.3,186.0L44.0,189.0L46.0,191.0L49.0,191.5L113.0,191.5L113.7,192.0L114.3,194.0ZM183.3,184.0L203.3,164.0L198.0,158.5L177.3,179.0L182.0,183.7L183.0,184.3Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="status_on">Защищено</string>
|
||||
<string name="status_off">Отключено</string>
|
||||
<string name="via">через %1$s</string>
|
||||
<string name="connect">Подключить</string>
|
||||
<string name="disconnect">Отключить</string>
|
||||
<string name="servers">Сервер DNS-over-HTTPS</string>
|
||||
<string name="custom">Свой сервер</string>
|
||||
<string name="custom_url">DoH URL</string>
|
||||
<string name="custom_bootstrap">Bootstrap IP (необязательно, через запятую)</string>
|
||||
<string name="invalid_url">Адрес должен начинаться с https://</string>
|
||||
<string name="switch_to">Переключиться на %1$s</string>
|
||||
<string name="test">Проверить сервер</string>
|
||||
<string name="testing">Проверка…</string>
|
||||
<string name="test_ok">Работает через %1$s — первый запрос %2$d мс, повторный %3$d мс</string>
|
||||
<string name="http3">HTTP/3 (QUIC)</string>
|
||||
<string name="http3_note">Android 14+. С HTTP/3 bootstrap IP не используются</string>
|
||||
<string name="test_rcode">Сервер ответил с кодом ошибки %1$d</string>
|
||||
<string name="test_fail">Ошибка: %1$s</string>
|
||||
<string name="crash_title">Приложение упало в прошлый раз</string>
|
||||
<string name="copy">Копировать</string>
|
||||
<string name="dismiss">Скрыть</string>
|
||||
<string name="hint">В настройках Android «Частный DNS» задан как %1$s, поэтому DNS идёт туда, мимо SparkleDNS. Переключите его на «Автоматически» или «Отключено».</string>
|
||||
<string name="notif_channel">Статус VPN</string>
|
||||
<string name="notif_title">Защищено через %1$s</string>
|
||||
<string name="notif_text">DNS через HTTPS активен</string>
|
||||
<string name="tab_home">Главная</string>
|
||||
<string name="tab_monitor">Монитор</string>
|
||||
<string name="tab_apps">Приложения</string>
|
||||
<string name="tab_blocklist">Блоклист</string>
|
||||
<string name="tab_about">О программе</string>
|
||||
<string name="mon_queries">Запросы</string>
|
||||
<string name="mon_cached">Из кэша</string>
|
||||
<string name="mon_blocked">Заблокировано</string>
|
||||
<string name="mon_latency">Средняя задержка</string>
|
||||
<string name="mon_ms">%1$d мс</string>
|
||||
<string name="mon_all">Все</string>
|
||||
<string name="mon_errors">Ошибки</string>
|
||||
<string name="mon_search">Поиск домена</string>
|
||||
<string name="mon_clear">Очистить</string>
|
||||
<string name="mon_empty">Запросов пока нет. Подключитесь и откройте любой сайт.</string>
|
||||
<string name="status_cached">Кэш</string>
|
||||
<string name="status_blocked">Блок</string>
|
||||
<string name="status_failed">Ошибка</string>
|
||||
<string name="mon_block">Заблокировать</string>
|
||||
<string name="mon_unblock">Разблокировать</string>
|
||||
<string name="mon_blocked_added">%1$s добавлен в блоклист</string>
|
||||
<string name="mon_unblocked">%1$s убран из блоклиста</string>
|
||||
<string name="apps_hint">Выключенные приложения идут мимо SparkleDNS и используют обычный DNS сети.</string>
|
||||
<string name="apps_search">Поиск приложений</string>
|
||||
<string name="apps_pending">Изменения вступят в силу после переподключения</string>
|
||||
<string name="reconnect">Переподключить</string>
|
||||
<string name="apps_loading">Загрузка…</string>
|
||||
<string name="apps_count">Мимо DNS: %1$d</string>
|
||||
<string name="bl_note">Поддомены блокируются тоже. Поддерживаются списки hosts, доменов, AdBlock и dnsmasq. Изменения применяются сразу.</string>
|
||||
<string name="bl_my">Мои домены</string>
|
||||
<string name="bl_domain_hint">example.com</string>
|
||||
<string name="add">Добавить</string>
|
||||
<string name="bl_invalid">Неверный домен</string>
|
||||
<string name="bl_empty_custom">Пока пусто</string>
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="bl_lists">Списки</string>
|
||||
<string name="bl_update_all">Обновить все</string>
|
||||
<string name="bl_updating">Обновление…</string>
|
||||
<string name="bl_list_url">URL списка</string>
|
||||
<string name="bl_list_info">%1$s доменов · %2$s</string>
|
||||
<string name="bl_presets">Популярные списки</string>
|
||||
<string name="bl_download_failed">Ошибка загрузки: %1$s</string>
|
||||
<string name="about_desc">Шифрованный DNS через HTTPS и HTTP/3 для всего устройства. Через локальный VPN идёт только DNS, остальной трафик не затрагивается.</string>
|
||||
<string name="about_version">Версия</string>
|
||||
<string name="about_protocol">Протокол</string>
|
||||
<string name="about_http3_yes">Поддерживается на этом устройстве</string>
|
||||
<string name="about_http3_no">Нужен Android 14+, используется HTTP/2</string>
|
||||
<string name="group_social">Соцсети и мессенджеры</string>
|
||||
<string name="group_video">Видео</string>
|
||||
<string name="group_audio">Музыка и аудио</string>
|
||||
<string name="group_games">Игры</string>
|
||||
<string name="group_images">Фото и изображения</string>
|
||||
<string name="group_news">Новости</string>
|
||||
<string name="group_maps">Карты и навигация</string>
|
||||
<string name="group_productivity">Продуктивность</string>
|
||||
<string name="group_other">Другие</string>
|
||||
<string name="group_system">Системные</string>
|
||||
<string name="group_count">%1$d из %2$d через DNS</string>
|
||||
<string name="about_privacy_policy">Политика конфиденциальности</string>
|
||||
<string name="licenses_title">Лицензии открытого ПО</string>
|
||||
<string name="licenses_hint">SparkleDNS построен на этих проектах с открытым кодом. Нажмите на проект, чтобы открыть его страницу.</string>
|
||||
<string name="licenses_data">Данные блоклистов (загружаются по запросу, не входят в приложение)</string>
|
||||
<string name="license_view">Текст лицензии</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="bl_total_label">доменов заблокировано</string>
|
||||
<string name="about_source">Исходный код</string>
|
||||
<string name="open_settings">Настройки</string>
|
||||
<string name="mon_no_match">Ничего не найдено</string>
|
||||
<string name="bl_title">Блокировка</string>
|
||||
<string name="about_license">Лицензия</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0B1527</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">SparkleDNS</string>
|
||||
<string name="status_on">Protected</string>
|
||||
<string name="status_off">Disconnected</string>
|
||||
<string name="via">via %1$s</string>
|
||||
<string name="connect">Connect</string>
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="servers">DNS-over-HTTPS server</string>
|
||||
<string name="custom">Custom server</string>
|
||||
<string name="custom_url">DoH URL</string>
|
||||
<string name="custom_bootstrap">Bootstrap IP (optional, comma-separated)</string>
|
||||
<string name="invalid_url">URL must start with https://</string>
|
||||
<string name="switch_to">Switch to %1$s</string>
|
||||
<string name="test">Test server</string>
|
||||
<string name="testing">Testing…</string>
|
||||
<string name="test_ok">Works via %1$s — first request %2$d ms, repeat %3$d ms</string>
|
||||
<string name="http3">HTTP/3 (QUIC)</string>
|
||||
<string name="http3_note">Android 14+. Bootstrap IPs are not used with HTTP/3</string>
|
||||
<string name="test_rcode">Server replied with error code %1$d</string>
|
||||
<string name="test_fail">Error: %1$s</string>
|
||||
<string name="crash_title">The app crashed last time</string>
|
||||
<string name="copy">Copy</string>
|
||||
<string name="dismiss">Dismiss</string>
|
||||
<string name="hint">Private DNS is set to %1$s in Android settings, so DNS goes there and bypasses SparkleDNS. Switch it to Automatic or Off.</string>
|
||||
<string name="notif_channel">VPN status</string>
|
||||
<string name="notif_title">Protected via %1$s</string>
|
||||
<string name="notif_text">DNS over HTTPS is active</string>
|
||||
<string name="tab_home">Home</string>
|
||||
<string name="tab_monitor">Monitor</string>
|
||||
<string name="tab_apps">Apps</string>
|
||||
<string name="tab_blocklist">Blocklist</string>
|
||||
<string name="tab_about">About</string>
|
||||
<string name="mon_queries">Queries</string>
|
||||
<string name="mon_cached">From cache</string>
|
||||
<string name="mon_blocked">Blocked</string>
|
||||
<string name="mon_latency">Avg latency</string>
|
||||
<string name="mon_ms">%1$d ms</string>
|
||||
<string name="mon_all">All</string>
|
||||
<string name="mon_errors">Errors</string>
|
||||
<string name="mon_search">Search domain</string>
|
||||
<string name="mon_clear">Clear</string>
|
||||
<string name="mon_empty">No queries yet. Connect and open any website.</string>
|
||||
<string name="status_cached">Cache</string>
|
||||
<string name="status_blocked">Blocked</string>
|
||||
<string name="status_failed">Error</string>
|
||||
<string name="mon_block">Block</string>
|
||||
<string name="mon_unblock">Unblock</string>
|
||||
<string name="mon_blocked_added">%1$s added to blocklist</string>
|
||||
<string name="mon_unblocked">%1$s removed from blocklist</string>
|
||||
<string name="apps_hint">Apps that are switched off bypass SparkleDNS and use the regular DNS of the network.</string>
|
||||
<string name="apps_search">Search apps</string>
|
||||
<string name="apps_pending">Changes apply after reconnecting</string>
|
||||
<string name="reconnect">Reconnect</string>
|
||||
<string name="apps_loading">Loading…</string>
|
||||
<string name="apps_count">Bypassing DNS: %1$d</string>
|
||||
<string name="bl_note">Subdomains are blocked too. Hosts, plain domain, AdBlock and dnsmasq lists are supported. Changes apply instantly.</string>
|
||||
<string name="bl_my">My domains</string>
|
||||
<string name="bl_domain_hint">example.com</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="bl_invalid">Invalid domain</string>
|
||||
<string name="bl_empty_custom">No domains yet</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="bl_lists">Lists</string>
|
||||
<string name="bl_update_all">Update all</string>
|
||||
<string name="bl_updating">Updating…</string>
|
||||
<string name="bl_list_url">List URL</string>
|
||||
<string name="bl_list_info">%1$s domains · %2$s</string>
|
||||
<string name="bl_presets">Popular lists</string>
|
||||
<string name="bl_download_failed">Download failed: %1$s</string>
|
||||
<string name="about_desc">Encrypted DNS over HTTPS and HTTP/3 for the whole device. Only DNS goes through the local VPN, all other traffic is untouched.</string>
|
||||
<string name="about_version">Version</string>
|
||||
<string name="about_protocol">Protocol</string>
|
||||
<string name="about_http3_yes">Supported on this device</string>
|
||||
<string name="about_http3_no">Needs Android 14+, HTTP/2 is used</string>
|
||||
<string name="group_social">Social & messaging</string>
|
||||
<string name="group_video">Video</string>
|
||||
<string name="group_audio">Music & audio</string>
|
||||
<string name="group_games">Games</string>
|
||||
<string name="group_images">Photos & images</string>
|
||||
<string name="group_news">News</string>
|
||||
<string name="group_maps">Maps & navigation</string>
|
||||
<string name="group_productivity">Productivity</string>
|
||||
<string name="group_other">Other</string>
|
||||
<string name="group_system">System</string>
|
||||
<string name="group_count">%1$d of %2$d via DNS</string>
|
||||
<string name="about_privacy_policy">Privacy policy</string>
|
||||
<string name="licenses_title">Open-source licenses</string>
|
||||
<string name="licenses_hint">SparkleDNS is built with these open-source projects. Tap a project to open its page.</string>
|
||||
<string name="licenses_data">Blocklist data (downloaded on demand, not bundled)</string>
|
||||
<string name="license_view">View license</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="bl_total_label">domains blocked</string>
|
||||
<string name="about_source">Source code</string>
|
||||
<string name="open_settings">Settings</string>
|
||||
<string name="mon_no_match">Nothing matches the filter</string>
|
||||
<string name="bl_title">Blocking</string>
|
||||
<string name="about_license">License</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.SparkleDNS" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">#FF0F1115</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user