From 77d4706458b3b17215bdfcc76d8369ad1061bf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Allyson=20Sim=C3=B3n?= Date: Sat, 25 Jul 2026 23:17:02 +0200 Subject: [PATCH 01/23] chore(l10n): add Spanish translation Translation: ELowry/DNSToggle Strings Language: Spanish Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/es/ --- app/src/main/res/values-es/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-es/strings.xml diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 6c2c05687756192d8d2fc219f4684fe89e952855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=E3=81=95=E3=82=93?= Date: Sun, 26 Jul 2026 04:35:09 +0200 Subject: [PATCH 02/23] chore(l10n): update Japanese translation Translation: ELowry/DNSToggle Strings Language: Japanese Progress: 36.6% (48 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/ja/ --- app/src/main/res/values-ja/strings.xml | 49 +++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 55344e5..0782891 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,3 +1,50 @@ - \ No newline at end of file + バージョン: %s + 更新情報 + v%s の更新内容 + GitHub (ソースコード) + サポート + OK + キャンセル + 閉じる + 設定 + オン + オフ + 自動 (オフ) + その他のオプション + 権限が必要です + この機能を使用するには、RootまたはShizukuで権限を付与するか、USBデバッグを有効にした状態で、PCで以下のADBコマンドを実行する必要があります: + ADBコマンド + ADBコマンド: %s + ADBコマンドをコピー + コマンドをクリップボードにコピーしました + Shizukuで許可 + Rootで許可 + 権限付与を実行 + Shizukuで権限の付与を試みています… + Rootで権限の付与を試みています… + 権限の付与を試みています… + Shizuku/Root権限を取得できませんでした + 権限が付与されました + システム設定の変更許可が必要です + プライベートDNSの設定を変更するには権限が必要です。 + 修正 + 設定を開く + プライベート DNS + 接続テストをスキップ + DNSが動作しているにもかかわらず、「到達不能」と表示される場合のみ有効にしてください。 + カスタムDNSプロバイダー + ホスト名を追加 + 使用中のホスト名を保存 + ホスト名を編集 + DNS ホスト名 (例: dns.nextdns.io) + ラベル (オプション) + このホスト名を削除しますか? + ホストアドレスが空のため、プライベートDNSを有効化できません + 無効なDNSホストアドレスです + 接続テスト中… + 現在の設定 (未保存) + アクティブ・接続可能 + ポート853 (DoT)でDNSサーバーに接続できません。Androidが別の方法で接続を確立している可能性があります。 + From dda4e0ef4c98f667dd383188e659976b6cc7508a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Allyson=20Sim=C3=B3n?= Date: Sat, 25 Jul 2026 23:50:39 +0200 Subject: [PATCH 03/23] chore(l10n): update Spanish translation Translation: ELowry/DNSToggle Strings Language: Spanish Progress: 100.0% (131 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/es/ --- app/src/main/res/values-es/strings.xml | 132 ++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 55344e5..da71bc9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,3 +1,133 @@ - \ No newline at end of file + Versión de la App: %s + Novedades + Novedades en la versión %s + GitHub (código fuente) + Obtener ayuda + De acuerdo + Cancelar + Cerrar + Ajustes + Encendido + Apagado + Automático (desactivado) + Más opciones + Se requiere permiso + Para usar esta opción, debes otorgar permisos de configuración a través de root, Shizuku o ejecutar este comando ADB en tu computadora con la depuración USB habilitada: + Comando ADB + Comando ADB: %s + Copiar comando ADB + Comando copiado al portapapeles + Grant a través de Shizuku + Grant a través de Root + Intentar otorgar permiso + Intentando obtener privilegios de Shizuku… + Intentando obtener privilegios de Root… + Intentando obtener privilegios automática… + Los privilegios de Shizuku/Root falló o fue denegada + Permiso concedido + Se requiere permiso para acceder a la configuración del sistema + La aplicación necesita un permiso especial para modificar la configuración del DNS privado. + Corregir + Abrir Configuración + DNS privado + Omitir prueba de conexión + Activa esta opción solo si tu DNS funciona, pero la aplicación lo muestra como inaccesible. + Proveedor(es) de DNS personalizado(s) + Agregar nombre de host + Guarda este nombre de host activo + Editar nombre de host + Nombre de host de DNS (p. ej., dns.nextdns.io) + Etiqueta (opcional) + ¿Deseas eliminar este nombre de host? + No se puede habilitar el DNS privado mientras la dirección del host esté vacía + Formato de dirección de host DNS no válido + Probando la conexión… + Actual (sin guardar) + Activo y localizable + No se puede acceder al servidor DNS en el puerto 853 (DoT). Es posible que Android aún pueda conectarse correctamente mediante otros métodos. + VPN anulada: activa + Lista negra de SSID activa (%s) + Lista negra de SSID + Información sobre el monitoreo de Wi-Fi + Para permitir el cambio automático, la aplicación supervisa los cambios en la red. El impacto en el consumo de batería debería ser mínimo, pero es posible que el sistema siga reportando actividad en segundo plano. + Algunos dispositivos desactivan el monitoreo en segundo plano para ahorrar batería. Si la automatización deja de funcionar, desactiva las restricciones para esta app. + Desactivar las restricciones de la batería + Incluir automáticamente en la lista negra al desactivar el DNS privado + Incluir automáticamente en la lista blanca al habilitar el DNS privado + Inclusión automática en la lista negra en caso de falla de conectividad + Cuando el DNS privado falla, pero sigue habiendo conexión a Internet, la aplicación agregará automáticamente la red actual a la lista negra para restablecer la conectividad. + Espera antes de desactivarlo + Servidores de prueba de conexión + Revisión de conectividad de los servidores de destino + Direcciones IP separadas por comas (p. ej., 9.9.9.9, 5.1.66.255) + Personalizado… + Ingresa un valor entre 5 y 300 segundos + %ds + El DNS privado está desactivado y %s ha sido incluido en la lista negra debido a un problema de conectividad + Se ha restablecido la conectividad. Se ha vuelto a habilitar el DNS privado para %s + Se requiere el permiso de ubicación/alrededores (incluido el permiso en segundo plano) para verificar el SSID de Wi-Fi. + Otorgar permisos + Se han denegado de forma permanente los permisos de ubicación y dispositivos cercanos. Actívalos en la configuración de la aplicación para poder usar esta función. + Para que recibas una notificación cuando el DNS privado se active o desactive automáticamente en segundo plano, y para garantizar que el monitoreo permanezca activo, por favor, habilita las notificaciones. + Para monitorear la red Wi-Fi mientras la app está cerrada, Android requiere el acceso a la ubicación con la opción «Permitir siempre». DNS Toggle solo utiliza esto para ver el nombre de la red Wi-Fi, y nunca rastrea tu ubicación física ni utiliza el GPS. + \n\nNota: Las conexiones VPN activas pueden ocultar el nombre de tu red Wi-Fi, lo que desactiva temporalmente las reglas de lista negra y lista blanca automáticas. + Agregar SSID + Editar SSID + SSID (nombre de la red Wi-Fi) + SSID incluido de forma permanente en la lista negra + Agregado automáticamente + ¿Quitar este SSID de la lista negra? + Anulación de VPN + Cambiar automáticamente el DNS privado cuando se conecte una VPN. + DNS en VPN + Se requiere permiso para recibir notificaciones a fin de que la automatización funcione en segundo plano. + Se eliminó el nombre de host DNS que se utilizaba anteriormente para la VPN. Se ha desactivado la opción «VPN Override». + Opciones + Mostrar notificaciones de estado en forma de mensaje emergente + Mostrar una notificación de estado cuando se active o desactive el DNS privado a través del mosaico de Ajustes rápidos. + Ocultar el ícono de la aplicación + Cuando esté habilitada, la app quedará oculta en tu menú de apps. Podrás seguir accediendo a la configuración al mantener presionado el mosaico de Configuración rápida. Nota: en algunos lanzadores, es posible que esta opción no oculte por completo el ícono. + Esta función no está disponible debido a las limitaciones de Android TV. + Depuración por USB + Mostrar «Depuración USB» + Muestra un mosaico adicional en Ajustes rápidos para activar o desactivar la depuración USB. Mantén presionado para abrir las opciones de desarrollador. + Toca %1$d veces más para habilitar un mosaico de depuración USB. + ¡Ya está disponible el mosaico de Ajustes rápidos para la depuración USB! + Activando… + Desactivando… + Cambiar el nombre de la aplicación + Elige un nombre que tenga sentido para ti. Este aparecerá en el ícono de la app y en el mosaico de Ajustes rápidos. + Configuración de exportación + Importar Configuración + Restablecimiento de seguridad + Debido a un cambio en la configuración de seguridad de tu dispositivo (como modificar el bloqueo de pantalla), tu lista negra de redes Wi-Fi se restableció para proteger tus datos. Tendrás que volver a agregar las redes que tenías en la lista negra. + Exportar + Ingresa una contraseña para cifrar tu archivo de respaldo. Necesitarás esta misma contraseña para restaurar esta configuración más adelante. + Contraseña + Importar + Al importar esta configuración, se sobrescribirán tus ajustes actuales, los nombres de host y la lista negra de redes Wi-Fi. ¿Deseas continuar? + La configuración se exportó correctamente + No se pudo exportar la configuración + La configuración se importó correctamente + No se pudo leer el archivo + El descifrado falló. Contraseña incorrecta o archivo dañado. + Alertas de automatización + Notificaciones cuando se activa o desactiva automáticamente el DNS privado o cuando se agregan redes a la lista negra. + Monitoreo de fondo + Una notificación silenciosa y persistente que requiere Android para monitorear las redes Wi-Fi en segundo plano. + Monitoreo del estado de la red + La opción «DNS Toggle» está activa para automatizar el modo DNS privado. + El DNS privado se desactivó automáticamente para %s + DNS privado configurado para VPN + Se restableció el DNS privado después de la VPN + Se ha agregado %s a la lista negra + Se ha eliminado %s de la lista negra + El DNS privado se cambió a %s + Nombre de host guardado + Activar/desactivar el DNS + Activar o desactivar el modo de DNS privado + Seleccionar DNS + Selecciona un nombre de host de DNS privado + From 74dd1494956f17e4e24d9c03246df8f9db11ad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Allyson=20Sim=C3=B3n?= Date: Sun, 26 Jul 2026 00:10:03 +0200 Subject: [PATCH 04/23] chore(l10n): update Spanish translation Translation: ELowry/DNSToggle Metadata Language: Spanish Progress: 100.0% (24 of 24 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-metadata/es/ --- fastlane/metadata/android/es-ES/changelogs/1.txt | 1 + fastlane/metadata/android/es-ES/changelogs/10.txt | 3 +++ fastlane/metadata/android/es-ES/changelogs/11.txt | 6 ++++++ fastlane/metadata/android/es-ES/changelogs/12.txt | 2 ++ fastlane/metadata/android/es-ES/changelogs/13.txt | 2 ++ fastlane/metadata/android/es-ES/changelogs/14.txt | 7 +++++++ fastlane/metadata/android/es-ES/changelogs/15.txt | 4 ++++ fastlane/metadata/android/es-ES/changelogs/16.txt | 2 ++ fastlane/metadata/android/es-ES/changelogs/17.txt | 4 ++++ fastlane/metadata/android/es-ES/changelogs/18.txt | 1 + fastlane/metadata/android/es-ES/changelogs/19.txt | 5 +++++ fastlane/metadata/android/es-ES/changelogs/2.txt | 1 + fastlane/metadata/android/es-ES/changelogs/20.txt | 5 +++++ fastlane/metadata/android/es-ES/changelogs/21.txt | 4 ++++ fastlane/metadata/android/es-ES/changelogs/3.txt | 1 + fastlane/metadata/android/es-ES/changelogs/4.txt | 1 + fastlane/metadata/android/es-ES/changelogs/5.txt | 1 + fastlane/metadata/android/es-ES/changelogs/6.txt | 1 + fastlane/metadata/android/es-ES/changelogs/7.txt | 6 ++++++ fastlane/metadata/android/es-ES/changelogs/8.txt | 4 ++++ fastlane/metadata/android/es-ES/changelogs/9.txt | 3 +++ fastlane/metadata/android/es-ES/full_description.txt | 1 + fastlane/metadata/android/es-ES/short_description.txt | 1 + fastlane/metadata/android/es-ES/title.txt | 1 + 24 files changed, 67 insertions(+) create mode 100644 fastlane/metadata/android/es-ES/changelogs/1.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/10.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/11.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/12.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/13.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/14.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/15.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/16.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/17.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/18.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/19.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/2.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/20.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/21.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/3.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/4.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/6.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/7.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/8.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/9.txt create mode 100644 fastlane/metadata/android/es-ES/full_description.txt create mode 100644 fastlane/metadata/android/es-ES/short_description.txt create mode 100644 fastlane/metadata/android/es-ES/title.txt diff --git a/fastlane/metadata/android/es-ES/changelogs/1.txt b/fastlane/metadata/android/es-ES/changelogs/1.txt new file mode 100644 index 0000000..5cda521 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/1.txt @@ -0,0 +1 @@ +Lanzamiento inicial diff --git a/fastlane/metadata/android/es-ES/changelogs/10.txt b/fastlane/metadata/android/es-ES/changelogs/10.txt new file mode 100644 index 0000000..0f3645f --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/10.txt @@ -0,0 +1,3 @@ +- Se corrigió el flujo de la experiencia de usuario para la solicitud de permisos de root. +- Se agregó un banner de requisitos de permisos. +- Se agregó información contextual para la configuración de cambio de nombre de la etiqueta de la aplicación. diff --git a/fastlane/metadata/android/es-ES/changelogs/11.txt b/fastlane/metadata/android/es-ES/changelogs/11.txt new file mode 100644 index 0000000..af05901 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/11.txt @@ -0,0 +1,6 @@ +- Se agregó el permiso INTERNET para realizar pruebas de ping y verificar la accesibilidad de DNS personalizadas. +- Se agregó un botón de activación/desactivación para proveedores de DNS estrictos. +- Se actualizaron los servicios en segundo plano para que sean compatibles con A14. +- Se evitó perdida de ajustes de la lista de bloqueo al cambiar los ajustes del dispositivo. +- Se corrigió el exceso de notificaciones al desconectarse y volver a conectarse con frecuencia a una red Wi-Fi. +-Más... diff --git a/fastlane/metadata/android/es-ES/changelogs/12.txt b/fastlane/metadata/android/es-ES/changelogs/12.txt new file mode 100644 index 0000000..566d1d4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/12.txt @@ -0,0 +1,2 @@ +- Se movió la carpeta «fastlane» a la raíz del proyecto +- Se actualizó la explicación sobre el acceso a la ubicación (opcional) «en todo momento». diff --git a/fastlane/metadata/android/es-ES/changelogs/13.txt b/fastlane/metadata/android/es-ES/changelogs/13.txt new file mode 100644 index 0000000..f29ea3f --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/13.txt @@ -0,0 +1,2 @@ +- Se redujo el tamaño del APK mediante la minificación con R8 y la compresión DEX tradicional. +- Se realizaron mejoras en el mantenimiento de la compilación y en la seguridad. diff --git a/fastlane/metadata/android/es-ES/changelogs/14.txt b/fastlane/metadata/android/es-ES/changelogs/14.txt new file mode 100644 index 0000000..0fea7e4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/14.txt @@ -0,0 +1,7 @@ +- Soporte para múltiples hostnames DNS con cambio rápido (manteniendo presionado el mosaico de Ajustes Rápidos). +- El mosaico muestra la etiqueta del DNS activo. +- Opción para usar DNS privado con VPN activa. +- Interruptor para desactivar notificaciones emergentes. +- Información de la app y enlaces en la pantalla principal. +- Rediseño de la interfaz a Material 3. +- Optimizaciones generales de código. diff --git a/fastlane/metadata/android/es-ES/changelogs/15.txt b/fastlane/metadata/android/es-ES/changelogs/15.txt new file mode 100644 index 0000000..bf723c3 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/15.txt @@ -0,0 +1,4 @@ +- Se agregaron etiquetas opcionales a las entradas de nombres de host de DNS. +- Ahora se pueden reordenar manualmente los nombres de host de DNS. +- Se agregó un mosaico oculto de depuración USB. +- Se realizaron algunas optimizaciones de rendimiento. diff --git a/fastlane/metadata/android/es-ES/changelogs/16.txt b/fastlane/metadata/android/es-ES/changelogs/16.txt new file mode 100644 index 0000000..26ed10e --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/16.txt @@ -0,0 +1,2 @@ +- Se agregaron opciones de configuración para la copia de seguridad e importación con almacenamiento cifrado con contraseña. +- Se incorporó compatibilidad con los archivos de copia de seguridad de .dnstoggle. diff --git a/fastlane/metadata/android/es-ES/changelogs/17.txt b/fastlane/metadata/android/es-ES/changelogs/17.txt new file mode 100644 index 0000000..a9cc54a --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/17.txt @@ -0,0 +1,4 @@ +- Opción para bloquear automáticamente el SSID cuando la conexión de DNS privado falla repetidamente. +- Compatibilidad con Shizuku para la concesión de permisos. +- Se muestra el formulario de DNS privado en la configuración del sistema utilizando un marcador de posición visual para que se pueda guardar fácilmente. +- Se corrigieron problemas con el interruptor de depuración USB, las copias de seguridad de dispositivos y las restauraciones de red. diff --git a/fastlane/metadata/android/es-ES/changelogs/18.txt b/fastlane/metadata/android/es-ES/changelogs/18.txt new file mode 100644 index 0000000..ecaea1b --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/18.txt @@ -0,0 +1 @@ +- Se actualizó la descripción completa de Fastlane para utilizar HTML en lugar de Markdown diff --git a/fastlane/metadata/android/es-ES/changelogs/19.txt b/fastlane/metadata/android/es-ES/changelogs/19.txt new file mode 100644 index 0000000..70cb367 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/19.txt @@ -0,0 +1,5 @@ +- Se simplificó el proceso de configuración inicial de permisos +- Se agregó un indicador de carga al otorgar permisos a través de Root o Shizuku +- Se hizo que la detección automática de Wi-Fi sea mucho más rápida y confiable en redes restrictivas +- Se actualizó la información de la ficha de la tienda, las capturas de pantalla y el seguimiento de traducciones +- Reestructuración interna del código y optimizaciones de rendimiento diff --git a/fastlane/metadata/android/es-ES/changelogs/2.txt b/fastlane/metadata/android/es-ES/changelogs/2.txt new file mode 100644 index 0000000..6adadc2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/2.txt @@ -0,0 +1 @@ +Se eliminó una dependencia que no se utilizaba diff --git a/fastlane/metadata/android/es-ES/changelogs/20.txt b/fastlane/metadata/android/es-ES/changelogs/20.txt new file mode 100644 index 0000000..6fcbd50 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/20.txt @@ -0,0 +1,5 @@ +- Se agregó compatibilidad con Android TV +- Se optimizó la interfaz para pantallas grandes +- Se introdujeron accesos directos a aplicaciones para activarlas y seleccionarlas rápidamente desde la pantalla de inicio +- Se mejoró la navegación con el D-Pad +- Traducción al alemán en proceso (gracias, Menuel) diff --git a/fastlane/metadata/android/es-ES/changelogs/21.txt b/fastlane/metadata/android/es-ES/changelogs/21.txt new file mode 100644 index 0000000..fae9641 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/21.txt @@ -0,0 +1,4 @@ +- Correcciones en la interfaz de usuario y mejora del desplazamiento en Android TV +- Se agregó la sección «Novedades» para mostrar los cambios más recientes +- Se corrigió la visualización de los colores en dispositivos y televisores más antiguos +- Traducción al turco en proceso (gracias, Kyo-sama) diff --git a/fastlane/metadata/android/es-ES/changelogs/3.txt b/fastlane/metadata/android/es-ES/changelogs/3.txt new file mode 100644 index 0000000..eae5264 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/3.txt @@ -0,0 +1 @@ +Se trasladaron los metadatos de Fastlane a la ubicación correcta diff --git a/fastlane/metadata/android/es-ES/changelogs/4.txt b/fastlane/metadata/android/es-ES/changelogs/4.txt new file mode 100644 index 0000000..f0e21e7 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4.txt @@ -0,0 +1 @@ +Se cambió a JDK 21 para las compilaciones automatizadas diff --git a/fastlane/metadata/android/es-ES/changelogs/5.txt b/fastlane/metadata/android/es-ES/changelogs/5.txt new file mode 100644 index 0000000..3701174 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5.txt @@ -0,0 +1 @@ +Me aseguré de que zipalign formara parte del proceso de compilación diff --git a/fastlane/metadata/android/es-ES/changelogs/6.txt b/fastlane/metadata/android/es-ES/changelogs/6.txt new file mode 100644 index 0000000..606bdb9 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/6.txt @@ -0,0 +1 @@ +Metadatos de dependencia desactivados diff --git a/fastlane/metadata/android/es-ES/changelogs/7.txt b/fastlane/metadata/android/es-ES/changelogs/7.txt new file mode 100644 index 0000000..9f420ce --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/7.txt @@ -0,0 +1,6 @@ +- Se agregó el estado actual a la descripción del mosaico de Ajustes rápidos. +- Se agregó una lista de bloqueo de Wi-Fi para la activación automática del DNS privado. +- Se agregó la opción de actualización automática de la lista de bloqueo a través de Ajustes rápidos. +- Se agregaron notificaciones de estado para los ajustes automáticos de DNS. +- Se agregó un monitoreo en segundo plano que ahorra batería. +- Se habilitó la copia de seguridad automática de la configuración y las listas de bloqueo. diff --git a/fastlane/metadata/android/es-ES/changelogs/8.txt b/fastlane/metadata/android/es-ES/changelogs/8.txt new file mode 100644 index 0000000..2eaac90 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/8.txt @@ -0,0 +1,4 @@ +- Se agregó un mensaje solicitando los permisos `WRITE_SECURE_SETTINGS` en dispositivos con acceso root. +- Se agregó un botón para copiar el comando adb al portapapeles, lo que permite otorgar fácilmente el permiso `WRITE_SECURE_SETTINGS` sin necesidad de acceso root. +- Se restauró el ícono de la aplicación en el lanzador. +- Se agregó un interruptor para ocultar el ícono de la aplicación en el lanzador. diff --git a/fastlane/metadata/android/es-ES/changelogs/9.txt b/fastlane/metadata/android/es-ES/changelogs/9.txt new file mode 100644 index 0000000..204e4fc --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/9.txt @@ -0,0 +1,3 @@ +- Ahora los SSID de Wi-Fi y la configuración de las aplicaciones se almacenan de forma encriptada. +- Se han agregado advertencias claras para direcciones DNS vacías o inválidas. +- Mejoras en el rendimiento. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt new file mode 100644 index 0000000..22b75a4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -0,0 +1 @@ +

DNS Toggle es una pequeña aplicación de Android que te permite activar y automatizar fácilmente el DNS privado de tu teléfono a través del panel de Ajustes rápidos.

IMPORTANTE:
Para modificar la configuración del sistema DNS privado, esta aplicación requiere el permiso WRITE_SECURE_SETTINGS. Debes tener un dispositivo rooteado, usar Shizuku o concederlo manualmente mediante ADB:
adb shell pm grant com.ericlowry.dnstoggle android.permission.WRITE_SECURE_SETTINGS

Características

  • Mosaico de Ajustes Rápidos: Activa/desactiva el DNS privado directamente desde el panel de Ajustes Rápidos.
  • Proveedores DNS Personalizados: Define uno o más nombres de host preferidos (por ejemplo, dns.nextdns.io) y cambia entre ellos sin problemas.
  • Filtrado Wi-Fi: Desactiva automáticamente el DNS privado cuando te conectas a redes Wi-Fi específicas.
  • Bloqueo Automático: Añade automáticamente a la lista negra el SSID Wi-Fi actual cuando el DNS privado causa problemas de conectividad. problemas.
  • Anulación de VPN: Aplique automáticamente un nombre de host DNS específico o desactive completamente el DNS privado cuando una VPN esté activa en su dispositivo.
  • Copia de seguridad y restauración: Exporte e importe su configuración mediante archivos cifrados con contraseña.
  • Etiquetado dinámico de mosaicos: Cambie el nombre del mosaico de Configuración rápida.
  • Ocultar icono de la aplicación: Mantenga su pantalla de inicio limpia mientras mantiene el acceso a través del mosaico de Configuración rápida.

Privacidad y permisos

  • Sin seguimiento, sin anuncios y sin recopilación de datos.
  • Los permisos de ubicación son opcionales y solo se utilizan para identificar los nombres de las redes Wi-Fi para la función de filtro SSID. Nunca se utilizan para rastrear su ubicación física.
diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt new file mode 100644 index 0000000..8557b5d --- /dev/null +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -0,0 +1 @@ +Activa y desactiva fácilmente el DNS privado a través del panel de Ajustes. diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt new file mode 100644 index 0000000..24ea4a1 --- /dev/null +++ b/fastlane/metadata/android/es-ES/title.txt @@ -0,0 +1 @@ +DNS Toggle From e136081223de921c3d9c752e4e9fcd578a36274b Mon Sep 17 00:00:00 2001 From: XieyilinMain Date: Sun, 26 Jul 2026 09:10:37 +0200 Subject: [PATCH 05/23] chore(l10n): add Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-zh-rCN/strings.xml diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From b6033b6d726f9d4b570eadb6c0568b79a974bb22 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Sun, 26 Jul 2026 14:58:10 +0200 Subject: [PATCH 06/23] chore(l10n): update French translation Translation: ELowry/DNSToggle Metadata Language: French Progress: 100.0% (24 of 24 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-metadata/fr/ --- fastlane/metadata/android/fr-FR/title.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 fastlane/metadata/android/fr-FR/title.txt diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt deleted file mode 100644 index 24ea4a1..0000000 --- a/fastlane/metadata/android/fr-FR/title.txt +++ /dev/null @@ -1 +0,0 @@ -DNS Toggle From b535ecd9fc1103e772e0a69bca54b11bd31198e2 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Sun, 26 Jul 2026 14:58:03 +0200 Subject: [PATCH 07/23] chore(l10n): update Spanish translation Translation: ELowry/DNSToggle Metadata Language: Spanish Progress: 100.0% (24 of 24 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-metadata/es/ --- fastlane/metadata/android/es-ES/title.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 fastlane/metadata/android/es-ES/title.txt diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt deleted file mode 100644 index 24ea4a1..0000000 --- a/fastlane/metadata/android/es-ES/title.txt +++ /dev/null @@ -1 +0,0 @@ -DNS Toggle From a33f41c958055fef73257d8dafae74db2cc72229 Mon Sep 17 00:00:00 2001 From: XieyilinMain Date: Sun, 26 Jul 2026 09:55:20 +0200 Subject: [PATCH 08/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Progress: 32.8% (43 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 44 +++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55344e5..37491b5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,3 +1,45 @@ - \ No newline at end of file + 应用版本:%s + GitHub(源代码) + 获取支持 + 确定 + 取消 + 关闭 + 设置 + + + 自动(关) + 更多选项 + 权限与ADB设置 + 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: + ADB命令 + ADB 命令:%s + 复制ADB命令 + 已将命令复制到剪贴板 + 通过Shizuku授予 + 通过Root授予 + 尝试授予权限 + 尝试授予Shizuku… + 尝试授予Root… + 尝试自动授予权限… + Shizuku/Root 授权失败或被拒绝 + 已授予权限 + 需要系统设置权限 + 本应用需要特殊权限来修改专用DNS设置。 + 解决 + 打开设置 + 专用DNS + 跳过连接测试 + 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 + 自定义DNS提供商 + 添加主机名 + 编辑主机名 + DNS主机名(如 dns.nextdns.io) + 标签(可选) + 删除此主机名? + 无法启用专用DNS,因为主机地址是空的 + DNS主机地址格式无效 + 正在测试连接… + 当前项目(未保存) + From ee34d5551b3352b5be114d3c66387c08b1b9dd49 Mon Sep 17 00:00:00 2001 From: XieyilinMain Date: Mon, 27 Jul 2026 02:33:27 +0200 Subject: [PATCH 09/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Progress: 39.6% (52 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 37491b5..c5279c4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -42,4 +42,7 @@ DNS主机地址格式无效 正在测试连接… 当前项目(未保存) + 新版特性 + 活动且可连接 + DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 From 0653fa85fe278cae2a2cce031c2ab78c1cf6dc77 Mon Sep 17 00:00:00 2001 From: reducedradius <137701630+flytothehighest@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:47:51 +0200 Subject: [PATCH 10/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Progress: 39.6% (52 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c5279c4..75a8365 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -45,4 +45,10 @@ 新版特性 活动且可连接 DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 + v%s 的新功能 + 自定义… + 输入一个位于5-300秒之间的值 + %ds + 选项 + 显示包含状态的toast通知 From 8852a40012e31d38113d5d07d6718488ba19d1c2 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Mon, 27 Jul 2026 01:55:42 +0200 Subject: [PATCH 11/23] v2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced SSID blacklist feature with Wi-Fi profiles - General UI and code cleanup - Fixed an issue that required extra taps for toggles - Fixed issue with scrolling on TVs - Added translations for Spanish (thanks Allyson Simón), WIP Chinese Han (thanks XieyilinMain), and WIP Japanese (thanks Oさん) --- .github/workflows/release.yml | 1 + README.md | 30 +- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 2 +- .../dnstoggle/DnsToggleApplication.kt | 79 ++- .../ericlowry/dnstoggle/data/BackupConfig.kt | 16 + .../com/ericlowry/dnstoggle/data/Constants.kt | 18 +- .../ericlowry/dnstoggle/data/DnsHostname.kt | 38 +- .../ericlowry/dnstoggle/data/DnsManager.kt | 159 +++--- .../dnstoggle/data/DnsSettingsRepository.kt | 506 ------------------ .../ericlowry/dnstoggle/data/DnsViewModel.kt | 211 +++++--- .../dnstoggle/data/NetworkProfile.kt | 17 + .../com/ericlowry/dnstoggle/data/SsidItem.kt | 6 - .../data/repository/DnsSettingsRepository.kt | 100 ++++ .../data/repository/HostnameRepository.kt | 204 +++++++ .../repository/NetworkProfileRepository.kt | 228 ++++++++ .../data/repository/SecurityRepository.kt | 53 ++ .../data/repository/VpnRepository.kt | 80 +++ .../service/ConnectivityWatchdogManager.kt | 147 +++++ .../dnstoggle/service/DnsToggleService.kt | 18 +- .../dnstoggle/service/TileServiceCompat.kt | 23 +- .../service/UsbDebuggingTileService.kt | 67 +-- .../service/WifiMonitoringService.kt | 450 ++++++++-------- .../dnstoggle/ui/DnsSelectionActivity.kt | 225 +++++++- .../ericlowry/dnstoggle/ui/MainActivity.kt | 126 +++-- .../dnstoggle/ui/MainPermissionHandler.kt | 13 +- .../ericlowry/dnstoggle/ui/ToggleActivity.kt | 7 +- .../dnstoggle/ui/adapter/HostnamesAdapter.kt | 32 +- .../ui/adapter/SsidTouchHelperCallback.kt | 70 +++ .../dnstoggle/ui/adapter/SsidsAdapter.kt | 141 +++-- .../ui/controller/BackupController.kt | 10 +- .../ui/controller/DnsSectionController.kt | 11 +- .../ui/controller/MiscSettingsController.kt | 9 +- .../ui/controller/SsidSectionController.kt | 142 +++-- .../ui/controller/VpnSectionController.kt | 9 +- .../dnstoggle/ui/dialog/DnsDialogHelper.kt | 6 +- .../dnstoggle/ui/dialog/SsidDialogHelper.kt | 183 ++++++- .../dnstoggle/ui/dialog/_InfoNoticeHelper.kt | 131 +++++ .../dnstoggle/util/EncryptionManager.kt | 5 +- .../ericlowry/dnstoggle/util/NetworkUtils.kt | 18 +- app/src/main/res/drawable/bg_pill_badge.xml | 5 + app/src/main/res/drawable/ic_ellipsis.xml | 12 - app/src/main/res/drawable/ic_info_i.xml | 12 + app/src/main/res/drawable/ic_question.xml | 12 + app/src/main/res/layout/_temp_dialog_info.xml | 93 ++++ app/src/main/res/layout/activity_main.xml | 14 +- app/src/main/res/layout/card_dns_settings.xml | 54 +- app/src/main/res/layout/card_vpn_settings.xml | 6 +- ...d_blacklist.xml => card_wifi_profiles.xml} | 93 +++- .../main/res/layout/dialog_dns_selection.xml | 18 +- .../main/res/layout/item_dns_selection.xml | 33 +- app/src/main/res/layout/item_hostname.xml | 34 +- app/src/main/res/layout/item_ssid.xml | 9 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 257 +++++---- app/src/main/res/values-tr/strings.xml | 12 +- app/src/main/res/values/strings.xml | 59 +- .../com/ericlowry/dnstoggle/DnsManagerTest.kt | 19 +- .../dnstoggle/DnsSettingsRepositoryTest.kt | 101 ++-- build.gradle.kts | 1 + .../metadata/android/en-US/changelogs/21.txt | 2 +- .../metadata/android/en-US/changelogs/22.txt | 5 + gradle/libs.versions.toml | 3 + 64 files changed, 2965 insertions(+), 1493 deletions(-) create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/BackupConfig.kt delete mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/DnsSettingsRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/NetworkProfile.kt delete mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/SsidItem.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/repository/DnsSettingsRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/repository/HostnameRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/repository/NetworkProfileRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/repository/SecurityRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/data/repository/VpnRepository.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/service/ConnectivityWatchdogManager.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidTouchHelperCallback.kt create mode 100644 app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/_InfoNoticeHelper.kt create mode 100644 app/src/main/res/drawable/bg_pill_badge.xml delete mode 100644 app/src/main/res/drawable/ic_ellipsis.xml create mode 100644 app/src/main/res/drawable/ic_info_i.xml create mode 100644 app/src/main/res/drawable/ic_question.xml create mode 100644 app/src/main/res/layout/_temp_dialog_info.xml rename app/src/main/res/layout/{card_ssid_blacklist.xml => card_wifi_profiles.xml} (78%) create mode 100644 fastlane/metadata/android/en-US/changelogs/22.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75eaf2e..b6bb4e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index cc4cd2c..aa1075f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 521733c..2ce2e44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlinx.serialization.plugin) } abstract class CopyChangelogsTask @Inject constructor( @@ -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" } @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf26f7e..c2918ce 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -199,7 +199,7 @@ android:foregroundServiceType="specialUse"> + android:value="Monitoring Wi-Fi network connectivity to automate Private DNS mode toggling based on user-defined Wi-Fi profiles." /> - 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 ) { @@ -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() @@ -139,8 +148,8 @@ class DnsToggleApplication : Application() { ) applicationScope.launch { - DnsSettingsRepository.blacklist.collect { blacklist -> - if (blacklist != null) { + NetworkProfileRepository.networkProfiles.collect { profiles -> + if (profiles != null) { updateWifiMonitoringRegistration() } } @@ -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) @@ -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 } } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/BackupConfig.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/BackupConfig.kt new file mode 100644 index 0000000..28bf4a2 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/BackupConfig.kt @@ -0,0 +1,16 @@ +package com.ericlowry.dnstoggle.data + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupConfig( + val hostnames: List = emptyList(), + val networkProfiles: List = 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 +) diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/Constants.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/Constants.kt index dbca58b..082bb83 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/Constants.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/Constants.kt @@ -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" @@ -26,7 +31,6 @@ 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" @@ -34,8 +38,6 @@ object Constants { // 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" @@ -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" } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsHostname.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsHostname.kt index c70aaf7..21883c6 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsHostname.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsHostname.kt @@ -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) - } - } - } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsManager.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsManager.kt index 8f9e8df..0c5fe41 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsManager.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsManager.kt @@ -9,6 +9,9 @@ import android.widget.Toast import androidx.core.content.edit import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R +import com.ericlowry.dnstoggle.data.repository.HostnameRepository +import com.ericlowry.dnstoggle.data.repository.NetworkProfileRepository +import com.ericlowry.dnstoggle.data.repository.VpnRepository import com.ericlowry.dnstoggle.util.EncryptionManager import com.ericlowry.dnstoggle.util.NetworkUtils import com.ericlowry.dnstoggle.util.NotificationUtils @@ -28,7 +31,8 @@ object DnsManager { enabled: Boolean, targetHostname: String? = null, isInteractiveMainUi: Boolean = false, - forceFeedback: Boolean = false + forceFeedback: Boolean = false, + isFromTile: Boolean = false ): ToggleResult { val resolver = context.contentResolver val app = context.applicationContext as DnsToggleApplication @@ -55,7 +59,6 @@ object DnsManager { } } if (effectiveHostname.isNullOrEmpty()) { - // Try current system specifier effectiveHostname = Settings.Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) } @@ -70,54 +73,75 @@ object DnsManager { val newMode = if (enabled) Constants.DNS_MODE_HOSTNAME else Constants.DNS_MODE_OPPORTUNISTIC val currentSsid = NetworkUtils.getCurrentWifiSsid(context) + val isInVpn = sharedPreferences.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) - if (sharedPreferences.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false)) { + if (isInVpn) { if (enabled) { - effectiveHostname?.let { hostname -> - DnsSettingsRepository.updateVpnDnsHostname(hostname) - } + effectiveHostname?.let { hostname -> VpnRepository.updateVpnDnsHostname(hostname) } } else { - DnsSettingsRepository.updateVpnDnsHostname(null) + VpnRepository.updateVpnDnsHostname(null) } } - var handledByAuto = false - - if (currentSsid != null) { - if (!enabled && sharedPreferences.getBoolean(Constants.PREF_AUTO_BLACKLIST, false)) { - DnsSettingsRepository.addToBlacklist(currentSsid) - if (forceFeedback || sharedPreferences.getBoolean( - Constants.PREF_SHOW_TOAST, - true - ) - ) { - NotificationUtils.showStatusNotification( - context, - context.getString(R.string.notif_ssid_added, currentSsid) + try { + if (enabled) { + effectiveHostname?.let { hostname -> + Settings.Global.putString( + resolver, + Constants.SETTINGS_PRIVATE_DNS_SPECIFIER, + hostname ) } - handledByAuto = true - } else if (enabled && sharedPreferences.getBoolean( - Constants.PREF_AUTO_WHITELIST, - false + } + Settings.Global.putString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE, newMode) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to update private DNS mode", e) + return ToggleResult.PermissionRequired + } catch (e: Exception) { + Log.e(TAG, "Unexpected error updating private DNS mode", e) + return ToggleResult.Error + } + + var handledByProfile = false + if (currentSsid != null && isFromTile && !isInVpn) { + val autoSaveState = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_STATE, false) + val autoSaveHost = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_HOST, false) + + val isExplicitHostChange = targetHostname != null + val shouldInterceptForProfile = + if (isExplicitHostChange) autoSaveHost else autoSaveState + + if (shouldInterceptForProfile) { + val targetHost = if (enabled && autoSaveHost) effectiveHostname else null + val existingProfile = + NetworkProfileRepository.networkProfiles.value?.find { it.ssid == currentSsid } + + NetworkProfileRepository.upsertNetworkProfile( + ssid = currentSsid, + isEnabled = enabled, + targetHostname = targetHost, + isAutoDetected = false, + isUnsaved = existingProfile == null, + preserveExistingHostname = !enabled ) - ) { - DnsSettingsRepository.removeFromBlacklist(currentSsid) + if (forceFeedback || sharedPreferences.getBoolean( Constants.PREF_SHOW_TOAST, true ) ) { + val messageRes = + if (enabled) R.string.notif_ssid_removed else R.string.notif_ssid_added NotificationUtils.showStatusNotification( context, - context.getString(R.string.notif_ssid_removed, currentSsid) + context.getString(messageRes, currentSsid) ) } - handledByAuto = true + handledByProfile = true } } - if (!handledByAuto) { + if (!handledByProfile && !isInVpn) { effectiveHostname?.let { hostname -> app.getEncryptedPrefs().edit { putString( @@ -126,67 +150,42 @@ object DnsManager { ) } } + sharedPreferences.edit { putString(Constants.PREF_PREFERRED_DNS_MODE, newMode) } } - return try { - if (enabled) { - effectiveHostname?.let { hostname -> - Settings.Global.putString( - resolver, - Constants.SETTINGS_PRIVATE_DNS_SPECIFIER, - hostname - ) - app.getEncryptedPrefs().edit { - putString( - Constants.PREF_LAST_USED_HOSTNAME, - EncryptionManager.encrypt(hostname) - ) - } - } - } - Settings.Global.putString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE, newMode) - if (!handledByAuto) { - sharedPreferences.edit { putString(Constants.PREF_PREFERRED_DNS_MODE, newMode) } - } - if (!isInteractiveMainUi && (forceFeedback || sharedPreferences.getBoolean( - Constants.PREF_SHOW_TOAST, - true - )) && !handledByAuto - ) { - Handler(Looper.getMainLooper()).post { - if (enabled) { - if (previousMode != Constants.DNS_MODE_HOSTNAME) { - Toast.makeText( - context, - "${context.getString(R.string.private_dns)}: ${context.getString(R.string.on_label)}", - Toast.LENGTH_SHORT - ).show() - } else if (previousHostname != effectiveHostname) { - val displayName = DnsSettingsRepository.dnsHostnames.value - ?.find { it.hostname == effectiveHostname } + if (!isInteractiveMainUi && (forceFeedback || sharedPreferences.getBoolean( + Constants.PREF_SHOW_TOAST, + true + )) && !handledByProfile + ) { + Handler(Looper.getMainLooper()).post { + if (enabled) { + if (previousMode != Constants.DNS_MODE_HOSTNAME) { + Toast.makeText( + context, + "${context.getString(R.string.private_dns)}: ${context.getString(R.string.on_label)}", + Toast.LENGTH_SHORT + ).show() + } else if (previousHostname != effectiveHostname) { + val displayName = + HostnameRepository.dnsHostnames.value?.find { it.hostname == effectiveHostname } ?.getDisplayName() ?: effectiveHostname - Toast.makeText( - context, - context.getString(R.string.toast_dns_changed, displayName), - Toast.LENGTH_SHORT - ).show() - } - } else if (previousMode == Constants.DNS_MODE_HOSTNAME) { Toast.makeText( context, - "${context.getString(R.string.private_dns)}: ${context.getString(R.string.off_label)}", + context.getString(R.string.toast_dns_changed, displayName), Toast.LENGTH_SHORT ).show() } + } else if (previousMode == Constants.DNS_MODE_HOSTNAME) { + Toast.makeText( + context, + "${context.getString(R.string.private_dns)}: ${context.getString(R.string.off_label)}", + Toast.LENGTH_SHORT + ).show() } } - ToggleResult.Success - } catch (e: SecurityException) { - Log.e(TAG, "Failed to update private DNS mode", e) - ToggleResult.PermissionRequired - } catch (e: Exception) { - Log.e(TAG, "Unexpected error updating private DNS mode", e) - ToggleResult.Error } + + return ToggleResult.Success } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsSettingsRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsSettingsRepository.kt deleted file mode 100644 index 3d8eec7..0000000 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsSettingsRepository.kt +++ /dev/null @@ -1,506 +0,0 @@ -package com.ericlowry.dnstoggle.data - -import android.content.Context -import android.content.SharedPreferences -import android.util.Log -import androidx.core.content.edit -import com.ericlowry.dnstoggle.DnsToggleApplication -import com.ericlowry.dnstoggle.util.EncryptionManager -import com.ericlowry.dnstoggle.util.NetworkUtils -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.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.json.JSONArray -import org.json.JSONObject - -object DnsSettingsRepository { - private lateinit var app: DnsToggleApplication - private lateinit var sharedPreferences: SharedPreferences - private lateinit var encryptedPrefs: SharedPreferences - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private val _blacklist = MutableStateFlow?>(null) - val blacklist: StateFlow?> = _blacklist.asStateFlow() - - private val _autoDetectedBlacklist = MutableStateFlow?>(null) - val autoDetectedBlacklist: StateFlow?> = _autoDetectedBlacklist.asStateFlow() - - private val _dnsHostnames = MutableStateFlow?>(null) - val dnsHostnames: StateFlow?> = _dnsHostnames.asStateFlow() - - private val _isKeyInvalidated = MutableStateFlow(false) - val isKeyInvalidated: StateFlow = _isKeyInvalidated.asStateFlow() - - private val _vpnOverrideEnabled = MutableStateFlow(false) - val vpnOverrideEnabled: StateFlow = _vpnOverrideEnabled.asStateFlow() - - private val _vpnDnsHostname = MutableStateFlow(null) - val vpnDnsHostname: StateFlow = _vpnDnsHostname.asStateFlow() - - fun initialize(context: Context) { - if (::app.isInitialized) return - app = context.applicationContext as DnsToggleApplication - sharedPreferences = app.getPrefs() - encryptedPrefs = app.getEncryptedPrefs() - - // Migration Block: Move encrypted keys from standard to encrypted prefs - val keysToMigrate = listOf( - Constants.PREF_DNS_HOSTNAMES, - Constants.PREF_SSID_BLACKLIST, - Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST, - Constants.PREF_LAST_USED_HOSTNAME, - Constants.PREF_VPN_DNS_HOSTNAME - ) - - sharedPreferences.all.forEach { (key, value) -> - if (key in keysToMigrate) { - encryptedPrefs.edit { - when (value) { - is String -> putString(key, value) - is Set<*> -> @Suppress("UNCHECKED_CAST") putStringSet( - key, - value as Set - ) - } - } - sharedPreferences.edit { remove(key) } - } - } - - loadBlacklist() - loadAutoDetectedBlacklist() - loadHostnames() - _vpnOverrideEnabled.value = - sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) - - scope.launch { - val encryptedVpnHostname = - encryptedPrefs.getString(Constants.PREF_VPN_DNS_HOSTNAME, null) - _vpnDnsHostname.value = if (encryptedVpnHostname == null) { - null - } else { - when (val result = EncryptionManager.decrypt(encryptedVpnHostname)) { - is EncryptionManager.DecryptResult.Success -> { - if (result.data == "off") { - // Migration from app versions <1.6 - encryptedPrefs.edit { remove(Constants.PREF_VPN_DNS_HOSTNAME) } - null - } else { - result.data - } - } - - is EncryptionManager.DecryptResult.KeyInvalidated -> { - _isKeyInvalidated.value = true - null - } - - else -> null - } - } - } - } - - private fun loadBlacklist() { - scope.launch { - val encryptedSet = - encryptedPrefs.getStringSet(Constants.PREF_SSID_BLACKLIST, emptySet()) - ?: emptySet() - - var keyInvalidated = false - val decryptedSet = encryptedSet.mapNotNull { - when (val result = EncryptionManager.decrypt(it)) { - is EncryptionManager.DecryptResult.Success -> result.data - is EncryptionManager.DecryptResult.KeyInvalidated -> { - keyInvalidated = true - null - } - - else -> null - } - }.toSet() - - if (keyInvalidated) { - _isKeyInvalidated.value = true - encryptedPrefs.edit { remove(Constants.PREF_SSID_BLACKLIST) } - } - _blacklist.value = decryptedSet - } - } - - private fun loadAutoDetectedBlacklist() { - scope.launch { - val encryptedSet = - encryptedPrefs.getStringSet( - Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST, - emptySet() - ) - ?: emptySet() - - var keyInvalidated = false - val decryptedSet = encryptedSet.mapNotNull { - when (val result = EncryptionManager.decrypt(it)) { - is EncryptionManager.DecryptResult.Success -> result.data - is EncryptionManager.DecryptResult.KeyInvalidated -> { - keyInvalidated = true - null - } - - else -> null - } - }.toSet() - - if (keyInvalidated) { - _isKeyInvalidated.value = true - encryptedPrefs.edit { remove(Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST) } - } - _autoDetectedBlacklist.value = decryptedSet - } - } - - private fun loadHostnames() { - scope.launch { - val rawData = encryptedPrefs.all[Constants.PREF_DNS_HOSTNAMES] - - var keyInvalidated = false - val resultList = mutableListOf() - - if (rawData is Set<*>) { - // Legacy StringSet migration - @Suppress("UNCHECKED_CAST") - val encryptedSet = rawData as? Set ?: emptySet() - encryptedSet.forEach { - when (val result = EncryptionManager.decrypt(it)) { - is EncryptionManager.DecryptResult.Success -> { - resultList.add(DnsHostname.fromSerializedString(result.data)) - } - - is EncryptionManager.DecryptResult.KeyInvalidated -> keyInvalidated = true - else -> {} - } - } - // Initial sort for legacy data - resultList.sortWith { a, b -> - String.CASE_INSENSITIVE_ORDER.compare( - a.getDisplayName(), - b.getDisplayName() - ) - } - if (resultList.isNotEmpty() && !keyInvalidated) { - saveHostnamesAsync(resultList) - } - } else if (rawData is String) { - // New JSON format - when (val result = EncryptionManager.decrypt(rawData)) { - is EncryptionManager.DecryptResult.Success -> { - try { - val jsonArray = JSONArray(result.data) - for (i in 0 until jsonArray.length()) { - resultList.add( - DnsHostname.fromSerializedString( - jsonArray.getString( - i - ) - ) - ) - } - } catch (e: Exception) { - Log.e("DnsSettingsRepository", "Failed to parse hostnames JSON", e) - } - } - - is EncryptionManager.DecryptResult.KeyInvalidated -> keyInvalidated = true - else -> {} - } - } - - if (keyInvalidated) { - _isKeyInvalidated.value = true - encryptedPrefs.edit { - remove(Constants.PREF_SSID_BLACKLIST) - remove(Constants.PREF_DNS_HOSTNAMES) - remove(Constants.PREF_LAST_USED_HOSTNAME) - } - } - - _dnsHostnames.value = resultList - } - } - - fun resetKeyInvalidated() { - _isKeyInvalidated.value = false - } - - fun promoteAutoDetectedSsid(ssid: String) { - _autoDetectedBlacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (ssid !in safeCurrent) return@update safeCurrent - val next = safeCurrent - ssid - saveAutoDetectedBlacklistAsync(next) - next - } - } - - fun addToBlacklist(ssid: String, autoDetected: Boolean = false) { - if (autoDetected) { - _autoDetectedBlacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (ssid in safeCurrent) return@update safeCurrent - val next = safeCurrent + ssid - saveAutoDetectedBlacklistAsync(next) - next - } - } - _blacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (ssid in safeCurrent) return@update safeCurrent - val next = safeCurrent + ssid - saveBlacklistAsync(next) - next - } - } - - fun removeFromBlacklist(ssid: String) { - _blacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (ssid !in safeCurrent) return@update safeCurrent - val next = safeCurrent - ssid - saveBlacklistAsync(next) - next - } - _autoDetectedBlacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (ssid !in safeCurrent) return@update safeCurrent - val next = safeCurrent - ssid - saveAutoDetectedBlacklistAsync(next) - next - } - } - - fun updateSsidInBlacklist(oldSsid: String, newSsid: String) { - _blacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (oldSsid !in safeCurrent) return@update safeCurrent - val next = safeCurrent - oldSsid + newSsid - saveBlacklistAsync(next) - next - } - _autoDetectedBlacklist.update { current -> - val safeCurrent = current ?: emptySet() - if (oldSsid !in safeCurrent) return@update safeCurrent - val next = safeCurrent - oldSsid - saveAutoDetectedBlacklistAsync(next) - next - } - } - - private fun saveBlacklistAsync(list: Set) { - scope.launch { - val encryptedSet = list.map { EncryptionManager.encrypt(it) }.toSet() - encryptedPrefs.edit { putStringSet(Constants.PREF_SSID_BLACKLIST, encryptedSet) } - } - } - - private fun saveAutoDetectedBlacklistAsync(list: Set) { - scope.launch { - val encryptedSet = list.map { EncryptionManager.encrypt(it) }.toSet() - encryptedPrefs.edit { - putStringSet(Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST, encryptedSet) - } - } - } - - fun addHostname(hostname: String, label: String? = null) { - _dnsHostnames.update { current -> - val safeCurrent = current ?: emptyList() - val newEntry = DnsHostname(hostname, label) - - val existingIndex = safeCurrent.indexOfFirst { it.hostname == hostname } - val next = if (existingIndex != -1) { - safeCurrent.mapIndexed { index, dnsHostname -> - if (index == existingIndex) newEntry else dnsHostname - } - } else { - listOf(newEntry) + safeCurrent - } - - saveHostnamesAsync(next) - next - } - } - - fun removeHostname(hostname: String) { - _dnsHostnames.update { current -> - val safeCurrent = current ?: emptyList() - if (safeCurrent.none { it.hostname == hostname }) return@update safeCurrent - val next = safeCurrent.filter { it.hostname != hostname } - - // Check if the removed hostname was being used for VPN override - if (_vpnDnsHostname.value == hostname) { - updateVpnDnsHostname(null) - sharedPreferences.edit { - putBoolean(Constants.PREF_VPN_HOSTNAME_REMOVED_WARNING, true) - } - } else if (next.size == 1 && _vpnDnsHostname.value != null) { - updateVpnDnsHostname(null) - } - - saveHostnamesAsync(next) - next - } - } - - fun updateHostname(oldHostname: String, newHostname: String, newLabel: String? = null) { - _dnsHostnames.update { current -> - val safeCurrent = current ?: emptyList() - val next = safeCurrent.map { - if (it.hostname == oldHostname) DnsHostname(newHostname, newLabel) else it - } - - if (_vpnDnsHostname.value == oldHostname) { - updateVpnDnsHostname(newHostname) - } - - saveHostnamesAsync(next) - next - } - } - - fun updateHostnamesOrder(newList: List) { - _dnsHostnames.value = newList - saveHostnamesAsync(newList) - } - - fun updateVpnOverrideEnabled(enabled: Boolean) { - _vpnOverrideEnabled.value = enabled - sharedPreferences.edit { putBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, enabled) } - } - - fun updateVpnDnsHostname(hostname: String?) { - _vpnDnsHostname.value = hostname - if (hostname == null) { - encryptedPrefs.edit { remove(Constants.PREF_VPN_DNS_HOSTNAME) } - } else { - val encrypted = EncryptionManager.encrypt(hostname) - encryptedPrefs.edit { putString(Constants.PREF_VPN_DNS_HOSTNAME, encrypted) } - } - } - - private fun saveHostnamesAsync(list: List) { - scope.launch { - val jsonArray = JSONArray() - list.forEach { jsonArray.put(it.toSerializedString()) } - val encrypted = EncryptionManager.encrypt(jsonArray.toString()) - encryptedPrefs.edit { putString(Constants.PREF_DNS_HOSTNAMES, encrypted) } - } - } - - fun exportConfigToJson(): String { - val json = JSONObject() - val hostnamesArray = JSONArray() - _dnsHostnames.value?.forEach { hostnamesArray.put(it.toSerializedString()) } - json.put("hostnames", hostnamesArray) - - val blacklistArray = JSONArray() - _blacklist.value?.forEach { blacklistArray.put(it) } - json.put("blacklist", blacklistArray) - - json.put( - "auto_blacklist", - sharedPreferences.getBoolean(Constants.PREF_AUTO_BLACKLIST, false) - ) - json.put( - "auto_whitelist", - sharedPreferences.getBoolean(Constants.PREF_AUTO_WHITELIST, false) - ) - json.put( - "vpn_override", - sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) - ) - json.put("vpn_dns", _vpnDnsHostname.value ?: "off") - json.put( - "hide_launcher_icon", - sharedPreferences.getBoolean(Constants.PREF_HIDE_LAUNCHER_ICON, false) - ) - json.put( - "disable_dns_test", - sharedPreferences.getBoolean(Constants.PREF_DISABLE_DNS_TEST, false) - ) - json.put("show_toast", sharedPreferences.getBoolean(Constants.PREF_SHOW_TOAST, false)) - - return json.toString() - } - - fun importConfigFromJson(jsonString: String): Boolean { - return try { - val json = JSONObject(jsonString) - - if (json.has("hostnames")) { - val hostnamesArray = json.getJSONArray("hostnames") - val importedHostnames = mutableListOf() - for (i in 0 until hostnamesArray.length()) { - val parsedEntry = DnsHostname.fromSerializedString(hostnamesArray.getString(i)) - if (NetworkUtils.isValidDnsHostname(parsedEntry.hostname)) { - importedHostnames.add(parsedEntry) - } - } - updateHostnamesOrder(importedHostnames) - } - - if (json.has("blacklist")) { - val blacklistArray = json.getJSONArray("blacklist") - val importedBlacklist = mutableSetOf() - for (i in 0 until blacklistArray.length()) { - importedBlacklist.add(blacklistArray.getString(i)) - } - scope.launch { - val encryptedSet = - importedBlacklist.map { EncryptionManager.encrypt(it) }.toSet() - encryptedPrefs.edit { - putStringSet( - Constants.PREF_SSID_BLACKLIST, - encryptedSet, - ) - } - _blacklist.value = importedBlacklist - } - } - - sharedPreferences.edit { - putBoolean(Constants.PREF_AUTO_BLACKLIST, json.optBoolean("auto_blacklist", false)) - putBoolean(Constants.PREF_AUTO_WHITELIST, json.optBoolean("auto_whitelist", false)) - putBoolean( - Constants.PREF_VPN_OVERRIDE_ENABLED, - json.optBoolean("vpn_override", false), - ) - putBoolean( - Constants.PREF_HIDE_LAUNCHER_ICON, - json.optBoolean("hide_launcher_icon", false), - ) - putBoolean( - Constants.PREF_DISABLE_DNS_TEST, - json.optBoolean("disable_dns_test", false), - ) - putBoolean(Constants.PREF_SHOW_TOAST, json.optBoolean("show_toast", false)) - } - _vpnOverrideEnabled.value = json.optBoolean("vpn_override", false) - - if (json.has("vpn_dns")) { - val parsedVpnDns = json.getString("vpn_dns") - if (parsedVpnDns == "off") { - updateVpnDnsHostname(null) - } else if (NetworkUtils.isValidDnsHostname(parsedVpnDns)) { - updateVpnDnsHostname(parsedVpnDns) - } - } - true - } catch (_: Exception) { - false - } - } -} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsViewModel.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsViewModel.kt index 17a4279..ae12b74 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/DnsViewModel.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/DnsViewModel.kt @@ -16,6 +16,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R +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.util.NetworkUtils @@ -25,6 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.InetSocketAddress import java.net.Socket +import java.util.concurrent.ConcurrentHashMap class DnsViewModel(application: Application) : AndroidViewModel(application) { @@ -48,22 +53,19 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { private val _hasPermissionError = MutableLiveData(false) val hasPermissionError: LiveData = _hasPermissionError - private val reachabilityJobs = mutableMapOf() + private val reachabilityJobs = ConcurrentHashMap() - private val _ssidBlacklist = MutableLiveData>() - val ssidBlacklist: LiveData> = _ssidBlacklist - - private val _autoDetectedBlacklist = MutableLiveData>() - val autoDetectedBlacklist: LiveData> = _autoDetectedBlacklist + private val _networkProfiles = MutableLiveData>() + val networkProfiles: LiveData> = _networkProfiles private val _dnsHostnames = MutableLiveData>() val dnsHostnames: LiveData> = _dnsHostnames - private val _autoBlacklistEnabled = MutableLiveData() - val autoBlacklistEnabled: LiveData = _autoBlacklistEnabled + private val _autoSaveStateEnabled = MutableLiveData() + val autoSaveStateEnabled: LiveData = _autoSaveStateEnabled - private val _autoWhitelistEnabled = MutableLiveData() - val autoWhitelistEnabled: LiveData = _autoWhitelistEnabled + private val _autoSaveHostEnabled = MutableLiveData() + val autoSaveHostEnabled: LiveData = _autoSaveHostEnabled private val _connectivityWatchdogEnabled = MutableLiveData() val connectivityWatchdogEnabled: LiveData = _connectivityWatchdogEnabled @@ -117,12 +119,12 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> when (key) { - Constants.PREF_AUTO_BLACKLIST -> { - _autoBlacklistEnabled.value = prefs.getBoolean(key, false) + Constants.PREF_AUTO_SAVE_STATE -> { + _autoSaveStateEnabled.value = prefs.getBoolean(key, false) } - Constants.PREF_AUTO_WHITELIST -> { - _autoWhitelistEnabled.value = prefs.getBoolean(key, false) + Constants.PREF_AUTO_SAVE_HOST -> { + _autoSaveHostEnabled.value = prefs.getBoolean(key, false) } Constants.PREF_HIDE_LAUNCHER_ICON -> { @@ -169,22 +171,17 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { init { loadSettings() viewModelScope.launch { - DnsSettingsRepository.blacklist.collect { list -> - list?.let { _ssidBlacklist.postValue(it) } + NetworkProfileRepository.networkProfiles.collect { list -> + list?.let { _networkProfiles.postValue(it) } } } viewModelScope.launch { - DnsSettingsRepository.autoDetectedBlacklist.collect { list -> - list?.let { _autoDetectedBlacklist.postValue(it) } - } - } - viewModelScope.launch { - DnsSettingsRepository.dnsHostnames.collect { + HostnameRepository.dnsHostnames.collect { refreshDisplayList() } } viewModelScope.launch { - DnsSettingsRepository.isKeyInvalidated.collect { invalidated -> + SecurityRepository.isKeyInvalidated.collect { invalidated -> if (invalidated) { _isKeyInvalidated.postValue(true) } @@ -213,8 +210,8 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { val specifier = Settings.Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) - val autoBlacklist = sharedPreferences.getBoolean(Constants.PREF_AUTO_BLACKLIST, false) - val autoWhitelist = sharedPreferences.getBoolean(Constants.PREF_AUTO_WHITELIST, false) + val autoSaveState = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_STATE, false) + val autoSaveHost = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_HOST, false) val connectivityWatchdog = sharedPreferences.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) val watchdogDebounce = sharedPreferences.getInt( @@ -230,8 +227,8 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { val disableDnsTest = sharedPreferences.getBoolean(Constants.PREF_DISABLE_DNS_TEST, false) val showToast = sharedPreferences.getBoolean(Constants.PREF_SHOW_TOAST, true) - val vpnOverride = DnsSettingsRepository.vpnOverrideEnabled.value - val vpnDns = DnsSettingsRepository.vpnDnsHostname.value + val vpnOverride = VpnRepository.vpnOverrideEnabled.value + val vpnDns = VpnRepository.vpnDnsHostname.value val vpnRemovedWarning = sharedPreferences.getBoolean(Constants.PREF_VPN_HOSTNAME_REMOVED_WARNING, false) val isInVpn = sharedPreferences.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) @@ -241,8 +238,8 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { withContext(Dispatchers.Main) { _privateDnsMode.value = mode _privateDnsSpecifier.value = specifier - _autoBlacklistEnabled.value = autoBlacklist - _autoWhitelistEnabled.value = autoWhitelist + _autoSaveStateEnabled.value = autoSaveState + _autoSaveHostEnabled.value = autoSaveHost _connectivityWatchdogEnabled.value = connectivityWatchdog _connectivityWatchdogDebounceSeconds.value = watchdogDebounce _connectivityWatchdogProbeTargets.value = watchdogTargets @@ -260,24 +257,54 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { } viewModelScope.launch { - DnsSettingsRepository.vpnOverrideEnabled.collect { + VpnRepository.vpnOverrideEnabled.collect { _vpnOverrideEnabled.postValue(it) } } viewModelScope.launch { - DnsSettingsRepository.vpnDnsHostname.collect { + VpnRepository.vpnDnsHostname.collect { _vpnDnsHostname.postValue(it) } } } - fun addToBlacklist(ssid: String) { - DnsSettingsRepository.addToBlacklist(ssid) + fun upsertNetworkProfile( + ssid: String, + isEnabled: Boolean, + targetHostname: String? = null, + isAutoDetected: Boolean = false, + isUnsaved: Boolean = false, + preserveExistingHostname: Boolean = false + ) { + NetworkProfileRepository.upsertNetworkProfile( + ssid, + isEnabled, + targetHostname, + isAutoDetected, + isUnsaved, + preserveExistingHostname + ) + } + + fun saveNetworkProfile( + oldSsid: String?, + newSsid: String, + isEnabled: Boolean, + targetHostname: String? + ) { + if (oldSsid != null && oldSsid != newSsid) { + NetworkProfileRepository.removeNetworkProfile(oldSsid) + } + NetworkProfileRepository.upsertNetworkProfile( + ssid = newSsid, + isEnabled = isEnabled, + targetHostname = targetHostname + ) } private fun refreshDisplayList() { - val savedHostnames = DnsSettingsRepository.dnsHostnames.value ?: emptyList() + val savedHostnames = HostnameRepository.dnsHostnames.value ?: emptyList() val currentSpecifier = _privateDnsSpecifier.value val currentMode = _privateDnsMode.value @@ -307,41 +334,41 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { } } - fun removeFromBlacklist(ssid: String) { - DnsSettingsRepository.removeFromBlacklist(ssid) + fun removeNetworkProfile(ssid: String) { + NetworkProfileRepository.removeNetworkProfile(ssid) } fun promoteSsidToPermanent(ssid: String) { - DnsSettingsRepository.promoteAutoDetectedSsid(ssid) - } - - fun updateSsidInBlacklist(oldSsid: String, newSsid: String) { - DnsSettingsRepository.updateSsidInBlacklist(oldSsid, newSsid) + NetworkProfileRepository.promoteUnsavedProfile(ssid) } fun addHostname(hostname: String, label: String? = null) { - DnsSettingsRepository.addHostname(hostname, label) + HostnameRepository.addHostname(hostname, label) if (NetworkUtils.isValidDnsHostname(hostname)) { testReachability(hostname) } } fun removeHostname(hostname: String) { - DnsSettingsRepository.removeHostname(hostname) + HostnameRepository.removeHostname(hostname) reachabilityJobs[hostname]?.cancel() reachabilityJobs.remove(hostname) - val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() - currentMap.remove(hostname) - _dnsReachability.postValue(currentMap) + viewModelScope.launch { + val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() + currentMap.remove(hostname) + _dnsReachability.value = currentMap + } } fun updateHostname(oldHostname: String, newHostname: String, newLabel: String? = null) { - DnsSettingsRepository.updateHostname(oldHostname, newHostname, newLabel) + HostnameRepository.updateHostname(oldHostname, newHostname, newLabel) reachabilityJobs[oldHostname]?.cancel() reachabilityJobs.remove(oldHostname) - val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() - currentMap.remove(oldHostname) - _dnsReachability.postValue(currentMap) + viewModelScope.launch { + val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() + currentMap.remove(oldHostname) + _dnsReachability.value = currentMap + } if (NetworkUtils.isValidDnsHostname(newHostname)) { testReachability(newHostname) @@ -377,21 +404,25 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { private fun testReachability(hostname: String) { reachabilityJobs[hostname]?.cancel() - if (hostname.isEmpty() || sharedPreferences.getBoolean( - Constants.PREF_DISABLE_DNS_TEST, - false - ) - ) { - val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() - currentMap[hostname] = ReachabilityState.IDLE - _dnsReachability.postValue(currentMap) - return - } - val job = viewModelScope.launch(Dispatchers.IO) { - val startMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() - startMap[hostname] = ReachabilityState.TESTING - _dnsReachability.postValue(startMap) + if (hostname.isEmpty() || sharedPreferences.getBoolean( + Constants.PREF_DISABLE_DNS_TEST, + false + ) + ) { + withContext(Dispatchers.Main) { + val currentMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() + currentMap[hostname] = ReachabilityState.IDLE + _dnsReachability.value = currentMap + } + return@launch + } + + withContext(Dispatchers.Main) { + val startMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() + startMap[hostname] = ReachabilityState.TESTING + _dnsReachability.value = startMap + } val isReachable = try { val socket = Socket() @@ -403,22 +434,24 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { false } - val endMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() - endMap[hostname] = - if (isReachable) ReachabilityState.REACHABLE else ReachabilityState.UNREACHABLE - _dnsReachability.postValue(endMap) + withContext(Dispatchers.Main) { + val endMap = _dnsReachability.value?.toMutableMap() ?: mutableMapOf() + endMap[hostname] = + if (isReachable) ReachabilityState.REACHABLE else ReachabilityState.UNREACHABLE + _dnsReachability.value = endMap + } } reachabilityJobs[hostname] = job } - fun setAutoBlacklist(enabled: Boolean) { - sharedPreferences.edit { putBoolean(Constants.PREF_AUTO_BLACKLIST, enabled) } - _autoBlacklistEnabled.value = enabled + fun setAutoSaveState(enabled: Boolean) { + sharedPreferences.edit { putBoolean(Constants.PREF_AUTO_SAVE_STATE, enabled) } + _autoSaveStateEnabled.value = enabled } - fun setAutoWhitelist(enabled: Boolean) { - sharedPreferences.edit { putBoolean(Constants.PREF_AUTO_WHITELIST, enabled) } - _autoWhitelistEnabled.value = enabled + fun setAutoSaveHost(enabled: Boolean) { + sharedPreferences.edit { putBoolean(Constants.PREF_AUTO_SAVE_HOST, enabled) } + _autoSaveHostEnabled.value = enabled } fun setConnectivityWatchdogEnabled(enabled: Boolean) { @@ -465,15 +498,19 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { } fun setVpnOverrideEnabled(enabled: Boolean) { - DnsSettingsRepository.updateVpnOverrideEnabled(enabled) + VpnRepository.updateVpnOverrideEnabled(enabled) } fun setVpnDnsHostname(hostname: String?) { - DnsSettingsRepository.updateVpnDnsHostname(hostname) + VpnRepository.updateVpnDnsHostname(hostname) } fun updateHostnameOrder(newList: List) { - DnsSettingsRepository.updateHostnamesOrder(newList.filter { !it.isUnsaved }) + HostnameRepository.updateHostnamesOrder(newList.filter { !it.isUnsaved }) + } + + fun updateNetworkProfileOrder(newList: List) { + NetworkProfileRepository.updateNetworkProfilesOrder(newList.filter { !it.isAutoDetected }) } fun dismissVpnHostnameWarning() { @@ -483,13 +520,31 @@ class DnsViewModel(application: Application) : AndroidViewModel(application) { fun dismissKeyInvalidatedAlert() { _isKeyInvalidated.value = false - DnsSettingsRepository.resetKeyInvalidated() + SecurityRepository.resetKeyInvalidated() } fun refreshCurrentSsid() { _currentSsid.postValue(NetworkUtils.getCurrentWifiSsid(getApplication())) } + fun getGlobalPreferredHostname(): String? { + val app = getApplication() + val encryptedPrefs = app.getEncryptedPrefs() + val encryptedHostname = encryptedPrefs.getString(Constants.PREF_LAST_USED_HOSTNAME, null) + val lastUsed = encryptedHostname?.let { + when (val result = com.ericlowry.dnstoggle.util.EncryptionManager.decrypt(it)) { + is com.ericlowry.dnstoggle.util.EncryptionManager.DecryptResult.Success -> result.data + else -> null + } + } + if (!lastUsed.isNullOrEmpty()) return lastUsed + + return Settings.Global.getString( + app.contentResolver, + Constants.SETTINGS_PRIVATE_DNS_SPECIFIER + ) + } + override fun onCleared() { sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) getApplication().contentResolver.unregisterContentObserver(dnsSettingsObserver) diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/NetworkProfile.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/NetworkProfile.kt new file mode 100644 index 0000000..9ae1d69 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/NetworkProfile.kt @@ -0,0 +1,17 @@ +package com.ericlowry.dnstoggle.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkProfile( + @SerialName("s") val ssid: String, + @SerialName("e") val isEnabled: Boolean, + @SerialName("h") val targetHostname: String? = null, + @SerialName("a") val isAutoDetected: Boolean = false, + @SerialName("u") val isUnsaved: Boolean = false +) { + init { + require(ssid.isNotBlank()) { "SSID cannot be blank" } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/SsidItem.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/SsidItem.kt deleted file mode 100644 index 647946a..0000000 --- a/app/src/main/java/com/ericlowry/dnstoggle/data/SsidItem.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.ericlowry.dnstoggle.data - -data class SsidItem( - val ssid: String, - val isAutoDetected: Boolean = false, -) diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/repository/DnsSettingsRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/DnsSettingsRepository.kt new file mode 100644 index 0000000..8bd5de6 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/DnsSettingsRepository.kt @@ -0,0 +1,100 @@ +package com.ericlowry.dnstoggle.data.repository + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.BackupConfig +import com.ericlowry.dnstoggle.data.Constants +import com.ericlowry.dnstoggle.data.NetworkProfile +import com.ericlowry.dnstoggle.util.NetworkUtils +import kotlinx.serialization.json.Json +import org.json.JSONObject + +object DnsSettingsRepository { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private lateinit var sharedPreferences: SharedPreferences + + fun initialize(context: Context) { + val app = context.applicationContext as DnsToggleApplication + sharedPreferences = app.getPrefs() + } + + fun exportConfigToJson(): String { + val backupConfig = BackupConfig( + hostnames = HostnameRepository.dnsHostnames.value ?: emptyList(), + networkProfiles = NetworkProfileRepository.networkProfiles.value ?: emptyList(), + autoSaveState = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_STATE, false), + autoSaveHost = sharedPreferences.getBoolean(Constants.PREF_AUTO_SAVE_HOST, false), + vpnOverride = VpnRepository.vpnOverrideEnabled.value, + vpnDns = VpnRepository.vpnDnsHostname.value, + hideLauncherIcon = sharedPreferences.getBoolean( + Constants.PREF_HIDE_LAUNCHER_ICON, + false + ), + disableDnsTest = sharedPreferences.getBoolean(Constants.PREF_DISABLE_DNS_TEST, false), + showToast = sharedPreferences.getBoolean(Constants.PREF_SHOW_TOAST, false) + ) + return json.encodeToString(backupConfig) + } + + fun importConfigFromJson(jsonString: String): Boolean { + return try { + val config = json.decodeFromString(jsonString) + + if (config.hostnames.isNotEmpty()) { + val validHostnames = + config.hostnames.filter { NetworkUtils.isValidDnsHostname(it.hostname) } + HostnameRepository.updateHostnamesOrder(validHostnames) + } + + if (config.networkProfiles.isNotEmpty()) { + NetworkProfileRepository.updateNetworkProfilesFromBackup(config.networkProfiles) + } else { + // START_LEGACY_MIGRATION_CODE: Legacy flat array JSON backup import + try { + val rawJson = JSONObject(jsonString) + if (rawJson.has("blacklist")) { + val blacklistArray = rawJson.getJSONArray("blacklist") + val importedProfiles = mutableListOf() + for (i in 0 until blacklistArray.length()) { + importedProfiles.add( + NetworkProfile( + ssid = blacklistArray.getString(i), + isEnabled = false, + targetHostname = null, + isAutoDetected = false + ) + ) + } + NetworkProfileRepository.updateNetworkProfilesFromBackup(importedProfiles) + } + } catch (_: Exception) { + } + // END_LEGACY_MIGRATION_CODE + } + + sharedPreferences.edit { + putBoolean(Constants.PREF_AUTO_SAVE_STATE, config.autoSaveState) + putBoolean(Constants.PREF_AUTO_SAVE_HOST, config.autoSaveHost) + putBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, config.vpnOverride) + putBoolean(Constants.PREF_HIDE_LAUNCHER_ICON, config.hideLauncherIcon) + putBoolean(Constants.PREF_DISABLE_DNS_TEST, config.disableDnsTest) + putBoolean(Constants.PREF_SHOW_TOAST, config.showToast) + } + VpnRepository.updateVpnOverrideEnabled(config.vpnOverride) + + val parsedVpnDns = config.vpnDns + if (parsedVpnDns == null || parsedVpnDns == "off") { + VpnRepository.updateVpnDnsHostname(null) + } else if (NetworkUtils.isValidDnsHostname(parsedVpnDns)) { + VpnRepository.updateVpnDnsHostname(parsedVpnDns) + } + true + } catch (e: Exception) { + Log.e("DnsSettingsRepository", "Failed to import config", e) + false + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/repository/HostnameRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/HostnameRepository.kt new file mode 100644 index 0000000..f20a54f --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/HostnameRepository.kt @@ -0,0 +1,204 @@ +package com.ericlowry.dnstoggle.data.repository + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants +import com.ericlowry.dnstoggle.data.DnsHostname +import com.ericlowry.dnstoggle.util.EncryptionManager +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.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.json.JSONArray + +object HostnameRepository { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private lateinit var sharedPreferences: SharedPreferences + private lateinit var encryptedPrefs: SharedPreferences + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _dnsHostnames = MutableStateFlow?>(null) + val dnsHostnames: StateFlow?> = _dnsHostnames.asStateFlow() + + fun initialize(context: Context) { + val app = context.applicationContext as DnsToggleApplication + sharedPreferences = app.getPrefs() + encryptedPrefs = app.getEncryptedPrefs() + loadHostnames() + } + + fun loadHostnames() { + scope.launch { + val rawData = encryptedPrefs.all[Constants.PREF_DNS_HOSTNAMES] + + var keyInvalidated = false + var resultList = mutableListOf() + + // START_LEGACY_MIGRATION_CODE: Legacy StringSet to JSON Array hostname migration + if (rawData is Set<*>) { + @Suppress("UNCHECKED_CAST") + val encryptedSet = rawData as? Set ?: emptySet() + encryptedSet.forEach { + when (val result = EncryptionManager.decrypt(it)) { + is EncryptionManager.DecryptResult.Success -> { + val entry = result.data + if (entry.startsWith("j:")) { + try { + resultList.add( + json.decodeFromString( + entry.substring( + 2 + ) + ) + ) + } catch (_: Exception) { + resultList.add(DnsHostname(hostname = entry)) + } + } else { + resultList.add(DnsHostname(hostname = entry)) + } + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> keyInvalidated = true + else -> {} + } + } + resultList.sortWith { a, b -> + String.CASE_INSENSITIVE_ORDER.compare( + a.getDisplayName(), + b.getDisplayName() + ) + } + if (resultList.isNotEmpty() && !keyInvalidated) { + saveHostnamesAsync(resultList) + } + } + // END_LEGACY_MIGRATION_CODE + + if (rawData is String) { + when (val result = EncryptionManager.decrypt(rawData)) { + is EncryptionManager.DecryptResult.Success -> { + try { + resultList = + json.decodeFromString>(result.data) + } catch (_: Exception) { + // START_LEGACY_MIGRATION_CODE: JSONArray of strings to List migration + try { + val jsonArray = JSONArray(result.data) + for (i in 0 until jsonArray.length()) { + val entry = jsonArray.getString(i) + if (entry.startsWith("j:")) { + resultList.add( + json.decodeFromString( + entry.substring( + 2 + ) + ) + ) + } else { + resultList.add(DnsHostname(hostname = entry)) + } + } + } catch (e2: Exception) { + Log.e("HostnameRepository", "Failed to parse hostnames JSON", e2) + } + // END_LEGACY_MIGRATION_CODE + } + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> keyInvalidated = true + else -> {} + } + } + + if (keyInvalidated) { + SecurityRepository.setKeyInvalidated(true) + encryptedPrefs.edit { + remove(Constants.PREF_NETWORK_PROFILES) + remove(Constants.PREF_DNS_HOSTNAMES) + remove(Constants.PREF_LAST_USED_HOSTNAME) + } + } + + _dnsHostnames.value = resultList + } + } + + fun addHostname(hostname: String, label: String? = null) { + _dnsHostnames.update { current -> + val safeCurrent = current ?: emptyList() + val newEntry = DnsHostname(hostname, label) + + val existingIndex = safeCurrent.indexOfFirst { it.hostname == hostname } + val next = if (existingIndex != -1) { + safeCurrent.mapIndexed { index, dnsHostname -> + if (index == existingIndex) newEntry else dnsHostname + } + } else { + listOf(newEntry) + safeCurrent + } + + saveHostnamesAsync(next) + next + } + } + + fun removeHostname(hostname: String) { + _dnsHostnames.update { current -> + val safeCurrent = current ?: emptyList() + if (safeCurrent.none { it.hostname == hostname }) return@update safeCurrent + val next = safeCurrent.filter { it.hostname != hostname } + + if (VpnRepository.vpnDnsHostname.value == hostname) { + VpnRepository.updateVpnDnsHostname(null) + sharedPreferences.edit { + putBoolean(Constants.PREF_VPN_HOSTNAME_REMOVED_WARNING, true) + } + } else if (next.size == 1 && VpnRepository.vpnDnsHostname.value != null) { + VpnRepository.updateVpnDnsHostname(null) + } + + saveHostnamesAsync(next) + next + } + + NetworkProfileRepository.updateProfilesOnHostnameRemoval(hostname) + } + + fun updateHostname(oldHostname: String, newHostname: String, newLabel: String? = null) { + _dnsHostnames.update { current -> + val safeCurrent = current ?: emptyList() + val next = safeCurrent.map { + if (it.hostname == oldHostname) DnsHostname(newHostname, newLabel) else it + } + + if (VpnRepository.vpnDnsHostname.value == oldHostname) { + VpnRepository.updateVpnDnsHostname(newHostname) + } + + saveHostnamesAsync(next) + next + } + } + + fun updateHostnamesOrder(newList: List) { + _dnsHostnames.value = newList + saveHostnamesAsync(newList) + } + + fun saveHostnamesAsync(list: List) { + scope.launch { + val jsonString = json.encodeToString(list) + val encrypted = EncryptionManager.encrypt(jsonString) + encryptedPrefs.edit { putString(Constants.PREF_DNS_HOSTNAMES, encrypted) } + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/repository/NetworkProfileRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/NetworkProfileRepository.kt new file mode 100644 index 0000000..8a0df59 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/NetworkProfileRepository.kt @@ -0,0 +1,228 @@ +package com.ericlowry.dnstoggle.data.repository + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants +import com.ericlowry.dnstoggle.data.NetworkProfile +import com.ericlowry.dnstoggle.util.EncryptionManager +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.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.json.JSONArray + +object NetworkProfileRepository { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private lateinit var encryptedPrefs: SharedPreferences + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _networkProfiles = MutableStateFlow?>(null) + val networkProfiles: StateFlow?> = _networkProfiles.asStateFlow() + + fun initialize(context: Context) { + val app = context.applicationContext as DnsToggleApplication + encryptedPrefs = app.getEncryptedPrefs() + loadNetworkProfiles() + } + + fun loadNetworkProfiles() { + scope.launch { + // START_LEGACY_MIGRATION_CODE: Local PREF_SSID_BLACKLIST to NetworkProfile migration + val legacyBlacklist = encryptedPrefs.getStringSet(Constants.PREF_SSID_BLACKLIST, null) + val legacyAutoBlacklist = + encryptedPrefs.getStringSet(Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST, null) + + if (legacyBlacklist != null || legacyAutoBlacklist != null) { + migrateLegacyBlacklist(legacyBlacklist, legacyAutoBlacklist) + return@launch + } + // END_LEGACY_MIGRATION_CODE + + val encryptedData = encryptedPrefs.getString(Constants.PREF_NETWORK_PROFILES, null) + var resultList = listOf() + var keyInvalidated = false + + if (encryptedData != null) { + when (val result = EncryptionManager.decrypt(encryptedData)) { + is EncryptionManager.DecryptResult.Success -> { + resultList = try { + json.decodeFromString>(result.data) + } catch (_: Exception) { + // START_LEGACY_MIGRATION_CODE: JSONArray of strings to List migration + try { + val legacyList = mutableListOf() + val jsonArray = JSONArray(result.data) + for (i in 0 until jsonArray.length()) { + json.decodeFromString(jsonArray.getString(i)) + .let { legacyList.add(it) } + } + legacyList + } catch (e2: Exception) { + Log.e("NetworkProfileRepo", "Failed to parse profiles JSON", e2) + emptyList() + } + // END_LEGACY_MIGRATION_CODE + } + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> keyInvalidated = true + else -> {} + } + } + + if (keyInvalidated) { + SecurityRepository.setKeyInvalidated(true) + encryptedPrefs.edit { remove(Constants.PREF_NETWORK_PROFILES) } + } + _networkProfiles.value = resultList + } + } + + // START_LEGACY_MIGRATION_CODE: Local PREF_SSID_BLACKLIST to NetworkProfile migration + private fun migrateLegacyBlacklist(legacy: Set?, autoDetected: Set?) { + val profiles = mutableMapOf() + + legacy?.forEach { encrypted -> + when (val result = EncryptionManager.decrypt(encrypted)) { + is EncryptionManager.DecryptResult.Success -> { + profiles[result.data] = NetworkProfile( + ssid = result.data, + isEnabled = false, + targetHostname = null, + isAutoDetected = false + ) + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> SecurityRepository.setKeyInvalidated( + true + ) + + else -> {} + } + } + + autoDetected?.forEach { encrypted -> + when (val result = EncryptionManager.decrypt(encrypted)) { + is EncryptionManager.DecryptResult.Success -> { + val existing = profiles[result.data] + profiles[result.data] = existing?.copy(isAutoDetected = true) + ?: NetworkProfile( + ssid = result.data, + isEnabled = false, + targetHostname = null, + isAutoDetected = true + ) + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> SecurityRepository.setKeyInvalidated( + true + ) + + else -> {} + } + } + + val profileList = profiles.values.toList() + _networkProfiles.value = profileList + saveNetworkProfilesAsync(profileList) + + encryptedPrefs.edit { + remove(Constants.PREF_SSID_BLACKLIST) + remove(Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST) + } + } + // END_LEGACY_MIGRATION_CODE + + fun promoteUnsavedProfile(ssid: String) { + _networkProfiles.update { current -> + val safeCurrent = current ?: emptyList() + val index = safeCurrent.indexOfFirst { it.ssid == ssid } + if (index == -1 || (!safeCurrent[index].isAutoDetected && !safeCurrent[index].isUnsaved)) return@update safeCurrent + + val next = safeCurrent.toMutableList() + next[index] = next[index].copy(isAutoDetected = false, isUnsaved = false) + saveNetworkProfilesAsync(next) + next + } + } + + fun upsertNetworkProfile( + ssid: String, + isEnabled: Boolean, + targetHostname: String? = null, + isAutoDetected: Boolean = false, + isUnsaved: Boolean = false, + preserveExistingHostname: Boolean = false + ) { + _networkProfiles.update { current -> + val safeCurrent = current ?: emptyList() + val index = safeCurrent.indexOfFirst { it.ssid == ssid } + val next = safeCurrent.toMutableList() + + val newProfile = if (index != -1) { + val existing = safeCurrent[index] + existing.copy( + isEnabled = isEnabled, + targetHostname = if (preserveExistingHostname) existing.targetHostname else targetHostname, + isAutoDetected = isAutoDetected, + isUnsaved = isUnsaved + ) + } else { + NetworkProfile(ssid, isEnabled, targetHostname, isAutoDetected, isUnsaved) + } + + if (index != -1) next[index] = newProfile else next.add(newProfile) + saveNetworkProfilesAsync(next) + next + } + } + + fun removeNetworkProfile(ssid: String) { + _networkProfiles.update { current -> + val safeCurrent = current ?: emptyList() + if (safeCurrent.none { it.ssid == ssid }) return@update safeCurrent + val next = safeCurrent.filter { it.ssid != ssid } + saveNetworkProfilesAsync(next) + next + } + } + + fun updateNetworkProfilesFromBackup(list: List) { + _networkProfiles.value = list + saveNetworkProfilesAsync(list) + } + + fun updateNetworkProfilesOrder(newList: List) { + _networkProfiles.value = newList + saveNetworkProfilesAsync(newList) + } + + fun saveNetworkProfilesAsync(list: List) { + scope.launch { + val jsonString = json.encodeToString(list) + val encrypted = EncryptionManager.encrypt(jsonString) + encryptedPrefs.edit { putString(Constants.PREF_NETWORK_PROFILES, encrypted) } + } + } + + internal fun updateProfilesOnHostnameRemoval(hostname: String) { + _networkProfiles.update { current -> + val safeCurrent = current ?: emptyList() + val next = safeCurrent.map { + if (it.targetHostname == hostname) it.copy(targetHostname = null) else it + } + if (next != safeCurrent) { + saveNetworkProfilesAsync(next) + } + next + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/repository/SecurityRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/SecurityRepository.kt new file mode 100644 index 0000000..33fd44b --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/SecurityRepository.kt @@ -0,0 +1,53 @@ +package com.ericlowry.dnstoggle.data.repository + +import android.content.Context +import androidx.core.content.edit +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object SecurityRepository { + private val _isKeyInvalidated = MutableStateFlow(false) + val isKeyInvalidated: StateFlow = _isKeyInvalidated.asStateFlow() + + fun initialize(context: Context) { + val app = context.applicationContext as DnsToggleApplication + val sharedPreferences = app.getPrefs() + val encryptedPrefs = app.getEncryptedPrefs() + + // START_LEGACY_MIGRATION_CODE: Move unencrypted preferences to encrypted storage + val keysToMigrate = listOf( + Constants.PREF_DNS_HOSTNAMES, + Constants.PREF_SSID_BLACKLIST, + Constants.PREF_SSID_AUTO_DETECTED_BLACKLIST, + Constants.PREF_LAST_USED_HOSTNAME, + Constants.PREF_VPN_DNS_HOSTNAME + ) + + sharedPreferences.all.forEach { (key, value) -> + if (key in keysToMigrate) { + encryptedPrefs.edit { + when (value) { + is String -> putString(key, value) + is Set<*> -> @Suppress("UNCHECKED_CAST") putStringSet( + key, + value as Set + ) + } + } + sharedPreferences.edit { remove(key) } + } + } + // END_LEGACY_MIGRATION_CODE + } + + fun setKeyInvalidated(invalidated: Boolean) { + _isKeyInvalidated.value = invalidated + } + + fun resetKeyInvalidated() { + _isKeyInvalidated.value = false + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/data/repository/VpnRepository.kt b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/VpnRepository.kt new file mode 100644 index 0000000..0a42264 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/data/repository/VpnRepository.kt @@ -0,0 +1,80 @@ +package com.ericlowry.dnstoggle.data.repository + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants +import com.ericlowry.dnstoggle.util.EncryptionManager +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.flow.asStateFlow +import kotlinx.coroutines.launch + +object VpnRepository { + private lateinit var sharedPreferences: SharedPreferences + private lateinit var encryptedPrefs: SharedPreferences + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _vpnOverrideEnabled = MutableStateFlow(false) + val vpnOverrideEnabled: StateFlow = _vpnOverrideEnabled.asStateFlow() + + private val _vpnDnsHostname = MutableStateFlow(null) + val vpnDnsHostname: StateFlow = _vpnDnsHostname.asStateFlow() + + fun initialize(context: Context) { + val app = context.applicationContext as DnsToggleApplication + sharedPreferences = app.getPrefs() + encryptedPrefs = app.getEncryptedPrefs() + + _vpnOverrideEnabled.value = + sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) + + scope.launch { + val encryptedVpnHostname = + encryptedPrefs.getString(Constants.PREF_VPN_DNS_HOSTNAME, null) + _vpnDnsHostname.value = if (encryptedVpnHostname == null) { + null + } else { + when (val result = EncryptionManager.decrypt(encryptedVpnHostname)) { + is EncryptionManager.DecryptResult.Success -> { + // START_LEGACY_MIGRATION_CODE: VPN DNS 'off' string migration + if (result.data == "off") { + // Migration from app versions <1.6 + encryptedPrefs.edit { remove(Constants.PREF_VPN_DNS_HOSTNAME) } + null + } else { + result.data + } + // END_LEGACY_MIGRATION_CODE + } + + is EncryptionManager.DecryptResult.KeyInvalidated -> { + SecurityRepository.setKeyInvalidated(true) + null + } + + else -> null + } + } + } + } + + fun updateVpnOverrideEnabled(enabled: Boolean) { + _vpnOverrideEnabled.value = enabled + sharedPreferences.edit { putBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, enabled) } + } + + fun updateVpnDnsHostname(hostname: String?) { + _vpnDnsHostname.value = hostname + if (hostname == null) { + encryptedPrefs.edit { remove(Constants.PREF_VPN_DNS_HOSTNAME) } + } else { + val encrypted = EncryptionManager.encrypt(hostname) + encryptedPrefs.edit { putString(Constants.PREF_VPN_DNS_HOSTNAME, encrypted) } + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/service/ConnectivityWatchdogManager.kt b/app/src/main/java/com/ericlowry/dnstoggle/service/ConnectivityWatchdogManager.kt new file mode 100644 index 0000000..f8e9442 --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/service/ConnectivityWatchdogManager.kt @@ -0,0 +1,147 @@ +package com.ericlowry.dnstoggle.service + +import android.content.Context +import android.net.Network +import android.net.NetworkCapabilities +import android.provider.Settings.Global +import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants +import com.ericlowry.dnstoggle.data.repository.NetworkProfileRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +class ConnectivityWatchdogManager( + private val context: Context, + private val serviceScope: CoroutineScope +) { + private var debounceJob: Job? = null + private var connectivityWatchdogJob: Job? = null + private var autoRecoveryJob: Job? = null + var retriedAutoProfileForNetwork: Network? = null + + private fun getPrefs() = (context.applicationContext as DnsToggleApplication).getPrefs() + + fun cancelAll() { + debounceJob?.cancel() + connectivityWatchdogJob?.cancel() + autoRecoveryJob?.cancel() + debounceJob = null + connectivityWatchdogJob = null + autoRecoveryJob = null + } + + fun cancelDebounce() { + debounceJob?.cancel() + debounceJob = null + } + + fun evaluateConnectivityWatchdog( + ssid: String, + wifiCaps: NetworkCapabilities?, + activeNetworks: Map, + cachedDnsMode: String? + ) { + val prefs = getPrefs() + if (!prefs.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) || + cachedDnsMode != Constants.DNS_MODE_HOSTNAME + ) { + connectivityWatchdogJob?.cancel() + connectivityWatchdogJob = null + return + } + + if (wifiCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true) { + connectivityWatchdogJob?.cancel() + connectivityWatchdogJob = null + return + } + + if (connectivityWatchdogJob?.isActive == true) return + + val hostname = + Global.getString(context.contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) + if (hostname.isNullOrEmpty()) return + + val debounceSeconds = prefs.getInt( + Constants.PREF_CONNECTIVITY_WATCHDOG_DEBOUNCE_SECONDS, + Constants.CONNECTIVITY_WATCHDOG_DEFAULT_DEBOUNCE_SECONDS + ) + val probeTargets = prefs.getString( + Constants.PREF_CONNECTIVITY_WATCHDOG_PROBE_TARGETS, + Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS + ) ?: Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS + + connectivityWatchdogJob = serviceScope.launch { + delay(debounceSeconds.seconds) + + val stillNotValidated = activeNetworks.values + .find { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) != true + + if (!stillNotValidated || + cachedDnsMode != Constants.DNS_MODE_HOSTNAME || + (context.applicationContext as DnsToggleApplication).detectedSsid != ssid || + !getPrefs().getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) + ) { + return@launch + } + + if (ConnectivityWatchdog.isDnsSpecificFailure(hostname, probeTargets)) { + NetworkProfileRepository.upsertNetworkProfile( + ssid = ssid, + isEnabled = false, + isAutoDetected = true, + preserveExistingHostname = true + ) + } + } + } + + fun maybeRetryAutoDetectedSsid( + ssid: String, + isAutoDetected: Boolean, + wifiNetwork: Network?, + onRecovered: (String) -> Unit + ) { + val prefs = getPrefs() + if (!isAutoDetected || + !prefs.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) || + retriedAutoProfileForNetwork == wifiNetwork + ) { + return + } + retriedAutoProfileForNetwork = wifiNetwork + + val hostname = + Global.getString(context.contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) + if (hostname.isNullOrEmpty()) return + + val probeTargets = prefs.getString( + Constants.PREF_CONNECTIVITY_WATCHDOG_PROBE_TARGETS, + Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS + ) ?: Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS + + autoRecoveryJob?.cancel() + autoRecoveryJob = serviceScope.launch { + if (ConnectivityWatchdog.isRecovered(hostname, probeTargets)) { + NetworkProfileRepository.removeNetworkProfile(ssid) + onRecovered(ssid) + } + } + } + + fun restorePreferredDns(immediate: Boolean, onRestore: () -> Unit) { + debounceJob?.cancel() + if (immediate) { + onRestore() + } else { + debounceJob = serviceScope.launch { + delay(2.seconds) // Wait to avoid rapid ping-pong + onRestore() + } + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/service/DnsToggleService.kt b/app/src/main/java/com/ericlowry/dnstoggle/service/DnsToggleService.kt index f85d295..7806c12 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/service/DnsToggleService.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/service/DnsToggleService.kt @@ -14,7 +14,7 @@ import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsManager -import com.ericlowry.dnstoggle.data.DnsSettingsRepository +import com.ericlowry.dnstoggle.data.repository.HostnameRepository import com.ericlowry.dnstoggle.ui.MainActivity import com.ericlowry.dnstoggle.util.attemptSecureSettingsGrant import kotlinx.coroutines.CoroutineScope @@ -37,6 +37,11 @@ class DnsToggleService : TileService() { super.onDestroy() } + override fun onTileAdded() { + super.onTileAdded() + updateTileFromSystemSettings() + } + override fun onClick() { super.onClick() @@ -60,7 +65,11 @@ class DnsToggleService : TileService() { val currentMode = Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE) val isEnabling = currentMode != Constants.DNS_MODE_HOSTNAME - val result = DnsManager.togglePrivateDns(this@DnsToggleService, isEnabling) + val result = DnsManager.togglePrivateDns( + context = this@DnsToggleService, + enabled = isEnabling, + isFromTile = true + ) withContext(Dispatchers.Main) { when (result) { @@ -91,7 +100,10 @@ class DnsToggleService : TileService() { override fun onStartListening() { super.onStartListening() + updateTileFromSystemSettings() + } + private fun updateTileFromSystemSettings() { try { val currentMode = Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_MODE) val tileState = @@ -131,7 +143,7 @@ class DnsToggleService : TileService() { Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) ?: getString(R.string.on_label) - val displayName = DnsSettingsRepository.dnsHostnames.value + val displayName = HostnameRepository.dnsHostnames.value ?.find { it.hostname == specifier } ?.getDisplayName() ?: specifier diff --git a/app/src/main/java/com/ericlowry/dnstoggle/service/TileServiceCompat.kt b/app/src/main/java/com/ericlowry/dnstoggle/service/TileServiceCompat.kt index 90e87d9..b3da6a3 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/service/TileServiceCompat.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/service/TileServiceCompat.kt @@ -7,13 +7,28 @@ import android.content.Intent import android.os.Build import android.service.quicksettings.TileService import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds object TileServiceCompat { + private val debounceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val debounceJobs = ConcurrentHashMap() + fun requestListeningState(context: Context, componentName: ComponentName) { - try { - TileService.requestListeningState(context, componentName) - } catch (e: Exception) { - Log.w("TileServiceCompat", "Failed to request tile listening state", e) + debounceJobs[componentName]?.cancel() + debounceJobs[componentName] = debounceScope.launch { + delay(500.milliseconds) + try { + TileService.requestListeningState(context.applicationContext, componentName) + } catch (e: Exception) { + Log.w("TileServiceCompat", "Failed to request tile listening state", e) + } } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/service/UsbDebuggingTileService.kt b/app/src/main/java/com/ericlowry/dnstoggle/service/UsbDebuggingTileService.kt index 998bcbc..c6a3607 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/service/UsbDebuggingTileService.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/service/UsbDebuggingTileService.kt @@ -3,18 +3,23 @@ package com.ericlowry.dnstoggle.service import android.content.Intent import android.graphics.drawable.Icon import android.os.Build -import android.os.Handler -import android.os.Looper import android.provider.Settings import android.service.quicksettings.Tile import android.service.quicksettings.TileService +import android.util.Log import com.ericlowry.dnstoggle.R import com.ericlowry.dnstoggle.ui.MainActivity class UsbDebuggingTileService : TileService() { - private var isProcessing = false - private val handler = Handler(Looper.getMainLooper()) + companion object { + private const val TAG = "UsbDebuggingTile" + } + + override fun onTileAdded() { + super.onTileAdded() + updateTile() + } override fun onStartListening() { super.onStartListening() @@ -23,51 +28,47 @@ class UsbDebuggingTileService : TileService() { override fun onClick() { super.onClick() - if (isProcessing) return - val isEnabled = Settings.Global.getInt(contentResolver, Settings.Global.ADB_ENABLED, 0) != 0 + val isEnabled = getAdbEnabledState() val nextState = if (isEnabled) 0 else 1 - isProcessing = true - updateTile() - - handler.postDelayed( - { - isProcessing = false - updateTile() - }, - 1500, - ) + Log.i(TAG, "Toggling ADB: $isEnabled -> ${nextState != 0}") try { - Settings.Global.putInt(contentResolver, Settings.Global.ADB_ENABLED, nextState) - } catch (_: SecurityException) { - isProcessing = false + val success = + Settings.Global.putInt(contentResolver, Settings.Global.ADB_ENABLED, nextState) + if (!success) { + Log.e(TAG, "Failed to update ADB_ENABLED setting") + } + } catch (e: SecurityException) { + Log.e(TAG, "Permission denied toggling ADB: ${e.message}") launchMainActivityWithPermissionPrompt() } + + updateTile() } private fun updateTile() { val tile = qsTile ?: return - val isEnabled = Settings.Global.getInt(contentResolver, Settings.Global.ADB_ENABLED, 0) != 0 + val isEnabled = getAdbEnabledState() - if (isProcessing) { - tile.state = Tile.STATE_UNAVAILABLE - tile.icon = Icon.createWithResource(this, R.drawable.ic_ellipsis) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = - if (isEnabled) getString(R.string.disabling) else getString(R.string.enabling) - } - } else { - tile.state = if (isEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - tile.icon = Icon.createWithResource(this, R.drawable.ic_tool) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = null - } + tile.state = if (isEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.icon = Icon.createWithResource(this, R.drawable.ic_tool) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + tile.subtitle = null } tile.updateTile() } + private fun getAdbEnabledState(): Boolean { + return try { + Settings.Global.getInt(contentResolver, Settings.Global.ADB_ENABLED, 0) != 0 + } catch (e: SecurityException) { + Log.w(TAG, "SecurityException reading ADB_ENABLED: ${e.message}") + false + } + } + private fun launchMainActivityWithPermissionPrompt() { val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK diff --git a/app/src/main/java/com/ericlowry/dnstoggle/service/WifiMonitoringService.kt b/app/src/main/java/com/ericlowry/dnstoggle/service/WifiMonitoringService.kt index 3cd57a6..f8c95f9 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/service/WifiMonitoringService.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/service/WifiMonitoringService.kt @@ -29,31 +29,27 @@ import androidx.core.content.edit import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R import com.ericlowry.dnstoggle.data.Constants -import com.ericlowry.dnstoggle.data.DnsSettingsRepository +import com.ericlowry.dnstoggle.data.repository.NetworkProfileRepository +import com.ericlowry.dnstoggle.data.repository.VpnRepository import com.ericlowry.dnstoggle.ui.MainActivity import com.ericlowry.dnstoggle.util.EncryptionManager import com.ericlowry.dnstoggle.util.NotificationUtils import com.ericlowry.dnstoggle.util.stripSsidQuotes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.seconds +import java.util.concurrent.ConcurrentHashMap class WifiMonitoringService : Service() { private lateinit var connectivityManager: ConnectivityManager private var networkCallback: NetworkCallback? = null private var cachedDnsMode: String? = null - private val activeNetworks = mutableMapOf() + private val activeNetworks = ConcurrentHashMap() private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) - private var debounceJob: Job? = null - private var connectivityWatchdogJob: Job? = null - private var autoRecoveryJob: Job? = null - private var retriedAutoBlacklistForNetwork: Network? = null + private lateinit var watchdogManager: ConnectivityWatchdogManager private val dnsSettingsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { @@ -87,7 +83,8 @@ class WifiMonitoringService : Service() { override fun onCreate() { super.onCreate() connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - // Initialize cache and register observer + watchdogManager = ConnectivityWatchdogManager(this, serviceScope) + cachedDnsMode = Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_MODE) contentResolver.registerContentObserver( Global.getUriFor(Constants.SETTINGS_PRIVATE_DNS_MODE), @@ -98,7 +95,7 @@ class WifiMonitoringService : Service() { getPrefs().registerOnSharedPreferenceChangeListener(preferenceChangeListener) serviceScope.launch { - DnsSettingsRepository.blacklist.collect { + NetworkProfileRepository.networkProfiles.collect { evaluateActiveNetworks() } } @@ -121,9 +118,7 @@ class WifiMonitoringService : Service() { override fun onDestroy() { serviceScope.cancel() - debounceJob = null - connectivityWatchdogJob = null - autoRecoveryJob = null + watchdogManager.cancelAll() unregisterNetworkCallback() contentResolver.unregisterContentObserver(dnsSettingsObserver) getPrefs().unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) @@ -133,7 +128,7 @@ class WifiMonitoringService : Service() { prefs.edit(true) { putBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) } } if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != null) { - restorePreferredDns() + restorePreferredDnsSync() prefs.edit(true) { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) } } @@ -248,116 +243,170 @@ class WifiMonitoringService : Service() { val newInfo = newCaps.transportInfo as? WifiInfo if (oldInfo?.ssid != newInfo?.ssid) return true } else { - if (newCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return true + if (oldCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) != newCaps.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) + ) return true + if (oldCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) != newCaps.hasTransport( + NetworkCapabilities.TRANSPORT_WIFI + ) + ) return true + if (oldCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != newCaps.hasTransport( + NetworkCapabilities.TRANSPORT_VPN + ) + ) return true + return false } return false } private fun evaluateActiveNetworks() { - val allCaps = activeNetworks.values - val isVpnActive = allCaps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } - val wifiEntry = - activeNetworks.entries.find { it.value.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } - val wifiNetwork = wifiEntry?.key - val wifiCaps = wifiEntry?.value + serviceScope.launch(Dispatchers.Default) { + val allCaps = activeNetworks.values + val isVpnActive = allCaps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } + val wifiEntry = + activeNetworks.entries.find { it.value.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } + val wifiNetwork = wifiEntry?.key + val wifiCaps = wifiEntry?.value + + val hasLocationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED + } else { + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + } - val hasLocationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED - } else { - checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - } + val currentSsid = if (hasLocationPermission) { + wifiCaps?.let { caps -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + (caps.transportInfo as? WifiInfo)?.ssid?.stripSsidQuotes() + } else { + val wm = getSystemService(WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") wm.connectionInfo?.ssid?.stripSsidQuotes() + } + } + } else null + + val app = application as DnsToggleApplication + app.detectedSsid = + if (currentSsid == "" || currentSsid?.isEmpty() == true) null else currentSsid + + val prefs = getPrefs() + val vpnOverrideEnabled = prefs.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) + val isInVpnOverride = prefs.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) + + if (isVpnActive && vpnOverrideEnabled) { + watchdogManager.cancelAll() + if (!isInVpnOverride) { + // Enter VPN override + prefs.edit { + putBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, true) + putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) + } + } + applyVpnDns() + return@launch + } else if (!isVpnActive && isInVpnOverride) { + // Exit VPN override + prefs.edit { putBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) } + dispatchStatusNotification(getString(R.string.notif_vpn_dns_restored)) + } - val currentSsid = if (hasLocationPermission) { - wifiCaps?.let { caps -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - (caps.transportInfo as? WifiInfo)?.ssid?.stripSsidQuotes() - } else { - val wm = getSystemService(WIFI_SERVICE) as WifiManager - @Suppress("DEPRECATION") wm.connectionInfo?.ssid?.stripSsidQuotes() + // Normal Wi-Fi logic + if (currentSsid == null || currentSsid == "" || currentSsid.isEmpty()) { + watchdogManager.cancelAll() + if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != null) { + prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) } } + restorePreferredDns() + return@launch } - } else null - val app = application as DnsToggleApplication - app.detectedSsid = - if (currentSsid == "" || currentSsid?.isEmpty() == true) null else currentSsid + val profiles = NetworkProfileRepository.networkProfiles.value ?: emptyList() + val profile = profiles.find { it.ssid == currentSsid } - val prefs = getPrefs() - val vpnOverrideEnabled = prefs.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) - val isInVpnOverride = prefs.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) - - if (isVpnActive && vpnOverrideEnabled) { - connectivityWatchdogJob?.cancel() - connectivityWatchdogJob = null - autoRecoveryJob?.cancel() - autoRecoveryJob = null - if (!isInVpnOverride) { - // Enter VPN override - saveCurrentDnsState() - prefs.edit { - putBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, true) - putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) + if (profile != null) { + if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != currentSsid) { + prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, currentSsid) } } - } - applyVpnDns() - return - } else if (!isVpnActive && isInVpnOverride) { - // Exit VPN override - prefs.edit { putBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) } - dispatchStatusNotification(getString(R.string.notif_vpn_dns_restored)) - } - // Normal Wi-Fi logic - if (currentSsid == null || currentSsid == "" || currentSsid.isEmpty()) { - connectivityWatchdogJob?.cancel() - connectivityWatchdogJob = null - autoRecoveryJob?.cancel() - autoRecoveryJob = null - if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != null) { - prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) } + if (profile.isEnabled) { + watchdogManager.cancelAll() // Cancels recovery if enabled + + val targetHostname = profile.targetHostname ?: getGlobalPreferredHostname() + if (targetHostname != null) { + applyHostnameDns(currentSsid, targetHostname) + } else { + restorePreferredDns() + } + + watchdogManager.evaluateConnectivityWatchdog( + currentSsid, + wifiCaps, + activeNetworks, + cachedDnsMode + ) + } else { + watchdogManager.cancelAll() // Cancels watchdog if disabled + + applyOpportunisticDns( + currentSsid, + if (profile.isAutoDetected) R.string.notif_connectivity_watchdog_disabled else R.string.notif_dns_disabled_auto + ) + watchdogManager.maybeRetryAutoDetectedSsid( + currentSsid, + profile.isAutoDetected, + wifiNetwork + ) { ssid -> + dispatchStatusNotification( + getString( + R.string.notif_connectivity_watchdog_restored, + ssid + ) + ) + } + } + } else { + if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != null) { + prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) } + } + restorePreferredDns(immediate = true) + + watchdogManager.cancelAll() + + watchdogManager.evaluateConnectivityWatchdog( + currentSsid, + wifiCaps, + activeNetworks, + cachedDnsMode + ) } - restorePreferredDns() - return } + } - val blacklist = DnsSettingsRepository.blacklist.value ?: emptySet() - if (blacklist.contains(currentSsid)) { - connectivityWatchdogJob?.cancel() - connectivityWatchdogJob = null - autoRecoveryJob?.cancel() - autoRecoveryJob = null - if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != currentSsid) { - prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, currentSsid) } - } - val isAutoDetected = - DnsSettingsRepository.autoDetectedBlacklist.value?.contains(currentSsid) == true - applyOpportunisticDns( - currentSsid, - if (isAutoDetected) R.string.notif_connectivity_watchdog_disabled else R.string.notif_dns_disabled_auto - ) - maybeRetryAutoDetectedSsid(currentSsid, isAutoDetected, wifiNetwork) - } else { - if (prefs.getString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) != null) { - prefs.edit { putString(Constants.PREF_ACTIVE_SSID_OVERRIDE, null) } + private fun getGlobalPreferredHostname(): String? { + val encryptedPrefs = (application as DnsToggleApplication).getEncryptedPrefs() + val encryptedHostname = encryptedPrefs.getString(Constants.PREF_LAST_USED_HOSTNAME, null) + return encryptedHostname?.let { + when (val result = EncryptionManager.decrypt(it)) { + is EncryptionManager.DecryptResult.Success -> result.data + else -> null } - restorePreferredDns() - evaluateConnectivityWatchdog(currentSsid, wifiCaps) - } + } ?: Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) } - private fun saveCurrentDnsState() { - val resolver = contentResolver - val currentMode = Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE) - val currentSpecifier = Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) - getPrefs().edit(true) { - putString(Constants.PREF_PRE_VPN_DNS_MODE, currentMode) - putString(Constants.PREF_PRE_VPN_DNS_SPECIFIER, currentSpecifier) - } + private fun applyHostnameDns(ssid: String, hostname: String) { + watchdogManager.cancelDebounce() + updateDnsSetting( + Constants.DNS_MODE_HOSTNAME, + ssid, + R.string.notif_dns_enabled_auto, + hostname = hostname + ) } private fun applyVpnDns() { - val vpnDns = DnsSettingsRepository.vpnDnsHostname.value + val vpnDns = VpnRepository.vpnDnsHostname.value val resolver = contentResolver val currentMode = Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE) @@ -397,162 +446,99 @@ class WifiMonitoringService : Service() { ssid: String, reasonStringResId: Int = R.string.notif_dns_disabled_auto ) { - debounceJob?.cancel() + watchdogManager.cancelDebounce() updateDnsSetting(Constants.DNS_MODE_OPPORTUNISTIC, ssid, reasonStringResId) } - private fun evaluateConnectivityWatchdog(ssid: String, wifiCaps: NetworkCapabilities?) { - val prefs = getPrefs() - if (!prefs.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) || - cachedDnsMode != Constants.DNS_MODE_HOSTNAME - ) { - connectivityWatchdogJob?.cancel() - connectivityWatchdogJob = null - return - } - - if (wifiCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true) { - connectivityWatchdogJob?.cancel() - connectivityWatchdogJob = null - return - } - - if (connectivityWatchdogJob?.isActive == true) return - - val hostname = Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) - if (hostname.isNullOrEmpty()) return - - val debounceSeconds = prefs.getInt( - Constants.PREF_CONNECTIVITY_WATCHDOG_DEBOUNCE_SECONDS, - Constants.CONNECTIVITY_WATCHDOG_DEFAULT_DEBOUNCE_SECONDS - ) - val probeTargets = prefs.getString( - Constants.PREF_CONNECTIVITY_WATCHDOG_PROBE_TARGETS, - Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS - ) ?: Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS - - connectivityWatchdogJob = serviceScope.launch { - delay(debounceSeconds.seconds) - - val stillNotValidated = activeNetworks.values - .find { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) != true - - if (!stillNotValidated || - cachedDnsMode != Constants.DNS_MODE_HOSTNAME || - (application as DnsToggleApplication).detectedSsid != ssid || - !getPrefs().getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) - ) { - return@launch - } - - if (ConnectivityWatchdog.isDnsSpecificFailure(hostname, probeTargets)) { - DnsSettingsRepository.addToBlacklist(ssid, autoDetected = true) - } + private fun restorePreferredDns(immediate: Boolean = false) { + watchdogManager.restorePreferredDns(immediate) { + restorePreferredDnsSync() } } - private fun maybeRetryAutoDetectedSsid( - ssid: String, - isAutoDetected: Boolean, - wifiNetwork: Network? - ) { - val prefs = getPrefs() - if (!isAutoDetected || - !prefs.getBoolean(Constants.PREF_CONNECTIVITY_WATCHDOG_ENABLED, false) || - retriedAutoBlacklistForNetwork == wifiNetwork + private fun restorePreferredDnsSync() { + val sharedPreferences = getPrefs() + + if (sharedPreferences.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) && + sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) ) { return } - retriedAutoBlacklistForNetwork = wifiNetwork - - val hostname = Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) - if (hostname.isNullOrEmpty()) return - - val probeTargets = prefs.getString( - Constants.PREF_CONNECTIVITY_WATCHDOG_PROBE_TARGETS, - Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS - ) ?: Constants.CONNECTIVITY_WATCHDOG_DEFAULT_PROBE_TARGETS - - autoRecoveryJob?.cancel() - autoRecoveryJob = serviceScope.launch { - if (ConnectivityWatchdog.isRecovered(hostname, probeTargets)) { - DnsSettingsRepository.removeFromBlacklist(ssid) - dispatchStatusNotification( - getString( - R.string.notif_connectivity_watchdog_restored, - ssid - ) - ) - } - } - } - private fun restorePreferredDns() { - debounceJob?.cancel() - debounceJob = serviceScope.launch { - delay(2.seconds) // Wait to avoid rapid ping-pong - val sharedPreferences = getPrefs() - - if (sharedPreferences.getBoolean(Constants.PREF_IS_IN_VPN_OVERRIDE, false) && - sharedPreferences.getBoolean(Constants.PREF_VPN_OVERRIDE_ENABLED, false) - ) { - return@launch - } - - val preferredMode = sharedPreferences.getString( - Constants.PREF_PREFERRED_DNS_MODE, - Constants.DNS_MODE_HOSTNAME - ) ?: Constants.DNS_MODE_HOSTNAME - - if (preferredMode == Constants.DNS_MODE_HOSTNAME) { - // Try to restore from last used hostname - val encryptedPrefs = (application as DnsToggleApplication).getEncryptedPrefs() - val encryptedHostname = - encryptedPrefs.getString(Constants.PREF_LAST_USED_HOSTNAME, null) - val hostname = encryptedHostname?.let { - when (val result = EncryptionManager.decrypt(it)) { - is EncryptionManager.DecryptResult.Success -> result.data - else -> null - } + val preferredMode = sharedPreferences.getString( + Constants.PREF_PREFERRED_DNS_MODE, + Constants.DNS_MODE_HOSTNAME + ) ?: Constants.DNS_MODE_HOSTNAME + + if (preferredMode == Constants.DNS_MODE_HOSTNAME) { + // Try to restore from last used hostname + val encryptedPrefs = (application as DnsToggleApplication).getEncryptedPrefs() + val encryptedHostname = + encryptedPrefs.getString(Constants.PREF_LAST_USED_HOSTNAME, null) + val hostname = encryptedHostname?.let { + when (val result = EncryptionManager.decrypt(it)) { + is EncryptionManager.DecryptResult.Success -> result.data + else -> null } + } - if (hostname != null) { - try { - Global.putString( - contentResolver, - Constants.SETTINGS_PRIVATE_DNS_SPECIFIER, - hostname - ) - updateDnsSetting(preferredMode, null) - } catch (e: SecurityException) { - Log.e(TAG, "Failed to restore preferred DNS specifier", e) - } - } else { - // Safety fallback to avoid breaking network with a null hostname - updateDnsSetting(Constants.DNS_MODE_OPPORTUNISTIC, null) - dispatchStatusNotification( - getString(R.string.keystore_error_title) + ": " + getString( - R.string.automatic_off - ) + if (hostname != null) { + try { + Global.putString( + contentResolver, + Constants.SETTINGS_PRIVATE_DNS_SPECIFIER, + hostname ) + updateDnsSetting(preferredMode, null) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to restore preferred DNS specifier", e) } } else { - updateDnsSetting(preferredMode, null) + // Safety fallback to avoid breaking network with a null hostname + updateDnsSetting(Constants.DNS_MODE_OPPORTUNISTIC, null) + dispatchStatusNotification( + getString(R.string.keystore_error_title) + ": " + getString( + R.string.automatic_off + ) + ) } + } else { + updateDnsSetting(preferredMode, null) } } private fun updateDnsSetting( newMode: String, ssidForNotification: String?, - reasonStringResId: Int = R.string.notif_dns_disabled_auto + reasonStringResId: Int = R.string.notif_dns_disabled_auto, + hostname: String? = null ) { try { val resolver = contentResolver + var changed = false + + if (newMode == Constants.DNS_MODE_HOSTNAME && hostname != null) { + val currentHostname = + Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) + if (currentHostname != hostname) { + Global.putString(resolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER, hostname) + changed = true + } + } + if (cachedDnsMode != newMode) { - Global.putString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE, newMode) - cachedDnsMode = newMode + val currentSystemMode = + Global.getString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE) + if (currentSystemMode != newMode) { + Global.putString(resolver, Constants.SETTINGS_PRIVATE_DNS_MODE, newMode) + cachedDnsMode = newMode + changed = true + } else { + cachedDnsMode = newMode + } + } + + if (changed) { ssidForNotification?.let { dispatchStatusNotification(getString(reasonStringResId, it)) } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/DnsSelectionActivity.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/DnsSelectionActivity.kt index d847629..4d183f7 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/DnsSelectionActivity.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/DnsSelectionActivity.kt @@ -22,8 +22,11 @@ import com.ericlowry.dnstoggle.R import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsHostname import com.ericlowry.dnstoggle.data.DnsManager -import com.ericlowry.dnstoggle.data.DnsSettingsRepository +import com.ericlowry.dnstoggle.data.repository.HostnameRepository +import com.ericlowry.dnstoggle.data.repository.NetworkProfileRepository import com.ericlowry.dnstoggle.service.UsbDebuggingTileService +import com.ericlowry.dnstoggle.util.EncryptionManager +import com.ericlowry.dnstoggle.util.NetworkUtils import com.google.android.material.button.MaterialButton import com.google.android.material.listitem.ListItemCardView import com.google.android.material.listitem.ListItemLayout @@ -49,7 +52,7 @@ class DnsSelectionActivity : AppCompatActivity() { } lifecycleScope.launch { - val hostnames = DnsSettingsRepository.dnsHostnames.first { it != null } ?: emptyList() + val hostnames = HostnameRepository.dnsHostnames.first { it != null } ?: emptyList() if (hostnames.size <= 1) { startActivity(Intent(this@DnsSelectionActivity, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -74,18 +77,49 @@ class DnsSelectionActivity : AppCompatActivity() { } val tvPopupTitle = dialogView.findViewById(R.id.tvPopupTitle) + val tvSsidContext = dialogView.findViewById(R.id.tvSsidContext) val prefs = (application as DnsToggleApplication).getPrefs() + + val currentSsid = NetworkUtils.getCurrentWifiSsid(this) + val autoSaveState = prefs.getBoolean(Constants.PREF_AUTO_SAVE_STATE, false) + val autoSaveHost = prefs.getBoolean(Constants.PREF_AUTO_SAVE_HOST, false) + val profiles = NetworkProfileRepository.networkProfiles.value ?: emptyList() + val activeProfile = profiles.find { it.ssid == currentSsid } + + val isOverrideHostContext = currentSsid != null && autoSaveHost + val isOverrideStateContext = currentSsid != null && autoSaveState + val isShadowedContext = + currentSsid != null && !autoSaveHost && !autoSaveState && activeProfile != null + + val currentProfile = + if (isOverrideHostContext || isOverrideStateContext) activeProfile else null + tvPopupTitle.text = prefs.getString(Constants.PREF_DYNAMIC_APP_NAME, getString(R.string.app_name)) + when { + isOverrideHostContext -> { + tvSsidContext.text = getString(R.string.qs_hostname_context, currentSsid) + tvSsidContext.visibility = View.VISIBLE + } + + isShadowedContext -> { + tvSsidContext.text = getString(R.string.qs_shadowed_warning, currentSsid) + tvSsidContext.visibility = View.VISIBLE + } + + else -> tvSsidContext.visibility = View.GONE + } + val listContainer = dialogView.findViewById(R.id.dnsListContainer) val btnSettings = dialogView.findViewById(R.id.btnSettings) - val currentSpecifier = - Settings.Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_SPECIFIER) - val currentMode = + val systemMode = Settings.Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_MODE) + val globalMode = prefs.getString(Constants.PREF_PREFERRED_DNS_MODE, systemMode) + val globalSpecifier = getGlobalFallbackHostname() + fun selectOption(selectedIndex: Int, onSelected: () -> Unit) { listContainer.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) @@ -97,44 +131,154 @@ class DnsSelectionActivity : AppCompatActivity() { }, 250) } - val totalItems = hostnames.size + 1 + val globalFallbackHostname = getGlobalFallbackHostname() + val totalItems = hostnames.size + (if (isOverrideHostContext) 2 else 1) var currentPosition = 0 + if (isOverrideHostContext) { + val defaultLabel = if (globalFallbackHostname != null) { + getString(R.string.default_dns_with_host_format, globalFallbackHostname) + } else { + getString(R.string.default_dns_label) + } + + val isOverrideMatch = + currentProfile?.isEnabled == true && currentProfile.targetHostname == null + + val isSubduedRadio = false + val showOverrideBadge = false + + val index = currentPosition++ + + val itemView = createListItem( + parent = listContainer, + text = defaultLabel, + secondaryText = null, + subtitleText = null, + isCardChecked = isOverrideMatch, + isRadioChecked = isOverrideMatch, + isSubduedRadio = isSubduedRadio, + showOverrideBadge = showOverrideBadge, + position = index, + totalItems = totalItems + ) { + selectOption(index) { + DnsManager.togglePrivateDns( + context = this@DnsSelectionActivity, + enabled = true, + targetHostname = null, + isFromTile = true + ) + } + } + listContainer.addView(itemView) + } + hostnames.forEach { dnsEntry -> val hostname = dnsEntry.hostname - val isActive = - (hostname == currentSpecifier && currentMode == Constants.DNS_MODE_HOSTNAME) - val index = currentPosition + + val isGlobalMatch = + (hostname == globalSpecifier && globalMode == Constants.DNS_MODE_HOSTNAME) + val isOverrideMatch = + (activeProfile?.isEnabled == true && activeProfile.targetHostname == hostname) + + val isCardChecked: Boolean + val isRadioChecked: Boolean + val isSubduedRadio: Boolean + val showOverrideBadge: Boolean + + if (isShadowedContext) { + isCardChecked = isGlobalMatch + isRadioChecked = isGlobalMatch || isOverrideMatch + isSubduedRadio = isGlobalMatch + showOverrideBadge = isOverrideMatch + } else if (isOverrideHostContext) { + isCardChecked = isOverrideMatch + isRadioChecked = isOverrideMatch + isSubduedRadio = false + showOverrideBadge = false + } else { + isCardChecked = isGlobalMatch + isRadioChecked = isGlobalMatch + isSubduedRadio = false + showOverrideBadge = false + } + + val index = currentPosition++ val itemView = createListItem( parent = listContainer, text = dnsEntry.getDisplayName(), secondaryText = dnsEntry.label?.let { hostname }, - isActive = isActive, - position = currentPosition, + subtitleText = null, + isCardChecked = isCardChecked, + isRadioChecked = isRadioChecked, + isSubduedRadio = isSubduedRadio, + showOverrideBadge = showOverrideBadge, + position = index, totalItems = totalItems ) { selectOption(index) { - DnsManager.togglePrivateDns(this@DnsSelectionActivity, true, hostname) + DnsManager.togglePrivateDns( + context = this@DnsSelectionActivity, + enabled = true, + targetHostname = hostname, + isFromTile = true + ) } } listContainer.addView(itemView) - currentPosition++ } - val isAutomaticActive = (currentMode != Constants.DNS_MODE_HOSTNAME) + val isGlobalOffMatch = (globalMode != Constants.DNS_MODE_HOSTNAME) + val isOverrideOffMatch = (activeProfile?.isEnabled == false) + + val isOffCardChecked: Boolean + val isOffRadioChecked: Boolean + val isOffSubduedRadio: Boolean + val showOffOverrideBadge: Boolean + + if (isShadowedContext) { + isOffCardChecked = isGlobalOffMatch + isOffRadioChecked = isGlobalOffMatch || isOverrideOffMatch + isOffSubduedRadio = isGlobalOffMatch + showOffOverrideBadge = isOverrideOffMatch + } else if (isOverrideStateContext) { + isOffCardChecked = isOverrideOffMatch + isOffRadioChecked = isOverrideOffMatch + isOffSubduedRadio = false + showOffOverrideBadge = false + } else { + isOffCardChecked = isGlobalOffMatch + isOffRadioChecked = isGlobalOffMatch + isOffSubduedRadio = false + showOffOverrideBadge = false + } + + val offSubtitle = if (isOverrideStateContext) { + getString(R.string.qs_ssid_context, currentSsid) + } else null + val autoIndex = currentPosition val autoItemView = createListItem( parent = listContainer, text = getString(R.string.automatic_off), secondaryText = null, - isActive = isAutomaticActive, - position = currentPosition, + subtitleText = offSubtitle, + isCardChecked = isOffCardChecked, + isRadioChecked = isOffRadioChecked, + isSubduedRadio = isOffSubduedRadio, + showOverrideBadge = showOffOverrideBadge, + position = autoIndex, totalItems = totalItems ) { selectOption(autoIndex) { - DnsManager.togglePrivateDns(this@DnsSelectionActivity, false) + DnsManager.togglePrivateDns( + context = this@DnsSelectionActivity, + enabled = false, + isFromTile = true + ) } } listContainer.addView(autoItemView) @@ -161,7 +305,11 @@ class DnsSelectionActivity : AppCompatActivity() { parent: ViewGroup, text: String, secondaryText: String?, - isActive: Boolean, + subtitleText: String?, + isCardChecked: Boolean, + isRadioChecked: Boolean, + isSubduedRadio: Boolean, + showOverrideBadge: Boolean, position: Int, totalItems: Int, onClick: () -> Unit @@ -171,17 +319,33 @@ class DnsSelectionActivity : AppCompatActivity() { val cardView = itemView.findViewById(R.id.listItemCard) val textView = itemView.findViewById(R.id.tvHostname) val secondaryTextView = itemView.findViewById(R.id.tvSecondaryHostname) + val overrideBadge = itemView.findViewById(R.id.tvOverrideBadge) val radioButton = itemView.findViewById(R.id.radioDns) textView.text = text - if (secondaryText != null) { + if (subtitleText != null) { + secondaryTextView.text = subtitleText + secondaryTextView.visibility = View.VISIBLE + } else if (secondaryText != null) { secondaryTextView.text = secondaryText secondaryTextView.visibility = View.VISIBLE } else { secondaryTextView.visibility = View.GONE } - cardView.isChecked = isActive - radioButton.isChecked = isActive + + overrideBadge.visibility = if (showOverrideBadge) View.VISIBLE else View.GONE + + cardView.isChecked = isCardChecked + radioButton.isChecked = isRadioChecked + + val radioColorAttr = when { + showOverrideBadge -> com.google.android.material.R.attr.colorTertiary + isSubduedRadio -> com.google.android.material.R.attr.colorOutline + else -> android.R.attr.colorPrimary + } + val resolvedColor = + com.google.android.material.color.MaterialColors.getColor(itemView, radioColorAttr) + radioButton.buttonTintList = android.content.res.ColorStateList.valueOf(resolvedColor) listItemLayout.updateAppearance(position, totalItems) cardView.setOnClickListener { onClick() } @@ -189,6 +353,24 @@ class DnsSelectionActivity : AppCompatActivity() { return itemView } + private fun getGlobalFallbackHostname(): String? { + val app = application as DnsToggleApplication + val encryptedPrefs = app.getEncryptedPrefs() + val encryptedHostname = encryptedPrefs.getString(Constants.PREF_LAST_USED_HOSTNAME, null) + val lastUsed = encryptedHostname?.let { + when (val result = EncryptionManager.decrypt(it)) { + is EncryptionManager.DecryptResult.Success -> result.data + else -> null + } + } + if (!lastUsed.isNullOrEmpty()) return lastUsed + + return Settings.Global.getString( + contentResolver, + Constants.SETTINGS_PRIVATE_DNS_SPECIFIER + ) + } + private var isFinishingAnimated = false override fun finish() { @@ -205,7 +387,6 @@ class DnsSelectionActivity : AppCompatActivity() { val popupView = findViewById(R.id.dialogRootCard) - // Skip the animation if the view is not ready. if (popupView == null || popupView.height == 0) { isFinishingAnimated = true finish() diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/MainActivity.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/MainActivity.kt index 6bba10f..d3723c0 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/MainActivity.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/MainActivity.kt @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.ComponentName import android.content.Intent import android.graphics.Color -import android.graphics.Rect import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -33,6 +32,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R +import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsViewModel import com.ericlowry.dnstoggle.service.DnsToggleService import com.ericlowry.dnstoggle.service.TileServiceCompat @@ -44,6 +44,7 @@ import com.ericlowry.dnstoggle.ui.controller.VpnSectionController import com.ericlowry.dnstoggle.ui.dialog.CommonDialogHelper import com.ericlowry.dnstoggle.ui.dialog.PermissionDialogHelper import com.ericlowry.dnstoggle.ui.dialog.SsidDialogHelper +import com.ericlowry.dnstoggle.ui.dialog._InfoNoticeHelper // TEMPORARY BETA INFO import com.ericlowry.dnstoggle.util.PermissionHelper import com.ericlowry.dnstoggle.util.RootUtils import com.ericlowry.dnstoggle.util.ShizukuUtils @@ -79,12 +80,14 @@ class MainActivity : AppCompatActivity() { private lateinit var btnFixMainPermission: Button private lateinit var cardOverrideStatus: MaterialCardView private lateinit var tvOverrideStatus: TextView + private lateinit var mainScrollView: NestedScrollView private var permissionDialog: AlertDialog? = null private var scrollSpring: SpringAnimation? = null + private var lastSpringValue = 0f private val focusChangeListener = ViewTreeObserver.OnGlobalFocusChangeListener { _, newFocus -> - if (newFocus != null && !findViewById(R.id.mainScrollView).isInTouchMode) { + if (newFocus != null && !mainScrollView.isInTouchMode) { smoothScrollToCenter(newFocus) } } @@ -127,19 +130,42 @@ class MainActivity : AppCompatActivity() { startPermissionPolling() handleIntentExtras(intent) - val mainScrollView = findViewById(R.id.mainScrollView) mainScrollView.viewTreeObserver.addOnGlobalFocusChangeListener(focusChangeListener) + setupScrollSpring() + + // TEMPORARY BETA INFO + _InfoNoticeHelper.showOnceOnStartup(this) + val contentWrapper = + findViewById(R.id.contentWrapper) + _InfoNoticeHelper.injectBetaFeedbackButton( + this, + contentWrapper, + R.id.cardMainPermissionLayout + ) + // TEMPORARY BETA INFO - END + } + + private fun setupScrollSpring() { scrollSpring = SpringAnimation(mainScrollView, DynamicAnimation.SCROLL_Y).apply { spring = SpringForce().apply { stiffness = SpringForce.STIFFNESS_MEDIUM dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY } + addUpdateListener { _, value, _ -> + val delta = (value - lastSpringValue).toInt() + lastSpringValue = value + if (delta != 0) { + mainScrollView.dispatchNestedScroll( + 0, delta, 0, 0, null, ViewCompat.TYPE_NON_TOUCH + ) + } + } } } override fun onDestroy() { - findViewById(R.id.mainScrollView).viewTreeObserver.removeOnGlobalFocusChangeListener( + mainScrollView.viewTreeObserver.removeOnGlobalFocusChangeListener( focusChangeListener ) super.onDestroy() @@ -176,7 +202,17 @@ class MainActivity : AppCompatActivity() { miscController = MiscSettingsController( activity = this, viewModel = dnsViewModel, - onOpenUrl = { openUrl(it) } + onOpenUrl = { openUrl(it) }, + onUsbToggleVisibilityChanged = { + mainScrollView.viewTreeObserver.addOnPreDrawListener(object : + ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + mainScrollView.viewTreeObserver.removeOnPreDrawListener(this) + mainScrollView.fullScroll(View.FOCUS_DOWN) + return true + } + }) + } ) } @@ -229,7 +265,6 @@ class MainActivity : AppCompatActivity() { val left = systemBars.left.coerceAtLeast(displayCutout.left) val right = systemBars.right.coerceAtLeast(displayCutout.right) - val statusParams = statusBarBackground.layoutParams statusParams.height = systemBars.top statusBarBackground.layoutParams = statusParams @@ -239,12 +274,16 @@ class MainActivity : AppCompatActivity() { contentWrapper.setPadding(left, 0, right, 0) - // 3. Spacing (Scroll Inner) + val scrollParams = + mainScrollView.layoutParams as android.view.ViewGroup.MarginLayoutParams + scrollParams.bottomMargin = systemBars.bottom + mainScrollView.layoutParams = scrollParams + scrollInnerContainer.setPadding( defaultPadding, defaultPadding, defaultPadding, - systemBars.bottom + defaultPadding + defaultPadding ) insets @@ -270,6 +309,7 @@ class MainActivity : AppCompatActivity() { } private fun initializeViews() { + mainScrollView = findViewById(R.id.mainScrollView) btnMenu = findViewById(R.id.btnMenu) cardMainPermission = findViewById(R.id.cardMainPermissionLayout) btnFixMainPermission = findViewById(R.id.btnFixMainPermission) @@ -277,6 +317,7 @@ class MainActivity : AppCompatActivity() { tvOverrideStatus = findViewById(R.id.tvOverrideStatus) dnsController.initialize( + rowPrivateDns = findViewById(R.id.rowPrivateDns), dnsToggleSwitch = findViewById(R.id.switchPrivateDns), addHostnameButton = findViewById(R.id.btnAddHostname), dnsHostnameListContainer = findViewById(R.id.dnsHostnameListContainer), @@ -287,15 +328,16 @@ class MainActivity : AppCompatActivity() { ssidController.initialize( btnSsidInfo = findViewById(R.id.btnSsidInfo), addSsidButton = findViewById(R.id.btnAddSsid), + tvWifiProfilesTitle = findViewById(R.id.tvWifiProfilesTitle), permissionNoticeText = findViewById(R.id.tvPermissionNotice), btnGrantPermission = findViewById(R.id.btnGrantPermission), dividerSsidList = findViewById(R.id.dividerSsidList), ssidListContainer = findViewById(R.id.ssidListContainer), dividerSsidSettings = findViewById(R.id.dividerSsidSettings), - rowAutoBlacklist = findViewById(R.id.rowAutoBlacklist), - switchAutoBlacklist = findViewById(R.id.switchAutoBlacklist), - rowAutoWhitelist = findViewById(R.id.rowAutoWhitelist), - switchAutoWhitelist = findViewById(R.id.switchAutoWhitelist), + rowAutoSaveState = findViewById(R.id.rowAutoSaveState), + switchAutoSaveState = findViewById(R.id.switchAutoSaveState), + rowAutoSaveHost = findViewById(R.id.rowAutoSaveHost), + switchAutoSaveHost = findViewById(R.id.switchAutoSaveHost), rowConnectivityWatchdogToggle = findViewById(R.id.rowConnectivityWatchdogToggle), switchConnectivityWatchdog = findViewById(R.id.switchConnectivityWatchdog), rowConnectivityWatchdogDebounce = findViewById(R.id.rowConnectivityWatchdogDebounce), @@ -343,6 +385,8 @@ class MainActivity : AppCompatActivity() { dnsViewModel.isInVpnOverride.observe(this) { _ -> updateOverrideStatusUi() } dnsViewModel.activeSsidOverride.observe(this) { _ -> updateOverrideStatusUi() } + dnsViewModel.currentSsid.observe(this) { _ -> updateOverrideStatusUi() } + dnsViewModel.networkProfiles.observe(this) { _ -> updateOverrideStatusUi() } dnsViewModel.hasPermissionError.observe(this) { hasError -> if (hasError == true) showInitialPermissionDialog() @@ -404,7 +448,10 @@ class MainActivity : AppCompatActivity() { private fun updateOverrideStatusUi() { val vpnActive = dnsViewModel.isInVpnOverride.value == true - val activeSsid = dnsViewModel.activeSsidOverride.value + val activeSsidOverride = dnsViewModel.activeSsidOverride.value + val currentSsid = dnsViewModel.currentSsid.value + val profiles = dnsViewModel.networkProfiles.value ?: emptyList() + val currentProfile = profiles.find { it.ssid == currentSsid } when { vpnActive -> { @@ -412,9 +459,20 @@ class MainActivity : AppCompatActivity() { tvOverrideStatus.text = getString(R.string.status_override_vpn) } - activeSsid != null -> { + currentProfile != null && (!currentProfile.isEnabled || currentProfile.targetHostname != null) -> { + cardOverrideStatus.visibility = View.VISIBLE + val state = if (currentProfile.isEnabled) { + currentProfile.targetHostname ?: getString(R.string.default_dns_label) + } else { + getString(R.string.off_label) + } + tvOverrideStatus.text = + getString(R.string.status_override_ssid_profile, currentSsid, state) + } + + activeSsidOverride != null -> { cardOverrideStatus.visibility = View.VISIBLE - tvOverrideStatus.text = getString(R.string.status_override_ssid, activeSsid) + tvOverrideStatus.text = getString(R.string.status_override_ssid, activeSsidOverride) } else -> cardOverrideStatus.visibility = View.GONE @@ -510,7 +568,10 @@ class MainActivity : AppCompatActivity() { private fun updateToolbarTitle() { val sharedPreferences = (application as DnsToggleApplication).getPrefs() supportActionBar?.title = - sharedPreferences.getString("dynamic_app_name", getString(R.string.app_name)) + sharedPreferences.getString( + Constants.PREF_DYNAMIC_APP_NAME, + getString(R.string.app_name) + ) } private fun requestTileUpdate() { @@ -596,34 +657,37 @@ class MainActivity : AppCompatActivity() { } private fun smoothScrollToCenter(view: View) { - val scrollView = findViewById(R.id.mainScrollView) + val contentChild = mainScrollView.getChildAt(0) ?: return + val appBarLayout = findViewById(R.id.appBarLayout) var isDescendant = false - var parent = view.parent - while (parent != null) { - if (parent == scrollView) { + var p = view.parent + while (p != null) { + if (p == mainScrollView) { isDescendant = true break } - parent = parent.parent + p = p.parent } - if (!isDescendant) return - val rect = Rect() + val rect = android.graphics.Rect() view.getDrawingRect(rect) - scrollView.offsetDescendantRectToMyCoords(view, rect) + (contentChild as? android.view.ViewGroup)?.offsetDescendantRectToMyCoords(view, rect) - val scrollViewHeight = scrollView.height + val absoluteTop = rect.top val viewHeight = view.height - val viewTop = rect.top + val scrollViewHeight = mainScrollView.height - val targetScrollY = viewTop - (scrollViewHeight / 2) + (viewHeight / 2) + val targetY = absoluteTop - (scrollViewHeight / 2) + (viewHeight / 2) - val contentHeight = scrollView.getChildAt(0)?.height ?: 0 - val maxScroll = contentHeight - scrollViewHeight - val clampedTargetY = targetScrollY.coerceIn(0, maxScroll.coerceAtLeast(0)) + if (targetY > (appBarLayout.height / 2)) { + appBarLayout.setExpanded(false, true) + } else if (targetY <= 0) { + appBarLayout.setExpanded(true, true) + } - scrollSpring?.animateToFinalPosition(clampedTargetY.toFloat()) + lastSpringValue = mainScrollView.scrollY.toFloat() + scrollSpring?.animateToFinalPosition(targetY.coerceAtLeast(0).toFloat()) } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/MainPermissionHandler.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/MainPermissionHandler.kt index a70e42a..cc7edbe 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/MainPermissionHandler.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/MainPermissionHandler.kt @@ -11,6 +11,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ericlowry.dnstoggle.DnsToggleApplication +import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.ui.dialog.PermissionDialogHelper import com.ericlowry.dnstoggle.util.PermissionHelper @@ -63,7 +64,8 @@ class MainPermissionHandler( val prefs = (activity.application as DnsToggleApplication).getPrefs() val isPermanentlyDenied = permissions.any { perm -> - val hasRequestedThisPerm = prefs.getBoolean("requested_$perm", false) + val hasRequestedThisPerm = + prefs.getBoolean(Constants.prefRequestedPermission(perm), false) hasRequestedThisPerm && ContextCompat.checkSelfPermission( activity, perm @@ -78,7 +80,7 @@ class MainPermissionHandler( prefs.edit { permissions.forEach { perm -> - putBoolean("requested_$perm", true) + putBoolean(Constants.prefRequestedPermission(perm), true) } } foregroundPermissionLauncher.launch(permissions) @@ -108,17 +110,18 @@ class MainPermissionHandler( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (!PermissionHelper.hasNotificationPermission(activity)) { val prefs = (activity.application as DnsToggleApplication).getPrefs() - val hasRequestedBefore = prefs.getBoolean("has_requested_notif_perms", false) + val hasRequestedBefore = + prefs.getBoolean(Constants.PREF_HAS_REQUESTED_NOTIF_PERMS, false) if (hasRequestedBefore && !activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { PermissionDialogHelper.showPermissionDeniedDialog(activity) { openAppSettings() } } else if (activity.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { PermissionDialogHelper.showNotificationPermissionRationale(activity) { - prefs.edit { putBoolean("has_requested_notif_perms", true) } + prefs.edit { putBoolean(Constants.PREF_HAS_REQUESTED_NOTIF_PERMS, true) } notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } else { - prefs.edit { putBoolean("has_requested_notif_perms", true) } + prefs.edit { putBoolean(Constants.PREF_HAS_REQUESTED_NOTIF_PERMS, true) } notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/ToggleActivity.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/ToggleActivity.kt index 7439732..ba29d79 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/ToggleActivity.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/ToggleActivity.kt @@ -14,7 +14,12 @@ class ToggleActivity : AppCompatActivity() { Settings.Global.getString(contentResolver, Constants.SETTINGS_PRIVATE_DNS_MODE) val shouldEnable = currentMode != Constants.DNS_MODE_HOSTNAME - DnsManager.togglePrivateDns(this, shouldEnable, forceFeedback = true) + DnsManager.togglePrivateDns( + context = this, + enabled = shouldEnable, + forceFeedback = true, + isFromTile = true + ) finish() } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/HostnamesAdapter.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/HostnamesAdapter.kt index 050d289..2f10750 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/HostnamesAdapter.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/HostnamesAdapter.kt @@ -37,15 +37,18 @@ class HostnamesAdapter( private var reachabilityMap: Map = emptyMap() private var activeSpecifier: String? = null private var isToggleChecked: Boolean = false + private var globalDefaultHostname: String? = null fun updateMetadata( reachability: Map?, specifier: String?, isToggled: Boolean, + globalDefaultHostname: String? = null ) { this.reachabilityMap = reachability ?: emptyMap() this.activeSpecifier = specifier this.isToggleChecked = isToggled + this.globalDefaultHostname = globalDefaultHostname notifyItemRangeChanged(0, itemCount, "metadata") } @@ -59,6 +62,7 @@ class HostnamesAdapter( val card: MaterialCardView = view as MaterialCardView val hostnameInfoContainer: View = view.findViewById(R.id.hostnameInfoContainer) val tvHostname: TextView = view.findViewById(R.id.tvHostname) + val tvDefaultBadge: TextView = view.findViewById(R.id.tvDefaultBadge) val tvSecondaryHostname: TextView = view.findViewById(R.id.tvSecondaryHostname) val tvStatus: TextView = view.findViewById(R.id.tvStatus) val btnEdit: View = view.findViewById(R.id.btnEditHostname) @@ -100,7 +104,13 @@ class HostnamesAdapter( if (payloads.size == 1 && payloads.contains("metadata")) { val reachability = reachabilityMap[hostname] ?: DnsViewModel.ReachabilityState.IDLE val isActive = (hostname == activeSpecifier) && isToggleChecked - updateStatusTextOnly(holder, reachability, isActive, dnsEntry.isUnsaved) + updateStatusTextOnly( + holder, + reachability, + isActive, + dnsEntry.isUnsaved, + holder.hostnameInfoContainer.hasFocus() + ) return } @@ -164,7 +174,16 @@ class HostnamesAdapter( } } - updateStatusTextOnly(holder, reachability, isActive, dnsEntry.isUnsaved) + holder.tvDefaultBadge.visibility = + if (hostname == globalDefaultHostname) View.VISIBLE else View.GONE + + updateStatusTextOnly( + holder, + reachability, + isActive, + dnsEntry.isUnsaved, + holder.hostnameInfoContainer.hasFocus() + ) holder.btnDelete.isEnabled = currentList.size > 1 holder.btnDelete.alpha = if (currentList.size > 1) 1.0f else 0.5f @@ -178,6 +197,10 @@ class HostnamesAdapter( } holder.itemView.setOnClickListener(mainClickListener) + holder.hostnameInfoContainer.setOnFocusChangeListener { _, hasFocus -> + updateStatusTextOnly(holder, reachability, isActive, dnsEntry.isUnsaved, hasFocus) + } + holder.hostnameInfoContainer.setOnKeyListener { v, keyCode, event -> if (event.action == KeyEvent.ACTION_UP && (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) @@ -195,12 +218,13 @@ class HostnamesAdapter( holder: ViewHolder, reachability: DnsViewModel.ReachabilityState, isActive: Boolean, - isUnsaved: Boolean + isUnsaved: Boolean, + hasFocus: Boolean ) { val context = holder.itemView.context if (!isUnsaved) { - if (isActive) { + if (isActive || hasFocus) { holder.card.strokeColor = colors.colorPrimary holder.card.strokeWidth = (2 * context.resources.displayMetrics.density).toInt() } else { diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidTouchHelperCallback.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidTouchHelperCallback.kt new file mode 100644 index 0000000..669942f --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidTouchHelperCallback.kt @@ -0,0 +1,70 @@ +package com.ericlowry.dnstoggle.ui.adapter + +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.ericlowry.dnstoggle.data.DnsViewModel + +class SsidTouchHelperCallback( + private val adapter: SsidsAdapter, + private val viewModel: DnsViewModel +) : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + + override fun isLongPressDragEnabled(): Boolean { + return adapter.itemCount > 1 + } + + override fun getDragDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val position = viewHolder.bindingAdapterPosition + val item = adapter.currentList.getOrNull(position) + if (item?.isAutoDetected == true) { + return 0 + } + return super.getDragDirs(recyclerView, viewHolder) + } + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val targetItem = adapter.currentList.getOrNull(target.bindingAdapterPosition) + if (targetItem?.isAutoDetected == true) { + return false + } + return super.canDropOver(recyclerView, current, target) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + + val targetItem = adapter.currentList.getOrNull(toPos) + if (targetItem?.isAutoDetected == true) return false + + adapter.moveItem(fromPos, toPos) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { + val currentList = adapter.currentList + viewModel.networkProfiles.value?.let { original -> + val filteredCurrent = currentList.filter { !it.isAutoDetected } + val filteredOriginal = original.filter { !it.isAutoDetected } + if (filteredCurrent != filteredOriginal) { + viewModel.updateNetworkProfileOrder(currentList) + } + } + } + } +} diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidsAdapter.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidsAdapter.kt index 1b5baea..c69d422 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidsAdapter.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/adapter/SsidsAdapter.kt @@ -1,5 +1,7 @@ package com.ericlowry.dnstoggle.ui.adapter +import android.text.SpannableString +import android.text.style.StyleSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -8,8 +10,10 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.ericlowry.dnstoggle.R -import com.ericlowry.dnstoggle.data.SsidItem +import com.ericlowry.dnstoggle.data.DnsHostname +import com.ericlowry.dnstoggle.data.NetworkProfile import com.google.android.material.card.MaterialCardView +import java.util.Collections data class SsidColors( val colorSurface: Int, @@ -19,21 +23,35 @@ data class SsidColors( ) class SsidsAdapter( - private val onEditClick: (String) -> Unit, - private val onDeleteClick: (String) -> Unit, - private val onConfirmClick: (String) -> Unit, + private val onToggleClick: (NetworkProfile) -> Unit, + private val onEditClick: (NetworkProfile) -> Unit, + private val onDeleteClick: (NetworkProfile) -> Unit, + private val onConfirmClick: (NetworkProfile) -> Unit, private val colors: SsidColors -) : ListAdapter(SsidItemDiffCallback()) { +) : ListAdapter(NetworkProfileDiffCallback()) { private var activeSsid: String? = null + private var hostnames: List = emptyList() fun updateActiveSsid(ssid: String?) { this.activeSsid = ssid notifyItemRangeChanged(0, itemCount, "active_state") } + fun updateHostnames(hostnames: List) { + this.hostnames = hostnames + notifyItemRangeChanged(0, itemCount, "hostname_labels") + } + + fun moveItem(fromPos: Int, toPos: Int) { + val mutableList = currentList.toMutableList() + Collections.swap(mutableList, fromPos, toPos) + submitList(mutableList) + } + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val card: MaterialCardView = view as MaterialCardView + val ssidInfoContainer: View = view.findViewById(R.id.ssidInfoContainer) val tvSsidName: TextView = view.findViewById(R.id.tvSsidName) val tvSsidLabel: TextView = view.findViewById(R.id.tvSsidLabel) val btnEdit: View = view.findViewById(R.id.btnEditSsid) @@ -57,57 +75,108 @@ class SsidsAdapter( val context = holder.itemView.context val isActive = (ssid == activeSsid) - if (payloads.isEmpty() || payloads.contains("active_state")) { - holder.tvSsidName.text = ssid + if (payloads.size == 1 && payloads.contains("active_state")) { + updateCardStroke(holder, item, isActive, holder.ssidInfoContainer.hasFocus()) + return + } - if (item.isAutoDetected) { + if (payloads.isEmpty() || payloads.contains("active_state") || payloads.contains("hostname_labels")) { + val isTransient = item.isAutoDetected || item.isUnsaved + + holder.tvSsidName.text = ssid + if (isTransient) { holder.tvSsidName.setTypeface(null, android.graphics.Typeface.ITALIC) - holder.tvSsidLabel.text = context.getString(R.string.ssid_auto_added_label) - holder.tvSsidLabel.visibility = View.VISIBLE + } else { + holder.tvSsidName.setTypeface(null, android.graphics.Typeface.NORMAL) + } - holder.btnEdit.visibility = View.GONE - holder.btnConfirm.visibility = View.VISIBLE - holder.unsavedBorder.visibility = View.VISIBLE + val hostnameLabel = + hostnames.find { it.hostname == item.targetHostname }?.getDisplayName() + val baseHostString = hostnameLabel ?: item.targetHostname + ?: context.getString(R.string.default_dns_label) - holder.card.setCardBackgroundColor(colors.colorSurface) - holder.card.alpha = 0.7f + val labelBase = if (item.isEnabled) { + baseHostString } else { - holder.tvSsidName.setTypeface(null, android.graphics.Typeface.NORMAL) - holder.tvSsidLabel.visibility = View.GONE + context.getString(R.string.off_profile_format, baseHostString) + } - holder.btnEdit.visibility = View.VISIBLE - holder.btnConfirm.visibility = View.GONE - holder.unsavedBorder.visibility = View.GONE + if (item.isAutoDetected) { + val prefix = context.getString(R.string.auto_blocked_prefix) + val fullText = "$prefix $labelBase" + val spannable = SpannableString(fullText) + spannable.setSpan( + StyleSpan(android.graphics.Typeface.ITALIC), + 0, + prefix.length, + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE + ) + holder.tvSsidLabel.text = spannable + } else { + holder.tvSsidLabel.text = labelBase + } + if (item.isEnabled) { holder.card.setCardBackgroundColor(colors.colorSurfaceContainer) holder.card.alpha = 1.0f + holder.card.cardElevation = (2 * context.resources.displayMetrics.density) + } else { + holder.card.setCardBackgroundColor(colors.colorSurface) + holder.card.alpha = 0.5f + holder.card.cardElevation = 0f } - if (isActive) { - holder.card.strokeColor = colors.colorPrimary - holder.card.strokeWidth = (2 * context.resources.displayMetrics.density).toInt() - } else { - if (item.isAutoDetected) { - holder.card.strokeWidth = 0 - } else { - holder.card.strokeColor = colors.colorOutlineVariant - holder.card.strokeWidth = (1 * context.resources.displayMetrics.density).toInt() - } + updateCardStroke(holder, item, isActive, holder.ssidInfoContainer.hasFocus()) + + holder.btnEdit.visibility = View.VISIBLE + holder.btnConfirm.visibility = if (isTransient) View.VISIBLE else View.GONE + holder.unsavedBorder.visibility = if (isTransient) View.VISIBLE else View.GONE + + holder.card.setOnClickListener { onToggleClick(item) } + holder.ssidInfoContainer.setOnClickListener { onToggleClick(item) } + + holder.ssidInfoContainer.setOnFocusChangeListener { _, hasFocus -> + updateCardStroke(holder, item, isActive, hasFocus) } - holder.btnEdit.setOnClickListener { onEditClick(ssid) } - holder.btnDelete.setOnClickListener { onDeleteClick(ssid) } - holder.btnConfirm.setOnClickListener { onConfirmClick(ssid) } + holder.btnEdit.setOnClickListener { onEditClick(item) } + holder.btnDelete.setOnClickListener { onDeleteClick(item) } + holder.btnConfirm.setOnClickListener { onConfirmClick(item) } + } + } + + private fun updateCardStroke( + holder: ViewHolder, + item: NetworkProfile, + isActive: Boolean, + hasFocus: Boolean + ) { + val context = holder.itemView.context + val isTransient = item.isAutoDetected || item.isUnsaved + + if (isTransient) { + // Set strokeWidth to 0 so the solid card stroke does not mask unsavedBorder + holder.card.strokeWidth = 0 + } else if (isActive || hasFocus) { + holder.card.strokeColor = colors.colorPrimary + holder.card.strokeWidth = (2 * context.resources.displayMetrics.density).toInt() + } else { + if (item.isEnabled) { + holder.card.strokeColor = colors.colorOutlineVariant + holder.card.strokeWidth = (1 * context.resources.displayMetrics.density).toInt() + } else { + holder.card.strokeWidth = 0 + } } } } -class SsidItemDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SsidItem, newItem: SsidItem): Boolean { +class NetworkProfileDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: NetworkProfile, newItem: NetworkProfile): Boolean { return oldItem.ssid == newItem.ssid } - override fun areContentsTheSame(oldItem: SsidItem, newItem: SsidItem): Boolean { + override fun areContentsTheSame(oldItem: NetworkProfile, newItem: NetworkProfile): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/BackupController.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/BackupController.kt index 7422c3c..8e73a88 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/BackupController.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/BackupController.kt @@ -14,8 +14,9 @@ import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import com.ericlowry.dnstoggle.DnsToggleApplication import com.ericlowry.dnstoggle.R -import com.ericlowry.dnstoggle.data.DnsSettingsRepository +import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsViewModel +import com.ericlowry.dnstoggle.data.repository.DnsSettingsRepository import com.ericlowry.dnstoggle.service.DnsToggleService import com.ericlowry.dnstoggle.service.TileServiceCompat import com.ericlowry.dnstoggle.ui.dialog.BackupDialogHelper @@ -155,10 +156,13 @@ class BackupController( fun showRenameAppDialog() { val sharedPreferences = (activity.application as DnsToggleApplication).getPrefs() val currentAppName = - sharedPreferences.getString("dynamic_app_name", activity.getString(R.string.app_name)) + sharedPreferences.getString( + Constants.PREF_DYNAMIC_APP_NAME, + activity.getString(R.string.app_name) + ) BackupDialogHelper.showRenameAppDialog(activity, currentAppName) { newAppName -> - sharedPreferences.edit { putString("dynamic_app_name", newAppName) } + sharedPreferences.edit { putString(Constants.PREF_DYNAMIC_APP_NAME, newAppName) } onUpdateToolbarTitle() requestTileUpdate() } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/DnsSectionController.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/DnsSectionController.kt index 100fa56..5f333db 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/DnsSectionController.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/DnsSectionController.kt @@ -26,6 +26,7 @@ class DnsSectionController( private val onShowInitialPermissionDialog: () -> Unit, private val onRequestTileUpdate: () -> Unit ) { + private lateinit var rowPrivateDns: View private lateinit var dnsToggleSwitch: MaterialSwitch private lateinit var addHostnameButton: ImageButton private lateinit var dnsHostnameListContainer: RecyclerView @@ -34,12 +35,14 @@ class DnsSectionController( private lateinit var hostnamesAdapter: HostnamesAdapter fun initialize( + rowPrivateDns: View, dnsToggleSwitch: MaterialSwitch, addHostnameButton: ImageButton, dnsHostnameListContainer: RecyclerView, rowDisableDnsTest: View, switchDisableDnsTest: MaterialSwitch ) { + this.rowPrivateDns = rowPrivateDns this.dnsToggleSwitch = dnsToggleSwitch this.addHostnameButton = addHostnameButton this.dnsHostnameListContainer = dnsHostnameListContainer @@ -71,14 +74,16 @@ class DnsSectionController( val specifier = viewModel.privateDnsSpecifier.value val reachability = viewModel.dnsReachability.value val isEnabled = (mode == Constants.DNS_MODE_HOSTNAME) + val globalDefault = viewModel.getGlobalPreferredHostname() dnsToggleSwitch.isChecked = isEnabled - hostnamesAdapter.updateMetadata(reachability, specifier, isEnabled) + hostnamesAdapter.updateMetadata(reachability, specifier, isEnabled, globalDefault) } private fun setupDnsToggle() { - dnsToggleSwitch.setOnClickListener { - val isChecked = dnsToggleSwitch.isChecked + rowPrivateDns.setOnClickListener { + val isChecked = !dnsToggleSwitch.isChecked + dnsToggleSwitch.isChecked = isChecked if (PermissionHelper.hasSecureSettingsPermission(activity)) { viewModel.togglePrivateDns(isChecked) diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/MiscSettingsController.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/MiscSettingsController.kt index db1aae3..7772bdd 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/MiscSettingsController.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/MiscSettingsController.kt @@ -22,7 +22,8 @@ import com.google.android.material.materialswitch.MaterialSwitch class MiscSettingsController( private val activity: AppCompatActivity, private val viewModel: DnsViewModel, - private val onOpenUrl: (String) -> Unit + private val onOpenUrl: (String) -> Unit, + private val onUsbToggleVisibilityChanged: () -> Unit ) { private lateinit var switchShowToast: MaterialSwitch private lateinit var rowShowToast: View @@ -117,12 +118,17 @@ class MiscSettingsController( switchUsbDebuggingTile.isChecked = prefs.getBoolean(Constants.PREF_USB_DEBUGGING_TILE_UNLOCKED, false) + rowUsbDebuggingTile.setOnClickListener { + switchUsbDebuggingTile.toggle() + } + switchUsbDebuggingTile.setOnCheckedChangeListener { _, isChecked -> prefs.edit { putBoolean(Constants.PREF_USB_DEBUGGING_TILE_UNLOCKED, isChecked) } if (!isChecked) { rowUsbDebuggingTile.visibility = View.GONE devHitCount = 0 + onUsbToggleVisibilityChanged() } (activity.application as DnsToggleApplication).updateUsbDebuggingTileAvailability() @@ -178,6 +184,7 @@ class MiscSettingsController( rowUsbDebuggingTile.visibility = View.VISIBLE switchUsbDebuggingTile.isChecked = true + onUsbToggleVisibilityChanged() (activity.application as DnsToggleApplication).updateUsbDebuggingTileAvailability() } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/SsidSectionController.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/SsidSectionController.kt index f835e2d..e5897c8 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/SsidSectionController.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/SsidSectionController.kt @@ -8,13 +8,15 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.ericlowry.dnstoggle.R import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsViewModel -import com.ericlowry.dnstoggle.data.SsidItem +import com.ericlowry.dnstoggle.data.NetworkProfile import com.ericlowry.dnstoggle.ui.MainPermissionHandler import com.ericlowry.dnstoggle.ui.adapter.SsidColors +import com.ericlowry.dnstoggle.ui.adapter.SsidTouchHelperCallback import com.ericlowry.dnstoggle.ui.adapter.SsidsAdapter import com.ericlowry.dnstoggle.ui.dialog.CommonDialogHelper import com.ericlowry.dnstoggle.ui.dialog.SsidDialogHelper @@ -32,15 +34,16 @@ class SsidSectionController( ) { private lateinit var btnSsidInfo: ImageButton private lateinit var addSsidButton: ImageButton + private lateinit var tvWifiProfilesTitle: TextView private lateinit var permissionNoticeText: TextView private lateinit var btnGrantPermission: Button private lateinit var dividerSsidList: View private lateinit var ssidListContainer: RecyclerView private lateinit var dividerSsidSettings: View - private lateinit var rowAutoBlacklist: View - private lateinit var switchAutoBlacklist: MaterialSwitch - private lateinit var rowAutoWhitelist: View - private lateinit var switchAutoWhitelist: MaterialSwitch + private lateinit var rowAutoSaveState: View + private lateinit var switchAutoSaveState: MaterialSwitch + private lateinit var rowAutoSaveHost: View + private lateinit var switchAutoSaveHost: MaterialSwitch private lateinit var rowConnectivityWatchdogToggle: View private lateinit var switchConnectivityWatchdog: MaterialSwitch private lateinit var rowConnectivityWatchdogDebounce: View @@ -52,15 +55,16 @@ class SsidSectionController( fun initialize( btnSsidInfo: ImageButton, addSsidButton: ImageButton, + tvWifiProfilesTitle: TextView, permissionNoticeText: TextView, btnGrantPermission: Button, dividerSsidList: View, ssidListContainer: RecyclerView, dividerSsidSettings: View, - rowAutoBlacklist: View, - switchAutoBlacklist: MaterialSwitch, - rowAutoWhitelist: View, - switchAutoWhitelist: MaterialSwitch, + rowAutoSaveState: View, + switchAutoSaveState: MaterialSwitch, + rowAutoSaveHost: View, + switchAutoSaveHost: MaterialSwitch, rowConnectivityWatchdogToggle: View, switchConnectivityWatchdog: MaterialSwitch, rowConnectivityWatchdogDebounce: View, @@ -70,15 +74,16 @@ class SsidSectionController( ) { this.btnSsidInfo = btnSsidInfo this.addSsidButton = addSsidButton + this.tvWifiProfilesTitle = tvWifiProfilesTitle this.permissionNoticeText = permissionNoticeText this.btnGrantPermission = btnGrantPermission this.dividerSsidList = dividerSsidList this.ssidListContainer = ssidListContainer this.dividerSsidSettings = dividerSsidSettings - this.rowAutoBlacklist = rowAutoBlacklist - this.switchAutoBlacklist = switchAutoBlacklist - this.rowAutoWhitelist = rowAutoWhitelist - this.switchAutoWhitelist = switchAutoWhitelist + this.rowAutoSaveState = rowAutoSaveState + this.switchAutoSaveState = switchAutoSaveState + this.rowAutoSaveHost = rowAutoSaveHost + this.switchAutoSaveHost = switchAutoSaveHost this.rowConnectivityWatchdogToggle = rowConnectivityWatchdogToggle this.switchConnectivityWatchdog = switchConnectivityWatchdog this.rowConnectivityWatchdogDebounce = rowConnectivityWatchdogDebounce @@ -93,24 +98,24 @@ class SsidSectionController( } fun observeViewModel() { - viewModel.ssidBlacklist.observe(activity) { blacklist -> - refreshSsidListView(blacklist) + viewModel.networkProfiles.observe(activity) { profiles -> + refreshSsidListView(profiles) } - viewModel.autoDetectedBlacklist.observe(activity) { - refreshSsidListView(viewModel.ssidBlacklist.value ?: emptySet()) + viewModel.dnsHostnames.observe(activity) { hostnames -> + ssidsAdapter.updateHostnames(hostnames) } viewModel.currentSsid.observe(activity) { ssid -> ssidsAdapter.updateActiveSsid(ssid) } - viewModel.autoBlacklistEnabled.observe(activity) { enabled -> - switchAutoBlacklist.isChecked = enabled + viewModel.autoSaveStateEnabled.observe(activity) { enabled -> + switchAutoSaveState.isChecked = enabled } - viewModel.autoWhitelistEnabled.observe(activity) { enabled -> - switchAutoWhitelist.isChecked = enabled + viewModel.autoSaveHostEnabled.observe(activity) { enabled -> + switchAutoSaveHost.isChecked = enabled } viewModel.connectivityWatchdogEnabled.observe(activity) { enabled -> @@ -152,29 +157,40 @@ class SsidSectionController( ) ssidsAdapter = SsidsAdapter( - onEditClick = { ssid -> showAddSsidDialog(ssid) }, - onDeleteClick = { ssid -> showDeleteConfirmDialog(ssid) }, - onConfirmClick = { ssid -> - viewModel.promoteSsidToPermanent(ssid) + onToggleClick = { profile -> + viewModel.upsertNetworkProfile( + ssid = profile.ssid, + isEnabled = !profile.isEnabled, + targetHostname = profile.targetHostname, + isAutoDetected = profile.isAutoDetected + ) + }, + onEditClick = { profile -> showAddSsidDialog(profile) }, + onDeleteClick = { profile -> showDeleteConfirmDialog(profile.ssid) }, + onConfirmClick = { profile -> + viewModel.promoteSsidToPermanent(profile.ssid) Toast.makeText(activity, R.string.ssid_saved, Toast.LENGTH_SHORT).show() }, colors = colors ) ssidListContainer.adapter = ssidsAdapter + + val itemTouchHelper = ItemTouchHelper(SsidTouchHelperCallback(ssidsAdapter, viewModel)) + itemTouchHelper.attachToRecyclerView(ssidListContainer) } private fun setupAutoSettings() { - rowAutoBlacklist.setOnClickListener { switchAutoBlacklist.toggle() } - switchAutoBlacklist.setOnCheckedChangeListener { _, isChecked -> - viewModel.setAutoBlacklist(isChecked) + rowAutoSaveState.setOnClickListener { switchAutoSaveState.toggle() } + switchAutoSaveState.setOnCheckedChangeListener { _, isChecked -> + viewModel.setAutoSaveState(isChecked) if (isChecked) { permissionHandler.checkSsidPermissions(requestIfNotGranted = true) } } - rowAutoWhitelist.setOnClickListener { switchAutoWhitelist.toggle() } - switchAutoWhitelist.setOnCheckedChangeListener { _, isChecked -> - viewModel.setAutoWhitelist(isChecked) + rowAutoSaveHost.setOnClickListener { switchAutoSaveHost.toggle() } + switchAutoSaveHost.setOnCheckedChangeListener { _, isChecked -> + viewModel.setAutoSaveHost(isChecked) if (isChecked) { permissionHandler.checkSsidPermissions(requestIfNotGranted = true) } @@ -226,8 +242,8 @@ class SsidSectionController( btnSsidInfo.setOnClickListener { showWifiMonitoringInfoDialog() } } - private fun refreshSsidListView(blacklist: Set) { - if (blacklist.isEmpty()) { + private fun refreshSsidListView(profiles: List?) { + if (profiles.isNullOrEmpty()) { dividerSsidList.visibility = View.GONE dividerSsidSettings.visibility = View.GONE ssidsAdapter.submitList(emptyList()) @@ -237,33 +253,27 @@ class SsidSectionController( dividerSsidList.visibility = View.VISIBLE dividerSsidSettings.visibility = View.VISIBLE - val autoDetected = viewModel.autoDetectedBlacklist.value ?: emptySet() - val items = blacklist.map { ssid -> - SsidItem(ssid, isAutoDetected = autoDetected.contains(ssid)) - }.sortedWith { a, b -> - when { - a.isAutoDetected != b.isAutoDetected -> if (a.isAutoDetected) -1 else 1 - else -> a.ssid.lowercase().compareTo(b.ssid.lowercase()) - } - } + val items = profiles.sortedByDescending { it.isAutoDetected } ssidsAdapter.submitList(items) } - fun showAddSsidDialog(existingSsid: String? = null) { + fun showAddSsidDialog(existingProfile: NetworkProfile? = null) { val suggestedSsid = - if (existingSsid == null) NetworkUtils.getCurrentWifiSsid(activity) else null - SsidDialogHelper.showAddSsidDialog(activity, existingSsid, suggestedSsid) { newSsidName -> - if (existingSsid != null) { - viewModel.updateSsidInBlacklist(existingSsid, newSsidName) - } else { - viewModel.addToBlacklist(newSsidName) - } + if (existingProfile == null) NetworkUtils.getCurrentWifiSsid(activity) else null + SsidDialogHelper.showAddSsidDialog( + activity = activity, + existingProfile = existingProfile, + suggestedSsid = suggestedSsid, + globalDefaultHostname = viewModel.getGlobalPreferredHostname(), + hostnames = viewModel.dnsHostnames.value ?: emptyList() + ) { ssid, isEnabled, targetHostname -> + viewModel.saveNetworkProfile(existingProfile?.ssid, ssid, isEnabled, targetHostname) } } private fun showDeleteConfirmDialog(ssidToDelete: String) { CommonDialogHelper.showDeleteConfirmation(activity, R.string.delete_ssid_confirm) { - viewModel.removeFromBlacklist(ssidToDelete) + viewModel.removeNetworkProfile(ssidToDelete) } } @@ -280,13 +290,22 @@ class SsidSectionController( permissionNoticeText.visibility = View.GONE btnGrantPermission.visibility = View.GONE addSsidButton.isEnabled = true - switchAutoBlacklist.isEnabled = true - switchAutoWhitelist.isEnabled = true + + rowAutoSaveState.isEnabled = true + rowAutoSaveState.alpha = 1.0f + switchAutoSaveState.isEnabled = true + + rowAutoSaveHost.isEnabled = true + rowAutoSaveHost.alpha = 1.0f + switchAutoSaveHost.isEnabled = true + ssidListContainer.alpha = 1.0f val watchdogEnabled = viewModel.connectivityWatchdogEnabled.value ?: false - switchConnectivityWatchdog.isEnabled = true + rowConnectivityWatchdogToggle.isEnabled = true rowConnectivityWatchdogToggle.alpha = 1.0f + switchConnectivityWatchdog.isEnabled = true + rowConnectivityWatchdogDebounce.isEnabled = watchdogEnabled rowConnectivityWatchdogDebounce.alpha = if (watchdogEnabled) 1.0f else 0.5f rowConnectivityWatchdogTargets.isEnabled = watchdogEnabled @@ -295,12 +314,21 @@ class SsidSectionController( permissionNoticeText.visibility = View.VISIBLE btnGrantPermission.visibility = View.VISIBLE addSsidButton.isEnabled = false - switchAutoBlacklist.isEnabled = false - switchAutoWhitelist.isEnabled = false + + rowAutoSaveState.isEnabled = false + rowAutoSaveState.alpha = 0.5f + switchAutoSaveState.isEnabled = false + + rowAutoSaveHost.isEnabled = false + rowAutoSaveHost.alpha = 0.5f + switchAutoSaveHost.isEnabled = false + ssidListContainer.alpha = 0.5f - switchConnectivityWatchdog.isEnabled = false + rowConnectivityWatchdogToggle.isEnabled = false rowConnectivityWatchdogToggle.alpha = 0.5f + switchConnectivityWatchdog.isEnabled = false + rowConnectivityWatchdogDebounce.isEnabled = false rowConnectivityWatchdogDebounce.alpha = 0.5f rowConnectivityWatchdogTargets.isEnabled = false diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/VpnSectionController.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/VpnSectionController.kt index 71a8b52..77883ca 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/VpnSectionController.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/controller/VpnSectionController.kt @@ -54,7 +54,7 @@ class VpnSectionController( } viewModel.vpnDnsHostname.observe(activity) { hostname -> - tvVpnDnsValue.text = if (hostname == null || hostname == "off") { + tvVpnDnsValue.text = if (hostname == null) { activity.getString(R.string.automatic_off) } else { viewModel.dnsHostnames.value @@ -88,9 +88,8 @@ class VpnSectionController( if (hostnames.size == 1) { val hostname = hostnames.first().hostname - // FIX: Fallback to "off" and assign "off" instead of null - val current = viewModel.vpnDnsHostname.value ?: "off" - val newValue = if (current == hostname) "off" else hostname + val current = viewModel.vpnDnsHostname.value + val newValue = if (current == hostname) null else hostname viewModel.setVpnDnsHostname(newValue) return@setOnClickListener } @@ -100,7 +99,7 @@ class VpnSectionController( hostnames, viewModel.vpnDnsHostname.value ) { hostname -> - viewModel.setVpnDnsHostname(hostname ?: "off") + viewModel.setVpnDnsHostname(hostname) } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/DnsDialogHelper.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/DnsDialogHelper.kt index 23510bb..71a73b8 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/DnsDialogHelper.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/DnsDialogHelper.kt @@ -97,7 +97,7 @@ object DnsDialogHelper { hostnames.forEachIndexed { index, dnsEntry -> val hostname = dnsEntry.hostname - val isActive = (hostname == currentVpnDns && currentVpnDns != "off") + val isActive = (hostname == currentVpnDns) val itemView = createDnsListItem( activity, listContainer, @@ -113,7 +113,7 @@ object DnsDialogHelper { listContainer.addView(itemView) } - val isAutoActive = (currentVpnDns == null || currentVpnDns == "off") + val isAutoActive = (currentVpnDns == null) val autoItemView = createDnsListItem( activity, listContainer, @@ -123,7 +123,7 @@ object DnsDialogHelper { totalItems - 1, totalItems ) { - onDnsSelected("off") + onDnsSelected(null) dialog.dismiss() } listContainer.addView(autoItemView) diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/SsidDialogHelper.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/SsidDialogHelper.kt index df61638..03d7396 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/SsidDialogHelper.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/SsidDialogHelper.kt @@ -3,20 +3,35 @@ package com.ericlowry.dnstoggle.ui.dialog import android.app.Activity import android.content.Context import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import com.ericlowry.dnstoggle.R +import com.ericlowry.dnstoggle.data.DnsHostname +import com.ericlowry.dnstoggle.data.NetworkProfile +import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.listitem.ListItemCardView +import com.google.android.material.listitem.ListItemLayout +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.radiobutton.MaterialRadioButton import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout object SsidDialogHelper { + private data class ProfileOption(val isEnabled: Boolean, val hostname: String?) + fun showAddSsidDialog( activity: Activity, - existingSsid: String?, + existingProfile: NetworkProfile?, suggestedSsid: String?, - onSave: (String) -> Unit, + globalDefaultHostname: String?, + hostnames: List, + onSave: (ssid: String, isEnabled: Boolean, targetHostname: String?) -> Unit, ) { val dialogView = LayoutInflater.from(activity).inflate( @@ -26,18 +41,176 @@ object SsidDialogHelper { ) val textInputLayout = dialogView.findViewById(R.id.textInputLayout) val inputTextField = dialogView.findViewById(R.id.etInput) + val mainContainer = textInputLayout.parent as LinearLayout + + val radioContainer = LinearLayout(activity).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + orientation = LinearLayout.VERTICAL + setPadding(0, 16, 0, 0) + } + + var selectedEnabled = existingProfile?.isEnabled ?: true + var selectedHostname = existingProfile?.targetHostname + + val switchRow = LinearLayout(activity).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.CENTER_VERTICAL + setPadding(0, 16, 0, 8) + + val textView = TextView(activity).apply { + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + text = activity.getString(R.string.enable_private_dns) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge) + } + val materialSwitch = MaterialSwitch(activity).apply { + id = View.generateViewId() + isChecked = selectedEnabled + } + + addView(textView) + addView(materialSwitch) + + setOnClickListener { materialSwitch.toggle() } + materialSwitch.setOnCheckedChangeListener { _, isChecked -> + selectedEnabled = isChecked + radioContainer.alpha = if (isChecked) 1.0f else 0.6f + } + } + + mainContainer.addView(switchRow) + + val headerView = TextView(activity).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins( + 0, + (16 * activity.resources.displayMetrics.density).toInt(), + 0, + (8 * activity.resources.displayMetrics.density).toInt() + ) + } + text = activity.getString(R.string.profile_dns_section_header) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium) + setTextColor( + MaterialColors.getColor( + this, + com.google.android.material.R.attr.colorOnSurfaceVariant + ) + ) + } + mainContainer.addView(headerView) + + mainContainer.addView(radioContainer) + radioContainer.alpha = if (selectedEnabled) 1.0f else 0.6f textInputLayout.hint = activity.getString(R.string.ssid_hint) - val textToSet = existingSsid ?: suggestedSsid + val textToSet = existingProfile?.ssid ?: suggestedSsid if (textToSet != null) { inputTextField.setText(textToSet) inputTextField.setSelection(textToSet.length) } + fun updateSelectionVisuals() { + for (i in 0 until radioContainer.childCount) { + val itemView = radioContainer.getChildAt(i) + val card = itemView.findViewById(R.id.listItemCard) + val radio = itemView.findViewById(R.id.radioDns) + + val option = itemView.tag as? ProfileOption + val isSelected = option != null && option.hostname == selectedHostname + + val bgColorAttr = if (isSelected) { + com.google.android.material.R.attr.colorSecondaryContainer + } else { + com.google.android.material.R.attr.colorSurfaceContainer + } + card.setCardBackgroundColor(MaterialColors.getColor(activity, bgColorAttr, 0)) + + card.isChecked = isSelected + radio.isChecked = isSelected + } + } + + fun createRadioItem( + text: String, + hostname: String?, + position: Int, + total: Int + ) { + val itemView = LayoutInflater.from(activity) + .inflate(R.layout.item_dns_selection, radioContainer, false) + val listItemLayout = itemView as ListItemLayout + val card = itemView.findViewById(R.id.listItemCard) + val tvMain = itemView.findViewById(R.id.tvHostname) + val tvSub = itemView.findViewById(R.id.tvSecondaryHostname) + + tvMain.text = text + if (hostname != null && hostname != text) { + tvSub.text = hostname + tvSub.visibility = View.VISIBLE + } else { + tvSub.visibility = View.GONE + } + + itemView.tag = ProfileOption(true, hostname) + listItemLayout.updateAppearance(position, total) + + card.setCardBackgroundColor( + MaterialColors.getColor( + activity, + com.google.android.material.R.attr.colorSurfaceContainer, + 0 + ) + ) + card.strokeColor = MaterialColors.getColor( + activity, + com.google.android.material.R.attr.colorOutlineVariant, + 0 + ) + card.isFocusableInTouchMode = false + card.setOnClickListener { + selectedHostname = hostname + updateSelectionVisuals() + } + + radioContainer.addView(itemView) + } + + val totalItems = hostnames.size + 1 + var currentPos = 0 + + val defaultLabel = if (globalDefaultHostname != null) { + activity.getString(R.string.default_dns_with_host_format, globalDefaultHostname) + } else { + activity.getString(R.string.default_dns_label) + } + + createRadioItem( + defaultLabel, + null, + currentPos++, + totalItems + ) + + hostnames.forEach { dns -> + createRadioItem(dns.getDisplayName(), dns.hostname, currentPos++, totalItems) + } + + updateSelectionVisuals() + val dialog = MaterialAlertDialogBuilder(activity) .setTitle( - if (existingSsid == null) activity.getString(R.string.add_ssid) else activity.getString( + if (existingProfile == null) activity.getString(R.string.add_ssid) else activity.getString( R.string.edit_ssid, ) ) @@ -45,7 +218,7 @@ object SsidDialogHelper { .setPositiveButton(activity.getString(R.string.ok)) { _, _ -> val newSsidName = inputTextField.text.toString().trim().removePrefix("\"").removeSuffix("\"") - if (newSsidName.isNotEmpty()) onSave(newSsidName) + if (newSsidName.isNotEmpty()) onSave(newSsidName, selectedEnabled, selectedHostname) } .setNegativeButton(activity.getString(R.string.cancel), null) .create() diff --git a/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/_InfoNoticeHelper.kt b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/_InfoNoticeHelper.kt new file mode 100644 index 0000000..89d7c8f --- /dev/null +++ b/app/src/main/java/com/ericlowry/dnstoggle/ui/dialog/_InfoNoticeHelper.kt @@ -0,0 +1,131 @@ +package com.ericlowry.dnstoggle.ui.dialog + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.edit +import androidx.core.net.toUri +import com.ericlowry.dnstoggle.R +import com.google.android.material.button.MaterialButton + +object _InfoNoticeHelper { + private const val TAG = "FeedbackHelper" + private const val PREF_FEEDBACK_SHOWN = "pref_feedback_shown_v2_pre" + + fun showOnceOnStartup(activity: AppCompatActivity) { + val prefs = activity.getSharedPreferences("app_prefs_v2", Context.MODE_PRIVATE) + if (!prefs.getBoolean(PREF_FEEDBACK_SHOWN, false)) { + showDialog(activity) + prefs.edit { putBoolean(PREF_FEEDBACK_SHOWN, true) } + } + } + + fun showDialog(context: Context) { + val view = LayoutInflater.from(context).inflate(R.layout._temp_dialog_info, null) + + val dialog = + com.google.android.material.dialog.MaterialAlertDialogBuilder(context).setView(view) + .create() + + view.findViewById(R.id.btnGithub)?.setOnClickListener { + openUrl( + context, + "https://github.com/ELowry/DNSToggle/discussions/new?category=chat&title=%5Bv2%3A%5D%20" + ) + dialog.dismiss() + } + + view.findViewById(R.id.btnSupport)?.setOnClickListener { + openUrl( + context, + "https://github.com/ELowry/DNSToggle/issues/new?template=bug-report.yml&title=%5BBug%2Fv2%3A%5D%20" + ) + dialog.dismiss() + } + + dialog.show() + } + + fun injectBetaFeedbackButton( + activity: AppCompatActivity, + parent: ConstraintLayout, + belowOfId: Int = -1 + ) { + val button = MaterialButton(activity).apply { + id = View.generateViewId() + text = "V2 Feedback" + setIconResource(R.drawable.ic_warning) + setOnClickListener { showDialog(activity) } + + isFocusable = true + isFocusableInTouchMode = false + + val padding = (4 * activity.resources.displayMetrics.density).toInt() + setPadding(padding * 2, padding, padding * 2, padding) + + setOnFocusChangeListener { view, hasFocus -> + val scale = if (hasFocus) 1.05f else 1.0f + val elevation = if (hasFocus) 8f * activity.resources.displayMetrics.density else 0f + + view.animate() + .scaleX(scale) + .scaleY(scale) + .translationZ(elevation) + .setDuration(150) + .start() + } + } + + parent.addView(button) + + val margin = (16 * activity.resources.displayMetrics.density).toInt() + + val set = ConstraintSet() + set.clone(parent) + + // Constrain button to top of parent + set.connect( + button.id, + ConstraintSet.TOP, + ConstraintSet.PARENT_ID, + ConstraintSet.TOP, + margin + ) + set.connect( + button.id, + ConstraintSet.START, + ConstraintSet.PARENT_ID, + ConstraintSet.START, + margin + ) + set.connect( + button.id, + ConstraintSet.END, + ConstraintSet.PARENT_ID, + ConstraintSet.END, + margin + ) + set.constrainWidth(button.id, ConstraintSet.WRAP_CONTENT) + set.constrainHeight(button.id, ConstraintSet.WRAP_CONTENT) + + // If there was a view at the top, push it down + if (belowOfId != -1) { + set.connect(belowOfId, ConstraintSet.TOP, button.id, ConstraintSet.BOTTOM, margin) + } + + set.applyTo(parent) + } + + private fun openUrl(context: Context, url: String) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (e: Exception) { + Log.e(TAG, "Failed to open URL: $url", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ericlowry/dnstoggle/util/EncryptionManager.kt b/app/src/main/java/com/ericlowry/dnstoggle/util/EncryptionManager.kt index 2b2412a..2360ee1 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/util/EncryptionManager.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/util/EncryptionManager.kt @@ -95,14 +95,15 @@ object EncryptionManager { return if (input.startsWith(PREFIX)) { decryptInternal(input.substring(PREFIX.length)) } else { - // Try legacy decryption (no prefix) + // START_LEGACY_MIGRATION_CODE: Legacy decryption (no prefix) val result = decryptInternal(input) if (result is DecryptResult.Success || result is DecryptResult.KeyInvalidated) { result } else { - // If legacy decryption failed, and it doesn't have the prefix, assume it is plaintext + // Assume plaintext on failure DecryptResult.Success(input) } + // END_LEGACY_MIGRATION_CODE } } diff --git a/app/src/main/java/com/ericlowry/dnstoggle/util/NetworkUtils.kt b/app/src/main/java/com/ericlowry/dnstoggle/util/NetworkUtils.kt index 2bff187..ea5a7c5 100644 --- a/app/src/main/java/com/ericlowry/dnstoggle/util/NetworkUtils.kt +++ b/app/src/main/java/com/ericlowry/dnstoggle/util/NetworkUtils.kt @@ -8,7 +8,7 @@ import android.net.wifi.WifiManager import android.os.Build import com.ericlowry.dnstoggle.DnsToggleApplication import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withTimeoutOrNull import java.net.InetSocketAddress import java.net.Socket @@ -57,16 +57,16 @@ object NetworkUtils { } suspend fun isHostReachable(host: String, port: Int, timeoutMs: Int = 3000): Boolean = - withContext(Dispatchers.IO) { - withTimeoutOrNull(timeoutMs.toLong().milliseconds) { - try { + withTimeoutOrNull(timeoutMs.toLong().milliseconds) { + try { + runInterruptible(Dispatchers.IO) { Socket().use { it.connect(InetSocketAddress(host, port), timeoutMs) } - true - } catch (_: Exception) { - false } - } ?: false - } + true + } catch (_: Exception) { + false + } + } ?: false fun isValidDnsHostname(hostname: String): Boolean { val hostnameRegex = diff --git a/app/src/main/res/drawable/bg_pill_badge.xml b/app/src/main/res/drawable/bg_pill_badge.xml new file mode 100644 index 0000000..0dfffb7 --- /dev/null +++ b/app/src/main/res/drawable/bg_pill_badge.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_ellipsis.xml b/app/src/main/res/drawable/ic_ellipsis.xml deleted file mode 100644 index 02b7d67..0000000 --- a/app/src/main/res/drawable/ic_ellipsis.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_info_i.xml b/app/src/main/res/drawable/ic_info_i.xml new file mode 100644 index 0000000..0e8e4fb --- /dev/null +++ b/app/src/main/res/drawable/ic_info_i.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_question.xml b/app/src/main/res/drawable/ic_question.xml new file mode 100644 index 0000000..b1ba48c --- /dev/null +++ b/app/src/main/res/drawable/ic_question.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/_temp_dialog_info.xml b/app/src/main/res/layout/_temp_dialog_info.xml new file mode 100644 index 0000000..a4f76f5 --- /dev/null +++ b/app/src/main/res/layout/_temp_dialog_info.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c123ea2..dfde263 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -100,9 +100,9 @@ android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="?attr/colorOnPrimaryContainer" android:textStyle="bold" - app:drawableStartCompat="@drawable/ic_info" + app:drawableStartCompat="@drawable/ic_info_i" app:drawableTint="?attr/colorPrimary" - tools:text="VPN Override Active" /> + tools:text="@string/status_override_vpn" /> + app:layout_constraintTop_toBottomOf="@id/wifiProfiles" /> - + android:background="@drawable/bg_row_selectable" + android:clickable="true" + android:focusable="true" + android:focusableInTouchMode="false" + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="4dp" + app:layout_constraintTop_toTopOf="parent"> - + + + + + app:layout_constraintTop_toBottomOf="@id/rowPrivateDns"> + tools:text="@string/automatic_off" /> @@ -37,7 +37,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_centerVertical="true" - android:layout_toEndOf="@id/tvSsidBlacklistTitle" + android:layout_toEndOf="@id/tvWifiProfilesTitle" android:background="?attr/selectableItemBackgroundBorderless" android:clickable="true" android:contentDescription="@string/wifi_monitoring_info_title" @@ -56,6 +56,15 @@ android:src="@drawable/ic_add" /> + + + + + + + + + + android:focusable="false" /> + + + + + + + + android:focusable="false" /> @@ -217,7 +268,7 @@ android:background="@drawable/bg_row_selectable" android:clickable="true" android:focusable="true" - android:focusableInTouchMode="true" + android:focusableInTouchMode="false" android:gravity="center_vertical" android:minHeight="48dp" android:orientation="horizontal" @@ -254,7 +305,7 @@ android:background="@drawable/bg_row_selectable" android:clickable="true" android:focusable="true" - android:focusableInTouchMode="true" + android:focusableInTouchMode="false" android:gravity="center_vertical" android:minHeight="48dp" android:orientation="horizontal" diff --git a/app/src/main/res/layout/dialog_dns_selection.xml b/app/src/main/res/layout/dialog_dns_selection.xml index 610d226..67ba56e 100644 --- a/app/src/main/res/layout/dialog_dns_selection.xml +++ b/app/src/main/res/layout/dialog_dns_selection.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/layout/item_dns_selection.xml b/app/src/main/res/layout/item_dns_selection.xml index 7af6f4c..2e2296a 100644 --- a/app/src/main/res/layout/item_dns_selection.xml +++ b/app/src/main/res/layout/item_dns_selection.xml @@ -1,5 +1,6 @@ @@ -25,12 +26,36 @@ android:layout_weight="1" android:orientation="vertical"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + tools:text="Default" /> Einstellungen An Aus - Automatisch (aus) + Aus (automatisch) Berechtigung erforderlich Um diesen Schalter nutzen zu können, müssen Sie über Root oder Shizuku die entsprechenden Berechtigungen erteilen oder diesen ADB-Befehl auf Ihrem Computer ausführen, wobei das USB-Debugging aktiviert sein muss: ADB Befehl diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index da71bc9..6d3f654 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -54,8 +54,6 @@ Para permitir el cambio automático, la aplicación supervisa los cambios en la red. El impacto en el consumo de batería debería ser mínimo, pero es posible que el sistema siga reportando actividad en segundo plano. Algunos dispositivos desactivan el monitoreo en segundo plano para ahorrar batería. Si la automatización deja de funcionar, desactiva las restricciones para esta app. Desactivar las restricciones de la batería - Incluir automáticamente en la lista negra al desactivar el DNS privado - Incluir automáticamente en la lista blanca al habilitar el DNS privado Inclusión automática en la lista negra en caso de falla de conectividad Cuando el DNS privado falla, pero sigue habiendo conexión a Internet, la aplicación agregará automáticamente la red actual a la lista negra para restablecer la conectividad. Espera antes de desactivarlo @@ -77,7 +75,6 @@ Editar SSID SSID (nombre de la red Wi-Fi) SSID incluido de forma permanente en la lista negra - Agregado automáticamente ¿Quitar este SSID de la lista negra? Anulación de VPN Cambiar automáticamente el DNS privado cuando se conecte una VPN. @@ -95,8 +92,6 @@ Muestra un mosaico adicional en Ajustes rápidos para activar o desactivar la depuración USB. Mantén presionado para abrir las opciones de desarrollador. Toca %1$d veces más para habilitar un mosaico de depuración USB. ¡Ya está disponible el mosaico de Ajustes rápidos para la depuración USB! - Activando… - Desactivando… Cambiar el nombre de la aplicación Elige un nombre que tenga sentido para ti. Este aparecerá en el ícono de la app y en el mosaico de Ajustes rápidos. Configuración de exportación diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c7729a1..a611068 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,146 +1,141 @@ - + Version de l\'application: %s - Nouveautés - Nouveau dans la v%s - GitHub (code source) - Obtenir de l\'aide - + Nouveautés + Nouveau dans la v%s + GitHub (code source) + Obtenir de l\'aide + OK - Annuler - Fermer - Paramètres - Activé - Désactivé - Automatique (désactivé) - + Annuler + Fermer + Paramètres + Activé + Désactivé + Désactivé (automatique) + Autorisation requise - Pour utiliser cette application, vous devez accorder des autorisations d\'accès aux paramètres via root, Shizuku, ou exécuter cette commande ADB sur votre ordinateur avec le débogage USB activé: - Commande ADB - Commande ADB: %s - Copier la commande ADB - Commande copiée dans le presse-papiers - Accorder via Shizuku - Accorder via Root - Tentative d\'octroi de permission - Tentative d\'octroi via Shizuku… - Tentative d\'octroi via Root… - Tentative d\'octroi automatique… - L\'octroi via Shizuku/Root a échoué ou été refusé - Autorisation accordée - Autorisation système requise - L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. - Fixer - Ouvrez les paramètres - + Pour utiliser cette application, vous devez accorder des autorisations d\'accès aux paramètres via root, Shizuku, ou exécuter cette commande ADB sur votre ordinateur avec le débogage USB activé: + Commande ADB + Commande ADB: %s + Copier la commande ADB + Commande copiée dans le presse-papiers + Accorder via Shizuku + Accorder via Root + Tentative d\'octroi de permission + Tentative d\'octroi via Shizuku… + Tentative d\'octroi via Root… + Tentative d\'octroi automatique… + L\'octroi via Shizuku/Root a échoué ou été refusé + Autorisation accordée + Autorisation système requise + L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. + Fixer + Ouvrez les paramètres + DNS privé - Ignorer le test de connexion - Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. - Fournisseur(s) DNS personnalisé - Ajouter une addresse DNS - Enregistrer cette addresse DNS active - Modifier l\'addresse - Addresse DNS (ex: dns.nextdns.io) - Surnom (optionnel) - Supprimer cette addresse? - Le DNS privé ne peut être active tant que l\'adresse de l\'hôte est vide - Format d\'adresse d\'hôte DNS non valide - + Ignorer le test de connexion + Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. + Fournisseur(s) DNS personnalisé + Ajouter une addresse DNS + Enregistrer cette addresse DNS active + Modifier l\'addresse + Addresse DNS (ex: dns.nextdns.io) + Surnom (optionnel) + Supprimer cette addresse? + Le DNS privé ne peut être active tant que l\'adresse de l\'hôte est vide + Format d\'adresse d\'hôte DNS non valide + Test de connexion… - Actif (non enregistré) - Actif et joignable - Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. - Override VPN en cours - SSID exclu (%s) - - Réseaux exclus - Info sur la surveillance du SSID - Pour prendre en charge le basculement automatique, l\'application surveille les changements de réseau. L\'impact sur la consommation de la batterie devrait être minimal, mais le système peut afficher une activité en arrière-plan continue. - Certains appareils arrêtent la surveillance en arrière-plan pour économiser la batterie. Si l\'automatisation cesse de fonctionner, désactivez les restrictions pour cette application. - Désactiver les restrictions de batterie - Ajouter aux exclusions lors d\'une désactivation - Retirer des exclusions lors d\'une activation - Exclure automatiquement si échec de connexion - Lorsque le DNS privé échoue mais que l\'internet est toujours disponible, l\'application ajoutera automatiquement le réseau actuel aux exclusions pour restaurer la connectivité. - Délai avant désactivation - Serveurs de test - Serveurs de test de connectivité - Adresses IP séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) - Personnalisé… - Saisissez une valeur entre 5 et 300 secondes - %ds - L\'autorisation de localisation/proximité (y compris en arrière-plan) est requise pour vérifier le nom du Wi-Fi. - Accorder les autorisations - Les autorisations de localisation et d\'appareils à proximité ont été refusées précédemment. Veuillez ouvrir les paramètres de l\'application pour lui donner les autorisations nécessaires afin d\'utiliser cette fonctionnalité. - Pour vous avertir lorsque le DNS privé est automatiquement activé en arrière-plan, et pour garantir la vérification en arrière-plan, veuillez autoriser les notifications. - Pour surveiller le Wi-Fi lorsque l\'application est fermée, Android nécessite l\'accès à la position \"Toujours autoriser\". DNS Toggle utilise uniquement cela pour voir le nom du Wi-Fi et ne suit jamais votre position physique ni n\'utilise le GPS. - \n\nRemarque: L\'utilisation d\'un VPN peut masquer le nom de votre réseau Wi-Fi, ce qui désactive temporairement l\'ajout ou le retrait automatique de SSID. - Ajouter un SSID - Modifier le SSID - SSID (Nom du Wi-Fi) - Réseau ajouté aux exclusions permanentes - Ajouté automatiquement - Retirer ce réseau des exclusions ? - + Actif (non enregistré) + Actif et joignable + Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. + Override VPN en cours + SSID exclu (%s) + Profils Wi-Fi + Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. + Info sur la surveillance du SSID + Pour prendre en charge le basculement automatique, l\'application surveille les changements de réseau. L\'impact sur la consommation de la batterie devrait être minimal, mais le système peut afficher une activité en arrière-plan continue. + Certains appareils arrêtent la surveillance en arrière-plan pour économiser la batterie. Si l\'automatisation cesse de fonctionner, désactivez les restrictions pour cette application. + Désactiver les restrictions de batterie + Exclure automatiquement si échec de connexion + Lorsque le DNS privé échoue mais que l\'internet est toujours disponible, l\'application ajoutera automatiquement le réseau actuel aux exclusions pour restaurer la connectivité. + Délai avant désactivation + Serveurs de test + Serveurs de test de connectivité + Adresses IP séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) + Personnalisé… + Saisissez une valeur entre 5 et 300 secondes + %ds + L\'autorisation de localisation/proximité (y compris en arrière-plan) est requise pour vérifier le nom du Wi-Fi. + Accorder les autorisations + Les autorisations de localisation et d\'appareils à proximité ont été refusées précédemment. Veuillez ouvrir les paramètres de l\'application pour lui donner les autorisations nécessaires afin d\'utiliser cette fonctionnalité. + Pour vous avertir lorsque le DNS privé est automatiquement activé en arrière-plan, et pour garantir la vérification en arrière-plan, veuillez autoriser les notifications. + Pour surveiller le Wi-Fi lorsque l\'application est fermée, Android nécessite l\'accès à la position \"Toujours autoriser\". DNS Toggle utilise uniquement cela pour voir le nom du Wi-Fi et ne suit jamais votre position physique ni n\'utilise le GPS. + \n\nRemarque: L\'utilisation d\'un VPN peut masquer le nom de votre réseau Wi-Fi, désactivant temporairement l\'automatisation des profils Wi-Fi. + Ajouter un SSID + Modifier le SSID + SSID (Nom du Wi-Fi) + Réseau ajouté aux exclusions permanentes + Retirer ce réseau des exclusions ? + Override VPN - Changer automatiquement le DNS privé lorsqu\'un VPN est connecté. - DNS lorsque VPN - L\'autorisation d\'afficher des notifications est requise pour les options d\'automatisation. - L\'addresse DNS précédemment utilisé pour le VPN a été supprimé. L\'override VPN a été désactivé. - + Changer automatiquement le DNS privé lorsqu\'un VPN est connecté. + DNS lorsque VPN + L\'autorisation d\'afficher des notifications est requise pour les options d\'automatisation. + L\'addresse DNS précédemment utilisé pour le VPN a été supprimé. L\'override VPN a été désactivé. + Options - Afficher les notifications toast d\'état - Afficher une notification toast lors des changements de mode de DNS privé. - "Masquer l\'icône de l\'application - "Une fois activée, l\'application sera masquée de votre tiroir d\'applications. Vous pourrez toujours y accéder en effectuant un appui long sur la tuile des paramètres rapides. Note: certains appareils refusent parfois de masquer les applications entièrement. - + Afficher les notifications toast d\'état + Afficher une notification toast lors des changements de mode de DNS privé. + "Masquer l\'icône de l\'application + "Une fois activée, l\'application sera masquée de votre tiroir d\'applications. Vous pourrez toujours y accéder en effectuant un appui long sur la tuile des paramètres rapides. Note: certains appareils refusent parfois de masquer les applications entièrement. + Débogage USB - Afficher la tuile de débogage USB - Afficher une tuile supplémentaire pour activer/désactiver le débogage USB. Un appui long ouvre les options développeur. - Cliquez encore %1$d fois pour activer de la tuile de débogage USB. - Tuile de débogage USB disponible dans les paramètres rapides! - Activation… - Désactivation… - + Afficher la tuile de débogage USB + Afficher une tuile supplémentaire pour activer/désactiver le débogage USB. Un appui long ouvre les options développeur. + Cliquez encore %1$d fois pour activer de la tuile de débogage USB. + Tuile de débogage USB disponible dans les paramètres rapides! + "Renommer l\'application - Choisissez un nom qui vous convient. Il sera affiché sur l\'icône de l\'application et la tuile des paramètres rapides. - Exporter la configuration - Importer une configuration - + Choisissez un nom qui vous convient. Il sera affiché sur l\'icône de l\'application et la tuile des paramètres rapides. + Exporter la configuration + Importer une configuration + Réinitialisation de sécurité - En raison d\'un changement dans les paramètres de sécurité de votre appareil (comme la modification du verrouillage de l\'écran), votre liste d\'exclusions Wi-Fi a été réinitialisée pour protéger vos données. Vous devrez rajouter vos réseaux exclus. - + En raison d\'un changement dans les paramètres de sécurité de votre appareil (comme la modification du verrouillage de l\'écran), votre liste d\'exclusions Wi-Fi a été réinitialisée pour protéger vos données. Vous devrez rajouter vos réseaux exclus. + Exporter - Saisissez un mot de passe pour chiffrer votre fichier de sauvegarde. Vous aurez besoin de ce même mot de passe pour restaurer cette configuration ulterieurement. - Mot de passe - Importer - Importer cette configuration remplacera les paramètres de l\'application ainsi que les addresses DNS et SSID enregistrées. Voulez-vous continuer? - Configuration exportée avec succès - L\'exportation a échoué - Configuration importée avec succès - Échec de la lecture du fichier - Échec du déchiffrement. Mot de passe incorrect ou fichier corrompu. - + Saisissez un mot de passe pour chiffrer votre fichier de sauvegarde. Vous aurez besoin de ce même mot de passe pour restaurer cette configuration ulterieurement. + Mot de passe + Importer + Importer cette configuration remplacera les paramètres de l\'application ainsi que les addresses DNS et SSID enregistrées. Voulez-vous continuer? + Configuration exportée avec succès + L\'exportation a échoué + Configuration importée avec succès + Échec de la lecture du fichier + Échec du déchiffrement. Mot de passe incorrect ou fichier corrompu. + Alertes d\'automatisation - Notifications lorsque le DNS privé est automatiquement activé/désactivé ou lorsque des réseaux sont exclus. - Surveillance en arrière-plan - Une notification silencieuse et persistante requise par Android pour surveiller les réseaux Wi-Fi en arrière-plan. - Surveillance du réseau activée - DNS Toggle est actif pour automatiser le mode DNS privé. - DNS privé désactivé automatiquement pour %s - DNS privé désactivé et %s exclu en raison d\'un problème de connexion - Connectivité rétablie. DNS privé réactivé pour %s - DNS privé défini par le VPN - DNS privé restauré post-VPN - %s ajouté aux exclusions - %s retiré des exclusions - DNS privé: %s - Addresse enregistrée - Cette fonctionnalité ne peut être utilisée sur Android TV. - Activer/désactiver DNS - Activer/désactiver le DNS Privé - Options DNS - Ouvrir le choix de DNS privé - Plus d\'options + Notifications lorsque le DNS privé est automatiquement activé/désactivé ou lorsque des réseaux sont exclus. + Surveillance en arrière-plan + Une notification silencieuse et persistante requise par Android pour surveiller les réseaux Wi-Fi en arrière-plan. + Surveillance du réseau activée + DNS Toggle est actif pour automatiser le mode DNS privé. + DNS privé désactivé automatiquement pour %s + DNS privé désactivé et %s exclu en raison d\'un problème de connexion + Connectivité rétablie. DNS privé réactivé pour %s + DNS privé défini par le VPN + DNS privé restauré post-VPN + %s ajouté aux exclusions + %s retiré des exclusions + DNS privé: %s + Addresse enregistrée + Cette fonctionnalité ne peut être utilisée sur Android TV. + Activer/désactiver DNS + Activer/désactiver le DNS Privé + Options DNS + Ouvrir le choix de DNS privé + Plus d\'options diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8e69e39..e52d91c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ Ayarlar Açık Kapalı - Otomatik (kapalı) + Kapalı (otomatik) Daha fazla seçenek @@ -59,13 +59,12 @@ SSID Kara Listesi Aktif (%s) - SSID Kara Listesi + Wi-Fi Profilleri + Ayrı Wi-Fi ağları için özel Özel DNS kuralları veya istisnaları ayarlayın. Wi-Fi İzleme Bilgisi Otomatik geçişi desteklemek için uygulama ağ değişikliklerini izler. Pil tüketimi üzerindeki etkisi minimum düzeyde olacaktır ancak sistem yine de arka plan etkinliği raporlayabilir. Bazı cihazlar pil tasarrufu sağlamak için arka plan izlemeyi kapatır. Otomasyon çalışmayı durdurursa, bu uygulama için kısıtlamaları devre dışı bırak. Pil kısıtlamalarını devre dışı bırak - Devre dışı bırakırken otomatik kara listeye al - Etkinleştirirken otomatik beyaz listeye al Bağlantı hatasında otomatik kara listeye al Özel DNS başarısız olduğunda ancak internet hala mevcut olduğunda, uygulama bağlantıyı geri yüklemek için mevcut ağı otomatik olarak kara listeye ekler. Devre dışı bırakmadan önce bekle @@ -82,12 +81,11 @@ Konum ve Yakındaki cihazlar izinleri kalıcı olarak reddedildi. Bu özelliği kullanmak için lütfen bunları Uygulama Ayarları\'ndan etkinleştirin. Arka planda Özel DNS otomatik olarak değiştirildiğinde sana bildirim göndermek ve izlemenin aktif kalmasını sağlamak için lütfen bildirimlere izin ver. Uygulama kapalıyken Wi-Fi\'ı izlemek için, Android \"Her zaman izin ver\" konum erişimi gerektirir. DNS Toggle bunu yalnızca Wi-Fi adını görmek için kullanır ve fiziksel konumunu asla takip etmez veya GPS\'i kullanmaz. - \n\nNot: Aktif VPN bağlantıları Wi-Fi ağ adınızı gizleyebilir ve otomatik kara liste/beyaz liste kurallarını geçici olarak devre dışı bırakabilir. + \n\nNot: Aktif VPN bağlantıları Wi-Fi ağ adınızı gizleyebilir ve Wi-Fi Profili otomasyonunu geçici olarak devre dışı bırakabilir. SSID Ekle SSID Düzenle SSID (Wi-Fi Adı) SSID kalıcı olarak kara listeye eklendi - Otomatik eklendi Bu SSID kara listeden kaldırılsın mı? @@ -111,8 +109,6 @@ USB Hata Ayıklamayı açıp kapatmak için ekstra bir Hızlı Ayarlar kutucuğu göster. Geliştirici seçeneklerini açmak için uzun bas. USB hata ayıklama kutucuğunu etkinleştirmek için %1$d kez daha dokun. USB hata ayıklama Hızlı Ayarlar kutucuğu artık kullanılabilir! - Etkinleştiriliyor… - Devre dışı bırakılıyor… Uygulamayı Yeniden Adlandır diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cef419f..240bdf8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Settings On Off - Automatic (off) + Off (automatic) More options @@ -54,21 +54,32 @@ Testing connection… Current (unsaved) - Active & Reachable + Active & Reachable + Default + DEFAULT + Auto-blocked: DNS server unreachable on port 853 (DoT). Android may still successfully connect via other methods. VPN Override Active - SSID Blacklist Active (%s) + Wi-Fi Profile Active (%s) + SSID Override Active: %1$s is set to %2$s + Off (%s) + Enable Private DNS - - SSID Blacklist + + Wi-Fi Profiles + Set specific Private DNS rules or bypasses for individual Wi-Fi networks. Wi-Fi Monitoring Info To support automatic toggling, the app monitors network changes. The impact on battery consumption should be minimal, but the system may still report background activity. Some devices kill background monitoring to save battery. If automation stops working, disable restrictions for this app. Disable battery restrictions - Auto-blacklist when disabling Private DNS - Auto-whitelist when enabling Private DNS - Auto-blacklist on connectivity failure - When Private DNS fails but internet is still available, the app will automatically add the current network to the blacklist to restore connectivity. + Auto-save Private DNS states (on/off) + Remember when you manually enable or disable Private DNS on specific Wi-Fi networks. + Auto-save DNS hostnames + Remember when you switch to a specific custom DNS provider while connected to a Wi-Fi network. + Private DNS Provider + Default (%s) + Block private DNS on incompatible networks + When Private DNS fails but internet is still available, the app will automatically disable Private DNS for the current network to restore connectivity. Wait before disabling Connection test servers Connectivity check target servers @@ -76,20 +87,19 @@ Custom… Enter a value between 5 and 300 seconds %ds - Private DNS disabled and %s blacklisted due to a connectivity issue + Private DNS disabled and Wi-Fi profile added for %s due to a connectivity issue Connectivity restored. Private DNS re-enabled for %s Location/Nearby permission (including background) required to verify the Wi-Fi SSID. Grant Permissions Location and Nearby devices permissions have been permanently denied. Please enable them in App Settings to use this feature. To notify you when Private DNS is automatically toggled in the background, and ensure monitoring remains active, please allow notifications. To monitor Wi-Fi while the app is closed, Android requires \"Allow all the time\" location access. DNS Toggle only uses this to see the Wi-Fi name, and never tracks your physical location or uses GPS. - \n\nNote: Active VPN connections may mask your Wi-Fi network name, temporarily disabling auto-blacklist/whitelist rules. - Add SSID - Edit SSID + \n\nNote: Active VPN connections may mask your Wi-Fi network name, temporarily disabling Wi-Fi Profile automation. + Add Wi-Fi Profile + Edit Wi-Fi Profile SSID (Wi-Fi Name) - SSID permanently blacklisted - Auto-added - Remove this SSID from blacklist? + Wi-Fi profile saved + Delete this Wi-Fi profile? VPN Override @@ -112,8 +122,6 @@ Show an extra Quick Settings tile to toggle USB Debugging. Long-press to open developer options. Tap %1$d more times to enable a USB debugging tile. USB debugging Quick Settings tile now available! - Enabling… - Disabling… Rename App @@ -123,14 +131,14 @@ Security Reset - Due to a change in your device\'s security settings (like changing your screen lock), your Wi-Fi blacklist was reset to protect your data. You will need to re-add your blacklisted networks. + Due to a change in your device\'s security settings (like changing your screen lock), your Wi-Fi profiles were reset to protect your data. You will need to re-add your saved networks. Export Enter a password to encrypt your backup file. You will need this same password to restore this configuration later. Password Import - Importing this configuration will overwrite your current settings, hostnames, and Wi-Fi blacklist. Do you want to proceed? + Importing this configuration will overwrite your current settings, hostnames, and Wi-Fi profiles. Do you want to proceed? Configuration exported successfully Failed to export configuration Configuration imported successfully @@ -139,17 +147,22 @@ Automation Alerts - Notifications when Private DNS is automatically toggled or networks are blacklisted. + Notifications when Private DNS is automatically toggled or Wi-Fi profiles are triggered. Background Monitoring A silent, persistent notification required by Android to monitor Wi-Fi networks in the background. Monitoring Network Status DNS Toggle is active to automate Private DNS mode. Private DNS disabled automatically for %s + Private DNS enabled automatically for %s Private DNS adjusted for VPN Private DNS restored after VPN - Added %s to blacklist - Removed %s from blacklist + Wi-Fi profile: Private DNS Off for %s + Wi-Fi profile: Private DNS On for %s Private DNS changed to %s + Changes apply to %s + Hostname changes apply to %s + Modifying global settings.\n%s override active. + OVERRIDE Hostname saved diff --git a/app/src/test/java/com/ericlowry/dnstoggle/DnsManagerTest.kt b/app/src/test/java/com/ericlowry/dnstoggle/DnsManagerTest.kt index e754e11..fbb24fb 100644 --- a/app/src/test/java/com/ericlowry/dnstoggle/DnsManagerTest.kt +++ b/app/src/test/java/com/ericlowry/dnstoggle/DnsManagerTest.kt @@ -4,7 +4,11 @@ import androidx.core.content.edit import androidx.test.core.app.ApplicationProvider import com.ericlowry.dnstoggle.data.Constants import com.ericlowry.dnstoggle.data.DnsManager -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 org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -20,6 +24,10 @@ class DnsManagerTest { @Before fun setup() { app = ApplicationProvider.getApplicationContext() + SecurityRepository.initialize(app) + VpnRepository.initialize(app) + NetworkProfileRepository.initialize(app) + HostnameRepository.initialize(app) DnsSettingsRepository.initialize(app) app.detectedSsid = null } @@ -51,20 +59,21 @@ class DnsManagerTest { } @Test - fun autoBlacklistDrivenToggleOff_doesNotOverwritePreferredDnsMode() { + fun autoSaveStateDrivenToggleOff_doesNotOverwritePreferredDnsMode() { val prefs = app.getPrefs() prefs.edit { - putBoolean(Constants.PREF_AUTO_BLACKLIST, true) + putBoolean(Constants.PREF_AUTO_SAVE_STATE, true) putString(Constants.PREF_PREFERRED_DNS_MODE, Constants.DNS_MODE_HOSTNAME) } app.detectedSsid = "TestSSID" - DnsManager.togglePrivateDns(app, enabled = false) + DnsManager.togglePrivateDns(app, enabled = false, isFromTile = true) assertEquals( Constants.DNS_MODE_HOSTNAME, prefs.getString(Constants.PREF_PREFERRED_DNS_MODE, null) ) - assertEquals(setOf("TestSSID"), DnsSettingsRepository.blacklist.value) + val profile = NetworkProfileRepository.networkProfiles.value?.find { it.ssid == "TestSSID" } + assertEquals(false, profile?.isEnabled) } } diff --git a/app/src/test/java/com/ericlowry/dnstoggle/DnsSettingsRepositoryTest.kt b/app/src/test/java/com/ericlowry/dnstoggle/DnsSettingsRepositoryTest.kt index 453cca6..bf533da 100644 --- a/app/src/test/java/com/ericlowry/dnstoggle/DnsSettingsRepositoryTest.kt +++ b/app/src/test/java/com/ericlowry/dnstoggle/DnsSettingsRepositoryTest.kt @@ -1,7 +1,10 @@ package com.ericlowry.dnstoggle import androidx.test.core.app.ApplicationProvider -import com.ericlowry.dnstoggle.data.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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -19,7 +22,10 @@ class DnsSettingsRepositoryTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - DnsSettingsRepository.initialize(context) + SecurityRepository.initialize(context) + VpnRepository.initialize(context) + NetworkProfileRepository.initialize(context) + HostnameRepository.initialize(context) } @Test @@ -27,75 +33,108 @@ class DnsSettingsRepositoryTest { val hostname = "dns.google" // Test duplicates handling - DnsSettingsRepository.addHostname(hostname) - DnsSettingsRepository.addHostname(hostname) + HostnameRepository.addHostname(hostname) + HostnameRepository.addHostname(hostname) - val hostnames = DnsSettingsRepository.dnsHostnames.value + val hostnames = HostnameRepository.dnsHostnames.value assertTrue(hostnames != null) assertEquals(1, hostnames?.count { it.hostname == hostname }) } @Test - fun addSsidToBlacklist_doesNotDuplicate() = runTest { + fun upsertNetworkProfile_doesNotDuplicate() = runTest { val ssid = "MyHomeWiFi" - DnsSettingsRepository.addToBlacklist(ssid) - DnsSettingsRepository.addToBlacklist(ssid) + NetworkProfileRepository.upsertNetworkProfile(ssid, isEnabled = false) + NetworkProfileRepository.upsertNetworkProfile(ssid, isEnabled = false) - val blacklist = DnsSettingsRepository.blacklist.value - assertTrue(blacklist != null) - assertEquals(1, blacklist?.count { it == ssid }) + val profiles = NetworkProfileRepository.networkProfiles.value + assertTrue(profiles != null) + assertEquals(1, profiles?.count { it.ssid == ssid }) } @Test - fun addToBlacklist_autoDetected_appearsInBothSets() = runTest { + fun upsertNetworkProfile_autoDetected_isSetCorrectly() = runTest { val ssid = "AutoDetectedWiFi" - DnsSettingsRepository.addToBlacklist(ssid, autoDetected = true) + NetworkProfileRepository.upsertNetworkProfile( + ssid, + isEnabled = false, + isAutoDetected = true + ) - assertTrue(DnsSettingsRepository.blacklist.value?.contains(ssid) == true) - assertTrue(DnsSettingsRepository.autoDetectedBlacklist.value?.contains(ssid) == true) + val profile = NetworkProfileRepository.networkProfiles.value?.find { it.ssid == ssid } + assertTrue(profile != null) + assertTrue(profile?.isAutoDetected == true) + assertTrue(profile?.isEnabled == false) } @Test - fun addToBlacklist_manual_notInAutoDetectedSet() = runTest { + fun upsertNetworkProfile_preservesHostname() = runTest { + val ssid = "PreserveHostWiFi" + val hostname = "dns.google" + + NetworkProfileRepository.upsertNetworkProfile( + ssid, + isEnabled = true, + targetHostname = hostname + ) + + NetworkProfileRepository.upsertNetworkProfile( + ssid, + isEnabled = false, + preserveExistingHostname = true + ) + + val profile = NetworkProfileRepository.networkProfiles.value?.find { it.ssid == ssid } + assertEquals(hostname, profile?.targetHostname) + assertEquals(false, profile?.isEnabled) + } + + @Test + fun upsertNetworkProfile_manual_notAutoDetected() = runTest { val ssid = "ManualWiFi" - DnsSettingsRepository.addToBlacklist(ssid) + NetworkProfileRepository.upsertNetworkProfile(ssid, isEnabled = false) - assertTrue(DnsSettingsRepository.blacklist.value?.contains(ssid) == true) - assertTrue(DnsSettingsRepository.autoDetectedBlacklist.value?.contains(ssid) != true) + val profile = NetworkProfileRepository.networkProfiles.value?.find { it.ssid == ssid } + assertTrue(profile != null) + assertTrue(profile?.isAutoDetected == false) } @Test - fun removeFromBlacklist_autoDetected_removedFromBothSets() = runTest { + fun removeNetworkProfile_removesCorrectly() = runTest { val ssid = "AutoDetectedRemovable" - DnsSettingsRepository.addToBlacklist(ssid, autoDetected = true) - DnsSettingsRepository.removeFromBlacklist(ssid) + NetworkProfileRepository.upsertNetworkProfile( + ssid, + isEnabled = false, + isAutoDetected = true + ) + NetworkProfileRepository.removeNetworkProfile(ssid) - assertTrue(DnsSettingsRepository.blacklist.value?.contains(ssid) != true) - assertTrue(DnsSettingsRepository.autoDetectedBlacklist.value?.contains(ssid) != true) + val profile = NetworkProfileRepository.networkProfiles.value?.find { it.ssid == ssid } + assertTrue(profile == null) } @Test fun vpnDnsHostname_nullableBehavior() = runTest { val hostname = "dns.google" - DnsSettingsRepository.updateVpnDnsHostname(hostname) - assertEquals(hostname, DnsSettingsRepository.vpnDnsHostname.value) + VpnRepository.updateVpnDnsHostname(hostname) + assertEquals(hostname, VpnRepository.vpnDnsHostname.value) - DnsSettingsRepository.updateVpnDnsHostname(null) - assertEquals(null, DnsSettingsRepository.vpnDnsHostname.value) + VpnRepository.updateVpnDnsHostname(null) + assertEquals(null, VpnRepository.vpnDnsHostname.value) } @Test fun removeHostname_allowsEmptyList() = runTest { val hostname = "dns.google" - DnsSettingsRepository.addHostname(hostname) + HostnameRepository.addHostname(hostname) - DnsSettingsRepository.removeHostname(hostname) + HostnameRepository.removeHostname(hostname) - val hostnames = DnsSettingsRepository.dnsHostnames.value + val hostnames = HostnameRepository.dnsHostnames.value assertTrue(hostnames?.isEmpty() == true) } } diff --git a/build.gradle.kts b/build.gradle.kts index c2b3d3c..84a08cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all subprojects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlinx.serialization.plugin) apply false } tasks.register("setupGitHooks") { diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/fastlane/metadata/android/en-US/changelogs/21.txt index 951c9a4..4e5c97a 100644 --- a/fastlane/metadata/android/en-US/changelogs/21.txt +++ b/fastlane/metadata/android/en-US/changelogs/21.txt @@ -1,4 +1,4 @@ - UI fixes and better scrolling on Android TV - Added What's New section to display latest changes - Fixed display colors on older devices and TVs -- Turkish translation WIP (thanks Kyo様) \ No newline at end of file +- Turkish translation (thanks Kyo様) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/22.txt b/fastlane/metadata/android/en-US/changelogs/22.txt new file mode 100644 index 0000000..214fa25 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22.txt @@ -0,0 +1,5 @@ +- Replaced SSID blacklist feature with Wi-Fi profiles +- General UI and code cleanup +- Fixed an issue that required extra taps for toggles +- Fixed issue with scrolling on TVs +- Added translations for Spanish (thanks Allyson Simón), WIP Chinese Han (thanks XieyilinMain), and WIP Japanese (thanks Oさん) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 254b50b..05a99cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ androidx-test-ext-junit = "1.3.0" androidx-test-runner = "1.7.0" shizuku = "13.1.5" dynamicanimation = "1.1.0" +kotlinx-serialization = "1.11.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -31,6 +32,8 @@ androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +kotlinx-serialization-plugin = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.4.10" } From bc8083a3a4a96850a97a2782b501d0f149285436 Mon Sep 17 00:00:00 2001 From: XieyilinMain Date: Wed, 29 Jul 2026 03:55:47 +0200 Subject: [PATCH 12/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Progress: 100.0% (131 of 131 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 87 +++++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 75a8365..8f1c102 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -11,7 +11,7 @@ 自动(关) 更多选项 - 权限与ADB设置 + 需要权限 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: ADB命令 ADB 命令:%s @@ -43,12 +43,91 @@ 正在测试连接… 当前项目(未保存) 新版特性 - 活动且可连接 + 处于活动状态,且可连接 DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 - v%s 的新功能 + v%s 的新特性 自定义… 输入一个位于5-300秒之间的值 - %ds + %d秒 选项 显示包含状态的toast通知 + VPN超控已激活 + SSID黑名单 + SSID黑名单(%s) + 有关Wi-Fi监控功能 + 为了支持自动切换相关设置,本应用需监控网络的变化。该动作对电量的消耗应该是最低的,但是系统或许仍然会反馈本应用的后台活动。 + 部分设备通过杀死后台监控组件来减少电量消耗。如果该自动化组件停止工作,请停止系统对此应用的限制。 + 忽略电池优化 + 当停用专用DNS时,自动将当前网络加入黑名单 + 当启用专用DNS时,自动将当前网络加入白名单 + 当连接主机失败后,自动将当前网络加入黑名单 + 当专用DNS无法连接,而互联网仍然可用时,本应用将自动将当前网络加入黑名单,以恢复网络连接性。 + 停用前等待时间 + 连接测试服务器 + 连接测试目标服务器 + 用逗号隔开多个IP(例如 9.9.9.9, 5.1.66.255) + 由于连接问题,已停用专用DNS,已将%s加入黑名单 + 连接已恢复。已为%s重新启用专用DNS + 需要位置信息/附近的设备权限(包括后台位置)用于验证Wi-Fi的SSID。 + 授予权限 + 位置信息 和 附近的设备权限已被永久关闭。请在应用设置里打开这些来使用这些功能。 + 为了在专用DNS在后台自动切换后提醒您,并确保监控组件活动,请允许通知权限。 + 保存此活动的主机名 + 为了在本应用关闭后继续监控Wi-Fi,Android端需要“始终允许”位置访问。DNS Toggle 仅使用位置信息来读取Wi-Fi名称,且从不跟踪您的物理位置或使用GPS。 + \n\n注意:正在活动的VPN连接可能会覆盖您的Wi-Fi网络名称,暂时禁用自动黑名单/白名单规则。 + 添加SSID + 编辑SSID + SSID (Wi-Fi 名称) + 自动添加 + 从黑名单里移除此SSID? + VPN超控 + 当VPN连接后,自动切换专用DNS。 + VPN下的DNS + 需要通知权限用于后台自动化操作。 + 此前用于VPN的DNS主机名已被移除。VPN超控功能已被关闭。 + 通过快速设置磁贴其诶专用DNS后显示状态通知。 + 隐藏应用图标 + 启用该设置后, 本应用将从应用抽屉里隐藏. 您仍然可用通过长按快速设置磁贴调出设置。 注意:在部分桌面上,此选项可能不会完全隐藏应用图标。 + 由于 Android TV系统限制,此功能不可用。 + USB调试 + 显示USB调试磁贴 + SSID已永久添加到黑名单 + 显示一个额外的快速设置磁贴,用于切换USB调试。长按打开开发者选项。 + 点击超过%1$d次以启用USB调试磁贴。 + USB调试快速设置磁贴现在可以使用了! + 正在启用… + 正在停用… + 重命名应用 + 选择一个对您来说更合理的名称。此名称将显示在应用图标和快速设置磁贴上。 + 导出配置项 + 导入设置项 + 由于您的设备的安全设置出现变化(如更改锁屏方式),您的Wi-Fi黑名单被重置以保护您的数据,您需要重新添加黑名单里的网络名称。 + 备份/恢复 + 输入密码来加密您的备份文件。之后您需要输入相同的密码来恢复此设置项。 + 密码 + 导入 + 导入此配置文件将覆盖您的当前设置、主机名和Wi-Fi黑名单。您需要继续吗? + 设置项导出成功 + 设置项导出失败 + 设置项导入成功 + 无法读取文件 + 解密失败。密码不正确或文件已损坏。 + 自动化提示 + 当自动切换专用DNS或网络加入黑名单时发出通知。 + 后台监控 + Android上必须的静默、持久通知,用于后台监控Wi-Fi网络。 + 监控网络状态 + DNS Toggle处于活动状态,用于自动设置专用DNS模式。 + 专用DNS因为%s而自动停用 + 专用DNS因VPN而调整 + 专用DNS在VPN关闭后恢复 + 已添加%s到黑名单 + 已将%s从黑名单中移除 + 专用DNS已切换到%s + 已保存主机名 + 切换DNS + 切换专用DNS模式 + 选择DNS + 选择专用DNS的主机名 + 安全性重置 From 4f58d3fd72dc3d5724fad662ab77cd8fc529c826 Mon Sep 17 00:00:00 2001 From: XieyilinMain Date: Tue, 28 Jul 2026 09:27:42 +0200 Subject: [PATCH 13/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Metadata Language: Chinese (Simplified Han script) Progress: 33.3% (8 of 24 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-metadata/zh_Hans/ --- fastlane/metadata/android/zh-CN/changelogs/1.txt | 1 + fastlane/metadata/android/zh-CN/changelogs/10.txt | 3 +++ fastlane/metadata/android/zh-CN/changelogs/11.txt | 6 ++++++ fastlane/metadata/android/zh-CN/changelogs/12.txt | 2 ++ fastlane/metadata/android/zh-CN/changelogs/13.txt | 2 ++ fastlane/metadata/android/zh-CN/full_description.txt | 1 + fastlane/metadata/android/zh-CN/short_description.txt | 1 + 7 files changed, 16 insertions(+) create mode 100644 fastlane/metadata/android/zh-CN/changelogs/1.txt create mode 100644 fastlane/metadata/android/zh-CN/changelogs/10.txt create mode 100644 fastlane/metadata/android/zh-CN/changelogs/11.txt create mode 100644 fastlane/metadata/android/zh-CN/changelogs/12.txt create mode 100644 fastlane/metadata/android/zh-CN/changelogs/13.txt create mode 100644 fastlane/metadata/android/zh-CN/full_description.txt create mode 100644 fastlane/metadata/android/zh-CN/short_description.txt diff --git a/fastlane/metadata/android/zh-CN/changelogs/1.txt b/fastlane/metadata/android/zh-CN/changelogs/1.txt new file mode 100644 index 0000000..33ea6b6 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/1.txt @@ -0,0 +1 @@ +初始发布版本 diff --git a/fastlane/metadata/android/zh-CN/changelogs/10.txt b/fastlane/metadata/android/zh-CN/changelogs/10.txt new file mode 100644 index 0000000..e67921f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/10.txt @@ -0,0 +1,3 @@ +- 修复root用户请求的用户体验流程。 +- 添加权限需求横幅。 +- 为应用标签重命名设置添加了上下文说明。 diff --git a/fastlane/metadata/android/zh-CN/changelogs/11.txt b/fastlane/metadata/android/zh-CN/changelogs/11.txt new file mode 100644 index 0000000..bfbb89f --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/11.txt @@ -0,0 +1,6 @@ +- 新增 `INTERNET` 权限,用于 ping 并验证自定义 DNS 地址的可达性。 +- 新增“禁用 DNS 测试”开关,适用于严格的 DNS 提供商。 +- 更新后台服务,以兼容 Android 14。 +- 防止设备安全设置更改时可能丢失阻止列表设置。 +- 修复了频繁断开并重新连接 Wi-Fi 时的通知轰炸问题。 +- 性能改进。 diff --git a/fastlane/metadata/android/zh-CN/changelogs/12.txt b/fastlane/metadata/android/zh-CN/changelogs/12.txt new file mode 100644 index 0000000..50064cb --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/12.txt @@ -0,0 +1,2 @@ +- 将 fastlane 文件夹移至项目根目录 +- 更新了关于(可选的)“始终”位置权限的说明。 diff --git a/fastlane/metadata/android/zh-CN/changelogs/13.txt b/fastlane/metadata/android/zh-CN/changelogs/13.txt new file mode 100644 index 0000000..080c6e6 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/13.txt @@ -0,0 +1,2 @@ +- 使用R8混淆和 传统DEX压缩减小了apk大小。 +- 构建维护和安全性改进。 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 0000000..b416aa0 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1 @@ +

DNS Toggles是一款小巧的应用,可让您通过磁贴面板,快速切换与自动化设置专用DNS。

重要信息:
本应用需要有WRITE_SECURE_SETTINGS权限,来修改专用DNS的系统设置。 您必须拥有已root设备, 或者使用Shizuku, 或者手动通过ADB授予权限:
adb shell pm grant com.ericlowry.dnstoggle android.permission.WRITE_SECURE_SETTINGS

功能

  • 快速设置磁贴: 直接从快速设置面板开关专用DNS
  • 自定义DNS提供商: 定义一个或多个偏好的主机名 (例如 dns.nextdns.io) 并无缝地切换这些主机名
  • Wi-Fi过滤: 当连接到特定的Wi-Fi网络时自动禁用专用DNS
  • 自动拦截: 当专用DNS导致连接问题时自动将当前Wi-Fi SSID列入黑名单
  • VPN 超控: 当VPN启动时自动强制使用特定的DNS主机名或完全禁用专用DNS
  • 备份与恢复: 通过密码加密文件导出和导入配置
  • 动态磁贴命名: 重命名快速设置磁贴
  • 隐藏应用图标: 保持主屏幕干净,同时通过快速设置磁贴访问应用

隐私与权限

  • 无跟踪,无广告,无数据收集。
  • 位置权限是可选的,仅用于识别Wi-Fi网络名称以进行SSID过滤功能。这些权限永远不会用于跟踪您的物理位置。
diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 0000000..66d4634 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +通过磁贴面板,快速切换与自动化设置专用DNS。 From 3b853f2af7932b3d4373859bc44a474d8189fd79 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 21:45:13 +0200 Subject: [PATCH 14/23] Updated dependencies --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05a99cf..3779408 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coreKtx = "1.19.0" appcompat = "1.7.1" material = "1.14.0" activity = "1.13.0" -constraintlayout = "2.2.1" +constraintlayout = "2.2.2" lifecycle = "2.11.0" junit = "4.13.2" robolectric = "4.16.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e01a4d6..28e75a0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Sat Apr 18 17:05:56 CEST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 1378d4dc395eb8c6751bc5ab1531c172bd1f8893 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 21:49:20 +0200 Subject: [PATCH 15/23] Updated French translation --- app/src/main/res/values-fr/strings.xml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a611068..a84378b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -52,15 +52,15 @@ Actif et joignable Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. Override VPN en cours - SSID exclu (%s) - Profils Wi-Fi + Profil Wi-Fi actif (%s) + Profils Wi-Fi Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. Info sur la surveillance du SSID Pour prendre en charge le basculement automatique, l\'application surveille les changements de réseau. L\'impact sur la consommation de la batterie devrait être minimal, mais le système peut afficher une activité en arrière-plan continue. Certains appareils arrêtent la surveillance en arrière-plan pour économiser la batterie. Si l\'automatisation cesse de fonctionner, désactivez les restrictions pour cette application. Désactiver les restrictions de batterie - Exclure automatiquement si échec de connexion - Lorsque le DNS privé échoue mais que l\'internet est toujours disponible, l\'application ajoutera automatiquement le réseau actuel aux exclusions pour restaurer la connectivité. + Ajout automatique d\'un profil Wi-Fi si échec de connexion + Lorsque le DNS privé échoue mais que l\'internet est toujours disponible, l\'application créera automatiquement un profil Wi-Fi pour le réseau actuel afin de restaurer la connectivité. Délai avant désactivation Serveurs de test Serveurs de test de connectivité @@ -74,11 +74,11 @@ Pour vous avertir lorsque le DNS privé est automatiquement activé en arrière-plan, et pour garantir la vérification en arrière-plan, veuillez autoriser les notifications. Pour surveiller le Wi-Fi lorsque l\'application est fermée, Android nécessite l\'accès à la position \"Toujours autoriser\". DNS Toggle utilise uniquement cela pour voir le nom du Wi-Fi et ne suit jamais votre position physique ni n\'utilise le GPS. \n\nRemarque: L\'utilisation d\'un VPN peut masquer le nom de votre réseau Wi-Fi, désactivant temporairement l\'automatisation des profils Wi-Fi. - Ajouter un SSID - Modifier le SSID + Ajouter un profil Wi-Fi + Modifier le profil Wi-Fi SSID (Nom du Wi-Fi) - Réseau ajouté aux exclusions permanentes - Retirer ce réseau des exclusions ? + Profil Wi-Fi enregistré + Supprimer ce profil Wi-Fi ? Override VPN Changer automatiquement le DNS privé lorsqu\'un VPN est connecté. @@ -104,13 +104,13 @@ Importer une configuration Réinitialisation de sécurité - En raison d\'un changement dans les paramètres de sécurité de votre appareil (comme la modification du verrouillage de l\'écran), votre liste d\'exclusions Wi-Fi a été réinitialisée pour protéger vos données. Vous devrez rajouter vos réseaux exclus. + En raison d\'un changement dans les paramètres de sécurité de votre appareil (comme la modification du verrouillage de l\'écran), vos profils Wi-Fi ont été réinitialisés pour protéger vos données. Vous devrez rajouter vos réseaux enregistrés. Exporter Saisissez un mot de passe pour chiffrer votre fichier de sauvegarde. Vous aurez besoin de ce même mot de passe pour restaurer cette configuration ulterieurement. Mot de passe Importer - Importer cette configuration remplacera les paramètres de l\'application ainsi que les addresses DNS et SSID enregistrées. Voulez-vous continuer? + Importer cette configuration remplacera les paramètres de l\'application ainsi que les addresses DNS et les profils Wi-Fi enregistrés. Voulez-vous continuer? Configuration exportée avec succès L\'exportation a échoué Configuration importée avec succès @@ -118,18 +118,18 @@ Échec du déchiffrement. Mot de passe incorrect ou fichier corrompu. Alertes d\'automatisation - Notifications lorsque le DNS privé est automatiquement activé/désactivé ou lorsque des réseaux sont exclus. + Notifications lorsque le DNS privé est automatiquement activé/désactivé ou lorsque des profils Wi-Fi sont déclenchés. Surveillance en arrière-plan Une notification silencieuse et persistante requise par Android pour surveiller les réseaux Wi-Fi en arrière-plan. Surveillance du réseau activée DNS Toggle est actif pour automatiser le mode DNS privé. DNS privé désactivé automatiquement pour %s - DNS privé désactivé et %s exclu en raison d\'un problème de connexion + DNS privé désactivé et profil Wi-Fi créé pour %s en raison d\'un problème de connexion Connectivité rétablie. DNS privé réactivé pour %s DNS privé défini par le VPN DNS privé restauré post-VPN - %s ajouté aux exclusions - %s retiré des exclusions + Profil Wi-Fi: DNS privé désactivé pour %s + Profil Wi-Fi: DNS privé activé pour %s DNS privé: %s Addresse enregistrée Cette fonctionnalité ne peut être utilisée sur Android TV. From 6c0076de5e554132c69c94807b27f045794c823b Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 21:49:53 +0200 Subject: [PATCH 16/23] Best-guess updates for existing translations --- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 250 ++++++++++----------- app/src/main/res/values-ja/strings.xml | 94 ++++---- app/src/main/res/values-tr/strings.xml | 30 +-- app/src/main/res/values-zh-rCN/strings.xml | 84 +++---- 5 files changed, 231 insertions(+), 230 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 566ab5b..316ba40 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -47,6 +47,7 @@ Aktiviert & erreichbar Der DNS-Server ist über Port 853 (DoT) nicht erreichbar. Android kann möglicherweise weiterhin über andere Methoden eine Verbindung herstellen. VPN überschreiben aktiv - SSID Blacklist aktiv (%s) + WLAN-Profil aktiv (%s) Informationen zur WLAN-Überwachung + WLAN-Profile diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6d3f654..2d2b363 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,128 +1,128 @@ - Versión de la App: %s - Novedades - Novedades en la versión %s - GitHub (código fuente) - Obtener ayuda - De acuerdo - Cancelar - Cerrar - Ajustes - Encendido - Apagado - Automático (desactivado) - Más opciones - Se requiere permiso - Para usar esta opción, debes otorgar permisos de configuración a través de root, Shizuku o ejecutar este comando ADB en tu computadora con la depuración USB habilitada: - Comando ADB - Comando ADB: %s - Copiar comando ADB - Comando copiado al portapapeles - Grant a través de Shizuku - Grant a través de Root - Intentar otorgar permiso - Intentando obtener privilegios de Shizuku… - Intentando obtener privilegios de Root… - Intentando obtener privilegios automática… - Los privilegios de Shizuku/Root falló o fue denegada - Permiso concedido - Se requiere permiso para acceder a la configuración del sistema - La aplicación necesita un permiso especial para modificar la configuración del DNS privado. - Corregir - Abrir Configuración - DNS privado - Omitir prueba de conexión - Activa esta opción solo si tu DNS funciona, pero la aplicación lo muestra como inaccesible. - Proveedor(es) de DNS personalizado(s) - Agregar nombre de host - Guarda este nombre de host activo - Editar nombre de host - Nombre de host de DNS (p. ej., dns.nextdns.io) - Etiqueta (opcional) - ¿Deseas eliminar este nombre de host? - No se puede habilitar el DNS privado mientras la dirección del host esté vacía - Formato de dirección de host DNS no válido - Probando la conexión… - Actual (sin guardar) - Activo y localizable - No se puede acceder al servidor DNS en el puerto 853 (DoT). Es posible que Android aún pueda conectarse correctamente mediante otros métodos. - VPN anulada: activa - Lista negra de SSID activa (%s) - Lista negra de SSID - Información sobre el monitoreo de Wi-Fi - Para permitir el cambio automático, la aplicación supervisa los cambios en la red. El impacto en el consumo de batería debería ser mínimo, pero es posible que el sistema siga reportando actividad en segundo plano. - Algunos dispositivos desactivan el monitoreo en segundo plano para ahorrar batería. Si la automatización deja de funcionar, desactiva las restricciones para esta app. - Desactivar las restricciones de la batería - Inclusión automática en la lista negra en caso de falla de conectividad - Cuando el DNS privado falla, pero sigue habiendo conexión a Internet, la aplicación agregará automáticamente la red actual a la lista negra para restablecer la conectividad. - Espera antes de desactivarlo - Servidores de prueba de conexión - Revisión de conectividad de los servidores de destino - Direcciones IP separadas por comas (p. ej., 9.9.9.9, 5.1.66.255) - Personalizado… - Ingresa un valor entre 5 y 300 segundos - %ds - El DNS privado está desactivado y %s ha sido incluido en la lista negra debido a un problema de conectividad - Se ha restablecido la conectividad. Se ha vuelto a habilitar el DNS privado para %s - Se requiere el permiso de ubicación/alrededores (incluido el permiso en segundo plano) para verificar el SSID de Wi-Fi. - Otorgar permisos - Se han denegado de forma permanente los permisos de ubicación y dispositivos cercanos. Actívalos en la configuración de la aplicación para poder usar esta función. - Para que recibas una notificación cuando el DNS privado se active o desactive automáticamente en segundo plano, y para garantizar que el monitoreo permanezca activo, por favor, habilita las notificaciones. - Para monitorear la red Wi-Fi mientras la app está cerrada, Android requiere el acceso a la ubicación con la opción «Permitir siempre». DNS Toggle solo utiliza esto para ver el nombre de la red Wi-Fi, y nunca rastrea tu ubicación física ni utiliza el GPS. - \n\nNota: Las conexiones VPN activas pueden ocultar el nombre de tu red Wi-Fi, lo que desactiva temporalmente las reglas de lista negra y lista blanca automáticas. - Agregar SSID - Editar SSID - SSID (nombre de la red Wi-Fi) - SSID incluido de forma permanente en la lista negra - ¿Quitar este SSID de la lista negra? - Anulación de VPN - Cambiar automáticamente el DNS privado cuando se conecte una VPN. - DNS en VPN - Se requiere permiso para recibir notificaciones a fin de que la automatización funcione en segundo plano. - Se eliminó el nombre de host DNS que se utilizaba anteriormente para la VPN. Se ha desactivado la opción «VPN Override». - Opciones - Mostrar notificaciones de estado en forma de mensaje emergente - Mostrar una notificación de estado cuando se active o desactive el DNS privado a través del mosaico de Ajustes rápidos. - Ocultar el ícono de la aplicación - Cuando esté habilitada, la app quedará oculta en tu menú de apps. Podrás seguir accediendo a la configuración al mantener presionado el mosaico de Configuración rápida. Nota: en algunos lanzadores, es posible que esta opción no oculte por completo el ícono. - Esta función no está disponible debido a las limitaciones de Android TV. - Depuración por USB - Mostrar «Depuración USB» - Muestra un mosaico adicional en Ajustes rápidos para activar o desactivar la depuración USB. Mantén presionado para abrir las opciones de desarrollador. - Toca %1$d veces más para habilitar un mosaico de depuración USB. - ¡Ya está disponible el mosaico de Ajustes rápidos para la depuración USB! - Cambiar el nombre de la aplicación - Elige un nombre que tenga sentido para ti. Este aparecerá en el ícono de la app y en el mosaico de Ajustes rápidos. - Configuración de exportación - Importar Configuración - Restablecimiento de seguridad - Debido a un cambio en la configuración de seguridad de tu dispositivo (como modificar el bloqueo de pantalla), tu lista negra de redes Wi-Fi se restableció para proteger tus datos. Tendrás que volver a agregar las redes que tenías en la lista negra. - Exportar - Ingresa una contraseña para cifrar tu archivo de respaldo. Necesitarás esta misma contraseña para restaurar esta configuración más adelante. - Contraseña - Importar - Al importar esta configuración, se sobrescribirán tus ajustes actuales, los nombres de host y la lista negra de redes Wi-Fi. ¿Deseas continuar? - La configuración se exportó correctamente - No se pudo exportar la configuración - La configuración se importó correctamente - No se pudo leer el archivo - El descifrado falló. Contraseña incorrecta o archivo dañado. - Alertas de automatización - Notificaciones cuando se activa o desactiva automáticamente el DNS privado o cuando se agregan redes a la lista negra. - Monitoreo de fondo - Una notificación silenciosa y persistente que requiere Android para monitorear las redes Wi-Fi en segundo plano. - Monitoreo del estado de la red - La opción «DNS Toggle» está activa para automatizar el modo DNS privado. - El DNS privado se desactivó automáticamente para %s - DNS privado configurado para VPN - Se restableció el DNS privado después de la VPN - Se ha agregado %s a la lista negra - Se ha eliminado %s de la lista negra - El DNS privado se cambió a %s - Nombre de host guardado - Activar/desactivar el DNS - Activar o desactivar el modo de DNS privado - Seleccionar DNS - Selecciona un nombre de host de DNS privado + Versión de la App: %s + Novedades + Novedades en la versión %s + GitHub (código fuente) + Obtener ayuda + De acuerdo + Cancelar + Cerrar + Ajustes + Encendido + Apagado + Automático (desactivado) + Más opciones + Se requiere permiso + Para usar esta opción, debes otorgar permisos de configuración a través de root, Shizuku o ejecutar este comando ADB en tu computadora con la depuración USB habilitada: + Comando ADB + Comando ADB: %s + Copiar comando ADB + Comando copiado al portapapeles + Grant a través de Shizuku + Grant a través de Root + Intentar otorgar permiso + Intentando obtener privilegios de Shizuku… + Intentando obtener privilegios de Root… + Intentando obtener privilegios automática… + Los privilegios de Shizuku/Root falló o fue denegada + Permiso concedido + Se requiere permiso para acceder a la configuración del sistema + La aplicación necesita un permiso especial para modificar la configuración del DNS privado. + Corregir + Abrir Configuración + DNS privado + Omitir prueba de conexión + Activa esta opción solo si tu DNS funciona, pero la aplicación lo muestra como inaccesible. + Proveedor(es) de DNS personalizado(s) + Agregar nombre de host + Guarda este nombre de host activo + Editar nombre de host + Nombre de host de DNS (p. ej., dns.nextdns.io) + Etiqueta (opcional) + ¿Deseas eliminar este nombre de host? + No se puede habilitar el DNS privado mientras la dirección del host esté vacía + Formato de dirección de host DNS no válido + Probando la conexión… + Actual (sin guardar) + Activo y localizable + No se puede acceder al servidor DNS en el puerto 853 (DoT). Es posible que Android aún pueda conectarse correctamente mediante otros métodos. + VPN anulada: activa + Perfil Wi-Fi activo (%s) + Perfiles Wi-Fi + Información sobre el monitoreo de Wi-Fi + Para permitir el cambio automático, la aplicación supervisa los cambios en la red. El impacto en el consumo de batería debería ser mínimo, pero es posible que el sistema siga reportando actividad en segundo plano. + Algunos dispositivos desactivan el monitoreo en segundo plano para ahorrar batería. Si la automatización deja de funcionar, desactiva las restricciones para esta app. + Desactivar las restricciones de la batería + Inclusión automática en perfiles Wi-Fi en caso de falla de conectividad + Cuando el DNS privado falla, pero sigue habiendo conexión a Internet, la aplicación agregará automáticamente la red actual a los perfiles Wi-Fi para restablecer la conectividad. + Espera antes de desactivarlo + Servidores de prueba de conexión + Revisión de conectividad de los servidores de destino + Direcciones IP separadas por comas (p. ej., 9.9.9.9, 5.1.66.255) + Personalizado… + Ingresa un valor entre 5 y 300 segundos + %ds + El DNS privado está desactivado y se ha añadido un perfil Wi-Fi para %s debido a un problema de conectividad + Se ha restablecido la conectividad. Se ha vuelto a habilitar el DNS privado para %s + Se requiere el permiso de ubicación/alrededores (incluido el permiso en segundo plano) para verificar el SSID de Wi-Fi. + Otorgar permisos + Se han denegado de forma permanente los permisos de ubicación y dispositivos cercanos. Actívalos en la configuración de la aplicación para poder usar esta función. + Para que recibas una notificación cuando el DNS privado se active o desactive automáticamente en segundo plano, y para garantizar que el monitoreo permanezca activo, por favor, habilita las notificaciones. + Para monitorear la red Wi-Fi mientras la app está cerrada, Android requiere el acceso a la ubicación con la opción «Permitir siempre». DNS Toggle solo utiliza esto para ver el nombre de la red Wi-Fi, y nunca rastrea tu ubicación física ni utiliza el GPS. + \n\nNota: Las conexiones VPN activas pueden ocultar el nombre de tu red Wi-Fi, lo que desactiva temporalmente la automatización de perfiles Wi-Fi. + Agregar perfil Wi-Fi + Editar perfil Wi-Fi + SSID (nombre de la red Wi-Fi) + Perfil Wi-Fi guardado + ¿Eliminar este perfil Wi-Fi? + Anulación de VPN + Cambiar automáticamente el DNS privado cuando se conecte una VPN. + DNS en VPN + Se requiere permiso para recibir notificaciones a fin de que la automatización funcione en segundo plano. + Se eliminó el nombre de host DNS que se utilizaba anteriormente para la VPN. Se ha desactivado la opción «VPN Override». + Opciones + Mostrar notificaciones de estado en forma de mensaje emergente + Mostrar una notificación de estado cuando se active o desactive el DNS privado a través del mosaico de Ajustes rápidos. + Ocultar el ícono de la aplicación + Cuando esté habilitada, la app quedará oculta en tu menú de apps. Podrás seguir accediendo a la configuración al mantener presionado el mosaico de Configuración rápida. Nota: en algunos lanzadores, es posible que esta opción no oculte por completo el ícono. + Esta función no está disponible debido a las limitaciones de Android TV. + Depuración por USB + Mostrar «Depuración USB» + Muestra un mosaico adicional en Ajustes rápidos para activar o desactivar la depuración USB. Mantén presionado para abrir las opciones de desarrollador. + Toca %1$d veces más para habilitar un mosaico de depuración USB. + ¡Ya está disponible el mosaico de Ajustes rápidos para la depuración USB! + Cambiar el nombre de la aplicación + Elige un nombre que tenga sentido para ti. Este aparecerá en el ícono de la app y en el mosaico de Ajustes rápidos. + Configuración de exportación + Importar Configuración + Restablecimiento de seguridad + Debido a un cambio en la configuración de seguridad de tu dispositivo (como modificar el bloqueo de pantalla), tus perfiles Wi-Fi se restablecieron para proteger tus datos. Tendrás que volver a agregar tus redes guardadas. + Exportar + Ingresa una contraseña para cifrar tu archivo de respaldo. Necesitarás esta misma contraseña para restaurar esta configuración más adelante. + Contraseña + Importar + Al importar esta configuración, se sobrescribirán tus ajustes actuales, los nombres de host y los perfiles Wi-Fi. ¿Deseas continuar? + La configuración se exportó correctamente + No se pudo exportar la configuración + La configuración se importó correctamente + No se pudo leer el archivo + El descifrado falló. Contraseña incorrecta o archivo dañado. + Alertas de automatización + Notificaciones cuando se activa o desactiva automáticamente el DNS privado o cuando se activan perfiles Wi-Fi. + Monitoreo de fondo + Una notificación silenciosa y persistente que requiere Android para monitorear las redes Wi-Fi en segundo plano. + Monitoreo del estado de la red + La opción «DNS Toggle» está activa para automatizar el modo DNS privado. + El DNS privado se desactivó automáticamente para %s + DNS privado configurado para VPN + Se restableció el DNS privado después de la VPN + Perfil Wi-Fi: DNS privado desactivado para %s + Perfil Wi-Fi: DNS privado activado para %s + El DNS privado se cambió a %s + Nombre de host guardado + Activar/desactivar el DNS + Activar o desactivar el modo de DNS privado + Seleccionar DNS + Selecciona un nombre de host de DNS privado diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0782891..4d56c79 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,50 +1,50 @@ - バージョン: %s - 更新情報 - v%s の更新内容 - GitHub (ソースコード) - サポート - OK - キャンセル - 閉じる - 設定 - オン - オフ - 自動 (オフ) - その他のオプション - 権限が必要です - この機能を使用するには、RootまたはShizukuで権限を付与するか、USBデバッグを有効にした状態で、PCで以下のADBコマンドを実行する必要があります: - ADBコマンド - ADBコマンド: %s - ADBコマンドをコピー - コマンドをクリップボードにコピーしました - Shizukuで許可 - Rootで許可 - 権限付与を実行 - Shizukuで権限の付与を試みています… - Rootで権限の付与を試みています… - 権限の付与を試みています… - Shizuku/Root権限を取得できませんでした - 権限が付与されました - システム設定の変更許可が必要です - プライベートDNSの設定を変更するには権限が必要です。 - 修正 - 設定を開く - プライベート DNS - 接続テストをスキップ - DNSが動作しているにもかかわらず、「到達不能」と表示される場合のみ有効にしてください。 - カスタムDNSプロバイダー - ホスト名を追加 - 使用中のホスト名を保存 - ホスト名を編集 - DNS ホスト名 (例: dns.nextdns.io) - ラベル (オプション) - このホスト名を削除しますか? - ホストアドレスが空のため、プライベートDNSを有効化できません - 無効なDNSホストアドレスです - 接続テスト中… - 現在の設定 (未保存) - アクティブ・接続可能 - ポート853 (DoT)でDNSサーバーに接続できません。Androidが別の方法で接続を確立している可能性があります。 + バージョン: %s + 更新情報 + v%s の更新内容 + GitHub (ソースコード) + サポート + OK + キャンセル + 閉じる + 設定 + オン + オフ + 自動 (オフ) + その他のオプション + 権限が必要です + この機能を使用するには、RootまたはShizukuで権限を付与するか、USBデバッグを有効にした状態で、PCで以下のADBコマンドを実行する必要があります: + ADBコマンド + ADBコマンド: %s + ADBコマンドをコピー + コマンドをクリップボードにコピーしました + Shizukuで許可 + Rootで許可 + 権限付与を実行 + Shizukuで権限の付与を試みています… + Rootで権限の付与を試みています… + 権限の付与を試みています… + Shizuku/Root権限を取得できませんでした + 権限が付与されました + システム設定の変更許可が必要です + プライベートDNSの設定を変更するには権限が必要です。 + 修正 + 設定を開く + プライベート DNS + 接続テストをスキップ + DNSが動作しているにもかかわらず、「到達不能」と表示される場合のみ有効にしてください。 + カスタムDNSプロバイダー + ホスト名を追加 + 使用中のホスト名を保存 + ホスト名を編集 + DNS ホスト名 (例: dns.nextdns.io) + ラベル (オプション) + このホスト名を削除しますか? + ホストアドレスが空のため、プライベートDNSを有効化できません + 無効なDNSホストアドレスです + 接続テスト中… + 現在の設定 (未保存) + アクティブ・接続可能 + ポート853 (DoT)でDNSサーバーに接続できません。Androidが別の方法で接続を確立している可能性があります。 diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e52d91c..7f519c6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -56,17 +56,17 @@ Aktif ve Ulaşılabilir DNS sunucusuna 853 numaralı bağlantı noktasından (DoT) ulaşılamıyor. Android yine de başka yöntemlerle başarılı bir şekilde bağlanabilir. VPN Geçersiz Kılma Aktif - SSID Kara Listesi Aktif (%s) + Wi-Fi Profili Aktif (%s) - - Wi-Fi Profilleri + + Wi-Fi Profilleri Ayrı Wi-Fi ağları için özel Özel DNS kuralları veya istisnaları ayarlayın. Wi-Fi İzleme Bilgisi Otomatik geçişi desteklemek için uygulama ağ değişikliklerini izler. Pil tüketimi üzerindeki etkisi minimum düzeyde olacaktır ancak sistem yine de arka plan etkinliği raporlayabilir. Bazı cihazlar pil tasarrufu sağlamak için arka plan izlemeyi kapatır. Otomasyon çalışmayı durdurursa, bu uygulama için kısıtlamaları devre dışı bırak. Pil kısıtlamalarını devre dışı bırak - Bağlantı hatasında otomatik kara listeye al - Özel DNS başarısız olduğunda ancak internet hala mevcut olduğunda, uygulama bağlantıyı geri yüklemek için mevcut ağı otomatik olarak kara listeye ekler. + Bağlantı hatasında otomatik Wi-Fi profili oluştur + Özel DNS başarısız olduğunda ancak internet hala mevcut olduğunda, uygulama bağlantıyı geri yüklemek için mevcut ağ için otomatik olarak bir Wi-Fi profili oluşturur. Devre dışı bırakmadan önce bekle Bağlantı testi sunucuları Bağlantı kontrolü hedef sunucuları @@ -74,7 +74,7 @@ Özel… 5 ile 300 saniye arasında bir değer girin %d sn - Bağlantı sorunu nedeniyle Özel DNS devre dışı bırakıldı ve %s kara listeye alındı + Bağlantı sorunu nedeniyle Özel DNS devre dışı bırakıldı ve %s için Wi-Fi profili oluşturuldu Bağlantı geri yüklendi. Özel DNS %s için yeniden etkinleştirildi Wi-Fi SSID\'sini doğrulamak için Konum/Yakındaki cihazlar izni (arka plan dahil) gereklidir. İzinleri Ver @@ -82,11 +82,11 @@ Arka planda Özel DNS otomatik olarak değiştirildiğinde sana bildirim göndermek ve izlemenin aktif kalmasını sağlamak için lütfen bildirimlere izin ver. Uygulama kapalıyken Wi-Fi\'ı izlemek için, Android \"Her zaman izin ver\" konum erişimi gerektirir. DNS Toggle bunu yalnızca Wi-Fi adını görmek için kullanır ve fiziksel konumunu asla takip etmez veya GPS\'i kullanmaz. \n\nNot: Aktif VPN bağlantıları Wi-Fi ağ adınızı gizleyebilir ve Wi-Fi Profili otomasyonunu geçici olarak devre dışı bırakabilir. - SSID Ekle - SSID Düzenle + Wi-Fi Profili Ekle + Wi-Fi Profilini Düzenle SSID (Wi-Fi Adı) - SSID kalıcı olarak kara listeye eklendi - Bu SSID kara listeden kaldırılsın mı? + Wi-Fi profili kaydedildi + Bu Wi-Fi profili kaldırılsın mı? VPN Geçersiz Kılma @@ -118,14 +118,14 @@ Güvenlik Sıfırlaması - Cihazının güvenlik ayarlarındaki bir değişiklik nedeniyle (ekran kilidini değiştirmek gibi), verilerini korumak için Wi-Fi kara listen sıfırlandı. Kara listeye aldığın ağları yeniden eklemen gerekecek. + Cihazının güvenlik ayarlarındaki bir değişiklik nedeniyle (ekran kilidini değiştirmek gibi), verilerini korumak için Wi-Fi profillerin sıfırlandı. Kaydettiğin ağları yeniden eklemen gerekecek. Dışa Aktar Yedekleme dosyanı şifrelemek için bir parola gir. Bu yapılandırmayı daha sonra geri yüklemek için aynı parolaya ihtiyacın olacak. Parola İçe Aktar - Bu yapılandırmayı içe aktarmak mevcut ayarlarının, sunucu adlarının ve Wi-Fi kara listenin üzerine yazacak. Devam etmek istiyor musun? + Bu yapılandırmayı içe aktarmak mevcut ayarlarının, sunucu adlarının ve Wi-Fi profillerinin üzerine yazacak. Devam etmek istiyor musun? Yapılandırma başarıyla dışa aktarıldı Yapılandırma dışa aktarılamadı Yapılandırma başarıyla içe aktarıldı @@ -134,7 +134,7 @@ Otomasyon Uyarıları - Özel DNS otomatik olarak değiştirildiğinde veya ağlar kara listeye alındığında bildirimler. + Özel DNS otomatik olarak değiştirildiğinde veya Wi-Fi profilleri tetiklendiğinde bildirimler. Arka Plan İzleme Arka planda Wi-Fi ağlarını izlemek için Android tarafından istenen sessiz, kalıcı bir bildirim. Ağ Durumu İzleniyor @@ -142,8 +142,8 @@ %s için Özel DNS otomatik olarak devre dışı bırakıldı Özel DNS, VPN için ayarlandı VPN sonrasında Özel DNS geri yüklendi - %s kara listeye eklendi - %s kara listeden kaldırıldı + Wi-Fi profili: %s için Özel DNS Kapalı + Wi-Fi profili: %s için Özel DNS Açık Özel DNS %s olarak değiştirildi Sunucu adı kaydedildi diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 37491b5..4fc5677 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,45 +1,45 @@ - 应用版本:%s - GitHub(源代码) - 获取支持 - 确定 - 取消 - 关闭 - 设置 - - - 自动(关) - 更多选项 - 权限与ADB设置 - 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: - ADB命令 - ADB 命令:%s - 复制ADB命令 - 已将命令复制到剪贴板 - 通过Shizuku授予 - 通过Root授予 - 尝试授予权限 - 尝试授予Shizuku… - 尝试授予Root… - 尝试自动授予权限… - Shizuku/Root 授权失败或被拒绝 - 已授予权限 - 需要系统设置权限 - 本应用需要特殊权限来修改专用DNS设置。 - 解决 - 打开设置 - 专用DNS - 跳过连接测试 - 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 - 自定义DNS提供商 - 添加主机名 - 编辑主机名 - DNS主机名(如 dns.nextdns.io) - 标签(可选) - 删除此主机名? - 无法启用专用DNS,因为主机地址是空的 - DNS主机地址格式无效 - 正在测试连接… - 当前项目(未保存) + 应用版本:%s + GitHub(源代码) + 获取支持 + 确定 + 取消 + 关闭 + 设置 + + + 自动(关) + 更多选项 + 权限与ADB设置 + 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: + ADB命令 + ADB 命令:%s + 复制ADB命令 + 已将命令复制到剪贴板 + 通过Shizuku授予 + 通过Root授予 + 尝试授予权限 + 尝试授予Shizuku… + 尝试授予Root… + 尝试自动授予权限… + Shizuku/Root 授权失败或被拒绝 + 已授予权限 + 需要系统设置权限 + 本应用需要特殊权限来修改专用DNS设置。 + 解决 + 打开设置 + 专用DNS + 跳过连接测试 + 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 + 自定义DNS提供商 + 添加主机名 + 编辑主机名 + DNS主机名(如 dns.nextdns.io) + 标签(可选) + 删除此主机名? + 无法启用专用DNS,因为主机地址是空的 + DNS主机地址格式无效 + 正在测试连接… + 当前项目(未保存) From 8eb20ae88cf4322091cfeb78b5f2a516d34f9dd5 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:08:58 +0200 Subject: [PATCH 17/23] chore(l10n): update German translation Translation: ELowry/DNSToggle Strings Language: German Progress: 36.1% (52 of 144 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/de/ --- app/src/main/res/values-de/strings.xml | 101 +++++++++++++------------ 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 316ba40..22ce393 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,53 +1,54 @@ - App Version: %s - Was gibt’s Neues - Neu in v%s - GitHub (Quellcode) - Hilfe erhalten - OK - Abbrechen - Schließen - Einstellungen - An - Aus - Aus (automatisch) - Berechtigung erforderlich - Um diesen Schalter nutzen zu können, müssen Sie über Root oder Shizuku die entsprechenden Berechtigungen erteilen oder diesen ADB-Befehl auf Ihrem Computer ausführen, wobei das USB-Debugging aktiviert sein muss: - ADB Befehl - ADB Befehl: %s - ADB Befehl kopieren - Befehl in Zwischenablage kopiert - Über Shizuku berechtigen - Über Root berechtigen - Berechtigungserteilung versuchen - Shizuku-Rechte werden erteilt… - Root-Rechte werden erteilt… - Automatische Berechtigung wird versucht… - Shizuku-/Root-Berechtigung fehlgeschlagen oder abgelehnt - Berechtigung erteilt - Systemeinstellungen-Berechtigung erforderlich - Die App benötigt eine spezielle Berechtigung, um die privaten DNS-Einstellungen zu ändern. - Korrigieren - Einstellungen öffnen - Privates DNS - Verbindungstest überspringen - Aktivieren Sie diese Option nur, wenn Ihr DNS funktioniert, die App es jedoch als nicht erreichbar anzeigt. - Benutzerdefinierte(r) DNS-Anbieter - Hostnamen hinzufügen - Diesen aktiven Hostname speichern - Hostname bearbeiten - DNS Hostname (z.B. dns.nextdns.io) - Bezeichnung (optional) - Hostname entfernen? - Privates DNS kann nicht aktiviert werden, solange die Host-Adresse leer ist - Ungültiges DNS-Hostname-Format - Prüfe Verbindung… - Aktuell (nicht gespeichert) - Aktiviert & erreichbar - Der DNS-Server ist über Port 853 (DoT) nicht erreichbar. Android kann möglicherweise weiterhin über andere Methoden eine Verbindung herstellen. - VPN überschreiben aktiv - WLAN-Profil aktiv (%s) - Informationen zur WLAN-Überwachung - WLAN-Profile + App Version: %s + Was gibt’s Neues + Neu in v%s + GitHub (Quellcode) + Hilfe erhalten + OK + Abbrechen + Schließen + Einstellungen + An + Aus + Aus (automatisch) + Berechtigung erforderlich + Um diesen Schalter nutzen zu können, müssen Sie über Root oder Shizuku die entsprechenden Berechtigungen erteilen oder diesen ADB-Befehl auf Ihrem Computer ausführen, wobei das USB-Debugging aktiviert sein muss: + ADB Befehl + ADB Befehl: %s + ADB Befehl kopieren + Befehl in Zwischenablage kopiert + Über Shizuku berechtigen + Über Root berechtigen + Berechtigungserteilung versuchen + Shizuku-Rechte werden erteilt… + Root-Rechte werden erteilt… + Automatische Berechtigung wird versucht… + Shizuku-/Root-Berechtigung fehlgeschlagen oder abgelehnt + Berechtigung erteilt + Systemeinstellungen-Berechtigung erforderlich + Die App benötigt eine spezielle Berechtigung, um die privaten DNS-Einstellungen zu ändern. + Korrigieren + Einstellungen öffnen + Privates DNS + Verbindungstest überspringen + Aktivieren Sie diese Option nur, wenn Ihr DNS funktioniert, die App es jedoch als nicht erreichbar anzeigt. + Benutzerdefinierte(r) DNS-Anbieter + Hostnamen hinzufügen + Diesen aktiven Hostname speichern + Hostname bearbeiten + DNS Hostname (z.B. dns.nextdns.io) + Bezeichnung (optional) + Hostname entfernen? + Privates DNS kann nicht aktiviert werden, solange die Host-Adresse leer ist + Ungültiges DNS-Hostname-Format + Prüfe Verbindung… + Aktuell (nicht gespeichert) + Aktiviert & erreichbar + Der DNS-Server ist über Port 853 (DoT) nicht erreichbar. Android kann möglicherweise weiterhin über andere Methoden eine Verbindung herstellen. + VPN überschreiben aktiv + WLAN-Profil aktiv (%s) + Informationen zur WLAN-Überwachung + WLAN-Profile + Weitere Einstellungen From 0a292d0b7346f1134d75b81cd11241552bcf370d Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:09:18 +0200 Subject: [PATCH 18/23] chore(l10n): update Chinese (Simplified Han script) translation Translation: ELowry/DNSToggle Strings Language: Chinese (Simplified Han script) Progress: 86.8% (125 of 144 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 249 +++++++++++---------- 1 file changed, 125 insertions(+), 124 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index eccaca8..fe87858 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,127 +1,128 @@ - 应用版本:%s - 应用版本:%s - GitHub(源代码) - 获取支持 - 确定 - 取消 - 关闭 - 设置 - - - 关(自动) - 更多选项 - 需要权限 - 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: - ADB命令 - ADB 命令:%s - 复制ADB命令 - 已将命令复制到剪贴板 - 通过Shizuku授予 - 通过Root授予 - 尝试授予权限 - 尝试授予Shizuku… - 尝试授予Root… - 尝试自动授予权限… - Shizuku/Root 授权失败或被拒绝 - 已授予权限 - 需要系统设置权限 - 本应用需要特殊权限来修改专用DNS设置。 - 解决 - 打开设置 - 专用DNS - 跳过连接测试 - 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 - 自定义DNS提供商 - 添加主机名 - 编辑主机名 - DNS主机名(如 dns.nextdns.io) - 标签(可选) - 无法启用专用DNS,因为主机地址是空的 - DNS主机地址格式无效 - 正在测试连接… - 当前项目(未保存) - 新版特性 - 处于活动状态,且可连接 - DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 - v%s 的新特性 - 自定义… - 输入一个位于5-300秒之间的值 - %d秒 - 选项 - 显示包含状态的toast通知 - VPN超控已激活 - Wi-Fi 配置已激活(%s) - 有关Wi-Fi监控功能 - 为了支持自动切换相关设置,本应用需监控网络的变化。该动作对电量的消耗应该是最低的,但是系统或许仍然会反馈本应用的后台活动。 - 部分设备通过杀死后台监控组件来减少电量消耗。如果该自动化组件停止工作,请停止系统对此应用的限制。 - 忽略电池优化 - 在不兼容的网络上阻止专用 DNS - 当专用 DNS 无法连接,而互联网仍然可用时,本应用将自动停用当前网络的专用 DNS,以恢复网络连接性。 - 停用前等待时间 - 连接测试服务器 - 连接测试目标服务器 - 用逗号隔开多个IP(例如 9.9.9.9, 5.1.66.255) - 由于连接问题,已停用专用 DNS,并为 %s 添加了 Wi-Fi 配置 - 连接已恢复。已为%s重新启用专用DNS - 需要位置信息/附近的设备权限(包括后台位置)用于验证Wi-Fi的SSID。 - 授予权限 - 位置信息 和 附近的设备权限已被永久关闭。请在应用设置里打开这些来使用这些功能。 - 为了在专用DNS在后台自动切换后提醒您,并确保监控组件活动,请允许通知权限。 - 保存此活动的主机名 - 为了在本应用关闭后继续监控Wi-Fi,Android端需要“始终允许”位置访问。DNS Toggle 仅使用位置信息来读取Wi-Fi名称,且从不跟踪您的物理位置或使用GPS。 - \n\n注意:正在活动的VPN连接可能会覆盖您的Wi-Fi网络名称,暂时禁用 Wi-Fi 配置自动化。 - 添加 Wi-Fi 配置 - 编辑 Wi-Fi 配置 - SSID (Wi-Fi 名称) - 删除此 Wi-Fi 配置? - Wi-Fi 配置已保存 - VPN超控 - 当VPN连接后,自动切换专用DNS。 - VPN下的DNS - 需要通知权限用于后台自动化操作。 - 此前用于VPN的DNS主机名已被移除。VPN超控功能已被关闭。 - 通过快速设置磁贴其诶专用DNS后显示状态通知。 - 隐藏应用图标 - 启用该设置后, 本应用将从应用抽屉里隐藏. 您仍然可用通过长按快速设置磁贴调出设置。 注意:在部分桌面上,此选项可能不会完全隐藏应用图标。 - 由于 Android TV系统限制,此功能不可用。 - USB调试 - 显示USB调试磁贴 - 显示一个额外的快速设置磁贴,用于切换USB调试。长按打开开发者选项。 - 点击超过%1$d次以启用USB调试磁贴。 - USB调试快速设置磁贴现在可以使用了! - 重命名应用 - 选择一个对您来说更合理的名称。此名称将显示在应用图标和快速设置磁贴上。 - 导出配置项 - 导入设置项 - 由于您的设备的安全设置出现变化(如更改锁屏方式),您的 Wi-Fi 配置被重置以保护您的数据。您需要重新添加保存的网络。 - 备份/恢复 - 输入密码来加密您的备份文件。之后您需要输入相同的密码来恢复此设置项。 - 密码 - 导入 - 导入此配置文件将覆盖您的当前设置、主机名和 Wi-Fi 配置。您需要继续吗? - 设置项导出成功 - 设置项导出失败 - 设置项导入成功 - 无法读取文件 - 解密失败。密码不正确或文件已损坏。 - 自动化提示 - 当自动切换专用DNS或触发 Wi-Fi 配置时发出通知。 - 后台监控 - Android上必须的静默、持久通知,用于后台监控Wi-Fi网络。 - 监控网络状态 - DNS Toggle处于活动状态,用于自动设置专用DNS模式。 - 专用DNS因为%s而自动停用 - 专用DNS因VPN而调整 - 专用DNS在VPN关闭后恢复 - Wi-Fi 配置:%s 的专用 DNS 已关闭 - Wi-Fi 配置:%s 的专用 DNS 已开启 - 专用DNS已切换到%s - 已保存主机名 - 切换DNS - 切换专用DNS模式 - 选择DNS - 选择专用DNS的主机名 - 安全性重置 + 应用版本:%s + 应用版本:%s + GitHub(源代码) + 获取支持 + 确定 + 取消 + 关闭 + 设置 + + + 关(自动) + 更多选项 + 需要权限 + 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: + ADB命令 + ADB 命令:%s + 复制ADB命令 + 已将命令复制到剪贴板 + 通过Shizuku授予 + 通过Root授予 + 尝试授予权限 + 尝试授予Shizuku… + 尝试授予Root… + 尝试自动授予权限… + Shizuku/Root 授权失败或被拒绝 + 已授予权限 + 需要系统设置权限 + 本应用需要特殊权限来修改专用DNS设置。 + 解决 + 打开设置 + 专用DNS + 跳过连接测试 + 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 + 自定义DNS提供商 + 添加主机名 + 编辑主机名 + DNS主机名(如 dns.nextdns.io) + 标签(可选) + 无法启用专用DNS,因为主机地址是空的 + DNS主机地址格式无效 + 正在测试连接… + 当前项目(未保存) + 新版特性 + 处于活动状态,且可连接 + DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 + v%s 的新特性 + 自定义… + 输入一个位于5-300秒之间的值 + %d秒 + 选项 + 显示包含状态的toast通知 + VPN超控已激活 + Wi-Fi 配置已激活(%s) + 有关Wi-Fi监控功能 + 为了支持自动切换相关设置,本应用需监控网络的变化。该动作对电量的消耗应该是最低的,但是系统或许仍然会反馈本应用的后台活动。 + 部分设备通过杀死后台监控组件来减少电量消耗。如果该自动化组件停止工作,请停止系统对此应用的限制。 + 忽略电池优化 + 在不兼容的网络上阻止专用 DNS + 当专用 DNS 无法连接,而互联网仍然可用时,本应用将自动停用当前网络的专用 DNS,以恢复网络连接性。 + 停用前等待时间 + 连接测试服务器 + 连接测试目标服务器 + 用逗号隔开多个IP(例如 9.9.9.9, 5.1.66.255) + 由于连接问题,已停用专用 DNS,并为 %s 添加了 Wi-Fi 配置 + 连接已恢复。已为%s重新启用专用DNS + 需要位置信息/附近的设备权限(包括后台位置)用于验证Wi-Fi的SSID。 + 授予权限 + 位置信息 和 附近的设备权限已被永久关闭。请在应用设置里打开这些来使用这些功能。 + 为了在专用DNS在后台自动切换后提醒您,并确保监控组件活动,请允许通知权限。 + 保存此活动的主机名 + 为了在本应用关闭后继续监控Wi-Fi,Android端需要“始终允许”位置访问。DNS Toggle 仅使用位置信息来读取Wi-Fi名称,且从不跟踪您的物理位置或使用GPS。 + \n\n注意:正在活动的VPN连接可能会覆盖您的Wi-Fi网络名称,暂时禁用 Wi-Fi 配置自动化。 + 添加 Wi-Fi 配置 + 编辑 Wi-Fi 配置 + SSID (Wi-Fi 名称) + 删除此 Wi-Fi 配置? + Wi-Fi 配置已保存 + VPN超控 + 当VPN连接后,自动切换专用DNS。 + VPN下的DNS + 需要通知权限用于后台自动化操作。 + 此前用于VPN的DNS主机名已被移除。VPN超控功能已被关闭。 + 通过快速设置磁贴其诶专用DNS后显示状态通知。 + 隐藏应用图标 + 启用该设置后, 本应用将从应用抽屉里隐藏. 您仍然可用通过长按快速设置磁贴调出设置。 注意:在部分桌面上,此选项可能不会完全隐藏应用图标。 + 由于 Android TV系统限制,此功能不可用。 + USB调试 + 显示USB调试磁贴 + 显示一个额外的快速设置磁贴,用于切换USB调试。长按打开开发者选项。 + 点击超过%1$d次以启用USB调试磁贴。 + USB调试快速设置磁贴现在可以使用了! + 重命名应用 + 选择一个对您来说更合理的名称。此名称将显示在应用图标和快速设置磁贴上。 + 导出配置项 + 导入设置项 + 由于您的设备的安全设置出现变化(如更改锁屏方式),您的 Wi-Fi 配置被重置以保护您的数据。您需要重新添加保存的网络。 + 备份/恢复 + 输入密码来加密您的备份文件。之后您需要输入相同的密码来恢复此设置项。 + 密码 + 导入 + 导入此配置文件将覆盖您的当前设置、主机名和 Wi-Fi 配置。您需要继续吗? + 设置项导出成功 + 设置项导出失败 + 设置项导入成功 + 无法读取文件 + 解密失败。密码不正确或文件已损坏。 + 自动化提示 + 当自动切换专用DNS或触发 Wi-Fi 配置时发出通知。 + 后台监控 + Android上必须的静默、持久通知,用于后台监控Wi-Fi网络。 + 监控网络状态 + DNS Toggle处于活动状态,用于自动设置专用DNS模式。 + 专用DNS因为%s而自动停用 + 专用DNS因VPN而调整 + 专用DNS在VPN关闭后恢复 + Wi-Fi 配置:%s 的专用 DNS 已关闭 + Wi-Fi 配置:%s 的专用 DNS 已开启 + 专用DNS已切换到%s + 已保存主机名 + 切换DNS + 切换专用DNS模式 + 选择DNS + 选择专用DNS的主机名 + 安全性重置 + 删除此主机名? From 9600d82df0bd5e7edb5278340e31fcebcbaf2039 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:30:23 +0200 Subject: [PATCH 19/23] Cleaned up French translation --- app/src/main/res/values-fr/strings.xml | 163 +++++++++++++++---------- 1 file changed, 98 insertions(+), 65 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a84378b..8762f55 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,11 +1,13 @@ - Version de l\'application: %s + DNS Toggle + Version: %s Nouveautés Nouveau dans la v%s GitHub (code source) Obtenir de l\'aide + OK Annuler @@ -13,129 +15,160 @@ Paramètres Activé Désactivé - Désactivé (automatique) + Désactivé (auto) + Plus d\'options + Autorisation requise - Pour utiliser cette application, vous devez accorder des autorisations d\'accès aux paramètres via root, Shizuku, ou exécuter cette commande ADB sur votre ordinateur avec le débogage USB activé: + Pour utiliser ce bouton, accordez l\'accès aux paramètres via Root, Shizuku, ou exécutez cette commande ADB sur votre PC avec le débogage USB activé: Commande ADB Commande ADB: %s Copier la commande ADB Commande copiée dans le presse-papiers Accorder via Shizuku Accorder via Root - Tentative d\'octroi de permission + Tentative d\'octroi Tentative d\'octroi via Shizuku… Tentative d\'octroi via Root… - Tentative d\'octroi automatique… + Tentative d\'octroi auto… L\'octroi via Shizuku/Root a échoué ou été refusé Autorisation accordée Autorisation système requise L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. Fixer - Ouvrez les paramètres + Ouvrir les paramètres + DNS privé Ignorer le test de connexion Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. Fournisseur(s) DNS personnalisé - Ajouter une addresse DNS - Enregistrer cette addresse DNS active - Modifier l\'addresse - Addresse DNS (ex: dns.nextdns.io) + Ajouter une adresse + Enregistrer cette adresse active + Modifier l\'adresse + Adresse DNS (ex: dns.nextdns.io) Surnom (optionnel) - Supprimer cette addresse? - Le DNS privé ne peut être active tant que l\'adresse de l\'hôte est vide - Format d\'adresse d\'hôte DNS non valide - + Supprimer cette adresse? + L\'adresse DNS ne peut pas être vide + Format d\'adresse DNS non valide + + Test de connexion… Actif (non enregistré) Actif et joignable + Par défaut + PAR DÉFAUT + Auto-bloqué: Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. - Override VPN en cours + Override VPN actif Profil Wi-Fi actif (%s) + Override SSID actif: %1$s est défini sur %2$s + Désactivé (%s) + Activer le DNS privé + + Profils Wi-Fi Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. - Info sur la surveillance du SSID - Pour prendre en charge le basculement automatique, l\'application surveille les changements de réseau. L\'impact sur la consommation de la batterie devrait être minimal, mais le système peut afficher une activité en arrière-plan continue. - Certains appareils arrêtent la surveillance en arrière-plan pour économiser la batterie. Si l\'automatisation cesse de fonctionner, désactivez les restrictions pour cette application. + Info surveillance SSID + Pour l\'automatisation, l\'app surveille les changements de réseau. L\'impact batterie est minimal, mais le système peut afficher une activité en arrière-plan. + Certains appareils arrêtent la surveillance pour économiser la batterie. Si l\'automatisation s\'arrête, désactivez les restrictions pour cette app. Désactiver les restrictions de batterie - Ajout automatique d\'un profil Wi-Fi si échec de connexion - Lorsque le DNS privé échoue mais que l\'internet est toujours disponible, l\'application créera automatiquement un profil Wi-Fi pour le réseau actuel afin de restaurer la connectivité. + Auto-enregistrement d\'état (on/off) + Mémorise l\'activation manuelle du DNS privé sur chaque réseau Wi-Fi. + Auto-enregistrement d\'adresses + Mémorise l\'adresse DNS personnalisée utilisée sur chaque réseau Wi-Fi. + Fournisseur DNS + Par défaut (%s) + Bloquer DNS privé sur les réseaux incompatibles + Si le DNS privé échoue, mais qu\'Internet est disponible, l\'app désactive le DNS privé sur ce réseau pour restaurer la connexion. Délai avant désactivation Serveurs de test Serveurs de test de connectivité - Adresses IP séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) + IPs séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) Personnalisé… Saisissez une valeur entre 5 et 300 secondes %ds - L\'autorisation de localisation/proximité (y compris en arrière-plan) est requise pour vérifier le nom du Wi-Fi. + DNS privé désactivé et profil ajouté pour %s (problème de connexion) + Connectivité rétablie. DNS privé réactivé pour %s + Accès position/proximité (même en arrière-plan) requis pour vérifier le nom du Wi-Fi. Accorder les autorisations - Les autorisations de localisation et d\'appareils à proximité ont été refusées précédemment. Veuillez ouvrir les paramètres de l\'application pour lui donner les autorisations nécessaires afin d\'utiliser cette fonctionnalité. - Pour vous avertir lorsque le DNS privé est automatiquement activé en arrière-plan, et pour garantir la vérification en arrière-plan, veuillez autoriser les notifications. - Pour surveiller le Wi-Fi lorsque l\'application est fermée, Android nécessite l\'accès à la position \"Toujours autoriser\". DNS Toggle utilise uniquement cela pour voir le nom du Wi-Fi et ne suit jamais votre position physique ni n\'utilise le GPS. - \n\nRemarque: L\'utilisation d\'un VPN peut masquer le nom de votre réseau Wi-Fi, désactivant temporairement l\'automatisation des profils Wi-Fi. + Les autorisations de position et d\'appareils à proximité ont été refusées. Veuillez les activer dans les paramètres de l\'application. + Pour vous avertir des changements auto et garantir la surveillance, veuillez autoriser les notifications. + Pour surveiller le Wi-Fi en arrière-plan, Android requiert l\'accès position \"Toujours autoriser\". DNS Toggle l\'utilise uniquement pour lire le nom du Wi-Fi. + \n\nNote: Un VPN actif peut masquer le nom de votre réseau Wi-Fi et désactiver l\'automatisation des profils. Ajouter un profil Wi-Fi Modifier le profil Wi-Fi SSID (Nom du Wi-Fi) Profil Wi-Fi enregistré - Supprimer ce profil Wi-Fi ? + Supprimer ce profil Wi-Fi? + Override VPN - Changer automatiquement le DNS privé lorsqu\'un VPN est connecté. - DNS lorsque VPN - L\'autorisation d\'afficher des notifications est requise pour les options d\'automatisation. - L\'addresse DNS précédemment utilisé pour le VPN a été supprimé. L\'override VPN a été désactivé. + Changer auto le DNS privé lorsqu\'un VPN est connecté. + DNS sous VPN + L\'autorisation de notifications est requise pour l\'automatisation. + L\'adresse DNS utilisée pour le VPN a été supprimée. L\'override VPN a été désactivé. + Options - Afficher les notifications toast d\'état - Afficher une notification toast lors des changements de mode de DNS privé. - "Masquer l\'icône de l\'application - "Une fois activée, l\'application sera masquée de votre tiroir d\'applications. Vous pourrez toujours y accéder en effectuant un appui long sur la tuile des paramètres rapides. Note: certains appareils refusent parfois de masquer les applications entièrement. + Notifications toast d\'état + Afficher un toast lors des changements de mode de DNS privé. + Masquer l\'icône de l\'application + Une fois activée, l\'app sera masquée de votre lanceur. Accédez aux réglages via un appui long sur la tuile. Note: certains appareils ne masquent pas entièrement l\'icône. + Cette fonctionnalité est indisponible sur Android TV. + Débogage USB - Afficher la tuile de débogage USB - Afficher une tuile supplémentaire pour activer/désactiver le débogage USB. Un appui long ouvre les options développeur. - Cliquez encore %1$d fois pour activer de la tuile de débogage USB. - Tuile de débogage USB disponible dans les paramètres rapides! + Tuile de débogage USB + Ajouter une tuile pour basculer le débogage USB. Appui long pour les options développeur. + Cliquez encore %1$d fois pour activer la tuile. + Tuile de débogage USB disponible! + - "Renommer l\'application - Choisissez un nom qui vous convient. Il sera affiché sur l\'icône de l\'application et la tuile des paramètres rapides. + Renommer l\'application + Choisissez un nom personnalisé pour l\'icône et la tuile des paramètres rapides. Exporter la configuration Importer une configuration + Réinitialisation de sécurité - En raison d\'un changement dans les paramètres de sécurité de votre appareil (comme la modification du verrouillage de l\'écran), vos profils Wi-Fi ont été réinitialisés pour protéger vos données. Vous devrez rajouter vos réseaux enregistrés. + Suite à un changement de sécurité (ex: verrouillage écran), vos profils ont été réinitialisés pour protéger vos données. Veuillez rajouter vos réseaux. + Exporter - Saisissez un mot de passe pour chiffrer votre fichier de sauvegarde. Vous aurez besoin de ce même mot de passe pour restaurer cette configuration ulterieurement. + Saisissez un mot de passe pour chiffrer la sauvegarde. Il sera requis pour la restauration. Mot de passe Importer - Importer cette configuration remplacera les paramètres de l\'application ainsi que les addresses DNS et les profils Wi-Fi enregistrés. Voulez-vous continuer? - Configuration exportée avec succès + L\'importation écrasera vos paramètres, adresses et profils. Continuer? + Configuration exportée L\'exportation a échoué - Configuration importée avec succès - Échec de la lecture du fichier - Échec du déchiffrement. Mot de passe incorrect ou fichier corrompu. + Configuration importée + Échec de lecture du fichier + Déchiffrement échoué. Mot de passe incorrect ou fichier corrompu. + Alertes d\'automatisation - Notifications lorsque le DNS privé est automatiquement activé/désactivé ou lorsque des profils Wi-Fi sont déclenchés. + Notifications lors des changements auto ou déclenchement de profils Wi-Fi. Surveillance en arrière-plan - Une notification silencieuse et persistante requise par Android pour surveiller les réseaux Wi-Fi en arrière-plan. - Surveillance du réseau activée - DNS Toggle est actif pour automatiser le mode DNS privé. - DNS privé désactivé automatiquement pour %s - DNS privé désactivé et profil Wi-Fi créé pour %s en raison d\'un problème de connexion - Connectivité rétablie. DNS privé réactivé pour %s - DNS privé défini par le VPN - DNS privé restauré post-VPN - Profil Wi-Fi: DNS privé désactivé pour %s - Profil Wi-Fi: DNS privé activé pour %s + Notification silencieuse requise par Android pour surveiller les réseaux Wi-Fi. + Surveillance réseau active + DNS Toggle automatise le mode DNS privé. + DNS privé désactivé auto pour %s + DNS privé activé auto pour %s + DNS privé ajusté pour VPN + DNS privé restauré après VPN + Profil Wi-Fi: DNS privé OFF pour %s + Profil Wi-Fi: DNS privé ON pour %s DNS privé: %s - Addresse enregistrée - Cette fonctionnalité ne peut être utilisée sur Android TV. - Activer/désactiver DNS - Activer/désactiver le DNS Privé + S\'applique à %s + Hôte s\'applique à %s + Paramètres globaux.\nOverride %s actif. + OVERRIDE + Adresse enregistrée + + + DNS Toggle + Activer/désactiver le DNS privé Options DNS - Ouvrir le choix de DNS privé - Plus d\'options + Choisir une adresse DNS privée From 009cd8feffd178a737ba5443ae41bd2aa34a344c Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:40:50 +0200 Subject: [PATCH 20/23] Added build step to ensure fastlane titles are present in all language folders --- app/src/main/res/values-de/strings.xml | 102 ++++----- app/src/main/res/values-zh-rCN/strings.xml | 249 ++++++++++----------- build.gradle.kts | 29 +++ fastlane/metadata/android/es-ES/title.txt | 1 + fastlane/metadata/android/fr-FR/title.txt | 1 + fastlane/metadata/android/zh-CN/title.txt | 1 + 6 files changed, 207 insertions(+), 176 deletions(-) create mode 100644 fastlane/metadata/android/es-ES/title.txt create mode 100644 fastlane/metadata/android/fr-FR/title.txt create mode 100644 fastlane/metadata/android/zh-CN/title.txt diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 22ce393..62df7b7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,54 +1,54 @@ - App Version: %s - Was gibt’s Neues - Neu in v%s - GitHub (Quellcode) - Hilfe erhalten - OK - Abbrechen - Schließen - Einstellungen - An - Aus - Aus (automatisch) - Berechtigung erforderlich - Um diesen Schalter nutzen zu können, müssen Sie über Root oder Shizuku die entsprechenden Berechtigungen erteilen oder diesen ADB-Befehl auf Ihrem Computer ausführen, wobei das USB-Debugging aktiviert sein muss: - ADB Befehl - ADB Befehl: %s - ADB Befehl kopieren - Befehl in Zwischenablage kopiert - Über Shizuku berechtigen - Über Root berechtigen - Berechtigungserteilung versuchen - Shizuku-Rechte werden erteilt… - Root-Rechte werden erteilt… - Automatische Berechtigung wird versucht… - Shizuku-/Root-Berechtigung fehlgeschlagen oder abgelehnt - Berechtigung erteilt - Systemeinstellungen-Berechtigung erforderlich - Die App benötigt eine spezielle Berechtigung, um die privaten DNS-Einstellungen zu ändern. - Korrigieren - Einstellungen öffnen - Privates DNS - Verbindungstest überspringen - Aktivieren Sie diese Option nur, wenn Ihr DNS funktioniert, die App es jedoch als nicht erreichbar anzeigt. - Benutzerdefinierte(r) DNS-Anbieter - Hostnamen hinzufügen - Diesen aktiven Hostname speichern - Hostname bearbeiten - DNS Hostname (z.B. dns.nextdns.io) - Bezeichnung (optional) - Hostname entfernen? - Privates DNS kann nicht aktiviert werden, solange die Host-Adresse leer ist - Ungültiges DNS-Hostname-Format - Prüfe Verbindung… - Aktuell (nicht gespeichert) - Aktiviert & erreichbar - Der DNS-Server ist über Port 853 (DoT) nicht erreichbar. Android kann möglicherweise weiterhin über andere Methoden eine Verbindung herstellen. - VPN überschreiben aktiv - WLAN-Profil aktiv (%s) - Informationen zur WLAN-Überwachung - WLAN-Profile - Weitere Einstellungen + App Version: %s + Was gibt’s Neues + Neu in v%s + GitHub (Quellcode) + Hilfe erhalten + OK + Abbrechen + Schließen + Einstellungen + An + Aus + Aus (automatisch) + Berechtigung erforderlich + Um diesen Schalter nutzen zu können, müssen Sie über Root oder Shizuku die entsprechenden Berechtigungen erteilen oder diesen ADB-Befehl auf Ihrem Computer ausführen, wobei das USB-Debugging aktiviert sein muss: + ADB Befehl + ADB Befehl: %s + ADB Befehl kopieren + Befehl in Zwischenablage kopiert + Über Shizuku berechtigen + Über Root berechtigen + Berechtigungserteilung versuchen + Shizuku-Rechte werden erteilt… + Root-Rechte werden erteilt… + Automatische Berechtigung wird versucht… + Shizuku-/Root-Berechtigung fehlgeschlagen oder abgelehnt + Berechtigung erteilt + Systemeinstellungen-Berechtigung erforderlich + Die App benötigt eine spezielle Berechtigung, um die privaten DNS-Einstellungen zu ändern. + Korrigieren + Einstellungen öffnen + Privates DNS + Verbindungstest überspringen + Aktivieren Sie diese Option nur, wenn Ihr DNS funktioniert, die App es jedoch als nicht erreichbar anzeigt. + Benutzerdefinierte(r) DNS-Anbieter + Hostnamen hinzufügen + Diesen aktiven Hostname speichern + Hostname bearbeiten + DNS Hostname (z.B. dns.nextdns.io) + Bezeichnung (optional) + Hostname entfernen? + Privates DNS kann nicht aktiviert werden, solange die Host-Adresse leer ist + Ungültiges DNS-Hostname-Format + Prüfe Verbindung… + Aktuell (nicht gespeichert) + Aktiviert & erreichbar + Der DNS-Server ist über Port 853 (DoT) nicht erreichbar. Android kann möglicherweise weiterhin über andere Methoden eine Verbindung herstellen. + VPN überschreiben aktiv + WLAN-Profil aktiv (%s) + Informationen zur WLAN-Überwachung + WLAN-Profile + Weitere Einstellungen diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fe87858..a8f93ff 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,128 +1,127 @@ - 应用版本:%s - 应用版本:%s - GitHub(源代码) - 获取支持 - 确定 - 取消 - 关闭 - 设置 - - - 关(自动) - 更多选项 - 需要权限 - 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: - ADB命令 - ADB 命令:%s - 复制ADB命令 - 已将命令复制到剪贴板 - 通过Shizuku授予 - 通过Root授予 - 尝试授予权限 - 尝试授予Shizuku… - 尝试授予Root… - 尝试自动授予权限… - Shizuku/Root 授权失败或被拒绝 - 已授予权限 - 需要系统设置权限 - 本应用需要特殊权限来修改专用DNS设置。 - 解决 - 打开设置 - 专用DNS - 跳过连接测试 - 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 - 自定义DNS提供商 - 添加主机名 - 编辑主机名 - DNS主机名(如 dns.nextdns.io) - 标签(可选) - 无法启用专用DNS,因为主机地址是空的 - DNS主机地址格式无效 - 正在测试连接… - 当前项目(未保存) - 新版特性 - 处于活动状态,且可连接 - DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 - v%s 的新特性 - 自定义… - 输入一个位于5-300秒之间的值 - %d秒 - 选项 - 显示包含状态的toast通知 - VPN超控已激活 - Wi-Fi 配置已激活(%s) - 有关Wi-Fi监控功能 - 为了支持自动切换相关设置,本应用需监控网络的变化。该动作对电量的消耗应该是最低的,但是系统或许仍然会反馈本应用的后台活动。 - 部分设备通过杀死后台监控组件来减少电量消耗。如果该自动化组件停止工作,请停止系统对此应用的限制。 - 忽略电池优化 - 在不兼容的网络上阻止专用 DNS - 当专用 DNS 无法连接,而互联网仍然可用时,本应用将自动停用当前网络的专用 DNS,以恢复网络连接性。 - 停用前等待时间 - 连接测试服务器 - 连接测试目标服务器 - 用逗号隔开多个IP(例如 9.9.9.9, 5.1.66.255) - 由于连接问题,已停用专用 DNS,并为 %s 添加了 Wi-Fi 配置 - 连接已恢复。已为%s重新启用专用DNS - 需要位置信息/附近的设备权限(包括后台位置)用于验证Wi-Fi的SSID。 - 授予权限 - 位置信息 和 附近的设备权限已被永久关闭。请在应用设置里打开这些来使用这些功能。 - 为了在专用DNS在后台自动切换后提醒您,并确保监控组件活动,请允许通知权限。 - 保存此活动的主机名 - 为了在本应用关闭后继续监控Wi-Fi,Android端需要“始终允许”位置访问。DNS Toggle 仅使用位置信息来读取Wi-Fi名称,且从不跟踪您的物理位置或使用GPS。 - \n\n注意:正在活动的VPN连接可能会覆盖您的Wi-Fi网络名称,暂时禁用 Wi-Fi 配置自动化。 - 添加 Wi-Fi 配置 - 编辑 Wi-Fi 配置 - SSID (Wi-Fi 名称) - 删除此 Wi-Fi 配置? - Wi-Fi 配置已保存 - VPN超控 - 当VPN连接后,自动切换专用DNS。 - VPN下的DNS - 需要通知权限用于后台自动化操作。 - 此前用于VPN的DNS主机名已被移除。VPN超控功能已被关闭。 - 通过快速设置磁贴其诶专用DNS后显示状态通知。 - 隐藏应用图标 - 启用该设置后, 本应用将从应用抽屉里隐藏. 您仍然可用通过长按快速设置磁贴调出设置。 注意:在部分桌面上,此选项可能不会完全隐藏应用图标。 - 由于 Android TV系统限制,此功能不可用。 - USB调试 - 显示USB调试磁贴 - 显示一个额外的快速设置磁贴,用于切换USB调试。长按打开开发者选项。 - 点击超过%1$d次以启用USB调试磁贴。 - USB调试快速设置磁贴现在可以使用了! - 重命名应用 - 选择一个对您来说更合理的名称。此名称将显示在应用图标和快速设置磁贴上。 - 导出配置项 - 导入设置项 - 由于您的设备的安全设置出现变化(如更改锁屏方式),您的 Wi-Fi 配置被重置以保护您的数据。您需要重新添加保存的网络。 - 备份/恢复 - 输入密码来加密您的备份文件。之后您需要输入相同的密码来恢复此设置项。 - 密码 - 导入 - 导入此配置文件将覆盖您的当前设置、主机名和 Wi-Fi 配置。您需要继续吗? - 设置项导出成功 - 设置项导出失败 - 设置项导入成功 - 无法读取文件 - 解密失败。密码不正确或文件已损坏。 - 自动化提示 - 当自动切换专用DNS或触发 Wi-Fi 配置时发出通知。 - 后台监控 - Android上必须的静默、持久通知,用于后台监控Wi-Fi网络。 - 监控网络状态 - DNS Toggle处于活动状态,用于自动设置专用DNS模式。 - 专用DNS因为%s而自动停用 - 专用DNS因VPN而调整 - 专用DNS在VPN关闭后恢复 - Wi-Fi 配置:%s 的专用 DNS 已关闭 - Wi-Fi 配置:%s 的专用 DNS 已开启 - 专用DNS已切换到%s - 已保存主机名 - 切换DNS - 切换专用DNS模式 - 选择DNS - 选择专用DNS的主机名 - 安全性重置 - 删除此主机名? + 应用版本:%s + GitHub(源代码) + 获取支持 + 确定 + 取消 + 关闭 + 设置 + + + 关(自动) + 更多选项 + 需要权限 + 要使用此切换工具,您必须通过 root、Shizuku 授予设置权限,或在启用 USB 调试的情况下在电脑上运行以下 ADB 命令: + ADB命令 + ADB 命令:%s + 复制ADB命令 + 已将命令复制到剪贴板 + 通过Shizuku授予 + 通过Root授予 + 尝试授予权限 + 尝试授予Shizuku… + 尝试授予Root… + 尝试自动授予权限… + Shizuku/Root 授权失败或被拒绝 + 已授予权限 + 需要系统设置权限 + 本应用需要特殊权限来修改专用DNS设置。 + 解决 + 打开设置 + 专用DNS + 跳过连接测试 + 只有当您的DNS正常工作但应用显示无法访问时,才启用此选项。 + 自定义DNS提供商 + 添加主机名 + 编辑主机名 + DNS主机名(如 dns.nextdns.io) + 标签(可选) + 无法启用专用DNS,因为主机地址是空的 + DNS主机地址格式无效 + 正在测试连接… + 当前项目(未保存) + 新版特性 + 处于活动状态,且可连接 + DNS服务器无法在端口号853(DoT)上连接。Android可能仍会通过其他方法成功连接。 + v%s 的新特性 + 自定义… + 输入一个位于5-300秒之间的值 + %d秒 + 选项 + 显示包含状态的toast通知 + VPN超控已激活 + Wi-Fi 配置已激活(%s) + 有关Wi-Fi监控功能 + 为了支持自动切换相关设置,本应用需监控网络的变化。该动作对电量的消耗应该是最低的,但是系统或许仍然会反馈本应用的后台活动。 + 部分设备通过杀死后台监控组件来减少电量消耗。如果该自动化组件停止工作,请停止系统对此应用的限制。 + 忽略电池优化 + 在不兼容的网络上阻止专用 DNS + 当专用 DNS 无法连接,而互联网仍然可用时,本应用将自动停用当前网络的专用 DNS,以恢复网络连接性。 + 停用前等待时间 + 连接测试服务器 + 连接测试目标服务器 + 用逗号隔开多个IP(例如 9.9.9.9, 5.1.66.255) + 由于连接问题,已停用专用 DNS,并为 %s 添加了 Wi-Fi 配置 + 连接已恢复。已为%s重新启用专用DNS + 需要位置信息/附近的设备权限(包括后台位置)用于验证Wi-Fi的SSID。 + 授予权限 + 位置信息 和 附近的设备权限已被永久关闭。请在应用设置里打开这些来使用这些功能。 + 为了在专用DNS在后台自动切换后提醒您,并确保监控组件活动,请允许通知权限。 + 保存此活动的主机名 + 为了在本应用关闭后继续监控Wi-Fi,Android端需要“始终允许”位置访问。DNS Toggle 仅使用位置信息来读取Wi-Fi名称,且从不跟踪您的物理位置或使用GPS。 + \n\n注意:正在活动的VPN连接可能会覆盖您的Wi-Fi网络名称,暂时禁用 Wi-Fi 配置自动化。 + 添加 Wi-Fi 配置 + 编辑 Wi-Fi 配置 + SSID (Wi-Fi 名称) + 删除此 Wi-Fi 配置? + Wi-Fi 配置已保存 + VPN超控 + 当VPN连接后,自动切换专用DNS。 + VPN下的DNS + 需要通知权限用于后台自动化操作。 + 此前用于VPN的DNS主机名已被移除。VPN超控功能已被关闭。 + 通过快速设置磁贴其诶专用DNS后显示状态通知。 + 隐藏应用图标 + 启用该设置后, 本应用将从应用抽屉里隐藏. 您仍然可用通过长按快速设置磁贴调出设置。 注意:在部分桌面上,此选项可能不会完全隐藏应用图标。 + 由于 Android TV系统限制,此功能不可用。 + USB调试 + 显示USB调试磁贴 + 显示一个额外的快速设置磁贴,用于切换USB调试。长按打开开发者选项。 + 点击超过%1$d次以启用USB调试磁贴。 + USB调试快速设置磁贴现在可以使用了! + 重命名应用 + 选择一个对您来说更合理的名称。此名称将显示在应用图标和快速设置磁贴上。 + 导出配置项 + 导入设置项 + 由于您的设备的安全设置出现变化(如更改锁屏方式),您的 Wi-Fi 配置被重置以保护您的数据。您需要重新添加保存的网络。 + 备份/恢复 + 输入密码来加密您的备份文件。之后您需要输入相同的密码来恢复此设置项。 + 密码 + 导入 + 导入此配置文件将覆盖您的当前设置、主机名和 Wi-Fi 配置。您需要继续吗? + 设置项导出成功 + 设置项导出失败 + 设置项导入成功 + 无法读取文件 + 解密失败。密码不正确或文件已损坏。 + 自动化提示 + 当自动切换专用DNS或触发 Wi-Fi 配置时发出通知。 + 后台监控 + Android上必须的静默、持久通知,用于后台监控Wi-Fi网络。 + 监控网络状态 + DNS Toggle处于活动状态,用于自动设置专用DNS模式。 + 专用DNS因为%s而自动停用 + 专用DNS因VPN而调整 + 专用DNS在VPN关闭后恢复 + Wi-Fi 配置:%s 的专用 DNS 已关闭 + Wi-Fi 配置:%s 的专用 DNS 已开启 + 专用DNS已切换到%s + 已保存主机名 + 切换DNS + 切换专用DNS模式 + 选择DNS + 选择专用DNS的主机名 + 安全性重置 + 删除此主机名? diff --git a/build.gradle.kts b/build.gradle.kts index 84a08cf..2345f77 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,30 @@ tasks.register("checkFastlaneMetadataLengths") { } } +tasks.register("ensureLocalizedTitles") { + group = "build setup" + description = "Ensures all locale directories have a title.txt, falling back to en-US." + + doLast { + val baseDir = file("$rootDir/fastlane/metadata/android") + val defaultTitleFile = File(baseDir, "en-US/title.txt") + + if (!defaultTitleFile.exists()) { + logger.warn("Default en-US title.txt not found. Cannot duplicate.") + return@doLast + } + + baseDir.listFiles()?.filter { it.isDirectory && it.name != "en-US" }?.forEach { localeDir -> + val titleFile = File(localeDir, "title.txt") + + if (!titleFile.exists()) { + logger.lifecycle("Missing title.txt in ${localeDir.name}. Copying from en-US...") + defaultTitleFile.copyTo(titleFile) + } + } + } +} + tasks.register("clean") { group = "build" description = "Deletes the build directory and runs verification tasks." @@ -73,7 +97,12 @@ allprojects { tasks.whenTaskAdded { if (name == "preBuild") { dependsOn(":setupGitHooks") + dependsOn(":ensureLocalizedTitles") dependsOn(":checkFastlaneMetadataLengths") } } +} + +tasks.named("checkFastlaneMetadataLengths") { + dependsOn("ensureLocalizedTitles") } \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt new file mode 100644 index 0000000..d9e325c --- /dev/null +++ b/fastlane/metadata/android/es-ES/title.txt @@ -0,0 +1 @@ +DNS Toggle \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 100644 index 0000000..d9e325c --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +DNS Toggle \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 100644 index 0000000..d9e325c --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +DNS Toggle \ No newline at end of file From b392ee0416cb893bba9a16f33a63256018ebbe16 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:50:08 +0200 Subject: [PATCH 21/23] chore(l10n): update French translation Translation: ELowry/DNSToggle Strings Language: French Progress: 100.0% (144 of 144 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-strings/fr/ --- app/src/main/res/values-fr/strings.xml | 303 ++++++++++++------------- 1 file changed, 145 insertions(+), 158 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8762f55..3f2c28c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,174 +1,161 @@ - + DNS Toggle - Version: %s - Nouveautés - Nouveau dans la v%s - GitHub (code source) - Obtenir de l\'aide - - + Version: %s + Nouveautés + Nouveau dans la v%s + GitHub (code source) + Obtenir de l\'aide + OK - Annuler - Fermer - Paramètres - Activé - Désactivé - Désactivé (auto) - Plus d\'options - - + Annuler + Fermer + Paramètres + Activé + Désactivé + Désactivé (auto) + Plus d\'options + Autorisation requise - Pour utiliser ce bouton, accordez l\'accès aux paramètres via Root, Shizuku, ou exécutez cette commande ADB sur votre PC avec le débogage USB activé: - Commande ADB - Commande ADB: %s - Copier la commande ADB - Commande copiée dans le presse-papiers - Accorder via Shizuku - Accorder via Root - Tentative d\'octroi - Tentative d\'octroi via Shizuku… - Tentative d\'octroi via Root… - Tentative d\'octroi auto… - L\'octroi via Shizuku/Root a échoué ou été refusé - Autorisation accordée - Autorisation système requise - L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. - Fixer - Ouvrir les paramètres - - + Pour utiliser ce bouton, accordez l\'accès aux paramètres via Root, Shizuku, ou exécutez cette commande ADB sur votre PC avec le débogage USB activé: + Commande ADB + Commande ADB: %s + Copier la commande ADB + Commande copiée dans le presse-papiers + Accorder via Shizuku + Accorder via Root + Tentative d\'octroi + Tentative d\'octroi via Shizuku… + Tentative d\'octroi via Root… + Tentative d\'octroi auto… + L\'octroi via Shizuku/Root a échoué ou été refusé + Autorisation accordée + Autorisation système requise + L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. + Fixer + Ouvrir les paramètres + DNS privé - Ignorer le test de connexion - Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. - Fournisseur(s) DNS personnalisé - Ajouter une adresse - Enregistrer cette adresse active - Modifier l\'adresse - Adresse DNS (ex: dns.nextdns.io) - Surnom (optionnel) - Supprimer cette adresse? - L\'adresse DNS ne peut pas être vide - Format d\'adresse DNS non valide - - + Ignorer le test de connexion + Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. + Fournisseur(s) DNS personnalisé + Ajouter une adresse + Enregistrer cette adresse active + Modifier l\'adresse + Adresse DNS (ex: dns.nextdns.io) + Surnom (optionnel) + Supprimer cette adresse? + L\'adresse DNS ne peut pas être vide + Format d\'adresse DNS non valide + Test de connexion… - Actif (non enregistré) - Actif et joignable - Par défaut - PAR DÉFAUT - Auto-bloqué: - Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. - Override VPN actif - Profil Wi-Fi actif (%s) - Override SSID actif: %1$s est défini sur %2$s - Désactivé (%s) - Activer le DNS privé - - + Actif (non enregistré) + Actif et joignable + Par défaut + PAR DÉFAUT + Auto-bloqué: + Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. + Override VPN actif + Profil Wi-Fi actif (%s) + Override SSID actif: %1$s est défini sur %2$s + Désactivé (%s) + Activer le DNS privé + Profils Wi-Fi - Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. - Info surveillance SSID - Pour l\'automatisation, l\'app surveille les changements de réseau. L\'impact batterie est minimal, mais le système peut afficher une activité en arrière-plan. - Certains appareils arrêtent la surveillance pour économiser la batterie. Si l\'automatisation s\'arrête, désactivez les restrictions pour cette app. - Désactiver les restrictions de batterie - Auto-enregistrement d\'état (on/off) - Mémorise l\'activation manuelle du DNS privé sur chaque réseau Wi-Fi. - Auto-enregistrement d\'adresses - Mémorise l\'adresse DNS personnalisée utilisée sur chaque réseau Wi-Fi. - Fournisseur DNS - Par défaut (%s) - Bloquer DNS privé sur les réseaux incompatibles - Si le DNS privé échoue, mais qu\'Internet est disponible, l\'app désactive le DNS privé sur ce réseau pour restaurer la connexion. - Délai avant désactivation - Serveurs de test - Serveurs de test de connectivité - IPs séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) - Personnalisé… - Saisissez une valeur entre 5 et 300 secondes - %ds - DNS privé désactivé et profil ajouté pour %s (problème de connexion) - Connectivité rétablie. DNS privé réactivé pour %s - Accès position/proximité (même en arrière-plan) requis pour vérifier le nom du Wi-Fi. - Accorder les autorisations - Les autorisations de position et d\'appareils à proximité ont été refusées. Veuillez les activer dans les paramètres de l\'application. - Pour vous avertir des changements auto et garantir la surveillance, veuillez autoriser les notifications. - Pour surveiller le Wi-Fi en arrière-plan, Android requiert l\'accès position \"Toujours autoriser\". DNS Toggle l\'utilise uniquement pour lire le nom du Wi-Fi. - \n\nNote: Un VPN actif peut masquer le nom de votre réseau Wi-Fi et désactiver l\'automatisation des profils. - Ajouter un profil Wi-Fi - Modifier le profil Wi-Fi - SSID (Nom du Wi-Fi) - Profil Wi-Fi enregistré - Supprimer ce profil Wi-Fi? - - + Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. + Info surveillance SSID + Pour l\'automatisation, l\'app surveille les changements de réseau. L\'impact batterie est minimal, mais le système peut afficher une activité en arrière-plan. + Certains appareils arrêtent la surveillance pour économiser la batterie. Si l\'automatisation s\'arrête, désactivez les restrictions pour cette app. + Désactiver les restrictions de batterie + Auto-enregistrement d\'état (on/off) + Mémorise l\'activation manuelle du DNS privé sur chaque réseau Wi-Fi. + Auto-enregistrement d\'adresses + Mémorise l\'adresse DNS personnalisée utilisée sur chaque réseau Wi-Fi. + Fournisseur DNS + Par défaut (%s) + Bloquer DNS privé sur les réseaux incompatibles + Si le DNS privé échoue, mais qu\'Internet est disponible, l\'app désactive le DNS privé sur ce réseau pour restaurer la connexion. + Délai avant désactivation + Serveurs de test + Serveurs de test de connectivité + IPs séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) + Personnalisé… + Saisissez une valeur entre 5 et 300 secondes + %ds + DNS privé désactivé et profil ajouté pour %s (problème de connexion) + Connectivité rétablie. DNS privé réactivé pour %s + Accès position/proximité (même en arrière-plan) requis pour vérifier le nom du Wi-Fi. + Accorder les autorisations + Les autorisations de position et d\'appareils à proximité ont été refusées. Veuillez les activer dans les paramètres de l\'application. + Pour vous avertir des changements auto et garantir la surveillance, veuillez autoriser les notifications. + Pour surveiller le Wi-Fi en arrière-plan, Android requiert l\'accès position \"Toujours autoriser\". DNS Toggle l\'utilise uniquement pour lire le nom du Wi-Fi. + \n\nNote: Un VPN actif peut masquer le nom de votre réseau Wi-Fi et désactiver l\'automatisation des profils. + Ajouter un profil Wi-Fi + Modifier le profil Wi-Fi + SSID (Nom du Wi-Fi) + Profil Wi-Fi enregistré + Supprimer ce profil Wi-Fi? + Override VPN - Changer auto le DNS privé lorsqu\'un VPN est connecté. - DNS sous VPN - L\'autorisation de notifications est requise pour l\'automatisation. - L\'adresse DNS utilisée pour le VPN a été supprimée. L\'override VPN a été désactivé. - - + Changer auto le DNS privé lorsqu\'un VPN est connecté. + DNS sous VPN + L\'autorisation de notifications est requise pour l\'automatisation. + L\'adresse DNS utilisée pour le VPN a été supprimée. L\'override VPN a été désactivé. + Options - Notifications toast d\'état - Afficher un toast lors des changements de mode de DNS privé. - Masquer l\'icône de l\'application - Une fois activée, l\'app sera masquée de votre lanceur. Accédez aux réglages via un appui long sur la tuile. Note: certains appareils ne masquent pas entièrement l\'icône. - Cette fonctionnalité est indisponible sur Android TV. - - + Notifications toast d\'état + Afficher un toast lors des changements de mode de DNS privé. + Masquer l\'icône de l\'application + Une fois activée, l\'app sera masquée de votre lanceur. Accédez aux réglages via un appui long sur la tuile. Note: certains appareils ne masquent pas entièrement l\'icône. + Cette fonctionnalité est indisponible sur Android TV. + Débogage USB - Tuile de débogage USB - Ajouter une tuile pour basculer le débogage USB. Appui long pour les options développeur. - Cliquez encore %1$d fois pour activer la tuile. - Tuile de débogage USB disponible! - - + Tuile de débogage USB + Ajouter une tuile pour basculer le débogage USB. Appui long pour les options développeur. + Cliquez encore %1$d fois pour activer la tuile. + Tuile de débogage USB disponible! + Renommer l\'application - Choisissez un nom personnalisé pour l\'icône et la tuile des paramètres rapides. - Exporter la configuration - Importer une configuration - - + Choisissez un nom personnalisé pour l\'icône et la tuile des paramètres rapides. + Exporter la configuration + Importer une configuration + Réinitialisation de sécurité - Suite à un changement de sécurité (ex: verrouillage écran), vos profils ont été réinitialisés pour protéger vos données. Veuillez rajouter vos réseaux. - - + Suite à un changement de sécurité (ex: verrouillage écran), vos profils ont été réinitialisés pour protéger vos données. Veuillez rajouter vos réseaux. + Exporter - Saisissez un mot de passe pour chiffrer la sauvegarde. Il sera requis pour la restauration. - Mot de passe - Importer - L\'importation écrasera vos paramètres, adresses et profils. Continuer? - Configuration exportée - L\'exportation a échoué - Configuration importée - Échec de lecture du fichier - Déchiffrement échoué. Mot de passe incorrect ou fichier corrompu. - - + Saisissez un mot de passe pour chiffrer la sauvegarde. Il sera requis pour la restauration. + Mot de passe + Importer + L\'importation écrasera vos paramètres, adresses et profils. Continuer? + Configuration exportée + L\'exportation a échoué + Configuration importée + Échec de lecture du fichier + Déchiffrement échoué. Mot de passe incorrect ou fichier corrompu. + Alertes d\'automatisation - Notifications lors des changements auto ou déclenchement de profils Wi-Fi. - Surveillance en arrière-plan - Notification silencieuse requise par Android pour surveiller les réseaux Wi-Fi. - Surveillance réseau active - DNS Toggle automatise le mode DNS privé. - DNS privé désactivé auto pour %s - DNS privé activé auto pour %s - DNS privé ajusté pour VPN - DNS privé restauré après VPN - Profil Wi-Fi: DNS privé OFF pour %s - Profil Wi-Fi: DNS privé ON pour %s - DNS privé: %s - S\'applique à %s - Hôte s\'applique à %s - Paramètres globaux.\nOverride %s actif. - OVERRIDE - Adresse enregistrée - - - DNS Toggle - Activer/désactiver le DNS privé - Options DNS - Choisir une adresse DNS privée + Notifications lors des changements auto ou déclenchement de profils Wi-Fi. + Surveillance en arrière-plan + Notification silencieuse requise par Android pour surveiller les réseaux Wi-Fi. + Surveillance réseau active + DNS Toggle automatise le mode DNS privé. + DNS privé désactivé auto pour %s + DNS privé activé auto pour %s + DNS privé ajusté pour VPN + DNS privé restauré après VPN + Profil Wi-Fi: DNS privé OFF pour %s + Profil Wi-Fi: DNS privé ON pour %s + DNS privé: %s + S\'applique à %s + Hôte s\'applique à %s + Paramètres globaux.\nOverride %s actif. + OVERRIDE + Adresse enregistrée + + Activer/Désactiver le DNS + Activer/désactiver le DNS privé + Options DNS + Choisir une adresse DNS privée From 52bd6b330965a968539ee99a72a965d9ffb89938 Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:50:27 +0200 Subject: [PATCH 22/23] chore(l10n): update French translation Translation: ELowry/DNSToggle Metadata Language: French Progress: 100.0% (25 of 25 strings) Translate-URL: https://hosted.weblate.org/projects/elowry/dnstoggle-metadata/fr/ --- fastlane/metadata/android/fr-FR/changelogs/21.txt | 2 +- fastlane/metadata/android/fr-FR/changelogs/22.txt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 fastlane/metadata/android/fr-FR/changelogs/22.txt diff --git a/fastlane/metadata/android/fr-FR/changelogs/21.txt b/fastlane/metadata/android/fr-FR/changelogs/21.txt index fb2da0e..164cf1c 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/21.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/21.txt @@ -1,4 +1,4 @@ - Amélioration de l'interface et du défilement sur Android TV - Ajout d'une section "Nouveautés" pour afficher les changements récents - Meilleure gestion des couleurs sur les appareils plus anciens et Android TV -- Traduction turque (merci Kyo様) \ No newline at end of file +- Traduction turque (merci Kyo様) diff --git a/fastlane/metadata/android/fr-FR/changelogs/22.txt b/fastlane/metadata/android/fr-FR/changelogs/22.txt new file mode 100644 index 0000000..53834e4 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/22.txt @@ -0,0 +1,5 @@ +- Le système de réseaux exclus a été remplacé par les profis Wi-Fi +- Mise à jour et nettoyage de l'interface et du code +- Réparation d'un problème d'activation tactile des options +- Réparation du défilement sur Android TV +- Ajout des traductions en espagnol (merci Allyson Simón), choinois Han (merci XieyilinMain), et japonais en cours (merci Oさん) From f5e6a3b0e89212c4ab100654dd1463950f22a85b Mon Sep 17 00:00:00 2001 From: Eric Lowry Date: Fri, 7 Aug 2026 22:53:22 +0200 Subject: [PATCH 23/23] Corrected changelog --- app/src/main/res/values-fr/strings.xml | 290 +++++++++--------- .../metadata/android/en-US/changelogs/22.txt | 2 +- 2 files changed, 146 insertions(+), 146 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3f2c28c..2ba2af7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,161 +1,161 @@ - - DNS Toggle - Version: %s - Nouveautés - Nouveau dans la v%s - GitHub (code source) - Obtenir de l\'aide - + + Version: %s + Nouveautés + Nouveau dans la v%s + GitHub (code source) + Obtenir de l\'aide + + OK - Annuler - Fermer - Paramètres - Activé - Désactivé - Désactivé (auto) - Plus d\'options - + Annuler + Fermer + Paramètres + Activé + Désactivé + Désactivé (auto) + Plus d\'options + Autorisation requise - Pour utiliser ce bouton, accordez l\'accès aux paramètres via Root, Shizuku, ou exécutez cette commande ADB sur votre PC avec le débogage USB activé: - Commande ADB - Commande ADB: %s - Copier la commande ADB - Commande copiée dans le presse-papiers - Accorder via Shizuku - Accorder via Root - Tentative d\'octroi - Tentative d\'octroi via Shizuku… - Tentative d\'octroi via Root… - Tentative d\'octroi auto… - L\'octroi via Shizuku/Root a échoué ou été refusé - Autorisation accordée - Autorisation système requise - L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. - Fixer - Ouvrir les paramètres - + Pour utiliser ce bouton, accordez l\'accès aux paramètres via Root, Shizuku, ou exécutez cette commande ADB sur votre PC avec le débogage USB activé: + Commande ADB + Commande ADB: %s + Copier la commande ADB + Commande copiée dans le presse-papiers + Accorder via Shizuku + Accorder via Root + Tentative d\'octroi + Tentative d\'octroi via Shizuku… + Tentative d\'octroi via Root… + Tentative d\'octroi auto… + L\'octroi via Shizuku/Root a échoué ou été refusé + Autorisation accordée + Autorisation système requise + L\'application requiert une autorisation spéciale pour modifier les paramètres DNS privés. + Fixer + Ouvrir les paramètres + DNS privé - Ignorer le test de connexion - Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. - Fournisseur(s) DNS personnalisé - Ajouter une adresse - Enregistrer cette adresse active - Modifier l\'adresse - Adresse DNS (ex: dns.nextdns.io) - Surnom (optionnel) - Supprimer cette adresse? - L\'adresse DNS ne peut pas être vide - Format d\'adresse DNS non valide - + Ignorer le test de connexion + Activez ceci uniquement si votre DNS fonctionne, mais que l\'app l\'affiche comme injoignable. + Fournisseur(s) DNS personnalisé + Ajouter une adresse + Enregistrer cette adresse active + Modifier l\'adresse + Adresse DNS (ex: dns.nextdns.io) + Surnom (optionnel) + Supprimer cette adresse? + L\'adresse DNS ne peut pas être vide + Format d\'adresse DNS non valide + Test de connexion… - Actif (non enregistré) - Actif et joignable - Par défaut - PAR DÉFAUT - Auto-bloqué: - Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. - Override VPN actif - Profil Wi-Fi actif (%s) - Override SSID actif: %1$s est défini sur %2$s - Désactivé (%s) - Activer le DNS privé - + Actif (non enregistré) + Actif et joignable + Par défaut + PAR DÉFAUT + Auto-bloqué: + Serveur DNS injoignable sur le port 853 (DoT). Android peut possiblement s\'y connecter par d\'autres méthodes. + Override VPN actif + Profil Wi-Fi actif (%s) + Override SSID actif: %1$s est défini sur %2$s + Désactivé (%s) + Activer le DNS privé + Profils Wi-Fi - Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. - Info surveillance SSID - Pour l\'automatisation, l\'app surveille les changements de réseau. L\'impact batterie est minimal, mais le système peut afficher une activité en arrière-plan. - Certains appareils arrêtent la surveillance pour économiser la batterie. Si l\'automatisation s\'arrête, désactivez les restrictions pour cette app. - Désactiver les restrictions de batterie - Auto-enregistrement d\'état (on/off) - Mémorise l\'activation manuelle du DNS privé sur chaque réseau Wi-Fi. - Auto-enregistrement d\'adresses - Mémorise l\'adresse DNS personnalisée utilisée sur chaque réseau Wi-Fi. - Fournisseur DNS - Par défaut (%s) - Bloquer DNS privé sur les réseaux incompatibles - Si le DNS privé échoue, mais qu\'Internet est disponible, l\'app désactive le DNS privé sur ce réseau pour restaurer la connexion. - Délai avant désactivation - Serveurs de test - Serveurs de test de connectivité - IPs séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) - Personnalisé… - Saisissez une valeur entre 5 et 300 secondes - %ds - DNS privé désactivé et profil ajouté pour %s (problème de connexion) - Connectivité rétablie. DNS privé réactivé pour %s - Accès position/proximité (même en arrière-plan) requis pour vérifier le nom du Wi-Fi. - Accorder les autorisations - Les autorisations de position et d\'appareils à proximité ont été refusées. Veuillez les activer dans les paramètres de l\'application. - Pour vous avertir des changements auto et garantir la surveillance, veuillez autoriser les notifications. - Pour surveiller le Wi-Fi en arrière-plan, Android requiert l\'accès position \"Toujours autoriser\". DNS Toggle l\'utilise uniquement pour lire le nom du Wi-Fi. - \n\nNote: Un VPN actif peut masquer le nom de votre réseau Wi-Fi et désactiver l\'automatisation des profils. - Ajouter un profil Wi-Fi - Modifier le profil Wi-Fi - SSID (Nom du Wi-Fi) - Profil Wi-Fi enregistré - Supprimer ce profil Wi-Fi? - + Définissez des règles DNS privées spécifiques ou des contournements pour chaque réseau Wi-Fi. + Info surveillance SSID + Pour l\'automatisation, l\'app surveille les changements de réseau. L\'impact batterie est minimal, mais le système peut afficher une activité en arrière-plan. + Certains appareils arrêtent la surveillance pour économiser la batterie. Si l\'automatisation s\'arrête, désactivez les restrictions pour cette app. + Désactiver les restrictions de batterie + Auto-enregistrement d\'état (on/off) + Mémorise l\'activation manuelle du DNS privé sur chaque réseau Wi-Fi. + Auto-enregistrement d\'adresses + Mémorise l\'adresse DNS personnalisée utilisée sur chaque réseau Wi-Fi. + Fournisseur DNS + Par défaut (%s) + Bloquer DNS privé sur les réseaux incompatibles + Si le DNS privé échoue, mais qu\'Internet est disponible, l\'app désactive le DNS privé sur ce réseau pour restaurer la connexion. + Délai avant désactivation + Serveurs de test + Serveurs de test de connectivité + IPs séparées par des virgules (ex: 9.9.9.9, 5.1.66.255) + Personnalisé… + Saisissez une valeur entre 5 et 300 secondes + %ds + DNS privé désactivé et profil ajouté pour %s (problème de connexion) + Connectivité rétablie. DNS privé réactivé pour %s + Accès position/proximité (même en arrière-plan) requis pour vérifier le nom du Wi-Fi. + Accorder les autorisations + Les autorisations de position et d\'appareils à proximité ont été refusées. Veuillez les activer dans les paramètres de l\'application. + Pour vous avertir des changements auto et garantir la surveillance, veuillez autoriser les notifications. + Pour surveiller le Wi-Fi en arrière-plan, Android requiert l\'accès position \"Toujours autoriser\". DNS Toggle l\'utilise uniquement pour lire le nom du Wi-Fi. + \n\nNote: Un VPN actif peut masquer le nom de votre réseau Wi-Fi et désactiver l\'automatisation des profils. + Ajouter un profil Wi-Fi + Modifier le profil Wi-Fi + SSID (Nom du Wi-Fi) + Profil Wi-Fi enregistré + Supprimer ce profil Wi-Fi? + Override VPN - Changer auto le DNS privé lorsqu\'un VPN est connecté. - DNS sous VPN - L\'autorisation de notifications est requise pour l\'automatisation. - L\'adresse DNS utilisée pour le VPN a été supprimée. L\'override VPN a été désactivé. - + Changer auto le DNS privé lorsqu\'un VPN est connecté. + DNS sous VPN + L\'autorisation de notifications est requise pour l\'automatisation. + L\'adresse DNS utilisée pour le VPN a été supprimée. L\'override VPN a été désactivé. + Options - Notifications toast d\'état - Afficher un toast lors des changements de mode de DNS privé. - Masquer l\'icône de l\'application - Une fois activée, l\'app sera masquée de votre lanceur. Accédez aux réglages via un appui long sur la tuile. Note: certains appareils ne masquent pas entièrement l\'icône. - Cette fonctionnalité est indisponible sur Android TV. - + Notifications toast d\'état + Afficher un toast lors des changements de mode de DNS privé. + Masquer l\'icône de l\'application + Une fois activée, l\'app sera masquée de votre lanceur. Accédez aux réglages via un appui long sur la tuile. Note: certains appareils ne masquent pas entièrement l\'icône. + Cette fonctionnalité est indisponible sur Android TV. + Débogage USB - Tuile de débogage USB - Ajouter une tuile pour basculer le débogage USB. Appui long pour les options développeur. - Cliquez encore %1$d fois pour activer la tuile. - Tuile de débogage USB disponible! - + Tuile de débogage USB + Ajouter une tuile pour basculer le débogage USB. Appui long pour les options développeur. + Cliquez encore %1$d fois pour activer la tuile. + Tuile de débogage USB disponible! + Renommer l\'application - Choisissez un nom personnalisé pour l\'icône et la tuile des paramètres rapides. - Exporter la configuration - Importer une configuration - + Choisissez un nom personnalisé pour l\'icône et la tuile des paramètres rapides. + Exporter la configuration + Importer une configuration + Réinitialisation de sécurité - Suite à un changement de sécurité (ex: verrouillage écran), vos profils ont été réinitialisés pour protéger vos données. Veuillez rajouter vos réseaux. - + Suite à un changement de sécurité (ex: verrouillage écran), vos profils ont été réinitialisés pour protéger vos données. Veuillez rajouter vos réseaux. + Exporter - Saisissez un mot de passe pour chiffrer la sauvegarde. Il sera requis pour la restauration. - Mot de passe - Importer - L\'importation écrasera vos paramètres, adresses et profils. Continuer? - Configuration exportée - L\'exportation a échoué - Configuration importée - Échec de lecture du fichier - Déchiffrement échoué. Mot de passe incorrect ou fichier corrompu. - + Saisissez un mot de passe pour chiffrer la sauvegarde. Il sera requis pour la restauration. + Mot de passe + Importer + L\'importation écrasera vos paramètres, adresses et profils. Continuer? + Configuration exportée + L\'exportation a échoué + Configuration importée + Échec de lecture du fichier + Déchiffrement échoué. Mot de passe incorrect ou fichier corrompu. + Alertes d\'automatisation - Notifications lors des changements auto ou déclenchement de profils Wi-Fi. - Surveillance en arrière-plan - Notification silencieuse requise par Android pour surveiller les réseaux Wi-Fi. - Surveillance réseau active - DNS Toggle automatise le mode DNS privé. - DNS privé désactivé auto pour %s - DNS privé activé auto pour %s - DNS privé ajusté pour VPN - DNS privé restauré après VPN - Profil Wi-Fi: DNS privé OFF pour %s - Profil Wi-Fi: DNS privé ON pour %s - DNS privé: %s - S\'applique à %s - Hôte s\'applique à %s - Paramètres globaux.\nOverride %s actif. - OVERRIDE - Adresse enregistrée - + Notifications lors des changements auto ou déclenchement de profils Wi-Fi. + Surveillance en arrière-plan + Notification silencieuse requise par Android pour surveiller les réseaux Wi-Fi. + Surveillance réseau active + DNS Toggle automatise le mode DNS privé. + DNS privé désactivé auto pour %s + DNS privé activé auto pour %s + DNS privé ajusté pour VPN + DNS privé restauré après VPN + Profil Wi-Fi: DNS privé OFF pour %s + Profil Wi-Fi: DNS privé ON pour %s + DNS privé: %s + S\'applique à %s + Hôte s\'applique à %s + Paramètres globaux.\nOverride %s actif. + OVERRIDE + Adresse enregistrée + Activer/Désactiver le DNS - Activer/désactiver le DNS privé - Options DNS - Choisir une adresse DNS privée + Activer/désactiver le DNS privé + Options DNS + Choisir une adresse DNS privée diff --git a/fastlane/metadata/android/en-US/changelogs/22.txt b/fastlane/metadata/android/en-US/changelogs/22.txt index 214fa25..e4d5a5a 100644 --- a/fastlane/metadata/android/en-US/changelogs/22.txt +++ b/fastlane/metadata/android/en-US/changelogs/22.txt @@ -2,4 +2,4 @@ - General UI and code cleanup - Fixed an issue that required extra taps for toggles - Fixed issue with scrolling on TVs -- Added translations for Spanish (thanks Allyson Simón), WIP Chinese Han (thanks XieyilinMain), and WIP Japanese (thanks Oさん) \ No newline at end of file +- Added translations for Spanish (thanks Allyson Simón), Chinese Han (thanks XieyilinMain), and WIP Japanese (thanks Oさん) \ No newline at end of file