Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
77d4706
chore(l10n): add Spanish translation
Jul 25, 2026
6c2c056
chore(l10n): update Japanese translation
minetaro12 Jul 26, 2026
dda4e0e
chore(l10n): update Spanish translation
Jul 25, 2026
74dd149
chore(l10n): update Spanish translation
Jul 25, 2026
e136081
chore(l10n): add Chinese (Simplified Han script) translation
XieyilinMain Jul 26, 2026
b6033b6
chore(l10n): update French translation
ELowry Jul 26, 2026
b535ecd
chore(l10n): update Spanish translation
ELowry Jul 26, 2026
a33f41c
chore(l10n): update Chinese (Simplified Han script) translation
XieyilinMain Jul 26, 2026
07aae24
Merge pull request #31 from ELowry/translation
ELowry Jul 26, 2026
ee34d55
chore(l10n): update Chinese (Simplified Han script) translation
XieyilinMain Jul 27, 2026
0653fa8
chore(l10n): update Chinese (Simplified Han script) translation
flytothehighest Jul 27, 2026
8852a40
v2.0.0
ELowry Jul 26, 2026
bc8083a
chore(l10n): update Chinese (Simplified Han script) translation
XieyilinMain Jul 29, 2026
4f58d3f
chore(l10n): update Chinese (Simplified Han script) translation
XieyilinMain Jul 28, 2026
3b853f2
Updated dependencies
ELowry Aug 7, 2026
1378d4d
Updated French translation
ELowry Aug 7, 2026
6c0076d
Best-guess updates for existing translations
ELowry Aug 7, 2026
94554b5
chore(l10n): merge Weblate translations and resolve conflicts
ELowry Aug 7, 2026
8eb20ae
chore(l10n): update German translation
ELowry Aug 7, 2026
0a292d0
chore(l10n): update Chinese (Simplified Han script) translation
ELowry Aug 7, 2026
9600d82
Cleaned up French translation
ELowry Aug 7, 2026
009cd8f
Added build step to ensure fastlane titles are present in all languag…
ELowry Aug 7, 2026
b392ee0
chore(l10n): update French translation
ELowry Aug 7, 2026
52bd6b3
chore(l10n): update French translation
ELowry Aug 7, 2026
f5e6a3b
Corrected changelog
ELowry Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:

CHANGELOG_PATH="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
if [ ! -f "$CHANGELOG_PATH" ]; then
mkdir -p "$(dirname "$CHANGELOG_PATH")" # Avoids errors in case of missing folder
echo "No specific changelog found for this release." > "$CHANGELOG_PATH"
fi

Expand Down
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,28 @@ Adds a Quick Settings tile to toggle your Private DNS on and off with a single t
By long-pressing the Quick Settings tile, you can access the configuration menu to:

- **Custom DNS Provider**:
Set and label one or more custom Private DNS hostnames you can easily toggle between (e.g., `dns.adguard.com`).
Set and label one or more custom Private DNS hostnames you can easily toggle between (e.g., `dns.quad9.net`).
- Optional automatic DNS hostname reachability check.
- Automatically detects your active system DNS so you can easily save it to your list.
- **Wi-Fi Blacklist**:
Automatically disable Private DNS when connected to specific Wi-Fi networks.
- Optional automated addition/removal of the current Wi-Fi SSID from the blacklist when manually toggling the Quick Settings tile.
- Optional connectivity watchdog that automatically blacklists the current Wi-Fi SSID when Private DNS causes connectivity issues.
- **VPN toggling**:
Automatically set a DNS hostname or toggle Private DNS when a VPN is in use on the device.
- **Wi-Fi Profiles**:
Automatically set or disable Private DNS when connected to specific Wi-Fi networks, including options to:
- Auto-save changes to a Wi-Fi network profile when you manually toggle DNS or switch hostnames.
- Automatically disable Private DNS for the current network if it causes connectivity issues, ensuring you never lose internet access.
- **VPN Override**:
Automatically set a specific DNS hostname or switch to "Opportunistic" (Off) mode when a VPN connection is detected.
- **Material You UI**:
Modern, dynamic interface that adapts to your system theme and accent colors (Android 12+).
- **Backup & Restore**:
Export and import your configuration via password-encrypted `.dnstoggle` files.
> _Note: For security, your encrypted hostnames and blacklists are excluded from cloud backups and must be transferred manually._
> _Note: For security, your encrypted hostnames and Wi-Fi profiles are excluded from cloud backups and must be transferred manually._
- **Dynamic Tile Labeling**:
Rename the Quick Settings tile.
- **Hide App Icon**:
Optionally hide the app from your launcher drawer to keep your home screen clean.
- **USB Debugging Tile**:
(_Requires Developer Mode_) Tap the app version number at the bottom of the settings menu 5 times in quick succession to add a USB Debugging Quick Settings tile.
- **App Shortcuts**:
Includes app shortcuts to toggle Private DNS or open the quick-sepect/settings menu.

## Usage

Expand All @@ -76,9 +80,11 @@ If you encounter issues not covered here, please [open a support ticket](https:/
- **Tile is Grayed Out**:
This usually means the permission was not granted correctly. See the warning above.
- **Custom DNS is marked as "Unreachable"**:
Some strict DNS providers block automatic connection tests. If you are certain the address is correct, you can toggle on the `Disable DNS Test` just option below.
- **Automation stops working**:
Some Android devices aggressively kill background apps to save battery. If the Wi-Fi Blocklist or VPN Override features stop working while the app is closed, you may need to manually disable `Battery Optimizations` or enable `Autostart` for DNS Toggle in your device's settings.
Some strict DNS providers block automatic connection tests. If you are certain the address is correct, you can enable the `Skip connection test` option.
- **"Unknown SSID" or Automation fails**:
- Ensure **Location Permissions** are set to "Allow all the time". Android requires this to identify Wi-Fi networks in the background.
- Active **VPNs** or "Private DNS" settings on some devices can mask the SSID.
- Some devices aggressively kill background apps. If automation stops working, you may need to disable `Battery Optimizations` or enable `Autostart` for DNS Toggle in your system settings.

## Permissions

Expand All @@ -87,7 +93,7 @@ If you encounter issues not covered here, please [open a support ticket](https:/
- **INTERNET** _(optional)_:
Used to verify that your Custom DNS Provider is online and reachable before applying it.
- **Location & Nearby Devices** _(optional)_:
Required only for **Wi-Fi Blocklist** automation. Used to identify the Wi-Fi network name (SSID) locally.
Required only for **Wi-Fi Profiles** automation. Used to identify the Wi-Fi network name (SSID) locally.
- **Notifications** _(optional)_:
Used for status alerts when Private DNS is automatically adjusted.

Expand Down
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlinx.serialization.plugin)
}

abstract class CopyChangelogsTask @Inject constructor(
Expand Down Expand Up @@ -36,8 +37,8 @@ android {
applicationId = "com.ericlowry.dnstoggle"
minSdk = 28
targetSdk = 37
versionCode = 21
versionName = "1.7.1"
versionCode = 22
versionName = "2.0.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -99,6 +100,7 @@ androidComponents {

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Monitoring Wi-Fi network connectivity to automate Private DNS mode toggling based on user-defined SSID blacklist." />
android:value="Monitoring Wi-Fi network connectivity to automate Private DNS mode toggling based on user-defined Wi-Fi profiles." />
</service>

<provider
Expand Down
79 changes: 51 additions & 28 deletions app/src/main/java/com/ericlowry/dnstoggle/DnsToggleApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.ericlowry.dnstoggle.data.Constants
import com.ericlowry.dnstoggle.data.DnsSettingsRepository
import com.ericlowry.dnstoggle.data.repository.DnsSettingsRepository
import com.ericlowry.dnstoggle.data.repository.HostnameRepository
import com.ericlowry.dnstoggle.data.repository.NetworkProfileRepository
import com.ericlowry.dnstoggle.data.repository.SecurityRepository
import com.ericlowry.dnstoggle.data.repository.VpnRepository
import com.ericlowry.dnstoggle.service.DnsToggleService
import com.ericlowry.dnstoggle.service.TileServiceCompat
import com.ericlowry.dnstoggle.service.UsbDebuggingTileService
Expand All @@ -34,11 +38,12 @@ class DnsToggleApplication : Application() {

var detectedSsid: String? = null
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isAdbObserverRegistered = false

private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == Constants.PREF_AUTO_BLACKLIST ||
key == Constants.PREF_AUTO_WHITELIST ||
if (key == Constants.PREF_AUTO_SAVE_STATE ||
key == Constants.PREF_AUTO_SAVE_HOST ||
key == Constants.PREF_VPN_OVERRIDE_ENABLED ||
key == Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED
) {
Expand Down Expand Up @@ -106,6 +111,10 @@ class DnsToggleApplication : Application() {
.setPrecondition { _, _ -> shouldApplyDynamicColors() }
.build()
DynamicColors.applyToActivitiesIfAvailable(this, dynamicOptions)
SecurityRepository.initialize(this)
VpnRepository.initialize(this)
NetworkProfileRepository.initialize(this)
HostnameRepository.initialize(this)
DnsSettingsRepository.initialize(this)
initializeNotificationChannels()
initializePreferredDnsMode()
Expand Down Expand Up @@ -139,8 +148,8 @@ class DnsToggleApplication : Application() {
)

applicationScope.launch {
DnsSettingsRepository.blacklist.collect { blacklist ->
if (blacklist != null) {
NetworkProfileRepository.networkProfiles.collect { profiles ->
if (profiles != null) {
updateWifiMonitoringRegistration()
}
}
Expand Down Expand Up @@ -223,22 +232,26 @@ class DnsToggleApplication : Application() {
private fun isWifiMonitoringRequired(): Boolean {
val sharedPreferences = getPrefs()
val vpnEnabled = sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false)
val autoEnabled = sharedPreferences.getBoolean(Constants.PREF_AUTO_BLACKLIST, false) ||
sharedPreferences.getBoolean(Constants.PREF_AUTO_WHITELIST, false) ||
val autoEnabled = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_STATE, false) ||
sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_HOST, false) ||
sharedPreferences.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false)

val blacklist = DnsSettingsRepository.blacklist.value
val hasActiveBlacklist = !blacklist.isNullOrEmpty()
val profiles = NetworkProfileRepository.networkProfiles.value
val hasNetworkProfiles = !profiles.isNullOrEmpty()

return vpnEnabled || autoEnabled || hasActiveBlacklist
return vpnEnabled || autoEnabled || hasNetworkProfiles
}

fun updateUsbDebuggingTileAvailability() {
val isDevMode = Settings.Global.getInt(
contentResolver,
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
0
) != 0
val isDevMode = try {
Settings.Global.getInt(
contentResolver,
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
0
) != 0
} catch (_: SecurityException) {
false
}

val isAppUnlocked = getPrefs().getBoolean(Constants.PREF_USB_DEBUGGING_TILE_UNLOCKED, false)

Expand All @@ -258,25 +271,35 @@ class DnsToggleApplication : Application() {
)

if (newState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
registerAdbObserver()
TileServiceCompat.requestListeningState(this, componentName)
} else {
unregisterAdbObserver()
}
} else if (newState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
registerAdbObserver()
}
}

private fun registerAdbObserver() {
if (!isAdbObserverRegistered) {
try {
contentResolver.registerContentObserver(
Settings.Global.getUriFor(Settings.Global.ADB_ENABLED),
false,
adbObserver
)
TileServiceCompat.requestListeningState(
this,
componentName
)
} else {
contentResolver.unregisterContentObserver(adbObserver)
isAdbObserverRegistered = true
} catch (e: Exception) {
Log.w("DnsToggleApplication", "Failed to register ADB observer: ${e.message}")
}
} else if (newState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
// Ensure observer is registered if it's already enabled (e.g. on app restart)
contentResolver.registerContentObserver(
Settings.Global.getUriFor(Settings.Global.ADB_ENABLED),
false,
adbObserver
)
}
}

private fun unregisterAdbObserver() {
if (isAdbObserverRegistered) {
contentResolver.unregisterContentObserver(adbObserver)
isAdbObserverRegistered = false
}
}
}
16 changes: 16 additions & 0 deletions app/src/main/java/com/ericlowry/dnstoggle/data/BackupConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ericlowry.dnstoggle.data

import kotlinx.serialization.Serializable

@Serializable
data class BackupConfig(
val hostnames: List<DnsHostname> = emptyList(),
val networkProfiles: List<NetworkProfile> = emptyList(),
val autoSaveState: Boolean = false,
val autoSaveHost: Boolean = false,
val vpnOverride: Boolean = false,
val vpnDns: String? = null,
val hideLauncherIcon: Boolean = false,
val disableDnsTest: Boolean = false,
val showToast: Boolean = false
)
18 changes: 12 additions & 6 deletions app/src/main/java/com/ericlowry/dnstoggle/data/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ object Constants {
const val DNS_MODE_OPPORTUNISTIC = "opportunistic"
const val DNS_MODE_HOSTNAME = "hostname"

// Shared Preferences Keys
// START_LEGACY_MIGRATION_CODE: Old shared preference keys
const val PREF_SSID_BLACKLIST = "ssid_blacklist"
const val PREF_AUTO_BLACKLIST = "auto_blacklist"
const val PREF_AUTO_WHITELIST = "auto_whitelist"
const val PREF_SSID_AUTO_DETECTED_BLACKLIST = "ssid_auto_detected_blacklist"
// END_LEGACY_MIGRATION_CODE

// Shared Preferences Keys
const val PREF_NETWORK_PROFILES = "network_profiles"
const val PREF_AUTO_SAVE_STATE = "pref_auto_save_state"
const val PREF_AUTO_SAVE_HOST = "pref_auto_save_host"
const val PREF_HIDE_LAUNCHER_ICON = "hide_launcher_icon"
const val PREF_DISABLE_DNS_TEST = "disable_dns_test"
const val PREF_PREFERRED_DNS_MODE = "preferred_dns_mode"
Expand All @@ -26,16 +31,13 @@ object Constants {
const val PREF_CONNECTIVITY_WATCHDOG_ENABLED = "connectivity_watchdog_enabled"
const val PREF_CONNECTIVITY_WATCHDOG_DEBOUNCE_SECONDS = "connectivity_watchdog_debounce_seconds"
const val PREF_CONNECTIVITY_WATCHDOG_PROBE_TARGETS = "connectivity_watchdog_probe_targets"
const val PREF_SSID_AUTO_DETECTED_BLACKLIST = "ssid_auto_detected_blacklist"
const val CONNECTIVITY_WATCHDOG_DEFAULT_DEBOUNCE_SECONDS = 15
const val CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS =
"91.198.174.192, 103.102.166.224, 173.239.79.196"

// VPN Override Keys
const val PREF_VPN_OVERRIDE_ENABLED = "vpn_override_enabled"
const val PREF_VPN_DNS_HOSTNAME = "vpn_dns_hostname"
const val PREF_PRE_VPN_DNS_MODE = "pre_vpn_dns_mode"
const val PREF_PRE_VPN_DNS_SPECIFIER = "pre_vpn_dns_specifier"
const val PREF_IS_IN_VPN_OVERRIDE = "is_in_vpn_override"
const val PREF_ACTIVE_SSID_OVERRIDE = "active_ssid_override"
const val PREF_VPN_HOSTNAME_REMOVED_WARNING = "vpn_hostname_removed_warning"
Expand All @@ -47,4 +49,8 @@ object Constants {
// Notification IDs
const val NOTIFICATION_ID_FOREGROUND = 2001
const val NOTIFICATION_ID_STATUS = 1001

// Permission Tracking
const val PREF_HAS_REQUESTED_NOTIF_PERMS = "has_requested_notif_perms"
fun prefRequestedPermission(permission: String): String = "requested_$permission"
}
38 changes: 6 additions & 32 deletions app/src/main/java/com/ericlowry/dnstoggle/data/DnsHostname.kt
Original file line number Diff line number Diff line change
@@ -1,46 +1,20 @@
package com.ericlowry.dnstoggle.data

import org.json.JSONObject
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class DnsHostname(
val hostname: String,
val label: String? = null,
@SerialName("h") val hostname: String,
@SerialName("l") val label: String? = null,
@Transient val isUnsaved: Boolean = false
) {
init {
require(hostname.isNotBlank()) { "Hostname cannot be blank" }
}

fun toSerializedString(): String {
return if (label == null) {
hostname
} else {
val json = JSONObject()
json.put("h", hostname)
json.put("l", label)
"j:$json"
}
}

fun getDisplayName(): String {
return label ?: hostname
}

companion object {
fun fromSerializedString(serialized: String): DnsHostname {
return if (serialized.startsWith("j:")) {
try {
val json = JSONObject(serialized.substring(2))
DnsHostname(
hostname = json.getString("h"),
label = json.optString("l").takeIf { it.isNotEmpty() },
)
} catch (_: Exception) {
DnsHostname(hostname = serialized)
}
} else {
DnsHostname(hostname = serialized)
}
}
}
}
Loading
Loading