diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e86dd35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.DS_Store diff --git a/README.md b/README.md index 6a8d7e5..d973d94 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,28 @@ Compare the SHA-256 checksum of the downloaded APK against the value shown for t sha256sum Sparkledns-vX.X.X.apk ``` +## Building from source + +```sh +./gradlew assembleRelease +``` + +Or open the project in Android Studio and run the `app` configuration. Requires Android 8.0 (API 26) or newer; HTTP/3 requires Android 14. + +The version lives in `app/build.gradle.kts` (`versionName`, `versionCode = major * 10000 + minor * 100 + patch`). For every release, add `fastlane/metadata/android//changelogs/.txt` and tag the commit as `v` (for example `v1.0.6`) — F-Droid detects new releases from these tags. + ## Repository structure ``` . -├── assets/ # Icon images +├── app/ # Android app source (Kotlin, Jetpack Compose) +├── assets/ # README images │ └── screenshots/ # App screenshots +├── fastlane/ # F-Droid store listing, screenshots and changelogs +├── fdroid/ # Build recipe for fdroiddata +├── gradle/ # Gradle wrapper ├── LICENSE # GNU GPLv3 └── README.md ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c6f75e4 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..8a61059 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Readable stack traces in crash reports +-keepattributes SourceFile,LineNumberTable +-dontobfuscate diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..06d3a70 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/cc/skysparkle/sparkledns/AboutScreen.kt b/app/src/main/java/cc/skysparkle/sparkledns/AboutScreen.kt new file mode 100644 index 0000000..7aa9e78 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/AboutScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/AppShell.kt b/app/src/main/java/cc/skysparkle/sparkledns/AppShell.kt new file mode 100644 index 0000000..5eb4b6b --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/AppShell.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/AppsScreen.kt b/app/src/main/java/cc/skysparkle/sparkledns/AppsScreen.kt new file mode 100644 index 0000000..9646b1f --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/AppsScreen.kt @@ -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(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?>(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) { + 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, + excluded: Set, + 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(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 { + 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() +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Blocklist.kt b/app/src/main/java/cc/skysparkle/sparkledns/Blocklist.kt new file mode 100644 index 0000000..834217d --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Blocklist.kt @@ -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 = _enabled + + private val _custom = MutableStateFlow>(emptyList()) + val custom: StateFlow> = _custom + + private val _lists = MutableStateFlow>(emptyList()) + val lists: StateFlow> = _lists + + private val _total = MutableStateFlow(0) + val total: StateFlow = _total + + // URL being downloaded, ALL_LISTS while updating everything, null when idle + private val _busy = MutableStateFlow(null) + val busy: StateFlow = _busy + + // Reason of the last failed download, null after a success + private val _error = MutableStateFlow(null) + val error: StateFlow = _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) = 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 { + 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 { + val file = File(dir, "custom.txt") + return if (file.exists()) file.readLines().map { it.trim() }.filter { it.isNotEmpty() } else emptyList() + } + + fun saveCustom(domains: List) { + 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 { + 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) { + 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 == '.' } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/BlocklistScreen.kt b/app/src/main/java/cc/skysparkle/sparkledns/BlocklistScreen.kt new file mode 100644 index 0000000..b59b6ea --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/BlocklistScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/CrashLog.kt b/app/src/main/java/cc/skysparkle/sparkledns/CrashLog.kt new file mode 100644 index 0000000..ff9f265 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/CrashLog.kt @@ -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() + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/DnsCache.kt b/app/src/main/java/cc/skysparkle/sparkledns/DnsCache.kt new file mode 100644 index 0000000..2643242 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/DnsCache.kt @@ -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(256, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = 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? { + 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(total) + val values = ArrayList(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 + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/DnsTileService.kt b/app/src/main/java/cc/skysparkle/sparkledns/DnsTileService.kt new file mode 100644 index 0000000..306eb43 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/DnsTileService.kt @@ -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) + } + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/DohClient.kt b/app/src/main/java/cc/skysparkle/sparkledns/DohClient.kt new file mode 100644 index 0000000..a79fe74 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/DohClient.kt @@ -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() + 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) : Dns { + private val addresses by lazy { + ips.mapNotNull { runCatching { InetAddress.getByName(it) }.getOrNull() } + } + + override fun lookup(hostname: String): List = + addresses.ifEmpty { Dns.SYSTEM.lookup(hostname) } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/DohVpnService.kt b/app/src/main/java/cc/skysparkle/sparkledns/DohVpnService.kt new file mode 100644 index 0000000..4636432 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/DohVpnService.kt @@ -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(null) + val active: StateFlow = _active + + // Apps that bypass the tunnel in the running session + private val _activeExcluded = MutableStateFlow>(emptySet()) + val activeExcluded: StateFlow> = _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) { + } + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/MainActivity.kt b/app/src/main/java/cc/skysparkle/sparkledns/MainActivity.kt new file mode 100644 index 0000000..9625129 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/MainActivity.kt @@ -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(null) } + var shownResult by remember { mutableStateOf(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(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() + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/MonitorScreen.kt b/app/src/main/java/cc/skysparkle/sparkledns/MonitorScreen.kt new file mode 100644 index 0000000..53148a8 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/MonitorScreen.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Motion.kt b/app/src/main/java/cc/skysparkle/sparkledns/Motion.kt new file mode 100644 index 0000000..1a20a7b --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Motion.kt @@ -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(durationMillis = 280, delayMillis = 60, easing = Emphasized) + val exitFade = tween(durationMillis = 140, easing = EmphasizedAccelerate) + val slide: FiniteAnimationSpec = tween(durationMillis = 380, easing = Emphasized) + val size: FiniteAnimationSpec = tween(durationMillis = 320, easing = Emphasized) + val color = tween(durationMillis = 400, easing = Emphasized) + val rotation = tween(durationMillis = 260, easing = Emphasized) + + val expandIn = fadeIn(enterFade) + expandVertically(size) + val collapseOut = fadeOut(exitFade) + shrinkVertically(size) + + fun sizeTransform() = SizeTransform(clip = false) { _, _ -> size } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Packet.kt b/app/src/main/java/cc/skysparkle/sparkledns/Packet.kt new file mode 100644 index 0000000..8ef18dc --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Packet.kt @@ -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 +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Prefs.kt b/app/src/main/java/cc/skysparkle/sparkledns/Prefs.kt new file mode 100644 index 0000000..ecc6fc7 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Prefs.kt @@ -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 + 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" + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Providers.kt b/app/src/main/java/cc/skysparkle/sparkledns/Providers.kt new file mode 100644 index 0000000..24a02c6 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Providers.kt @@ -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 = 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 = + 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 diff --git a/app/src/main/java/cc/skysparkle/sparkledns/QueryLog.kt b/app/src/main/java/cc/skysparkle/sparkledns/QueryLog.kt new file mode 100644 index 0000000..6bc0ddd --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/QueryLog.kt @@ -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() + 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 = 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 + } +} diff --git a/app/src/main/java/cc/skysparkle/sparkledns/Ui.kt b/app/src/main/java/cc/skysparkle/sparkledns/Ui.kt new file mode 100644 index 0000000..8d45320 --- /dev/null +++ b/app/src/main/java/cc/skysparkle/sparkledns/Ui.kt @@ -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 diff --git a/app/src/main/res/drawable-nodpi/app_logo.png b/app/src/main/res/drawable-nodpi/app_logo.png new file mode 100644 index 0000000..6aaf1e4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/app_logo.png differ diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..4388a1c --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_domain.xml b/app/src/main/res/drawable/ic_add_domain.xml new file mode 100644 index 0000000..37beba5 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_domain.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_alert.xml b/app/src/main/res/drawable/ic_alert.xml new file mode 100644 index 0000000..6728aa1 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_android.xml b/app/src/main/res/drawable/ic_android.xml new file mode 100644 index 0000000..5675502 --- /dev/null +++ b/app/src/main/res/drawable/ic_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..4da4f61 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..b34c577 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_clear.xml b/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 0000000..b598785 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_copyright.xml b/app/src/main/res/drawable/ic_copyright.xml new file mode 100644 index 0000000..a8d7f2a --- /dev/null +++ b/app/src/main/res/drawable/ic_copyright.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..554271d --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 0000000..766ca6d --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_audio.xml b/app/src/main/res/drawable/ic_group_audio.xml new file mode 100644 index 0000000..2fa316e --- /dev/null +++ b/app/src/main/res/drawable/ic_group_audio.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_games.xml b/app/src/main/res/drawable/ic_group_games.xml new file mode 100644 index 0000000..3b5658f --- /dev/null +++ b/app/src/main/res/drawable/ic_group_games.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_images.xml b/app/src/main/res/drawable/ic_group_images.xml new file mode 100644 index 0000000..9ca86e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_images.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_maps.xml b/app/src/main/res/drawable/ic_group_maps.xml new file mode 100644 index 0000000..0edf84e --- /dev/null +++ b/app/src/main/res/drawable/ic_group_maps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_news.xml b/app/src/main/res/drawable/ic_group_news.xml new file mode 100644 index 0000000..3a75797 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_news.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_productivity.xml b/app/src/main/res/drawable/ic_group_productivity.xml new file mode 100644 index 0000000..bb26dce --- /dev/null +++ b/app/src/main/res/drawable/ic_group_productivity.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_social.xml b/app/src/main/res/drawable/ic_group_social.xml new file mode 100644 index 0000000..1a4ecb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_group_social.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_system.xml b/app/src/main/res/drawable/ic_group_system.xml new file mode 100644 index 0000000..ba9a82a --- /dev/null +++ b/app/src/main/res/drawable/ic_group_system.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_video.xml b/app/src/main/res/drawable/ic_group_video.xml new file mode 100644 index 0000000..d9d944d --- /dev/null +++ b/app/src/main/res/drawable/ic_group_video.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml new file mode 100644 index 0000000..60315db --- /dev/null +++ b/app/src/main/res/drawable/ic_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..7bcd52f --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_privacy.xml b/app/src/main/res/drawable/ic_privacy.xml new file mode 100644 index 0000000..51631ef --- /dev/null +++ b/app/src/main/res/drawable/ic_privacy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..7e71b97 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..e36163c --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield_search.xml b/app/src/main/res/drawable/ic_shield_search.xml new file mode 100644 index 0000000..518164c --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_switch.xml b/app/src/main/res/drawable/ic_switch.xml new file mode 100644 index 0000000..1012e1f --- /dev/null +++ b/app/src/main/res/drawable/ic_switch.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab_apps.xml b/app/src/main/res/drawable/ic_tab_apps.xml new file mode 100644 index 0000000..105a146 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_apps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab_block.xml b/app/src/main/res/drawable/ic_tab_block.xml new file mode 100644 index 0000000..3bf98d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab_home.xml b/app/src/main/res/drawable/ic_tab_home.xml new file mode 100644 index 0000000..7bcd52f --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_home.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab_info.xml b/app/src/main/res/drawable/ic_tab_info.xml new file mode 100644 index 0000000..89ff164 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab_monitor.xml b/app/src/main/res/drawable/ic_tab_monitor.xml new file mode 100644 index 0000000..de23f97 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_monitor.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..bd2bb4c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7f95208 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..10b385e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..666391e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..19f4ed9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..24cc00c --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,98 @@ + + + Защищено + Отключено + через %1$s + Подключить + Отключить + Сервер DNS-over-HTTPS + Свой сервер + DoH URL + Bootstrap IP (необязательно, через запятую) + Адрес должен начинаться с https:// + Переключиться на %1$s + Проверить сервер + Проверка… + Работает через %1$s — первый запрос %2$d мс, повторный %3$d мс + HTTP/3 (QUIC) + Android 14+. С HTTP/3 bootstrap IP не используются + Сервер ответил с кодом ошибки %1$d + Ошибка: %1$s + Приложение упало в прошлый раз + Копировать + Скрыть + В настройках Android «Частный DNS» задан как %1$s, поэтому DNS идёт туда, мимо SparkleDNS. Переключите его на «Автоматически» или «Отключено». + Статус VPN + Защищено через %1$s + DNS через HTTPS активен + Главная + Монитор + Приложения + Блоклист + О программе + Запросы + Из кэша + Заблокировано + Средняя задержка + %1$d мс + Все + Ошибки + Поиск домена + Очистить + Запросов пока нет. Подключитесь и откройте любой сайт. + Кэш + Блок + Ошибка + Заблокировать + Разблокировать + %1$s добавлен в блоклист + %1$s убран из блоклиста + Выключенные приложения идут мимо SparkleDNS и используют обычный DNS сети. + Поиск приложений + Изменения вступят в силу после переподключения + Переподключить + Загрузка… + Мимо DNS: %1$d + Поддомены блокируются тоже. Поддерживаются списки hosts, доменов, AdBlock и dnsmasq. Изменения применяются сразу. + Мои домены + example.com + Добавить + Неверный домен + Пока пусто + Удалить + Списки + Обновить все + Обновление… + URL списка + %1$s доменов · %2$s + Популярные списки + Ошибка загрузки: %1$s + Шифрованный DNS через HTTPS и HTTP/3 для всего устройства. Через локальный VPN идёт только DNS, остальной трафик не затрагивается. + Версия + Протокол + Поддерживается на этом устройстве + Нужен Android 14+, используется HTTP/2 + Соцсети и мессенджеры + Видео + Музыка и аудио + Игры + Фото и изображения + Новости + Карты и навигация + Продуктивность + Другие + Системные + %1$d из %2$d через DNS + Политика конфиденциальности + Лицензии открытого ПО + SparkleDNS построен на этих проектах с открытым кодом. Нажмите на проект, чтобы открыть его страницу. + Данные блоклистов (загружаются по запросу, не входят в приложение) + Текст лицензии + Назад + доменов заблокировано + Исходный код + Настройки + Ничего не найдено + Блокировка + Лицензия + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6392b06 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0B1527 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..315cf62 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,99 @@ + + + SparkleDNS + Protected + Disconnected + via %1$s + Connect + Disconnect + DNS-over-HTTPS server + Custom server + DoH URL + Bootstrap IP (optional, comma-separated) + URL must start with https:// + Switch to %1$s + Test server + Testing… + Works via %1$s — first request %2$d ms, repeat %3$d ms + HTTP/3 (QUIC) + Android 14+. Bootstrap IPs are not used with HTTP/3 + Server replied with error code %1$d + Error: %1$s + The app crashed last time + Copy + Dismiss + Private DNS is set to %1$s in Android settings, so DNS goes there and bypasses SparkleDNS. Switch it to Automatic or Off. + VPN status + Protected via %1$s + DNS over HTTPS is active + Home + Monitor + Apps + Blocklist + About + Queries + From cache + Blocked + Avg latency + %1$d ms + All + Errors + Search domain + Clear + No queries yet. Connect and open any website. + Cache + Blocked + Error + Block + Unblock + %1$s added to blocklist + %1$s removed from blocklist + Apps that are switched off bypass SparkleDNS and use the regular DNS of the network. + Search apps + Changes apply after reconnecting + Reconnect + Loading… + Bypassing DNS: %1$d + Subdomains are blocked too. Hosts, plain domain, AdBlock and dnsmasq lists are supported. Changes apply instantly. + My domains + example.com + Add + Invalid domain + No domains yet + Delete + Lists + Update all + Updating… + List URL + %1$s domains · %2$s + Popular lists + Download failed: %1$s + Encrypted DNS over HTTPS and HTTP/3 for the whole device. Only DNS goes through the local VPN, all other traffic is untouched. + Version + Protocol + Supported on this device + Needs Android 14+, HTTP/2 is used + Social & messaging + Video + Music & audio + Games + Photos & images + News + Maps & navigation + Productivity + Other + System + %1$d of %2$d via DNS + Privacy policy + Open-source licenses + SparkleDNS is built with these open-source projects. Tap a project to open its page. + Blocklist data (downloaded on demand, not bundled) + View license + Back + domains blocked + Source code + Settings + Nothing matches the filter + Blocking + License + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c574b27 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4af2a60 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + // Not applied to modules: only pins the Kotlin version used by AGP's built-in Kotlin + id("org.jetbrains.kotlin.android") version "2.4.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false +} diff --git a/fastlane/metadata/android/en-US/changelogs/10006.txt b/fastlane/metadata/android/en-US/changelogs/10006.txt new file mode 100644 index 0000000..d8a85d0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10006.txt @@ -0,0 +1,8 @@ +• Fixed: IPv6 of all apps was blocked while connected +• Switching servers no longer leaks DNS for a moment +• Blocklist downloads run in the background and never block adding domains +• dnsmasq and unbound blocklist formats +• Private DNS warning shows only when it really interferes +• Back returns to Home, text fields stay above the keyboard +• Quick Settings tile updates instantly +• Monitor filters fit narrow screens diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..64e87c8 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,31 @@ +SparkleDNS encrypts every DNS lookup on your device with DNS-over-HTTPS (RFC 8484) and HTTP/3. It uses Android's VPN API only locally: just DNS requests go into the tunnel, all other traffic takes your normal connection, so speed and battery stay the same. + +Servers +
    +
  • SkySparkle (default) with HTTP/3, run by the developer
  • +
  • Cloudflare, Google, Quad9, AdGuard, Mullvad
  • +
  • Any custom DoH server, with optional bootstrap IPs and HTTP/3
  • +
+ +Features +
    +
  • One-tap connect, Quick Settings tile, Always-on VPN support
  • +
  • Live monitor: every query with its status and latency, filters, search and one-tap blocking
  • +
  • Per-app rules: apps grouped by category, chosen ones bypass SparkleDNS
  • +
  • Blocklists: your own domains plus hosts, plain domain, AdBlock and dnsmasq lists by URL, HaGeZi Pro++ and TIF one tap away. Subdomains are blocked too, changes apply instantly
  • +
  • Local DNS cache that honours TTLs
  • +
  • Server test showing the protocol (h3 or h2) and latency
  • +
+ +Privacy +No analytics, no ads, no Google Play Services. The query log lives only in memory and never leaves the device. DNS requests go to the server you choose. The default SkySparkle server is run by the developer under this privacy policy: https://skysparkle.cc/privacy?lang=en + +Permissions +
    +
  • VPN: a local tunnel that carries DNS only, nothing is routed to a remote VPN
  • +
  • Notifications: connection status with a Disconnect button
  • +
  • Network state: needed by Android's HTTP/3 engine and to detect Private DNS
  • +
  • Installed apps (launcher entries only): to list apps for per-app rules
  • +
+ +HTTP/3 needs Android 14 or newer; older versions use HTTP/2. If Private DNS in Android settings is set to a specific hostname, Android sends DNS there instead; SparkleDNS shows a warning when that happens. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..68f6a2b Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..6aaf1e4 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..11dd266 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..a7a5a62 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..29210eb Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..9173f1a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..5f781b5 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..d6c54f8 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Encrypted DNS over HTTPS and HTTP/3 with blocklists and per-app rules diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..5528ce5 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +SparkleDNS diff --git a/fastlane/metadata/android/ru-RU/changelogs/10006.txt b/fastlane/metadata/android/ru-RU/changelogs/10006.txt new file mode 100644 index 0000000..559485c --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/10006.txt @@ -0,0 +1,8 @@ +• Исправлено: при подключении блокировался IPv6 всех приложений +• Смена сервера больше не пропускает DNS мимо туннеля +• Блоклисты загружаются в фоне и не мешают добавлять домены +• Форматы блоклистов dnsmasq и unbound +• Предупреждение о частном DNS — только когда он реально мешает +• «Назад» ведёт на главную, поля ввода не прячутся под клавиатурой +• Плитка в шторке обновляется сразу +• Фильтры монитора помещаются на узких экранах diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 0000000..ff19e5b --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1,31 @@ +SparkleDNS шифрует каждый DNS-запрос на устройстве с помощью DNS-over-HTTPS (RFC 8484) и HTTP/3. VPN Android используется только локально: в туннель попадают лишь DNS-запросы, весь остальной трафик идёт обычным путём, поэтому скорость и батарея не страдают. + +Серверы +
    +
  • SkySparkle (по умолчанию) с HTTP/3, работает у разработчика
  • +
  • Cloudflare, Google, Quad9, AdGuard, Mullvad
  • +
  • Любой свой DoH-сервер, с bootstrap IP и HTTP/3 по желанию
  • +
+ +Возможности +
    +
  • Подключение одним касанием, плитка в шторке, поддержка постоянного VPN
  • +
  • Монитор: каждый запрос со статусом и задержкой, фильтры, поиск и блокировка одним касанием
  • +
  • Правила для приложений: группы по категориям, выбранные приложения идут мимо SparkleDNS
  • +
  • Блоклисты: свои домены и списки hosts, доменов, AdBlock и dnsmasq по ссылке, HaGeZi Pro++ и TIF в одно касание. Поддомены блокируются тоже, изменения применяются сразу
  • +
  • Локальный DNS-кэш с учётом TTL
  • +
  • Проверка сервера с протоколом (h3 или h2) и задержкой
  • +
+ +Приватность +Никакой аналитики, рекламы и сервисов Google Play. Журнал запросов хранится только в памяти и никуда не отправляется. DNS-запросы уходят на выбранный вами сервер. Сервер SkySparkle по умолчанию работает у разработчика по этой политике конфиденциальности: https://skysparkle.cc/privacy?lang=en + +Разрешения +
    +
  • VPN: локальный туннель только для DNS, ничего не уходит на удалённый VPN
  • +
  • Уведомления: статус подключения и кнопка «Отключить»
  • +
  • Состояние сети: нужно движку HTTP/3 Android и для проверки частного DNS
  • +
  • Установленные приложения (только ярлыки лаунчера): для правил по приложениям
  • +
+ +Для HTTP/3 нужен Android 14 или новее, на более старых используется HTTP/2. Если в настройках Android «Частный DNS» задан конкретным хостом, Android отправляет DNS туда; SparkleDNS предупреждает об этом. diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 0000000..512f47e --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +Шифрованный DNS через HTTPS и HTTP/3 с блоклистами и правилами для приложений diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 100644 index 0000000..5528ce5 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +SparkleDNS diff --git a/fdroid/cc.skysparkle.sparkledns.yml b/fdroid/cc.skysparkle.sparkledns.yml new file mode 100644 index 0000000..5406215 --- /dev/null +++ b/fdroid/cc.skysparkle.sparkledns.yml @@ -0,0 +1,27 @@ +# Build recipe for the F-Droid main repository. +# Submit it as metadata/cc.skysparkle.sparkledns.yml in a merge request to https://gitlab.com/fdroid/fdroiddata +Categories: + - Connectivity + - Security +License: GPL-3.0-or-later +WebSite: https://skysparkle.cc +SourceCode: https://git.skysparkle.cc/kalarina/Sparkledns +IssueTracker: https://git.skysparkle.cc/kalarina/Sparkledns/issues + +AutoName: SparkleDNS + +RepoType: git +Repo: https://git.skysparkle.cc/kalarina/Sparkledns.git + +Builds: + - versionName: 1.0.6 + versionCode: 10006 + commit: v1.0.6 + subdir: app + gradle: + - yes + +AutoUpdateMode: Version +UpdateCheckMode: Tags ^v[0-9.]+$ +CurrentVersion: 1.0.6 +CurrentVersionCode: 10006 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2e34e97 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "SparkleDNS" +include(":app")