Major refactor

* Replaced server storage backend with Room, allowing for easier further expansion
* Add option to disable saved servers
* Improved backup handling
* Fixed desync bug while dragging servers
* Reorganized source file structure
* Updated Kotlin version
* Updated Java version
This commit is contained in:
Maksim Karasev 2024-09-13 12:45:11 +03:00
parent eead2a912d
commit 36be36d69b
29 changed files with 909 additions and 435 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.22" />
<option name="version" value="1.9.10" />
</component>
</project>

View file

@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.devtools.ksp'
}
android {
@ -8,8 +9,8 @@ android {
defaultConfig {
applicationId "ru.karasevm.privatednstoggle"
versionCode 15
versionName "1.8.1"
versionCode 16
versionName "1.9.0-beta1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
targetSdkVersion 34
@ -29,11 +30,11 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
@ -48,6 +49,9 @@ dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.activity:activity-ktx:1.9.2'
implementation 'androidx.fragment:fragment-ktx:1.8.3'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.guava:guava:33.1.0-android'
@ -60,6 +64,18 @@ dependencies {
implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
compileOnly 'dev.rikka.hidden:stub:4.3.2'
// Room components
def roomVersion = '2.6.1'
implementation "androidx.room:room-ktx:$roomVersion"
ksp "androidx.room:room-compiler:$roomVersion"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
// Lifecycle components
def lifecycleVersion = '2.8.5'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'

View file

@ -19,7 +19,7 @@
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<activity
android:name=".MainActivity"
android:name=".ui.MainActivity"
android:theme="@style/Theme.MyApplication"
android:taskAffinity="${applicationId}.main"
android:launchMode="singleInstance"
@ -31,7 +31,7 @@
</activity>
<activity
android:name=".SettingsDialogActivity"
android:name=".ui.SettingsDialogActivity"
android:theme="@style/Theme.Transparent"
android:excludeFromRecents="true"
android:exported="true">
@ -41,7 +41,7 @@
</activity>
<service
android:name=".DnsTileService"
android:name=".service.DnsTileService"
android:exported="true"
android:icon="@drawable/ic_unknown_black_24dp"
android:label="@string/tile_name"

View file

@ -2,8 +2,14 @@ package ru.karasevm.privatednstoggle
import android.app.Application
import com.google.android.material.color.DynamicColors
import ru.karasevm.privatednstoggle.data.DnsServerRepository
import ru.karasevm.privatednstoggle.data.database.DnsServerRoomDatabase
class PrivateDNSApp : Application() {
private val database by lazy { DnsServerRoomDatabase.getDatabase(this) }
val repository by lazy { DnsServerRepository(database.dnsServerDao()) }
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)

View file

@ -1,84 +0,0 @@
package ru.karasevm.privatednstoggle
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
class RecyclerAdapter(private val items: MutableList<String>, private val showDragHandle: Boolean, private val onDataChanged: () -> Unit) :
RecyclerView.Adapter<RecyclerAdapter.ViewHolder>() {
var onItemClick: ((Int) -> Unit)? = null
var onItemsChanged: ((MutableList<String>) -> Unit)? = null
var onDragStart: ((RecyclerAdapter.ViewHolder) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerAdapter.ViewHolder {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false)
val vh = ViewHolder(view)
return vh
}
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: RecyclerAdapter.ViewHolder, position: Int) {
val item = items[position]
val parts = item.split(" : ")
if (parts.size == 2) {
holder.labelTextView.visibility = View.VISIBLE
holder.labelTextView.text = parts[0]
holder.textView.text = parts[1]
} else {
holder.labelTextView.visibility = View.GONE
holder.textView.text = parts[0]
}
if (showDragHandle) {
holder.dragHandle.visibility = View.VISIBLE
holder.dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onDragStart?.invoke(holder)
}
return@setOnTouchListener true
}
}
}
override fun getItemCount(): Int {
return items.size
}
fun onItemMove(fromPosition: Int, toPosition: Int) {
// Swap items in your data list
Collections.swap(items, fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
onItemsChanged?.invoke(items)
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val textView: TextView = itemView.findViewById(R.id.textView)
val labelTextView: TextView = itemView.findViewById(R.id.labelTextView)
val dragHandle: ImageView = itemView.findViewById(R.id.dragHandle)
init {
itemView.setOnClickListener {
onItemClick?.invoke(adapterPosition)
}
}
}
fun setData(newItems: MutableList<String>) {
items.run {
clear()
addAll(newItems)
}
onDataChanged()
}
}

View file

@ -0,0 +1,84 @@
package ru.karasevm.privatednstoggle.data
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.karasevm.privatednstoggle.model.DnsServer
@Dao
interface DnsServerDao {
@Query("SELECT * FROM dns_servers ORDER BY sortOrder ASC")
fun getAll(): Flow<List<DnsServer>>
@Query("SELECT * FROM dns_servers WHERE enabled = 1 ORDER BY sortOrder ASC LIMIT 1")
suspend fun getFirstEnabled(): DnsServer
@Query("SELECT * FROM dns_servers WHERE server = :server LIMIT 1")
suspend fun getFirstByServer(server: String): DnsServer?
@Query("SELECT * FROM dns_servers WHERE id = :id")
suspend fun getById(id: Int): DnsServer?
@Query("SELECT * FROM dns_servers " +
"WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE server = :server) AND enabled = 1 " +
"ORDER BY sortOrder ASC " +
"LIMIT 1")
suspend fun getNextEnabledByServer(server: String): DnsServer?
@Query("DELETE FROM dns_servers")
suspend fun deleteAll()
@Query("DELETE FROM dns_servers WHERE id = :id")
suspend fun deleteById(id: Int)
@Query("UPDATE dns_servers SET sortOrder = sortOrder + 1 " +
"WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
suspend fun incrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
@Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
"WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
suspend fun decrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
@Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
"WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE id = :id)")
suspend fun decrementSortOrderById(id: Int)
@Transaction
suspend fun deleteAndDecrement(id: Int) {
decrementSortOrderById(id)
deleteById(id)
}
@Query("UPDATE dns_servers SET label = :label WHERE id = :id")
suspend fun updateLabel(id: Int, label: String)
@Query("UPDATE dns_servers SET server = :server WHERE id = :id")
suspend fun updateServer(id: Int, server: String)
@Query("UPDATE dns_servers " +
"SET server = COALESCE(:server, server), " +
" label = COALESCE(:label, label), " +
" sortOrder = COALESCE(:sortOrder, sortOrder), " +
" enabled = COALESCE(:enabled, enabled) " +
"WHERE id = :id")
suspend fun update(id: Int, server: String?, label: String?, sortOrder: Int?, enabled: Boolean?)
@Transaction
suspend fun moveUp(sortOrder: Int, newSortOrder: Int, id: Int){
incrementSortOrder(newSortOrder, sortOrder)
update(id, null, null, newSortOrder, null)
}
@Transaction
suspend fun moveDown(sortOrder: Int, newSortOrder: Int, id: Int){
decrementSortOrder(sortOrder, newSortOrder)
update(id, null, null, newSortOrder, null)
}
@Query("INSERT INTO dns_servers(server, label, sortOrder, enabled) " +
"VALUES(:server, :label, COALESCE((SELECT MAX(sortOrder) + 1 FROM dns_servers), 0), :enabled)")
suspend fun insert(server: String, label: String, enabled: Boolean)
}

View file

@ -0,0 +1,60 @@
package ru.karasevm.privatednstoggle.data
import androidx.annotation.WorkerThread
import kotlinx.coroutines.flow.Flow
import ru.karasevm.privatednstoggle.model.DnsServer
class DnsServerRepository(private val dnsServerDao: DnsServerDao) {
val allServers: Flow<List<DnsServer>> = dnsServerDao.getAll()
@WorkerThread
fun getAll() = dnsServerDao.getAll()
@WorkerThread
suspend fun getFirstEnabled() = dnsServerDao.getFirstEnabled()
@WorkerThread
suspend fun getById(id: Int) = dnsServerDao.getById(id)
@WorkerThread
suspend fun getFirstByServer(server: String) = dnsServerDao.getFirstByServer(server)
@WorkerThread
suspend fun getNextByServer(server: String) = dnsServerDao.getNextEnabledByServer(server)
@WorkerThread
suspend fun insert(dnsServer: DnsServer) {
dnsServerDao.insert(dnsServer.server, dnsServer.label, dnsServer.enabled)
}
@WorkerThread
suspend fun update(
id: Int,
server: String?,
label: String?,
sortOrder: Int?,
enabled: Boolean?
) {
dnsServerDao.update(id, server, label, sortOrder, enabled)
}
@WorkerThread
suspend fun move(sortOrder: Int, newSortOrder: Int, id: Int) {
if (sortOrder == newSortOrder) {
return
}
if (newSortOrder > sortOrder) {
dnsServerDao.moveDown(sortOrder, newSortOrder, id)
} else {
dnsServerDao.moveUp(sortOrder, newSortOrder, id)
}
}
@WorkerThread
suspend fun delete(id: Int) {
dnsServerDao.deleteAndDecrement(id)
}
}

View file

@ -0,0 +1,48 @@
package ru.karasevm.privatednstoggle.data
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import ru.karasevm.privatednstoggle.model.DnsServer
class DnsServerViewModel(private val dnsServerRepository: DnsServerRepository) : ViewModel() {
val allServers: LiveData<List<DnsServer>> = dnsServerRepository.allServers.asLiveData()
fun getAll() = dnsServerRepository.getAll()
suspend fun getById(id: Int) = dnsServerRepository.getById(id)
fun insert(dnsServer: DnsServer) =
viewModelScope.launch {
dnsServerRepository.insert(dnsServer)
}
fun update(
id: Int,
server: String? = null,
label: String? = null,
sortOrder: Int? = null,
enabled: Boolean? = null
) = viewModelScope.launch { dnsServerRepository.update(id, server, label, sortOrder, enabled) }
fun move(sortOrder: Int, newSortOrder: Int, id: Int) =
viewModelScope.launch { dnsServerRepository.move(sortOrder, newSortOrder, id) }
fun delete(id: Int) = viewModelScope.launch { dnsServerRepository.delete(id) }
}
class DnsServerViewModelFactory(private val dnsServerRepository: DnsServerRepository) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(DnsServerViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return DnsServerViewModel(dnsServerRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View file

@ -0,0 +1,34 @@
package ru.karasevm.privatednstoggle.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import ru.karasevm.privatednstoggle.data.DnsServerDao
import ru.karasevm.privatednstoggle.model.DnsServer
@Database(entities = [DnsServer::class], version = 1, exportSchema = false)
abstract class DnsServerRoomDatabase : RoomDatabase() {
abstract fun dnsServerDao(): DnsServerDao
companion object {
@Volatile
private var INSTANCE: DnsServerRoomDatabase? = null
fun getDatabase(context: Context): DnsServerRoomDatabase {
val tempInstance = INSTANCE
if (tempInstance != null) {
return tempInstance
}
synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DnsServerRoomDatabase::class.java,
"dns_server_database"
).build()
INSTANCE = instance
return instance
}
}
}
}

View file

@ -0,0 +1,22 @@
package ru.karasevm.privatednstoggle.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
// All fields must have default values for proper deserialization
@Entity(tableName = "dns_servers")
data class DnsServer(
@SerializedName("id")
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@SerializedName("server")
val server: String = "",
@SerializedName("label")
val label: String = "",
@SerializedName("enabled")
@ColumnInfo(defaultValue = "1")
val enabled: Boolean = true,
val sortOrder: Int? = null
)

View file

@ -1,4 +1,4 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.service
import android.content.BroadcastReceiver
import android.content.Context
@ -9,22 +9,33 @@ import android.provider.Settings
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat
import ru.karasevm.privatednstoggle.utils.DnsServer
import ru.karasevm.privatednstoggle.utils.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_AUTO
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_AUTO
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_OFF
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_PRIVATE
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.checkForPermission
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import ru.karasevm.privatednstoggle.PrivateDNSApp
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.data.DnsServerRepository
import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.util.PreferenceHelper
import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_AUTO
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_AUTO
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_OFF
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_PRIVATE
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
class DnsTileService : TileService() {
private val repository: DnsServerRepository by lazy { (application as PrivateDNSApp).repository }
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
override fun onTileAdded() {
super.onTileAdded()
checkForPermission(this)
@ -36,7 +47,7 @@ class DnsTileService : TileService() {
}
/**
* Set's the state of the tile to the next state
* Set's the state of the tile and system settings to the next state
*/
private fun cycleState() {
val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
@ -53,24 +64,31 @@ class DnsTileService : TileService() {
} else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
changeDNSServer(DNS_MODE_PRIVATE, null)
} else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
if (getNextAddress(dnsProvider) == null) {
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_PRIVATE) {
changeDNSServer(DNS_MODE_PRIVATE, null)
} else {
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_AUTO) {
changeDNSServer(DNS_MODE_AUTO, dnsProvider)
scope.launch {
if (getNextAddress(dnsProvider) == null) {
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_PRIVATE) {
changeDNSServer(DNS_MODE_PRIVATE, null)
} else {
changeDNSServer(DNS_MODE_OFF, dnsProvider)
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_AUTO) {
changeDNSServer(DNS_MODE_AUTO, dnsProvider)
} else {
changeDNSServer(DNS_MODE_OFF, dnsProvider)
}
}
} else {
changeDNSServer(DNS_MODE_PRIVATE, dnsProvider)
}
} else {
changeDNSServer(DNS_MODE_PRIVATE, dnsProvider)
}
}
}
private fun changeDNSServer(server: String, dnsProvider: String?) {
when (server) {
/**
* Sets the state of the tile to the provided values
* @param mode dns mode
* @param dnsProvider dns provider
*/
private fun changeDNSServer(mode: String, dnsProvider: String?) {
when (mode) {
DNS_MODE_OFF -> {
changeTileState(
qsTile,
@ -94,14 +112,19 @@ class DnsTileService : TileService() {
}
DNS_MODE_PRIVATE -> {
changeTileState(
qsTile,
Tile.STATE_ACTIVE,
getNextAddress(dnsProvider)?.label,
R.drawable.ic_private_black_24dp,
DNS_MODE_PRIVATE,
getNextAddress(dnsProvider)?.server
)
scope.launch {
val nextDnsServer = getNextAddress(dnsProvider)
if (nextDnsServer != null) {
changeTileState(
qsTile,
Tile.STATE_ACTIVE,
nextDnsServer.label.ifEmpty { nextDnsServer.server },
R.drawable.ic_private_black_24dp,
DNS_MODE_PRIVATE,
getNextAddress(dnsProvider)?.server
)
}
}
}
}
}
@ -112,6 +135,8 @@ class DnsTileService : TileService() {
return
}
val sharedPrefs = PreferenceHelper.defaultPreference(this)
// Require unlock to change mode according to user preference
val requireUnlock = sharedPrefs.requireUnlock
if (isLocked && requireUnlock) {
unlockAndRun(this::cycleState)
@ -127,63 +152,48 @@ class DnsTileService : TileService() {
*/
private fun refreshTile() {
val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) {
setTile(
qsTile,
Tile.STATE_INACTIVE,
getString(R.string.dns_off),
R.drawable.ic_off_black_24dp
)
} else if (dnsMode == null) {
setTile(
qsTile,
Tile.STATE_INACTIVE,
getString(R.string.dns_unknown),
R.drawable.ic_unknown_black_24dp
)
} else if (dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
setTile(
qsTile,
Tile.STATE_INACTIVE,
getString(R.string.dns_auto),
R.drawable.ic_auto_black_24dp
)
} else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier")
val sharedPrefs = PreferenceHelper.defaultPreference(this)
val items = sharedPrefs.dns_servers.map {
val parts = it.split(" : ")
if (parts.size == 2)
DnsServer(parts[0], parts[1])
else
DnsServer(parts[0], parts[0])
}
if (items.isEmpty() || items[0].server == "") {
when (dnsMode?.lowercase()) {
DNS_MODE_OFF -> {
setTile(
qsTile,
Tile.STATE_ACTIVE,
"Google",
R.drawable.ic_private_black_24dp
Tile.STATE_INACTIVE,
getString(R.string.dns_off),
R.drawable.ic_off_black_24dp
)
} else {
val index = items.indexOfFirst { it.server == dnsProvider }
if (index == -1) {
}
DNS_MODE_AUTO -> {
setTile(
qsTile,
Tile.STATE_INACTIVE,
getString(R.string.dns_auto),
R.drawable.ic_auto_black_24dp
)
}
DNS_MODE_PRIVATE -> {
scope.launch {
val activeAddress =
Settings.Global.getString(contentResolver, "private_dns_specifier")
val dnsServer = repository.getFirstByServer(activeAddress)
setTile(
qsTile,
Tile.STATE_ACTIVE,
dnsProvider,
R.drawable.ic_private_black_24dp
)
} else {
setTile(
qsTile,
Tile.STATE_ACTIVE,
items[index].label,
// display server address if either there is no label or the server is not known
dnsServer?.label?.ifBlank { activeAddress } ?: activeAddress,
R.drawable.ic_private_black_24dp
)
}
}
else -> {
setTile(
qsTile,
Tile.STATE_INACTIVE,
getString(R.string.dns_unknown),
R.drawable.ic_unknown_black_24dp
)
}
}
}
@ -203,6 +213,9 @@ class DnsTileService : TileService() {
if (qsTile == null) {
return
}
// Receive broadcasts to update the tile when server is changed from the dialog
ContextCompat.registerReceiver(
this,
broadcastReceiver,
@ -219,6 +232,11 @@ class DnsTileService : TileService() {
unregisterReceiver(broadcastReceiver)
}
override fun onDestroy() {
super.onDestroy()
job.cancelChildren()
}
/**
* Updates tile to specified parameters
*
@ -261,39 +279,17 @@ class DnsTileService : TileService() {
}
/**
* Gets next dns address from preferences,
* if current address is last returns null
* Gets next dns address from the database,
* if current address is last or unknown returns null
*
* @param currentAddress currently set address
* @return next address
*/
private fun getNextAddress(currentAddress: String?): DnsServer? {
val sharedPrefs = PreferenceHelper.defaultPreference(this)
val items = sharedPrefs.dns_servers.map {
val parts = it.split(" : ")
// Assuming string is in the format "$label : $server"
if (parts.size == 2)
DnsServer(parts[0], parts[1])
else
DnsServer(parts[0], parts[0])
}.toMutableList()
// Fallback if list is empty
if (items.isEmpty() || items[0].server == "") {
items.apply {
removeAt(0)
add(DnsServer("Google", "dns.google"))
}
private suspend fun getNextAddress(currentAddress: String?): DnsServer? {
return if (currentAddress.isNullOrEmpty()) {
repository.getFirstEnabled()
} else {
repository.getNextByServer(currentAddress)
}
val index = items.indexOfFirst { it.server == currentAddress }
if (index == -1 || currentAddress == null) {
return items[0]
}
if (index == items.size - 1) {
return null
}
return items[index + 1]
}
}

View file

@ -1,4 +1,4 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.app.Dialog
import android.content.Context
@ -11,10 +11,14 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.net.InternetDomainName
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.databinding.DialogAddBinding
import ru.karasevm.privatednstoggle.model.DnsServer
class AddServerDialogFragment(private val position: Int?, private val label: String?, private val server: String?) : DialogFragment() {
class AddServerDialogFragment(
private val dnsServer: DnsServer?
) : DialogFragment() {
// Use this instance of the interface to deliver action events
private lateinit var listener: NoticeDialogListener
@ -28,9 +32,9 @@ class AddServerDialogFragment(private val position: Int?, private val label: Str
* implement this interface in order to receive event callbacks.
* Each method passes the DialogFragment in case the host needs to query it. */
interface NoticeDialogListener {
fun onDialogPositiveClick(label: String? ,server: String)
fun onDialogPositiveClick(label: String?, server: String, position: Int)
fun onDeleteItemClicked(position: Int)
fun onAddDialogPositiveClick(label: String?, server: String)
fun onUpdateDialogPositiveClick(id: Int, server: String, label: String?, enabled: Boolean)
fun onDeleteItemClicked(id: Int)
}
// Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
@ -61,17 +65,21 @@ class AddServerDialogFragment(private val position: Int?, private val label: Str
val view = binding.root
// Inflate and set the layout for the dialog
// Pass null as the parent view because its going in the dialog layout
if (position != null) {
binding.editTextServerHint.setText(label)
binding.editTextServerAddr.setText(server)
if (dnsServer != null) {
binding.editTextServerHint.setText(dnsServer.label)
binding.editTextServerAddr.setText(dnsServer.server)
binding.serverEnabledSwitch.visibility = android.view.View.VISIBLE
binding.serverEnabledSwitch.isChecked = dnsServer.enabled
builder.setTitle(R.string.edit_server).setView(view)
.setPositiveButton(
R.string.menu_save
) { _, _ ->
listener.onDialogPositiveClick(
binding.editTextServerHint.text.toString().trim(),
binding.editTextServerAddr.text.toString().trim(),
position)
) { _, _ ->
listener.onUpdateDialogPositiveClick(
dnsServer.id,
binding.editTextServerAddr.text.toString().trim(),
binding.editTextServerHint.text.toString().trim(),
binding.serverEnabledSwitch.isChecked
)
}
.setNegativeButton(
R.string.cancel
@ -81,17 +89,16 @@ class AddServerDialogFragment(private val position: Int?, private val label: Str
.setNeutralButton(
R.string.delete
) { _, _ ->
listener.onDeleteItemClicked(position)
}
}
else {
listener.onDeleteItemClicked(dnsServer.id)
}
} else {
builder.setTitle(R.string.add_server)
.setView(view)
// Add action buttons
.setPositiveButton(
R.string.menu_add
) { _, _ ->
listener.onDialogPositiveClick(
listener.onAddDialogPositiveClick(
binding.editTextServerHint.text.toString().trim(),
binding.editTextServerAddr.text.toString().trim()
)

View file

@ -1,17 +1,22 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.app.Dialog
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import ru.karasevm.privatednstoggle.PrivateDNSApp
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.data.DnsServerViewModel
import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
import ru.karasevm.privatednstoggle.databinding.SheetDnsSelectorBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.defaultPreference
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils
import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
class DNSServerDialogFragment : DialogFragment() {
@ -19,37 +24,36 @@ class DNSServerDialogFragment : DialogFragment() {
private val binding get() = _binding!!
private lateinit var linearLayoutManager: LinearLayoutManager
private lateinit var adapter: RecyclerAdapter
private var items = mutableListOf<String>()
private lateinit var sharedPrefs: SharedPreferences
private lateinit var adapter: ServerListRecyclerAdapter
private var servers: MutableList<DnsServer> = mutableListOf()
private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((requireActivity().application as PrivateDNSApp).repository) }
override fun onCreateDialog(
savedInstanceState: Bundle?
): Dialog {
return activity?.let {
val startIntent = Intent(context, MainActivity::class.java)
val builder = MaterialAlertDialogBuilder(it)
val inflater = requireActivity().layoutInflater
_binding = SheetDnsSelectorBinding.inflate(inflater)
linearLayoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = linearLayoutManager
sharedPrefs = defaultPreference(requireContext())
items = sharedPrefs.dns_servers
if (items[0] == "") {
items.removeAt(0)
items.add("dns.google")
}
items.add(0, resources.getString(R.string.dns_auto))
items.add(0, resources.getString(R.string.dns_off))
adapter = RecyclerAdapter(items, false) {}
adapter = ServerListRecyclerAdapter(false)
binding.recyclerView.adapter = adapter
val startIntent = Intent(context, MainActivity::class.java)
lifecycleScope.launch {
dnsServerViewModel.getAll().collect { s ->
servers = s.toMutableList()
if (servers.isEmpty()) {
servers.add(DnsServer(0, "dns.google"))
}
servers.add(0, DnsServer(-1, resources.getString(R.string.dns_auto)))
servers.add(0, DnsServer(-2, resources.getString(R.string.dns_off)))
adapter.submitList(servers)
}
}
builder.setTitle(R.string.select_server)
.setView(binding.root)
.setPositiveButton(
@ -65,9 +69,9 @@ class DNSServerDialogFragment : DialogFragment() {
override fun onStart() {
super.onStart()
adapter.onItemClick = { position ->
when (position) {
0 -> {
adapter.onItemClick = { id ->
when (id) {
OFF_ID -> {
PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_OFF
@ -75,7 +79,7 @@ class DNSServerDialogFragment : DialogFragment() {
Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show()
}
1 -> {
AUTO_ID -> {
PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_AUTO
@ -84,20 +88,25 @@ class DNSServerDialogFragment : DialogFragment() {
}
else -> {
val server = items[position].split(" : ").last()
PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_PRIVATE
)
PrivateDNSUtils.setPrivateProvider(
requireActivity().contentResolver,
server
)
Toast.makeText(
context,
getString(R.string.set_to_provider_toast, server),
Toast.LENGTH_SHORT
).show()
lifecycleScope.launch {
val server = servers.find { server -> server.id == id }
PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_PRIVATE
)
PrivateDNSUtils.setPrivateProvider(
requireActivity().contentResolver,
server?.server
)
Toast.makeText(
context,
getString(
R.string.set_to_provider_toast,
server?.label?.ifEmpty { server.server }
),
Toast.LENGTH_SHORT
).show()
}
}
}
dialog?.dismiss()
@ -113,5 +122,7 @@ class DNSServerDialogFragment : DialogFragment() {
companion object {
const val TAG = "DNSServerDialogFragment"
private const val AUTO_ID = -1
private const val OFF_ID = -2
}
}

View file

@ -1,13 +1,14 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ru.karasevm.privatednstoggle.R
class DeleteServerDialogFragment(private val position: Int) : DialogFragment() {
class DeleteServerDialogFragment(private val id: Int) : DialogFragment() {
// Use this instance of the interface to deliver action events
private lateinit var listener: NoticeDialogListener
@ -15,7 +16,7 @@ class DeleteServerDialogFragment(private val position: Int) : DialogFragment() {
* implement this interface in order to receive event callbacks.
* Each method passes the DialogFragment in case the host needs to query it. */
interface NoticeDialogListener {
fun onDialogPositiveClick(position: Int)
fun onDeleteDialogPositiveClick(id: Int)
}
// Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
@ -43,7 +44,7 @@ class DeleteServerDialogFragment(private val position: Int) : DialogFragment() {
.setPositiveButton(
R.string.delete
) { _, _ ->
listener.onDialogPositiveClick(position)
listener.onDeleteDialogPositiveClick(id)
}
.setNegativeButton(
R.string.cancel

View file

@ -1,4 +1,4 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.Manifest
import android.content.ClipData
@ -18,9 +18,10 @@ import android.view.Menu
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.DOWN
import androidx.recyclerview.widget.ItemTouchHelper.UP
@ -31,16 +32,21 @@ import com.google.gson.GsonBuilder
import com.google.gson.JsonSyntaxException
import com.google.gson.ToNumberPolicy
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.ShizukuProvider
import rikka.shizuku.SystemServiceHelper
import ru.karasevm.privatednstoggle.PrivateDNSApp
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.data.DnsServerViewModel
import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.export
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.import
import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.util.BackupUtils
import ru.karasevm.privatednstoggle.util.PreferenceHelper
import ru.karasevm.privatednstoggle.util.PreferenceHelper.dns_servers
class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener,
@ -50,81 +56,129 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
private lateinit var binding: ActivityMainBinding
private var items = mutableListOf<String>()
private lateinit var sharedPrefs: SharedPreferences
private lateinit var adapter: RecyclerAdapter
private lateinit var adapter: ServerListRecyclerAdapter
private lateinit var clipboard: ClipboardManager
private lateinit var gson: Gson
private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((application as PrivateDNSApp).repository) }
private val itemTouchHelper by lazy {
val simpleItemTouchCallback = object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
val simpleItemTouchCallback =
object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
var dragFrom = -1
var dragTo = -1
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
if (dragFrom == viewHolder.bindingAdapterPosition && dragTo == target.bindingAdapterPosition) {
return true
}
// store the drag position
if (dragFrom == -1) dragFrom = viewHolder.bindingAdapterPosition
dragTo = target.bindingAdapterPosition
adapter.onItemMove(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition
)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
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_DRAG) {
viewHolder?.itemView?.apply {
// Example: Elevate the view
elevation = 8f
alpha = 0.5f
setBackgroundColor(Color.GRAY)
override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?, actionState: Int
) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.apply {
// Example: Elevate the view
elevation = 8f
alpha = 0.5f
setBackgroundColor(Color.GRAY)
}
}
}
}
override fun clearView(
recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.apply {
// Reset the appearance
elevation = 0f
alpha = 1.0f
setBackgroundColor(Color.TRANSPARENT)
override fun clearView(
recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder
) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.apply {
// Reset the appearance
elevation = 0f
alpha = 1.0f
setBackgroundColor(Color.TRANSPARENT)
}
// commit the change to the db
dnsServerViewModel.move(
dragFrom,
dragTo,
(viewHolder as ServerListRecyclerAdapter.DnsServerViewHolder).id
)
dragTo = -1
dragFrom = -1
}
}
}
ItemTouchHelper(simpleItemTouchCallback)
}
private fun importSettings(json: String) {
runCatching {
val objectType = object : TypeToken<Map<String, Any>>() {}.type
val data: Map<String, Any> = gson.fromJson(json, objectType)
sharedPrefs.import(data)
val data: BackupUtils.Backup = gson.fromJson(json, BackupUtils.Backup::class.java)
BackupUtils.import(data, dnsServerViewModel, sharedPrefs)
}.onSuccess {
Toast.makeText(
this, getString(R.string.import_success), Toast.LENGTH_SHORT
).show()
ActivityCompat.recreate(this)
}.onFailure { exception ->
Log.e("IMPORT", "Import failed", exception)
when (exception) {
is JsonSyntaxException -> {
Toast.makeText(
this, getString(R.string.import_failure_json), Toast.LENGTH_SHORT
).show()
}
runCatching {
val objectType = object : TypeToken<Map<String, Any>>() {}.type
val data: Map<String, Any> = gson.fromJson(json, objectType)
BackupUtils.importLegacy(data, dnsServerViewModel, sharedPrefs)
}.onSuccess {
Toast.makeText(
this, getString(R.string.import_success), Toast.LENGTH_SHORT
).show()
}.onFailure {
Log.e("IMPORT", "Import failed", exception)
when (exception) {
is JsonSyntaxException -> {
Toast.makeText(
this, getString(R.string.import_failure_json), Toast.LENGTH_SHORT
).show()
}
else -> {
Toast.makeText(
this, getString(R.string.import_failure), Toast.LENGTH_SHORT
).show()
else -> {
Toast.makeText(
this, getString(R.string.import_failure), Toast.LENGTH_SHORT
).show()
}
}
}
}
}
/**
* Migrate the SharedPreferences server list to Room
*/
private fun migrateServerList() {
if (sharedPrefs.dns_servers.isNotEmpty() && sharedPrefs.dns_servers[0] != "") {
Log.i(
"migrate",
"existing sharedPrefs list: ${sharedPrefs.dns_servers} ${sharedPrefs.dns_servers.size}"
)
sharedPrefs.dns_servers.forEach { server ->
val parts = server.split(" : ").toMutableList()
if (parts.size != 2) parts.add(0, "")
Log.i("migrate", "migrating: $server -> $parts")
dnsServerViewModel.insert(DnsServer(0, parts[1], parts[0]))
}
sharedPrefs.dns_servers = emptyList<String>().toMutableList()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -141,38 +195,41 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
gson = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create()
migrateServerList()
items = sharedPrefs.dns_servers
if (items[0] == "") {
items.removeAt(0)
}
updateEmptyView()
adapter = RecyclerAdapter(items, true) { updateEmptyView() }
adapter.onItemClick = { position ->
val data = items[position].split(" : ")
val label: String?
val server: String
if (data.size == 2) {
label = data[0]
server = data[1]
adapter = ServerListRecyclerAdapter(true)
binding.recyclerView.adapter = adapter
dnsServerViewModel.allServers.observe(this) { servers ->
adapter.submitList(servers)
if (servers.isEmpty()) {
binding.emptyView.visibility = View.VISIBLE
binding.emptyViewHint.visibility = View.VISIBLE
} else {
binding.emptyView.visibility = View.GONE
binding.emptyViewHint.visibility = View.GONE
}
else {
label = null
server = data[0]
}
val newFragment = AddServerDialogFragment(position, label, server)
newFragment.show(supportFragmentManager, "edit_server")
}
adapter.onItemsChanged = { swappedItems ->
items = swappedItems
sharedPrefs.dns_servers = swappedItems
updateEmptyView()
adapter.onItemClick = { id ->
dnsServerViewModel.viewModelScope.launch {
val server = dnsServerViewModel.getById(id)
if (server != null) {
val newFragment =
AddServerDialogFragment(server)
newFragment.show(supportFragmentManager, "edit_server")
}
}
}
adapter.onDragStart = { viewHolder ->
itemTouchHelper.startDrag(viewHolder)
}
binding.floatingActionButton.setOnClickListener {
val newFragment = AddServerDialogFragment(null, null, null)
val newFragment = AddServerDialogFragment(null)
newFragment.show(supportFragmentManager, "add_server")
}
binding.recyclerView.adapter = adapter
@ -190,31 +247,40 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
}
R.id.export_settings_clipboard -> {
val data = sharedPrefs.export()
val jsonData = gson.toJson(data)
clipboard.setPrimaryClip(ClipData.newPlainText("", jsonData))
// Only show a toast for Android 12 and lower.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast.makeText(
this, getString(R.string.copy_success), Toast.LENGTH_SHORT
).show()
dnsServerViewModel.viewModelScope.launch {
val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
val jsonData = gson.toJson(data)
clipboard.setPrimaryClip(ClipData.newPlainText("", jsonData))
// Only show a toast for Android 12 and lower.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast.makeText(
applicationContext, getString(R.string.copy_success), Toast.LENGTH_SHORT
).show()
}
true
}
R.id.export_settings_share -> {
val data = sharedPrefs.export()
val jsonData = gson.toJson(data)
ShareCompat.IntentBuilder(this).setText(jsonData).setType("text/plain")
.startChooser()
val activityContext = this
dnsServerViewModel.viewModelScope.launch {
val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
val jsonData = gson.toJson(data)
ShareCompat.IntentBuilder(activityContext).setText(jsonData)
.setType("text/plain")
.startChooser()
}
true
}
R.id.export_settings_file -> {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, "private-dns-export")
dnsServerViewModel.viewModelScope.launch {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, "private-dns-export")
}
saveResultLauncher.launch(intent)
}
saveResultLauncher.launch(intent)
true
}
@ -248,22 +314,12 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
}
}
private fun updateEmptyView() {
if (items.isEmpty()) {
binding.emptyView.visibility = View.VISIBLE
binding.emptyViewHint.visibility = View.VISIBLE
} else {
binding.emptyView.visibility = View.GONE
binding.emptyViewHint.visibility = View.GONE
}
}
private var saveResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val data: Intent? = result.data
data?.data?.also { uri ->
val jsonData = gson.toJson(sharedPrefs.export())
val jsonData = gson.toJson(BackupUtils.export(dnsServerViewModel, sharedPrefs))
val contentResolver = applicationContext.contentResolver
runCatching {
contentResolver.openOutputStream(uri)?.use { outputStream ->
@ -359,46 +415,58 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult)
}
override fun onDeleteItemClicked(position: Int) {
val newFragment = DeleteServerDialogFragment(position)
/**
* Show the dialog for deleting the server
* @param id The server id
*/
override fun onDeleteItemClicked(id: Int) {
val newFragment = DeleteServerDialogFragment(id)
newFragment.show(supportFragmentManager, "delete_server")
}
override fun onDialogPositiveClick(label: String?, server: String) {
/**
* Callback for adding the server
* @param label The label
* @param server The server
*/
override fun onAddDialogPositiveClick(label: String?, server: String) {
if (server.isEmpty()) {
Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
return
}
if (label.isNullOrEmpty()) {
items.add(server)
dnsServerViewModel.insert(DnsServer(0, server))
} else {
items.add("$label : $server")
dnsServerViewModel.insert(DnsServer(0, server, label))
}
adapter.setData(items.toMutableList())
binding.recyclerView.adapter?.notifyItemInserted(items.size - 1)
sharedPrefs.dns_servers = items
}
override fun onDialogPositiveClick(position: Int) {
items.removeAt(position)
adapter.setData(items.toMutableList())
adapter.notifyItemRemoved(position)
sharedPrefs.dns_servers = items
/**
* Callback for deleting the server
* @param id The server id
*/
override fun onDeleteDialogPositiveClick(id: Int) {
dnsServerViewModel.delete(id)
}
override fun onDialogPositiveClick(label: String?, server: String, position: Int) {
/**
* Callback for updating the server
* @param label New label
* @param server New server address
* @param id The server id
*/
override fun onUpdateDialogPositiveClick(
id: Int,
server: String,
label: String?,
enabled: Boolean
) {
if (server.isEmpty()) {
Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
return
}
if (label.isNullOrEmpty()) {
items[position] = server
} else {
items[position] = "$label : $server"
}
adapter.notifyItemChanged(position)
sharedPrefs.dns_servers = items
binding.recyclerView.adapter?.notifyItemChanged(position)
dnsServerViewModel.update(id, server, label, null, enabled)
}
/**

View file

@ -1,14 +1,15 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils
import ru.karasevm.privatednstoggle.util.PreferenceHelper
import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
class OptionsDialogFragment : DialogFragment() {
private var _binding: DialogOptionsBinding? = null
@ -27,6 +28,7 @@ class OptionsDialogFragment : DialogFragment() {
.setView(view)
.setPositiveButton(R.string.ok, null)
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}

View file

@ -0,0 +1,124 @@
package ru.karasevm.privatednstoggle.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.model.DnsServer
class ServerListRecyclerAdapter(private val showDragHandle: Boolean) :
RecyclerView.Adapter<ServerListRecyclerAdapter.DnsServerViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsServerViewHolder {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false)
val vh = DnsServerViewHolder(view)
return vh
}
override fun getItemCount(): Int {
return items.size
}
var onItemClick: ((Int) -> Unit)? = null
var onDragStart: ((DnsServerViewHolder) -> Unit)? = null
private var items: MutableList<DnsServer> = mutableListOf()
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: DnsServerViewHolder, position: Int) {
val item = items[position]
if (item.label.isNotEmpty()) {
holder.labelTextView.text = item.label
holder.labelTextView.visibility = View.VISIBLE
} else {
holder.labelTextView.visibility = View.GONE
}
holder.serverTextView.text = item.server
holder.id = item.id
if (item.enabled) {
holder.labelTextView.alpha = 1f
holder.serverTextView.alpha = 1f
} else {
holder.labelTextView.alpha = 0.5f
holder.serverTextView.alpha = 0.5f
}
if (showDragHandle) {
holder.dragHandle.visibility = View.VISIBLE
holder.dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onDragStart?.invoke(holder)
}
return@setOnTouchListener true
}
}
}
/**
* Update server position in memory
* @param fromPosition old position
* @param toPosition new position
*/
fun onItemMove(fromPosition: Int, toPosition: Int) {
items.add(toPosition, items.removeAt(fromPosition))
notifyItemMoved(fromPosition, toPosition)
}
class DiffCallback(
private val oldList: List<DnsServer>, private var newList: List<DnsServer>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.server == newItem.server && oldItem.label == newItem.label && oldItem.enabled == newItem.enabled
}
}
/**
* Submit list to adapter
* @param list list to submit
*/
fun submitList(list: List<DnsServer>) {
val diffCallback = DiffCallback(items, list)
val diffResult = DiffUtil.calculateDiff(diffCallback)
items.clear()
items.addAll(list)
diffResult.dispatchUpdatesTo(this)
}
inner class DnsServerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val labelTextView: TextView = view.findViewById(R.id.labelTextView)
val serverTextView: TextView = view.findViewById(R.id.textView)
val dragHandle: ImageView = itemView.findViewById(R.id.dragHandle)
var id = 0
init {
view.setOnClickListener {
onItemClick?.invoke(id)
}
}
}
}

View file

@ -1,4 +1,4 @@
package ru.karasevm.privatednstoggle
package ru.karasevm.privatednstoggle.ui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

View file

@ -0,0 +1,75 @@
package ru.karasevm.privatednstoggle.util
import android.content.SharedPreferences
import com.google.gson.annotations.SerializedName
import ru.karasevm.privatednstoggle.data.DnsServerViewModel
import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.util.PreferenceHelper.AUTO_MODE
import ru.karasevm.privatednstoggle.util.PreferenceHelper.DNS_SERVERS
import ru.karasevm.privatednstoggle.util.PreferenceHelper.REQUIRE_UNLOCK
import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
object BackupUtils {
data class Backup(
@SerializedName("dns_servers") val dnsServers: List<DnsServer>,
@SerializedName("auto_mode") val autoMode: Int?,
@SerializedName("require_unlock") val requireUnlock: Boolean?,
)
/**
* Exports all the preferences
* @param viewModel View model
* @param sharedPreferences Shared preferences
*/
fun export(viewModel: DnsServerViewModel, sharedPreferences: SharedPreferences): Backup {
return Backup(
viewModel.allServers.value ?: listOf(),
sharedPreferences.autoMode,
sharedPreferences.requireUnlock
)
}
/**
* Imports all the preferences
* @param backup Deserialized backup
* @param viewModel View model
*/
fun import(
backup: Backup,
viewModel: DnsServerViewModel,
sharedPreferences: SharedPreferences
) {
backup.dnsServers.forEach { viewModel.insert(it) }
sharedPreferences.autoMode = backup.autoMode ?: sharedPreferences.autoMode
sharedPreferences.requireUnlock = backup.requireUnlock ?: sharedPreferences.requireUnlock
}
/**
* Imports old server list
* @param map Deserialized backup
* @param viewModel View model
* @param sharedPreferences Shared preferences
*/
fun importLegacy(
map: Map<String, Any>,
viewModel: DnsServerViewModel,
sharedPreferences: SharedPreferences
) {
map[DNS_SERVERS]?.let { servers ->
if (servers is String) {
val serverList = servers.split(",")
serverList.forEach { server ->
val parts = server.split(" : ")
if (parts.size == 2) {
viewModel.insert(DnsServer(0, parts[1], parts[0]))
} else {
viewModel.insert(DnsServer(0, server, ""))
}
}
}
}
sharedPreferences.autoMode = map[AUTO_MODE] as? Int ?: 0
sharedPreferences.requireUnlock = map[REQUIRE_UNLOCK] as? Boolean ?: false
}
}

View file

@ -1,4 +1,4 @@
package ru.karasevm.privatednstoggle.utils
package ru.karasevm.privatednstoggle.util
import android.Manifest
import android.content.ContentResolver
@ -23,18 +23,23 @@ object PrivateDNSUtils {
private const val PRIVATE_DNS_MODE = "private_dns_mode"
private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier"
// Gets the system dns mode
fun getPrivateMode(contentResolver: ContentResolver): String {
return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE)
}
// Gets the system dns provider
fun getPrivateProvider(contentResolver: ContentResolver): String {
return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER)
}
// Sets the system dns mode
fun setPrivateMode(contentResolver: ContentResolver, value: String) {
Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value)
}
// Sets the system dns provider
fun setPrivateProvider(contentResolver: ContentResolver, value: String?) {
Settings.Global.putString(contentResolver, PRIVATE_DNS_PROVIDER, value)
}

View file

@ -1,13 +1,13 @@
package ru.karasevm.privatednstoggle.utils
package ru.karasevm.privatednstoggle.util
import android.content.Context
import android.content.SharedPreferences
object PreferenceHelper {
private const val DNS_SERVERS = "dns_servers"
private const val AUTO_MODE = "auto_mode"
private const val REQUIRE_UNLOCK = "require_unlock"
const val DNS_SERVERS = "dns_servers"
const val AUTO_MODE = "auto_mode"
const val REQUIRE_UNLOCK = "require_unlock"
fun defaultPreference(context: Context): SharedPreferences =
context.getSharedPreferences("app_prefs", 0)
@ -38,6 +38,7 @@ object PreferenceHelper {
}
}
var SharedPreferences.autoMode
get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF)
set(value) {
@ -53,24 +54,4 @@ object PreferenceHelper {
it.put(REQUIRE_UNLOCK to value)
}
}
// export all the preferences
fun SharedPreferences.export() = mapOf(
DNS_SERVERS to getString(DNS_SERVERS, ""),
AUTO_MODE to autoMode,
REQUIRE_UNLOCK to requireUnlock
)
// import all the preferences
fun SharedPreferences.import(map: Map<String, Any>) {
editMe {
map.forEach { (key, value) ->
if (value is Number) {
it.put(key to value.toInt())
return@forEach
}
it.put(key to value)
}
}
}
}

View file

@ -1,7 +0,0 @@
package ru.karasevm.privatednstoggle.utils
data class DnsServer(val label: String, val server: String) {
override fun toString(): String {
return "$label : $server"
}
}

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
tools:context=".ui.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/topAppBarLayout"
@ -48,7 +48,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/empty_hint"
android:textColor="@color/material_dynamic_neutral50"
android:textColor="@color/material_on_surface_emphasis_medium"
android:visibility="visible"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"

View file

@ -4,6 +4,20 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/serverEnabledSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:maxWidth="320dp"
android:text="@string/add_server_enabled"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/editTextServerHintLayout"
android:layout_width="match_parent"
@ -12,7 +26,7 @@
android:hint="@string/add_edittext_label_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintTop_toBottomOf="@id/serverEnabledSwitch">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextServerHint"

View file

@ -54,10 +54,11 @@
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/requireUnlockSwitch"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:maxWidth="320dp"
android:text="@string/require_unlock_setting"
android:textSize="16sp" />

View file

@ -12,6 +12,4 @@
<color name="md_theme_dark_secondary">#CCC2DC</color>
<color name="md_theme_dark_tertiary">#EFB8C8</color>
<color name="md_theme_dark_background">#1C1B1F</color>
<color name="material_dynamic_neutral50">#747879</color>
</resources>

View file

@ -47,4 +47,5 @@
<string name="edit_server">Edit server</string>
<string name="no_servers_added">No Servers Added</string>
<string name="empty_hint">Tap on the button below to add one</string>
<string name="add_server_enabled">Enabled</string>
</resources>

View file

@ -5,13 +5,17 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22"
classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
plugins {
id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
id "com.google.devtools.ksp" version "1.9.10-1.0.13" apply false
}
tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory

View file

@ -0,0 +1,7 @@
- Replaced server storage backend with Room, allowing for easier further expansion
- Add option to disable saved servers
- Improved backup handling
- Fixed desync bug while dragging servers
- Reorganized source file structure
- Updated Kotlin version
- Updated Java version