From 36be36d69bf3d5fec95e552ebee319fc6f23cfbb Mon Sep 17 00:00:00 2001 From: Maksim Karasev Date: Fri, 13 Sep 2024 12:45:11 +0300 Subject: [PATCH] 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 --- .idea/kotlinc.xml | 2 +- app/build.gradle | 26 +- app/src/main/AndroidManifest.xml | 6 +- .../privatednstoggle/PrivateDNSApp.kt | 6 + .../privatednstoggle/RecyclerAdapter.kt | 84 ----- .../privatednstoggle/data/DnsServerDao.kt | 84 +++++ .../data/DnsServerRepository.kt | 60 ++++ .../data/DnsServerViewModel.kt | 48 +++ .../data/database/DnsServerRoomDatabase.kt | 34 ++ .../privatednstoggle/model/DnsServer.kt | 22 ++ .../{ => service}/DnsTileService.kt | 216 ++++++------ .../{ => ui}/AddServerDialogFragment.kt | 43 ++- .../{ => ui}/DNSServerDialogFragment.kt | 95 +++--- .../{ => ui}/DeleteServerDialogFragment.kt | 9 +- .../privatednstoggle/{ => ui}/MainActivity.kt | 312 +++++++++++------- .../{ => ui}/OptionsDialogFragment.kt | 12 +- .../ui/ServerListRecyclerAdapter.kt | 124 +++++++ .../{ => ui}/SettingsDialogActivity.kt | 2 +- .../privatednstoggle/util/BackupUtils.kt | 75 +++++ .../{utils => util}/PrivateDNSUtils.kt | 7 +- .../{utils => util}/SharedPrefUtils.kt | 29 +- .../privatednstoggle/utils/DnsServer.kt | 7 - app/src/main/res/layout/activity_main.xml | 4 +- app/src/main/res/layout/dialog_add.xml | 16 +- app/src/main/res/layout/dialog_options.xml | 3 +- app/src/main/res/values/colors.xml | 2 - app/src/main/res/values/strings.xml | 1 + build.gradle | 8 +- .../metadata/android/en-US/changelogs/16.txt | 7 + 29 files changed, 909 insertions(+), 435 deletions(-) delete mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt rename app/src/main/java/ru/karasevm/privatednstoggle/{ => service}/DnsTileService.kt (53%) rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/AddServerDialogFragment.kt (77%) rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/DNSServerDialogFragment.kt (51%) rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/DeleteServerDialogFragment.kt (88%) rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/MainActivity.kt (59%) rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/OptionsDialogFragment.kt (88%) create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt rename app/src/main/java/ru/karasevm/privatednstoggle/{ => ui}/SettingsDialogActivity.kt (89%) create mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt rename app/src/main/java/ru/karasevm/privatednstoggle/{utils => util}/PrivateDNSUtils.kt (90%) rename app/src/main/java/ru/karasevm/privatednstoggle/{utils => util}/SharedPrefUtils.kt (67%) delete mode 100644 app/src/main/java/ru/karasevm/privatednstoggle/utils/DnsServer.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/16.txt diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 9a55c2d..f8467b4 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 78848c4..2d17775 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a695d06..21da365 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ android:exported="true" android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> @@ -41,7 +41,7 @@ , private val showDragHandle: Boolean, private val onDataChanged: () -> Unit) : - RecyclerView.Adapter() { - - var onItemClick: ((Int) -> Unit)? = null - var onItemsChanged: ((MutableList) -> 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) { - items.run { - clear() - addAll(newItems) - } - onDataChanged() - } - - -} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt new file mode 100644 index 0000000..026de12 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt @@ -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> + + @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) + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt new file mode 100644 index 0000000..4c7ddd4 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt @@ -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> = 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) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt new file mode 100644 index 0000000..bd1987a --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt @@ -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> = 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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(DnsServerViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return DnsServerViewModel(dnsServerRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt new file mode 100644 index 0000000..72b6f25 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt new file mode 100644 index 0000000..35375d1 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt @@ -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 +) diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt similarity index 53% rename from app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt index 02635c5..f1b25ae 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt @@ -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] } } \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt similarity index 77% rename from app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt index c05b39e..f5440d9 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt @@ -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() ) diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DNSServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt similarity index 51% rename from app/src/main/java/ru/karasevm/privatednstoggle/DNSServerDialogFragment.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt index 3ea8912..b0ac54a 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/DNSServerDialogFragment.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt @@ -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() - private lateinit var sharedPrefs: SharedPreferences + private lateinit var adapter: ServerListRecyclerAdapter + private var servers: MutableList = 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 } } \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt similarity index 88% rename from app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt index b69ce1d..a6a2d98 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt @@ -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 diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt similarity index 59% rename from app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt index 230eadb..1ee354e 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt @@ -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() 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>() {}.type - val data: Map = 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>() {}.type + val data: Map = 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().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) } /** diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/OptionsDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt similarity index 88% rename from app/src/main/java/ru/karasevm/privatednstoggle/OptionsDialogFragment.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt index e4b9cb4..094d1ba 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/OptionsDialogFragment.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt @@ -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") } diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt new file mode 100644 index 0000000..fec1eb0 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt @@ -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() { + + + 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 = 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, private var newList: List + ) : 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) { + 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) + } + } + } + +} diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/SettingsDialogActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt similarity index 89% rename from app/src/main/java/ru/karasevm/privatednstoggle/SettingsDialogActivity.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt index c8a86b6..4b15e05 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/SettingsDialogActivity.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt @@ -1,4 +1,4 @@ -package ru.karasevm.privatednstoggle +package ru.karasevm.privatednstoggle.ui import android.os.Bundle import androidx.appcompat.app.AppCompatActivity diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt new file mode 100644 index 0000000..715c720 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt @@ -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, + @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, + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/utils/PrivateDNSUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt similarity index 90% rename from app/src/main/java/ru/karasevm/privatednstoggle/utils/PrivateDNSUtils.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt index 79ae536..73a1bc9 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/utils/PrivateDNSUtils.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt @@ -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) } diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt similarity index 67% rename from app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt index 8e97931..129a56b 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt @@ -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) { - editMe { - map.forEach { (key, value) -> - if (value is Number) { - it.put(key to value.toInt()) - return@forEach - } - it.put(key to value) - } - } - } } diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/utils/DnsServer.kt b/app/src/main/java/ru/karasevm/privatednstoggle/utils/DnsServer.kt deleted file mode 100644 index 8a51706..0000000 --- a/app/src/main/java/ru/karasevm/privatednstoggle/utils/DnsServer.kt +++ /dev/null @@ -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" - } -} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index eb0201d..f918023 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -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"> + + + app:layout_constraintTop_toBottomOf="@id/serverEnabledSwitch"> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 22ecfdd..3643757 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -12,6 +12,4 @@ #CCC2DC #EFB8C8 #1C1B1F - - #747879 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbed076..1d41b73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,4 +47,5 @@ Edit server No Servers Added Tap on the button below to add one + Enabled \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5a61030..59c7c50 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/16.txt b/fastlane/metadata/android/en-US/changelogs/16.txt new file mode 100644 index 0000000..1005cb7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/16.txt @@ -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