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:
Claude
2026-10-06 18:23:12 +00:00
parent 7cd9875c87
commit 4c22ade5cd
89 changed files with 4816 additions and 1 deletions
+54
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
# Readable stack traces in crash reports
-keepattributes SourceFile,LineNumberTable
-dontobfuscate
+69
View File
@@ -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

+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+11
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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

+98
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B1527</color>
</resources>
+99
View File
@@ -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 &amp; messaging</string>
<string name="group_video">Video</string>
<string name="group_audio">Music &amp; audio</string>
<string name="group_games">Games</string>
<string name="group_images">Photos &amp; images</string>
<string name="group_news">News</string>
<string name="group_maps">Maps &amp; 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>
+6
View File
@@ -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>