diff --git a/app/build.gradle b/app/build.gradle index 6329201f..27773e78 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,14 +85,14 @@ dependencies { testImplementation 'org.json:json:20180813' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'androidx.core:core-ktx:1.6.0' + implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' testImplementation 'junit:junit:4.13.2' @@ -141,10 +141,13 @@ dependencies { //implementation 'com.github.TorrentStream:TorrentStream-Android:2.7.0' // Downloading - implementation "androidx.work:work-runtime:2.7.0-rc01" - implementation "androidx.work:work-runtime-ktx:2.7.0-rc01" + implementation "androidx.work:work-runtime:2.7.0" + implementation "androidx.work:work-runtime-ktx:2.7.0" // Networking implementation "com.squareup.okhttp3:okhttp:4.9.1" implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1" + + // Util to skip the URI file fuckery 🙏 + implementation "com.github.tachiyomiorg:unifile:17bec43" } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 328090d3..d66bbcb5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -1,16 +1,22 @@ package com.lagradost.cloudstream3.ui.settings +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.os.Environment import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager +import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiSettings import com.lagradost.cloudstream3.APIHolder.restrictedApis +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.MainActivity.Companion.setLocale import com.lagradost.cloudstream3.MainActivity.Companion.showToast @@ -26,12 +32,41 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadDir +import com.lagradost.cloudstream3.utils.VideoDownloadManager.isScopedStorage +import java.io.File import kotlin.concurrent.thread class SettingsFragment : PreferenceFragmentCompat() { private var beneneCount = 0 + // Open file picker + private val pathPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + val context = AcraApplication.context ?: return@registerForActivityResult + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val file = UniFile.fromUri(context, uri) + println("Selected URI path: $uri - Full path: ${file.filePath}") + + // Stores the real URI using download_path_key + // Important that the URI is stored instead of filepath due to permissions. + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_key), uri.toString()).apply() + + // From URI -> File path + // File path here is purely for cosmetic purposes in settings + (file.filePath ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_pref), it).apply() + } + } + // idk, if you find a way of automating this it would be great private val languages = arrayListOf( Triple("\uD83C\uDDEA\uD83C\uDDF8", "Spanish", "es"), @@ -61,6 +96,7 @@ class SettingsFragment : PreferenceFragmentCompat() { val legalPreference = findPreference(getString(R.string.legal_notice_key))!! val subdubPreference = findPreference(getString(R.string.display_sub_key))!! val providerLangPreference = findPreference(getString(R.string.provider_lang_key))!! + val downloadPathPreference = findPreference(getString(R.string.download_path_key))!! legalPreference.setOnPreferenceClickListener { val builder: AlertDialog.Builder = AlertDialog.Builder(it.context) @@ -141,6 +177,48 @@ class SettingsFragment : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + fun getDownloadDirs(): List { + val defaultDir = getDownloadDir()?.filePath + + // app_name_download_path = Cloudstream and does not change depending on release. + // DOES NOT WORK ON SCOPED STORAGE. + val secondaryDir = if (isScopedStorage) null else Environment.getExternalStorageDirectory().absolutePath + + File.separator + resources.getString(R.string.app_name_download_path) + + val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second } + + return (listOf(defaultDir, secondaryDir) + + requireContext().getExternalFilesDirs("").mapNotNull { it.path } + + currentDir).filterNotNull().distinct() + } + + downloadPathPreference.setOnPreferenceClickListener { + val dirs = getDownloadDirs() + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + + val currentDir = + settingsManager.getString(getString(R.string.download_path_pref), null) ?: getDownloadDir().toString() + + context?.showBottomDialog( + dirs + listOf("Custom"), + dirs.indexOf(currentDir), + getString(R.string.download_path_pref), + true, + {}) { + // Last = custom + if (it == dirs.size) { + pathPicker.launch(Uri.EMPTY) + } else { + // Sets both visual and actual paths. + // key = used path + // pref = visual path + settingsManager.edit().putString(getString(R.string.download_path_key), dirs[it]).apply() + settingsManager.edit().putString(getString(R.string.download_path_pref), dirs[it]).apply() + } + } + return@setOnPreferenceClickListener true + } + watchQualityPreference.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.quality_pref) val prefValues = resources.getIntArray(R.array.quality_pref_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index de15e20d..40b4e222 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -15,11 +15,14 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri +import androidx.preference.PreferenceManager import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.fasterxml.jackson.annotation.JsonProperty +import com.google.android.exoplayer2.util.UriUtil +import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -126,7 +129,8 @@ object VideoDownloadManager { val totalBytes: Long, val relativePath: String, val displayName: String, - val extraInfo: String? = null + val extraInfo: String? = null, + val basePath: String? = null // null is for legacy downloads. See getDefaultPath() ) data class DownloadedFileInfoResult( @@ -256,9 +260,9 @@ object VideoDownloadManager { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } else { - PendingIntent.getActivity(context, 0, intent, 0) + PendingIntent.getActivity(context, 0, intent, 0) } builder.setContentIntent(pendingIntent) } @@ -437,21 +441,29 @@ object VideoDownloadManager { } } + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ fun getFolder(context: Context, relativePath: String): List>? { - if (isScopedStorage()) { + val base = context.getBasePath().first + val folder = base?.gotoDir(relativePath, false) + + if (isScopedStorage && base.isDownloadDir()) { return context.contentResolver?.getExistingFolderStartName(relativePath) } else { - val normalPath = - "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( - '/', - File.separatorChar - ) - val folder = File(normalPath) - if (folder.isDirectory) { - return folder.listFiles()?.map { Pair(it.name, it.toUri()) } +// val normalPath = +// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( +// '/', +// File.separatorChar +// ) +// val folder = File(normalPath) + if (folder?.isDirectory == true) { + return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) } } - return null } + return null +// } } @RequiresApi(Build.VERSION_CODES.Q) @@ -501,9 +513,9 @@ object VideoDownloadManager { } } - private fun isScopedStorage(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - } + val isScopedStorage: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + data class CreateNotificationMetadata( val type: DownloadType, @@ -518,6 +530,56 @@ object VideoDownloadManager { val fileStream: OutputStream? = null, ) + /** + * Guarantees a directory is present with the dir name (if createMissingDirectories is true). + * Works recursively when '/' is present. + * Will remove any file with the dir name if present and add directory. + * + * @param directoryName if null will use the current path. + * @return UniFile / null if createMissingDirectories = false and folder is not found. + * */ + private fun UniFile.gotoDir(directoryName: String?, createMissingDirectories: Boolean = true): UniFile? { + + // Can give this error on scoped storage, haven't solved. + // W/DocumentsContract: Failed to create document + // java.lang.IllegalArgumentException: Parent document isn't a directory + + try { + val allDirectories = directoryName?.split("/") + return if (allDirectories?.size == 1 || allDirectories == null) { + val found = this.findFile(directoryName) + when { + directoryName.isNullOrBlank() -> this + found?.isDirectory == true -> found + + !createMissingDirectories -> null + // Below creates directories + found?.isFile == true -> { + found.delete() + this.createDirectory(directoryName) + } + this.isDirectory -> this.createDirectory(directoryName) + else -> this.parentFile?.createDirectory(directoryName) + } + } else { + var currentDirectory = this + allDirectories.forEach { + // If the next directory is not found it returns the deepest directory possible. + val nextDir = currentDirectory.gotoDir(it, createMissingDirectories) + currentDirectory = nextDir ?: return null + } + currentDirectory + } + } catch (e: Exception) { + logError(e) + return null + } + } + + /** + * Sets up the appropriate file and creates a data stream from the file. + * Used for initializing downloads. + * */ private fun setupStream( context: Context, name: String, @@ -525,12 +587,14 @@ object VideoDownloadManager { extension: String, tryResume: Boolean, ): StreamData { - val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) val fileStream: OutputStream val fileLength: Long var resume = tryResume - if (isScopedStorage()) { + val baseFile = context.getBasePath() + + if (isScopedStorage && baseFile.first?.isDownloadDir() == true) { + val relativePath = getRelativePath(folder) val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) val currentExistingFile = @@ -578,26 +642,25 @@ object VideoDownloadManager { fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) } else { - val normalPath = getNormalPath(relativePath, displayName) - // NORMAL NON SCOPED STORAGE FILE CREATION - val rFile = File(normalPath) - if (!rFile.exists()) { + println("SEYUP FFFFFFF") + val subDir = baseFile.first?.gotoDir(folder) + val rFile = subDir?.findFile(displayName) + if (rFile?.exists() != true) { fileLength = 0 - rFile.parentFile?.mkdirs() - if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) } else { if (resume) { fileLength = rFile.length() } else { fileLength = 0 - rFile.parentFile?.mkdirs() if (!rFile.delete()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + if (subDir.createFile(displayName) == null) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) } } - fileStream = FileOutputStream(rFile, false) + fileStream = (subDir.findFile(displayName) ?: subDir.createFile(displayName))!!.openOutputStream() +// fileStream = FileOutputStream(rFile, false) + if (fileLength == 0L) resume = false } - if (fileLength == 0L) resume = false return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) } @@ -615,12 +678,14 @@ object VideoDownloadManager { return ERROR_UNKNOWN } - val relativePath = getRelativePath(folder) + val basePath = context.getBasePath() + +// val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) fun deleteFile(): Int { - return delete(context, name, folder, extension, parentId) + return delete(context, name, folder, extension, parentId, basePath.first) } val stream = setupStream(context, name, folder, extension, tryResume) @@ -681,7 +746,7 @@ object VideoDownloadManager { context.setKey( KEY_DOWNLOAD_INFO, it.toString(), - DownloadedFileInfo(bytesTotal, relativePath, displayName) + DownloadedFileInfo(bytesTotal, folder ?: "", displayName, basePath = basePath.second) ) } @@ -832,16 +897,58 @@ object VideoDownloadManager { } } - private fun getRelativePath(folder: String?): String { - return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar) - } - private fun getDisplayName(name: String, extension: String): String { return "$name.$extension" } - private fun getNormalPath(relativePath: String, displayName: String): String { - return "${Environment.getExternalStorageDirectory()}${File.separatorChar}$relativePath$displayName" + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDownloadDir(): UniFile? { + // See https://www.py4u.net/discuss/614761 + return UniFile.fromFile( + File( + Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + + Environment.DIRECTORY_DOWNLOADS + ) + ) + } + + @Deprecated("TODO fix unifile to work with download directory.") + private fun getRelativePath(folder: String?): String { + return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar) + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + private fun basePathToFile(context: Context, path: String?): UniFile? { + return when { + path == null -> getDownloadDir() + path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) + else -> UniFile.fromFile(File(path)) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + private fun UniFile?.isDownloadDir(): Boolean { + println("DOWNLOAD DIR $this ${this?.filePath} ${this?.uri}") + return this != null && this.filePath == getDownloadDir()?.filePath } private fun delete( @@ -850,20 +957,29 @@ object VideoDownloadManager { folder: String?, extension: String, parentId: Int?, + basePath: UniFile? ): Int { - val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) - if (isScopedStorage()) { + // If scoped storage and using download dir (not accessible with UniFile) + if (isScopedStorage && basePath.isDownloadDir()) { + val relativePath = getRelativePath(folder) val lastContent = context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) if (lastContent != null) { context.contentResolver.delete(lastContent, null, null) } } else { - if (!File(getNormalPath(relativePath, displayName)).delete()) return ERROR_DELETING_FILE - } - parentId?.let { - downloadDeleteEvent.invoke(parentId) + val dir = basePath?.gotoDir(folder) + val success = dir?.findFile(displayName) + ?.delete() + if (success != true) return ERROR_DELETING_FILE else { + // Cleans up empty directory + if (dir.listFiles()?.isEmpty() == true) dir.delete() + } +// } + parentId?.let { + downloadDeleteEvent.invoke(parentId) + } } return SUCCESS_STOPPED } @@ -875,7 +991,7 @@ object VideoDownloadManager { folder: String?, parentId: Int?, startIndex: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit + createNotificationCallback: (VideoDownloadManager.CreateNotificationMetadata) -> Unit ): Int { val extension = "mp4" fun logcatPrint(vararg items: Any?) { @@ -905,9 +1021,11 @@ object VideoDownloadManager { val fileLengthAdd = stream.fileLength!! val tsIterator = m3u8Helper.hlsYield(listOf(m3u8), realIndex) - val relativePath = getRelativePath(folder) +// val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) + val basePath = context.getBasePath() + val fileStream = stream.fileStream!! val firstTs = tsIterator.next() @@ -920,7 +1038,7 @@ object VideoDownloadManager { val totalTs = firstTs.totalTs.toLong() fun deleteFile(): Int { - return delete(context, name, folder, extension, parentId) + return delete(context, name, folder, extension, parentId, basePath.first) } /* Most of the auto generated m3u8 out there have TS of the same size. @@ -939,13 +1057,15 @@ object VideoDownloadManager { it.toString(), DownloadedFileInfo( (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath, + folder ?: "", displayName, - tsProgress.toString() + tsProgress.toString(), + basePath = basePath.second ) ) } } + updateInfo() fun updateNotification() { @@ -1217,8 +1337,9 @@ object VideoDownloadManager { private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null + val base = basePathToFile(context, info.basePath) - if (isScopedStorage()) { + if (isScopedStorage && base.isDownloadDir()) { val cr = context.contentResolver ?: return null val fileUri = cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) ?: return null @@ -1226,10 +1347,12 @@ object VideoDownloadManager { if (fileLength == 0L) return null return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) } else { - val normalPath = getNormalPath(info.relativePath, info.displayName) - val dFile = File(normalPath) - if (!dFile.exists()) return null - return DownloadedFileInfoResult(dFile.length(), info.totalBytes, dFile.toUri()) + val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) +// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) +// val dFile = File(normalPath) + if (file?.exists() != true) return null + + return DownloadedFileInfoResult(file.length(), info.totalBytes, file.uri) } } @@ -1245,8 +1368,9 @@ object VideoDownloadManager { downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) downloadDeleteEvent.invoke(id) + val base = basePathToFile(context, info.basePath) - if (isScopedStorage()) { + if (isScopedStorage && base.isDownloadDir()) { val cr = context.contentResolver ?: return false val fileUri = cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) @@ -1254,10 +1378,11 @@ object VideoDownloadManager { return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 } else { - val normalPath = getNormalPath(info.relativePath, info.displayName) - val dFile = File(normalPath) - if (!dFile.exists()) return true - return dFile.delete() + val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName) +// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) +// val dFile = File(normalPath) + if (file?.exists() != true) return true + return file.delete() } } @@ -1272,19 +1397,19 @@ object VideoDownloadManager { setKey: Boolean = true ) { if (!currentDownloads.any { it == pkg.item.ep.id }) { - if (currentDownloads.size == maxConcurrentDownloads) { - main { -// showToast( // can be replaced with regular Toast -// context, -// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ -// context.getString( -// R.string.queued -// ) -// }", -// Toast.LENGTH_SHORT -// ) - } - } +// if (currentDownloads.size == maxConcurrentDownloads) { +// main { +//// showToast( // can be replaced with regular Toast +//// context, +//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ +//// context.getString( +//// R.string.queued +//// ) +//// }", +//// Toast.LENGTH_SHORT +//// ) +// } +// } downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue(context) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e057a0f3..a56e8b71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,10 @@ show_fillers_key provider_lang_key dns_key + download_path_key + + + Cloudstream @@ -267,6 +271,7 @@ DNS over HTTPS Useful for bypassing ISP blocks + Download path Display Dubbed/Subbed Anime diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index c866a536..ac48ce0d 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -99,6 +99,11 @@ android:summary="@string/dns_pref_summary" android:icon="@drawable/ic_baseline_dns_24"> + +