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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.22" /> <option name="version" value="1.9.10" />
</component> </component>
</project> </project>

View file

@ -1,6 +1,7 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'com.google.devtools.ksp'
} }
android { android {
@ -8,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "ru.karasevm.privatednstoggle" applicationId "ru.karasevm.privatednstoggle"
versionCode 15 versionCode 16
versionName "1.8.1" versionName "1.9.0-beta1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
targetSdkVersion 34 targetSdkVersion 34
@ -29,11 +30,11 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '17'
} }
dependenciesInfo { dependenciesInfo {
// Disables dependency metadata when building APKs. // Disables dependency metadata when building APKs.
@ -48,6 +49,9 @@ dependencies {
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0' 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 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.guava:guava:33.1.0-android' implementation 'com.google.guava:guava:33.1.0-android'
@ -60,6 +64,18 @@ dependencies {
implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
compileOnly 'dev.rikka.hidden:stub:4.3.2' 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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'

View file

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

View file

@ -2,8 +2,14 @@ package ru.karasevm.privatednstoggle
import android.app.Application import android.app.Application
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import ru.karasevm.privatednstoggle.data.DnsServerRepository
import ru.karasevm.privatednstoggle.data.database.DnsServerRoomDatabase
class PrivateDNSApp : Application() { class PrivateDNSApp : Application() {
private val database by lazy { DnsServerRoomDatabase.getDatabase(this) }
val repository by lazy { DnsServerRepository(database.dnsServerDao()) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this) 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.BroadcastReceiver
import android.content.Context import android.content.Context
@ -9,22 +9,33 @@ import android.provider.Settings
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.karasevm.privatednstoggle.utils.DnsServer import kotlinx.coroutines.CoroutineScope
import ru.karasevm.privatednstoggle.utils.PreferenceHelper import kotlinx.coroutines.Dispatchers
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.autoMode import kotlinx.coroutines.SupervisorJob
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers import kotlinx.coroutines.cancelChildren
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.requireUnlock import kotlinx.coroutines.launch
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils import ru.karasevm.privatednstoggle.PrivateDNSApp
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_AUTO import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO import ru.karasevm.privatednstoggle.data.DnsServerRepository
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_AUTO import ru.karasevm.privatednstoggle.util.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_OFF import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.DNS_MODE_PRIVATE import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils.checkForPermission 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() { 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() { override fun onTileAdded() {
super.onTileAdded() super.onTileAdded()
checkForPermission(this) 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() { private fun cycleState() {
val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") 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)) { } else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
changeDNSServer(DNS_MODE_PRIVATE, null) changeDNSServer(DNS_MODE_PRIVATE, null)
} else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) { } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
if (getNextAddress(dnsProvider) == null) { scope.launch {
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_PRIVATE) { if (getNextAddress(dnsProvider) == null) {
changeDNSServer(DNS_MODE_PRIVATE, null) if (sharedPrefs.autoMode == AUTO_MODE_OPTION_PRIVATE) {
} else { changeDNSServer(DNS_MODE_PRIVATE, null)
if (sharedPrefs.autoMode == AUTO_MODE_OPTION_AUTO) {
changeDNSServer(DNS_MODE_AUTO, dnsProvider)
} else { } 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 -> { DNS_MODE_OFF -> {
changeTileState( changeTileState(
qsTile, qsTile,
@ -94,14 +112,19 @@ class DnsTileService : TileService() {
} }
DNS_MODE_PRIVATE -> { DNS_MODE_PRIVATE -> {
changeTileState( scope.launch {
qsTile, val nextDnsServer = getNextAddress(dnsProvider)
Tile.STATE_ACTIVE, if (nextDnsServer != null) {
getNextAddress(dnsProvider)?.label, changeTileState(
R.drawable.ic_private_black_24dp, qsTile,
DNS_MODE_PRIVATE, Tile.STATE_ACTIVE,
getNextAddress(dnsProvider)?.server 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 return
} }
val sharedPrefs = PreferenceHelper.defaultPreference(this) val sharedPrefs = PreferenceHelper.defaultPreference(this)
// Require unlock to change mode according to user preference
val requireUnlock = sharedPrefs.requireUnlock val requireUnlock = sharedPrefs.requireUnlock
if (isLocked && requireUnlock) { if (isLocked && requireUnlock) {
unlockAndRun(this::cycleState) unlockAndRun(this::cycleState)
@ -127,63 +152,48 @@ class DnsTileService : TileService() {
*/ */
private fun refreshTile() { private fun refreshTile() {
val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) { when (dnsMode?.lowercase()) {
setTile( DNS_MODE_OFF -> {
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 == "") {
setTile( setTile(
qsTile, qsTile,
Tile.STATE_ACTIVE, Tile.STATE_INACTIVE,
"Google", getString(R.string.dns_off),
R.drawable.ic_private_black_24dp 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( setTile(
qsTile, qsTile,
Tile.STATE_ACTIVE, Tile.STATE_ACTIVE,
dnsProvider, // display server address if either there is no label or the server is not known
R.drawable.ic_private_black_24dp dnsServer?.label?.ifBlank { activeAddress } ?: activeAddress,
)
} else {
setTile(
qsTile,
Tile.STATE_ACTIVE,
items[index].label,
R.drawable.ic_private_black_24dp 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) { if (qsTile == null) {
return return
} }
// Receive broadcasts to update the tile when server is changed from the dialog
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
this, this,
broadcastReceiver, broadcastReceiver,
@ -219,6 +232,11 @@ class DnsTileService : TileService() {
unregisterReceiver(broadcastReceiver) unregisterReceiver(broadcastReceiver)
} }
override fun onDestroy() {
super.onDestroy()
job.cancelChildren()
}
/** /**
* Updates tile to specified parameters * Updates tile to specified parameters
* *
@ -261,39 +279,17 @@ class DnsTileService : TileService() {
} }
/** /**
* Gets next dns address from preferences, * Gets next dns address from the database,
* if current address is last returns null * if current address is last or unknown returns null
* *
* @param currentAddress currently set address * @param currentAddress currently set address
* @return next address * @return next address
*/ */
private fun getNextAddress(currentAddress: String?): DnsServer? { private suspend fun getNextAddress(currentAddress: String?): DnsServer? {
val sharedPrefs = PreferenceHelper.defaultPreference(this) return if (currentAddress.isNullOrEmpty()) {
val items = sharedPrefs.dns_servers.map { repository.getFirstEnabled()
val parts = it.split(" : ") } else {
// Assuming string is in the format "$label : $server" repository.getNextByServer(currentAddress)
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"))
}
} }
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.app.Dialog
import android.content.Context import android.content.Context
@ -11,10 +11,14 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.net.InternetDomainName import com.google.common.net.InternetDomainName
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.databinding.DialogAddBinding 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 // Use this instance of the interface to deliver action events
private lateinit var listener: NoticeDialogListener 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. * implement this interface in order to receive event callbacks.
* Each method passes the DialogFragment in case the host needs to query it. */ * Each method passes the DialogFragment in case the host needs to query it. */
interface NoticeDialogListener { interface NoticeDialogListener {
fun onDialogPositiveClick(label: String? ,server: String) fun onAddDialogPositiveClick(label: String?, server: String)
fun onDialogPositiveClick(label: String?, server: String, position: Int) fun onUpdateDialogPositiveClick(id: Int, server: String, label: String?, enabled: Boolean)
fun onDeleteItemClicked(position: Int) fun onDeleteItemClicked(id: Int)
} }
// Override the Fragment.onAttach() method to instantiate the NoticeDialogListener // 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 val view = binding.root
// Inflate and set the layout for the dialog // Inflate and set the layout for the dialog
// Pass null as the parent view because its going in the dialog layout // Pass null as the parent view because its going in the dialog layout
if (position != null) { if (dnsServer != null) {
binding.editTextServerHint.setText(label) binding.editTextServerHint.setText(dnsServer.label)
binding.editTextServerAddr.setText(server) 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) builder.setTitle(R.string.edit_server).setView(view)
.setPositiveButton( .setPositiveButton(
R.string.menu_save R.string.menu_save
) { _, _ -> ) { _, _ ->
listener.onDialogPositiveClick( listener.onUpdateDialogPositiveClick(
binding.editTextServerHint.text.toString().trim(), dnsServer.id,
binding.editTextServerAddr.text.toString().trim(), binding.editTextServerAddr.text.toString().trim(),
position) binding.editTextServerHint.text.toString().trim(),
binding.serverEnabledSwitch.isChecked
)
} }
.setNegativeButton( .setNegativeButton(
R.string.cancel R.string.cancel
@ -81,17 +89,16 @@ class AddServerDialogFragment(private val position: Int?, private val label: Str
.setNeutralButton( .setNeutralButton(
R.string.delete R.string.delete
) { _, _ -> ) { _, _ ->
listener.onDeleteItemClicked(position) listener.onDeleteItemClicked(dnsServer.id)
} }
} } else {
else {
builder.setTitle(R.string.add_server) builder.setTitle(R.string.add_server)
.setView(view) .setView(view)
// Add action buttons // Add action buttons
.setPositiveButton( .setPositiveButton(
R.string.menu_add R.string.menu_add
) { _, _ -> ) { _, _ ->
listener.onDialogPositiveClick( listener.onAddDialogPositiveClick(
binding.editTextServerHint.text.toString().trim(), binding.editTextServerHint.text.toString().trim(),
binding.editTextServerAddr.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.app.Dialog
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.databinding.SheetDnsSelectorBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.defaultPreference import ru.karasevm.privatednstoggle.model.DnsServer
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils
class DNSServerDialogFragment : DialogFragment() { class DNSServerDialogFragment : DialogFragment() {
@ -19,37 +24,36 @@ class DNSServerDialogFragment : DialogFragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var linearLayoutManager: LinearLayoutManager private lateinit var linearLayoutManager: LinearLayoutManager
private lateinit var adapter: RecyclerAdapter private lateinit var adapter: ServerListRecyclerAdapter
private var items = mutableListOf<String>() private var servers: MutableList<DnsServer> = mutableListOf()
private lateinit var sharedPrefs: SharedPreferences private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((requireActivity().application as PrivateDNSApp).repository) }
override fun onCreateDialog( override fun onCreateDialog(
savedInstanceState: Bundle? savedInstanceState: Bundle?
): Dialog { ): Dialog {
return activity?.let { return activity?.let {
val startIntent = Intent(context, MainActivity::class.java)
val builder = MaterialAlertDialogBuilder(it) val builder = MaterialAlertDialogBuilder(it)
val inflater = requireActivity().layoutInflater val inflater = requireActivity().layoutInflater
_binding = SheetDnsSelectorBinding.inflate(inflater) _binding = SheetDnsSelectorBinding.inflate(inflater)
linearLayoutManager = LinearLayoutManager(context) linearLayoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = linearLayoutManager binding.recyclerView.layoutManager = linearLayoutManager
sharedPrefs = defaultPreference(requireContext()) adapter = ServerListRecyclerAdapter(false)
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) {}
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
lifecycleScope.launch {
dnsServerViewModel.getAll().collect { s ->
val startIntent = Intent(context, MainActivity::class.java) 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) builder.setTitle(R.string.select_server)
.setView(binding.root) .setView(binding.root)
.setPositiveButton( .setPositiveButton(
@ -65,9 +69,9 @@ class DNSServerDialogFragment : DialogFragment() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
adapter.onItemClick = { position -> adapter.onItemClick = { id ->
when (position) { when (id) {
0 -> { OFF_ID -> {
PrivateDNSUtils.setPrivateMode( PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver, requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_OFF PrivateDNSUtils.DNS_MODE_OFF
@ -75,7 +79,7 @@ class DNSServerDialogFragment : DialogFragment() {
Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show()
} }
1 -> { AUTO_ID -> {
PrivateDNSUtils.setPrivateMode( PrivateDNSUtils.setPrivateMode(
requireActivity().contentResolver, requireActivity().contentResolver,
PrivateDNSUtils.DNS_MODE_AUTO PrivateDNSUtils.DNS_MODE_AUTO
@ -84,20 +88,25 @@ class DNSServerDialogFragment : DialogFragment() {
} }
else -> { else -> {
val server = items[position].split(" : ").last() lifecycleScope.launch {
PrivateDNSUtils.setPrivateMode( val server = servers.find { server -> server.id == id }
requireActivity().contentResolver, PrivateDNSUtils.setPrivateMode(
PrivateDNSUtils.DNS_MODE_PRIVATE requireActivity().contentResolver,
) PrivateDNSUtils.DNS_MODE_PRIVATE
PrivateDNSUtils.setPrivateProvider( )
requireActivity().contentResolver, PrivateDNSUtils.setPrivateProvider(
server requireActivity().contentResolver,
) server?.server
Toast.makeText( )
context, Toast.makeText(
getString(R.string.set_to_provider_toast, server), context,
Toast.LENGTH_SHORT getString(
).show() R.string.set_to_provider_toast,
server?.label?.ifEmpty { server.server }
),
Toast.LENGTH_SHORT
).show()
}
} }
} }
dialog?.dismiss() dialog?.dismiss()
@ -113,5 +122,7 @@ class DNSServerDialogFragment : DialogFragment() {
companion object { companion object {
const val TAG = "DNSServerDialogFragment" 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.app.Dialog
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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 // Use this instance of the interface to deliver action events
private lateinit var listener: NoticeDialogListener 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. * implement this interface in order to receive event callbacks.
* Each method passes the DialogFragment in case the host needs to query it. */ * Each method passes the DialogFragment in case the host needs to query it. */
interface NoticeDialogListener { interface NoticeDialogListener {
fun onDialogPositiveClick(position: Int) fun onDeleteDialogPositiveClick(id: Int)
} }
// Override the Fragment.onAttach() method to instantiate the NoticeDialogListener // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
@ -43,7 +44,7 @@ class DeleteServerDialogFragment(private val position: Int) : DialogFragment() {
.setPositiveButton( .setPositiveButton(
R.string.delete R.string.delete
) { _, _ -> ) { _, _ ->
listener.onDialogPositiveClick(position) listener.onDeleteDialogPositiveClick(id)
} }
.setNegativeButton( .setNegativeButton(
R.string.cancel R.string.cancel

View file

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

View file

@ -1,14 +1,15 @@
package ru.karasevm.privatednstoggle package ru.karasevm.privatednstoggle.ui
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ru.karasevm.privatednstoggle.R
import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper import ru.karasevm.privatednstoggle.util.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.autoMode import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.requireUnlock import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
import ru.karasevm.privatednstoggle.utils.PrivateDNSUtils import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
class OptionsDialogFragment : DialogFragment() { class OptionsDialogFragment : DialogFragment() {
private var _binding: DialogOptionsBinding? = null private var _binding: DialogOptionsBinding? = null
@ -27,6 +28,7 @@ class OptionsDialogFragment : DialogFragment() {
.setView(view) .setView(view)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
builder.create() builder.create()
} ?: throw IllegalStateException("Activity cannot be null") } ?: 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 android.os.Bundle
import androidx.appcompat.app.AppCompatActivity 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.Manifest
import android.content.ContentResolver import android.content.ContentResolver
@ -23,18 +23,23 @@ object PrivateDNSUtils {
private const val PRIVATE_DNS_MODE = "private_dns_mode" private const val PRIVATE_DNS_MODE = "private_dns_mode"
private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier" private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier"
// Gets the system dns mode
fun getPrivateMode(contentResolver: ContentResolver): String { fun getPrivateMode(contentResolver: ContentResolver): String {
return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE) return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE)
} }
// Gets the system dns provider
fun getPrivateProvider(contentResolver: ContentResolver): String { fun getPrivateProvider(contentResolver: ContentResolver): String {
return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER) return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER)
} }
// Sets the system dns mode
fun setPrivateMode(contentResolver: ContentResolver, value: String) { fun setPrivateMode(contentResolver: ContentResolver, value: String) {
Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value) Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value)
} }
// Sets the system dns provider
fun setPrivateProvider(contentResolver: ContentResolver, value: String?) { fun setPrivateProvider(contentResolver: ContentResolver, value: String?) {
Settings.Global.putString(contentResolver, PRIVATE_DNS_PROVIDER, value) 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
object PreferenceHelper { object PreferenceHelper {
private const val DNS_SERVERS = "dns_servers" const val DNS_SERVERS = "dns_servers"
private const val AUTO_MODE = "auto_mode" const val AUTO_MODE = "auto_mode"
private const val REQUIRE_UNLOCK = "require_unlock" const val REQUIRE_UNLOCK = "require_unlock"
fun defaultPreference(context: Context): SharedPreferences = fun defaultPreference(context: Context): SharedPreferences =
context.getSharedPreferences("app_prefs", 0) context.getSharedPreferences("app_prefs", 0)
@ -38,6 +38,7 @@ object PreferenceHelper {
} }
} }
var SharedPreferences.autoMode var SharedPreferences.autoMode
get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF) get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF)
set(value) { set(value) {
@ -53,24 +54,4 @@ object PreferenceHelper {
it.put(REQUIRE_UNLOCK to value) 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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity"> tools:context=".ui.MainActivity">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/topAppBarLayout" android:id="@+id/topAppBarLayout"
@ -48,7 +48,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="@string/empty_hint" android:text="@string/empty_hint"
android:textColor="@color/material_dynamic_neutral50" android:textColor="@color/material_on_surface_emphasis_medium"
android:visibility="visible" android:visibility="visible"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"

View file

@ -4,6 +4,20 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> 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 <com.google.android.material.textfield.TextInputLayout
android:id="@+id/editTextServerHintLayout" android:id="@+id/editTextServerHintLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -12,7 +26,7 @@
android:hint="@string/add_edittext_label_hint" android:hint="@string/add_edittext_label_hint"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toBottomOf="@id/serverEnabledSwitch">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextServerHint" android:id="@+id/editTextServerHint"

View file

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

View file

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

View file

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

View file

@ -5,13 +5,17 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.5.1' classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // 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) { tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory 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