wipp continues

pull/1112/head
deniscerri 8 months ago
parent 56979d78d8
commit ab524a9509
No known key found for this signature in database
GPG Key ID: 95C43D517D830350

@ -46,7 +46,12 @@ android {
defaultConfig {
applicationId "com.deniscerri.ytdl"
minSdk 24
targetSdk 36
/*
KEEP 28 TO ALLOW THE APP TO EXECUTE DOWNLOADABLE PLUGINS, IF PLAN TO UPGRADE PRE-BUNDLE plugins in jniLibs
OR LOOK INTO PLUGINS as APK and put the files in their respective jniLibs
* */
//noinspection ExpiredTargetSdkVersion
targetSdk 28
versionCode versionMajor * 1000000 + versionMinor * 10000 + versionPatch * 100 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}${versionExt}"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

@ -27,6 +27,7 @@
<application
android:usesCleartextTraffic="true"
android:name=".App"
android:allowBackup="false"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|locale"

@ -5,12 +5,13 @@ import android.os.Build
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.core.models.ExecuteException
import com.deniscerri.ytdl.core.models.ExecuteResponse
import com.deniscerri.ytdl.core.models.RuntimeLocation
import com.deniscerri.ytdl.core.models.YTDLRequest
import com.deniscerri.ytdl.core.plugins.Aria2c
import com.deniscerri.ytdl.core.plugins.FFmpeg
import com.deniscerri.ytdl.core.plugins.NodeJS
import com.deniscerri.ytdl.core.plugins.PluginBase
import com.deniscerri.ytdl.core.plugins.Python
import com.deniscerri.ytdl.core.plugins.QuickJS
import com.deniscerri.ytdl.core.stream.StreamGobbler
import com.deniscerri.ytdl.core.stream.StreamProcessExtractor
import org.apache.commons.io.FileUtils
@ -20,18 +21,16 @@ import java.util.Collections
object RuntimeManager {
val idProcessMap = Collections.synchronizedMap(HashMap<String, Process>())
lateinit var pythonLocation: RuntimeLocation
lateinit var ffmpegLocation: RuntimeLocation
lateinit var aria2Location: RuntimeLocation
lateinit var nodeLocation : RuntimeLocation
lateinit var quickJsLocation : RuntimeLocation
lateinit var pythonLocation: PluginBase.PluginLocation
lateinit var ffmpegLocation: PluginBase.PluginLocation
lateinit var aria2Location: PluginBase.PluginLocation
lateinit var nodeLocation : PluginBase.PluginLocation
lateinit var quickJsLocation : PluginBase.PluginLocation
var ytdlpPath: File? = null
const val PREFS_NAME = "runtime_prefs"
private var initialized = false
private const val RUNTIME_ROOT = "runtimes"
private const val PACKAGES_ROOT = "packages"
const val BASENAME = "ytdlnis"
const val ytdlpDirName = "yt-dlp"
const val ytdlpBin = "yt-dlp"
@ -39,6 +38,7 @@ object RuntimeManager {
private var ENV_LD_LIBRARY_PATH: String? = null
private var PATH: String? = null
private var ENV_SSL_CERT_FILE: String? = null
private var OPEN_SSL_CONF: String? = null
private var ENV_PYTHONHOME: String? = null
private var TMPDIR: String = ""
@ -46,22 +46,28 @@ object RuntimeManager {
if (initialized) return
val baseDir = File(appContext.noBackupFilesDir, BASENAME).apply { if (!exists()) mkdir() }
//extract bundled libraries if present
Python.getInstance().init(appContext)
FFmpeg.getInstance().init(appContext)
Aria2c.getInstance().init(appContext)
NodeJS.getInstance().init(appContext)
val python = Python.getInstance()
val ffmpeg = FFmpeg.getInstance()
val aria2c = Aria2c.getInstance()
val nodeJS = NodeJS.getInstance()
val quickJS = QuickJS.getInstance()
python.init(appContext)
ffmpeg.init(appContext)
aria2c.init(appContext)
nodeJS.init(appContext)
quickJS.init(appContext)
//find location of libraries either from bundled or downloaded paths
pythonLocation = getLocation(appContext, baseDir, "python", "python")
ffmpegLocation = getLocation(appContext, baseDir, "ffmpeg", "ffmpeg")
aria2Location = getLocation(appContext, baseDir, "aria2", "aria2")
nodeLocation = getLocation(appContext, baseDir, "node", "node")
quickJsLocation = getLocation(appContext, baseDir, "quickjs", "qjs")
pythonLocation = python.location
ffmpegLocation = ffmpeg.location
aria2Location = aria2c.location
nodeLocation = nodeJS.location
quickJsLocation = quickJS.location
val ytdlpDir = File(baseDir, ytdlpDirName)
ytdlpPath = File(ytdlpDir, ytdlpBin)
init_ytdlp(appContext, ytdlpDir)
initYTDLP(appContext, ytdlpDir)
val locations = listOf(
pythonLocation,
@ -73,30 +79,39 @@ object RuntimeManager {
val ldPaths = mutableListOf<String>()
locations.forEach {
val usrLib = File(it.ldLibraryDir, "usr/lib")
val usrLib = File(it.ldDir, "usr/lib")
if (usrLib.exists()) {
ldPaths.add(usrLib.absolutePath)
} else if (it.ldLibraryDirExists) {
ldPaths.add(it.ldLibraryDir.absolutePath)
} else if (it.ldDir.exists()) {
ldPaths.add(it.ldDir.absolutePath)
}
}
ldPaths.add(appContext.applicationInfo.nativeLibraryDir)
ENV_LD_LIBRARY_PATH = ldPaths.distinct().joinToString(":")
val binPaths = locations.filter { it.binDirExists }.map { it.binDir.absolutePath }.toMutableList()
val binPaths = locations.filter { it.binDir.exists() }.map { it.binDir.absolutePath }.toMutableList()
binPaths.add(System.getenv("PATH") ?: "/system/bin")
PATH = binPaths.distinct().joinToString(":")
ENV_SSL_CERT_FILE = if (pythonLocation.isDownloaded) {
File(pythonLocation.ldLibraryDir.parentFile, "usr/etc/tls/cert.pem").absolutePath
File(pythonLocation.ldDir.parentFile, "usr/etc/tls/cert.pem").absolutePath
} else {
pythonLocation.ldLibraryDir.absolutePath + "/usr/etc/tls/cert.pem"
pythonLocation.ldDir.absolutePath + "/usr/etc/tls/cert.pem"
}
OPEN_SSL_CONF = ""
if (nodeLocation.ldDir.exists()) {
OPEN_SSL_CONF = if (nodeLocation.isDownloaded) {
File(nodeLocation.ldDir.parentFile, "usr/etc/tls/openssl.cnf").absolutePath
} else {
nodeLocation.ldDir.absolutePath + "/usr/etc/tls/openssl.cnf"
}
}
ENV_PYTHONHOME = if (pythonLocation.isDownloaded) {
pythonLocation.ldLibraryDir.parent
pythonLocation.ldDir.parent
} else {
pythonLocation.ldLibraryDir.absolutePath + "/usr"
pythonLocation.ldDir.absolutePath + "/usr"
}
TMPDIR = appContext.cacheDir.absolutePath
@ -112,42 +127,8 @@ object RuntimeManager {
check(initialized) { "instance not initialized" }
}
fun getLocation(context: Context, baseDir: File, libName: String, exeName: String): RuntimeLocation {
val isDownloaded = isDownloaded(context, libName)
val binDir: File
val ldLibDir: File
val potentialExe: File
if (isDownloaded(context, libName)) {
val downloadedDir = File(context.noBackupFilesDir, "$RUNTIME_ROOT/$libName")
binDir = File(downloadedDir, "bin")
ldLibDir = downloadedDir
potentialExe = File(binDir, exeName)
} else {
val packagesDir = File(baseDir, PACKAGES_ROOT)
binDir = File(context.applicationInfo.nativeLibraryDir)
ldLibDir = File(packagesDir, libName)
potentialExe = File(binDir, "lib$exeName.so")
}
return RuntimeLocation(
binDir = binDir,
binDirExists = binDir.exists(),
ldLibraryDir = ldLibDir,
ldLibraryDirExists = ldLibDir.exists(),
isDownloaded = isDownloaded,
// Only store the path if the file exists and is a file
exePath = if (potentialExe.exists() && potentialExe.isFile) {
potentialExe.absolutePath
} else {
null
}
)
}
@Throws(ExecuteException::class)
fun init_ytdlp(appContext: Context, ytdlpDir: File) {
fun initYTDLP(appContext: Context, ytdlpDir: File) {
if (!ytdlpDir.exists()) ytdlpDir.mkdirs()
val ytdlpBinary = File(ytdlpDir, ytdlpBin)
if (!ytdlpBinary.exists()) {
@ -205,14 +186,16 @@ object RuntimeManager {
throw ExecuteException("Process ID already exists")
}
ffmpegLocation.exePath?.apply {
request.addOption("--ffmpeg-location", this)
if (ffmpegLocation.isAvailable) {
request.addOption("--ffmpeg-location", ffmpegLocation.executable.absolutePath)
}
if (nodeLocation.exePath != null) {
request.addOption("--js-runtimes", "node:${nodeLocation.exePath}")
} else if (quickJsLocation.exePath != null) {
request.addOption("--js-runtimes", "quickjs:${quickJsLocation.exePath}")
if (nodeLocation.isAvailable) {
request.addOption("--js-runtimes", "node:${nodeLocation.executable.absolutePath}")
}
if (quickJsLocation.isAvailable) {
request.addOption("--js-runtimes", "quickjs:${quickJsLocation.executable.absolutePath}")
}
if (!usingCacheDir) {
@ -220,12 +203,15 @@ object RuntimeManager {
}
val startTime = System.currentTimeMillis()
val fullCommand = mutableListOf<String>(pythonLocation.exePath!!, ytdlpPath!!.absolutePath) + request.buildCommand()
val fullCommand = mutableListOf<String>(pythonLocation.executable.absolutePath, ytdlpPath!!.absolutePath) + request.buildCommand()
val processBuilder = ProcessBuilder(fullCommand).redirectErrorStream(redirectErrorStream)
processBuilder.environment().apply {
this["LD_LIBRARY_PATH"] = ENV_LD_LIBRARY_PATH
if (OPEN_SSL_CONF != "") {
this["OPENSSL_CONF"] = OPEN_SSL_CONF
}
this["SSL_CERT_FILE"] = ENV_SSL_CERT_FILE
this["PATH"] = PATH
this["PYTHONHOME"] = ENV_PYTHONHOME
@ -320,11 +306,6 @@ object RuntimeManager {
}
}
//private helpers
private fun isDownloaded(context: Context, libName: String): Boolean {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean("${libName}_installed", false)
}
@JvmStatic
fun getInstance() = this
}

@ -53,7 +53,7 @@ internal object YTDLUpdater {
FileUtils.copyFile(file, binary)
} catch (e: Exception) {
FileUtils.deleteQuietly(ytdlpDir)
getInstance().init_ytdlp(appContext, ytdlpDir)
getInstance().initYTDLP(appContext, ytdlpDir)
throw ExecuteException(e)
} finally {
file.delete()

@ -1,11 +1,9 @@
package com.deniscerri.ytdl.core.plugins
object Aria2c : PluginBase() {
override val pluginName: String get() = "aria2c"
override val executableName: String get() = "aria2c"
override val pluginFolderName: String get() = "aria2c"
override val bundledZipName: String get() = "libaria2c.zip.so"
override val bundledVersion: String get() = "v1.37.0 [BUNDLED]"
override val manifestURL: String get() = ""
@JvmStatic
fun getInstance() = this
override val bundledVersion: String get() = "v1.37.0"
override val githubRepositoryPackageURL: String get() = ""
}

@ -1,12 +1,9 @@
package com.deniscerri.ytdl.core.plugins
object FFmpeg : PluginBase() {
override val pluginName: String get() = "ffmpeg"
override val executableName: String get() = "ffmpeg"
override val pluginFolderName: String get() = "ffmpeg"
override val bundledZipName: String get() = "libffmpeg.zip.so"
override val bundledVersion: String get() = "v7.1.1 [BUNDLED]"
override val manifestURL: String get() = ""
@JvmStatic
fun getInstance() = this
override val bundledVersion: String get() = "v7.1.1"
override val githubRepositoryPackageURL: String get() = ""
}

@ -1,11 +1,9 @@
package com.deniscerri.ytdl.core.plugins
object NodeJS : PluginBase() {
override val pluginName: String get() = "node"
override val executableName: String get() = "node"
override val pluginFolderName: String get() = "node"
override val bundledZipName: String get() = "libnode.zip.so"
override val bundledVersion: String get() = "v25.3.0 [BUNDLED]"
override val manifestURL: String get() = ""
@JvmStatic
fun getInstance() = this
override val bundledVersion: String get() = "v25.3.0"
override val githubRepositoryPackageURL: String get() = ""
}

@ -5,7 +5,7 @@ import android.os.Build
import androidx.core.content.edit
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import com.anggrayudi.storage.file.openInputStream
import com.anggrayudi.storage.file.toRawFile
import com.deniscerri.ytdl.core.RuntimeManager
import com.deniscerri.ytdl.core.ZipUtils
import kotlinx.coroutines.Dispatchers
@ -17,11 +17,16 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.apache.commons.io.FileUtils
import java.io.File
import java.time.LocalDate
abstract class PluginBase {
protected abstract val pluginName: String // e.g., "ffmpeg"
protected abstract val executableName: String // e.g., "ffmpeg"
protected abstract val pluginFolderName: String // e.g., "ffmpeg"
protected abstract val bundledZipName: String // e.g., "libffmpeg.zip.so"
protected abstract val bundledVersion: String // e.g., "v7.1"
fun getInstance(): PluginBase = this
abstract val bundledVersion: String?
var downloadedVersion: String? = null
protected abstract val githubRepositoryPackageURL: String // github repository package url
@Serializable
@ -35,12 +40,24 @@ abstract class PluginBase {
var isInstalled: Boolean
)
data class PluginLocation(
val binDir: File,
val ldDir: File,
val executable: File,
val isDownloaded: Boolean,
val isBundled: Boolean,
val isAvailable: Boolean
)
// Preferences Keys
private val installedKey get() = "${pluginName}_installed"
private val versionKey get() = "${pluginName}_version"
private val bundledVerKey get() = "${pluginName}_bundled_ver"
private val downloadedVersionKey get() = "${executableName}_downloaded_ver"
private val bundledVerKey get() = "${executableName}_bundled_ver"
private val packagesRoot = "packages"
private val downloadedPackagesRoot = "downloaded_packages"
lateinit var currentVersion : String
lateinit var location: PluginLocation
companion object {
val sharedClient: OkHttpClient by lazy {
@ -51,17 +68,16 @@ abstract class PluginBase {
fun init(context: Context) {
val baseDir = File(context.noBackupFilesDir, RuntimeManager.BASENAME)
val packageDir = File(baseDir, "packages/$pluginName")
if (!isDownloaded(context)) {
initBundled(context, packageDir)
}
val packageDir = File(baseDir, "$packagesRoot/$pluginFolderName")
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
currentVersion = if (prefs.getBoolean(installedKey, false)) {
prefs.getString(versionKey, "unknown") ?: "unknown"
//try init bundled
initBundled(context, packageDir)
location = getLocation(context, baseDir)
downloadedVersion = if (location.isDownloaded) {
prefs.getString(downloadedVersionKey, null)
} else {
bundledVersion
""
}
}
@ -84,18 +100,45 @@ abstract class PluginBase {
}
}
private fun getRuntimeDir(context: Context) : File {
return File(context.noBackupFilesDir, "runtimes/$pluginName")
private fun getDownloadedDir(context: Context) : File {
val baseDir = File(context.noBackupFilesDir, RuntimeManager.BASENAME)
return File(baseDir, "$downloadedPackagesRoot/$pluginFolderName")
}
fun getLocation(context: Context, baseDir: File): PluginLocation {
//downloaded
val downloadedDir = getDownloadedDir(context)
val downloadedBinDir = downloadedDir
val downloadedLDLibDir = downloadedDir
val downloadedExe = File(downloadedDir, "lib$executableName.so")
//bundled
val bundledDir = File(baseDir, packagesRoot)
val bundledBinDir = File(context.applicationInfo.nativeLibraryDir)
val bundledLDLibDir = File(bundledDir, pluginFolderName)
val bundledExe = File(bundledBinDir, "lib$executableName.so")
val isDownloaded = downloadedExe.exists()
val isBundled = bundledExe.exists()
return PluginLocation(
binDir = if (isDownloaded) downloadedBinDir else bundledBinDir,
ldDir = if (isDownloaded) downloadedLDLibDir else bundledLDLibDir,
executable = if (isDownloaded) downloadedExe else bundledExe,
isDownloaded,
isBundled,
isDownloaded || isBundled
)
}
suspend fun downloadRelease(context: Context, release: PluginRelease, onProgress: (Int) -> Unit) : File? {
val runtimeDir = getRuntimeDir(context)
suspend fun downloadRelease(context: Context, release: PluginRelease, onProgress: (Int) -> Unit) : DocumentFile? {
val runtimeDir = getDownloadedDir(context)
FileUtils.deleteQuietly(runtimeDir)
runtimeDir.mkdirs()
return withContext(Dispatchers.IO) {
try {
val tempZipFile = File(context.cacheDir, "${pluginName}_tmp.zip")
val tempZipFile = File(context.cacheDir, "${pluginFolderName}_tmp.zip")
//download
val request = Request.Builder().url(release.downloadUrl).build()
@ -122,7 +165,7 @@ abstract class PluginBase {
}
}
tempZipFile
DocumentFile.fromFile(tempZipFile)
} catch (e: Exception) {
null
}
@ -131,11 +174,13 @@ abstract class PluginBase {
fun installFromZip(context: Context, zipFile: DocumentFile, versionTag: String? = null) : Result<String> {
return kotlin.runCatching {
val runtimeDir = getRuntimeDir(context)
val runtimeDir = getDownloadedDir(context)
runtimeDir.deleteRecursively()
runtimeDir.createNewFile()
// 3. Unzip the main Bundle (contains libnode.so and libnode.zip.so)
val inputStream = zipFile.openInputStream(context)
ZipUtils.unzip(inputStream, runtimeDir)
inputStream?.close()
context.contentResolver.openInputStream(zipFile.uri).use { inputStream ->
ZipUtils.unzip(inputStream, runtimeDir)
}
// 4. Handle the Bootstrap Zip (Double Unzip)
// Look for any .zip.so file in the extracted directory
runtimeDir.listFiles()?.forEach { file ->
@ -144,8 +189,7 @@ abstract class PluginBase {
file.delete() // Remove the internal zip to save space
}
}
// 5. Global Permission Fix
// Scan for all files in any 'bin' folder and make them executable
applyExecutablePermissions(runtimeDir)
// 6. Save installation state
val version = versionTag ?: "IMPORTED"
@ -161,29 +205,47 @@ abstract class PluginBase {
}
}
private fun applyExecutablePermissions(file: File) {
if (file.isDirectory) {
// Check if this folder is a 'bin' folder
if (file.name == "bin") {
file.listFiles()?.forEach { it.setExecutable(true, false) }
fun uninstall(context: Context): Result<Unit> {
return kotlin.runCatching {
val runtimeDir = getDownloadedDir(context)
if (runtimeDir.exists()) {
val deleted = runtimeDir.deleteRecursively()
if (!deleted) {
throw Exception("Failed to delete runtime directory at ${runtimeDir.path}")
}
}
// Recurse into subdirectories
file.listFiles()?.forEach { applyExecutablePermissions(it) }
} else if (file.name == "libnode.so") {
// Specifically ensure our renamed main binary is executable
file.setExecutable(true, false)
Result.success(Unit)
}.getOrElse {
Result.failure(it)
}
}
private fun applyExecutablePermissions(file: File) {
Runtime.getRuntime().exec(arrayOf("chmod", "-R", "755", file.absolutePath)).waitFor()
}
private fun saveState(context: Context, version: String) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit(commit = true) {
putBoolean(installedKey, true)
putString(versionKey, version)
putString(downloadedVersionKey, version)
}
}
suspend fun getReleases(context: Context) : List<PluginRelease> {
suspend fun getReleases() : List<PluginRelease> {
//
// return listOf(
// PluginRelease(
// version = "1.0",
// downloadUrl = "http://192.168.1.144:8080/x86_64/x86_64.zip",
// createdAt = LocalDate.now().toString(),
// isInstalled = downloadedVersion == "1.0"
// )
// )
if (githubRepositoryPackageURL.isEmpty()) return listOf()
val request = Request.Builder()
.url(githubRepositoryPackageURL)
.header("Accept", "application/vnd.github+json")
@ -200,7 +262,7 @@ abstract class PluginBase {
json.decodeFromString<List<PluginRelease>>(jsonString)
.filter { it.version.contains(supportedArch) }
.onEach {
it.isInstalled = currentVersion == it.version
it.isInstalled = downloadedVersion == it.version
}
} else {
emptyList()
@ -211,8 +273,6 @@ abstract class PluginBase {
}
}
fun isDownloaded(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(installedKey, false)
fun getArchSuffix(): String {
val abi = Build.SUPPORTED_ABIS[0]

@ -1,11 +1,9 @@
package com.deniscerri.ytdl.core.plugins
object Python : PluginBase() {
override val pluginName: String get() = "python"
override val executableName: String get() = "python"
override val pluginFolderName: String get() = "python"
override val bundledZipName: String get() = "libpython.zip.so"
override val bundledVersion: String get() = "v3.12.11 [BUNDLED]"
override val manifestURL: String get() = ""
@JvmStatic
fun getInstance() = this
override val bundledVersion: String get() = "v3.12.11"
override val githubRepositoryPackageURL: String get() = ""
}

@ -0,0 +1,9 @@
package com.deniscerri.ytdl.core.plugins
object QuickJS : PluginBase() {
override val executableName: String get() = "qjs"
override val pluginFolderName: String get() = "quickjs"
override val bundledZipName: String get() = "libqjs.zip.so"
override val bundledVersion: String get() = "2025-04-26"
override val githubRepositoryPackageURL: String get() = ""
}

@ -2,9 +2,9 @@ package com.deniscerri.ytdl.database.models
import com.deniscerri.ytdl.core.plugins.PluginBase
data class PluginItem(
val title: String,
var version: String,
val instance: PluginBase
)
val plugin: PluginBase
) {
fun getInstance(): PluginBase = plugin.getInstance()
}

@ -0,0 +1,103 @@
package com.deniscerri.ytdl.ui.adapter
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.core.plugins.PluginBase
import com.deniscerri.ytdl.core.plugins.PluginBase.PluginRelease
import com.deniscerri.ytdl.database.models.DownloadItem
import com.deniscerri.ytdl.database.models.GithubRelease
import com.deniscerri.ytdl.database.models.PluginItem
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import java.sql.Date
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Locale
class PluginReleaseAdapter(onItemClickListener: OnItemClickListener, activity: Activity) : ListAdapter<PluginRelease?, PluginReleaseAdapter.ViewHolder>(AsyncDifferConfig.Builder(
DIFF_CALLBACK
).build()) {
private val activity: Activity
private val onItemClickListener: OnItemClickListener
init {
this.onItemClickListener = onItemClickListener
this.activity = activity
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var layoutParams: LinearLayout.LayoutParams
init {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT
)
layoutParams.setMargins(10, 10, 10, 0)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val cardView = LayoutInflater.from(parent.context)
.inflate(R.layout.plugin_release_item, parent, false)
return ViewHolder(cardView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position) ?: return
val card = holder.itemView
card.findViewById<TextView>(R.id.title).text = item.version
card.findViewById<TextView>(R.id.createdAt).text = item.createdAt
val actionBtn = card.findViewById<MaterialButton>(R.id.actionBtn)
if (item.isInstalled) {
actionBtn.setIconResource(R.drawable.ic_baseline_delete_outline_24)
} else {
actionBtn.setIconResource(R.drawable.ic_down)
}
card.setOnClickListener {
if (item.isInstalled) {
onItemClickListener.onDeleteReleaseClick(item)
} else {
onItemClickListener.onDownloadReleaseClick(item)
}
}
}
interface OnItemClickListener {
fun onDownloadReleaseClick(item: PluginRelease)
fun onDeleteReleaseClick(item: PluginRelease)
}
companion object {
private val DIFF_CALLBACK: DiffUtil.ItemCallback<PluginRelease> = object : DiffUtil.ItemCallback<PluginRelease>() {
override fun areItemsTheSame(oldItem: PluginRelease, newItem: PluginRelease): Boolean {
return oldItem.version == newItem.version
}
override fun areContentsTheSame(oldItem: PluginRelease, newItem: PluginRelease): Boolean {
return oldItem.isInstalled == newItem.isInstalled
}
}
}
}

@ -10,7 +10,9 @@ import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.collection.intSetOf
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@ -61,13 +63,37 @@ class PluginsAdapter(onItemClickListener: OnItemClickListener, activity: Activit
val card = holder.itemView
card.findViewById<TextView>(R.id.title).text = it.title
card.findViewById<TextView>(R.id.version).text = it.version
val instance = it.getInstance()
val location = instance.location
val isDownloaded = location.isDownloaded
val isBundled = location.isBundled
var currentVersion : String? = activity.getString(R.string.not_installed)
if (location.isAvailable) {
currentVersion = if (isDownloaded) instance.downloadedVersion else instance.bundledVersion
}
card.findViewById<TextView>(R.id.version).text = currentVersion
card.findViewById<TextView>(R.id.downloadedChip).isVisible = isDownloaded
card.findViewById<TextView>(R.id.bundledChip).isVisible = isBundled
card.setOnClickListener { cl ->
onItemClickListener.onCardClick(it)
}
card.setOnLongClickListener { c ->
if (location.isAvailable && location.isDownloaded) {
onItemClickListener.onDeleteDownloadedVersion(it, currentVersion)
}
true
}
}
interface OnItemClickListener {
fun onCardClick(item: PluginItem)
fun onDeleteDownloadedVersion(item: PluginItem, currentVersion: String?)
}
companion object {
@ -77,7 +103,7 @@ class PluginsAdapter(onItemClickListener: OnItemClickListener, activity: Activit
}
override fun areContentsTheSame(oldItem: PluginItem, newItem: PluginItem): Boolean {
return oldItem.version == newItem.version
return oldItem.title == newItem.title
}
}
}

@ -12,29 +12,48 @@ import android.view.ViewGroup
import android.view.Window
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.core.RuntimeManager
import com.deniscerri.ytdl.core.plugins.Aria2c
import com.deniscerri.ytdl.core.plugins.FFmpeg
import com.deniscerri.ytdl.core.plugins.NodeJS
import com.deniscerri.ytdl.core.plugins.PluginBase
import com.deniscerri.ytdl.core.plugins.Python
import com.deniscerri.ytdl.database.models.PluginItem
import com.deniscerri.ytdl.ui.adapter.PluginReleaseAdapter
import com.deniscerri.ytdl.ui.adapter.PluginsAdapter
import com.deniscerri.ytdl.ui.more.settings.SettingsActivity
import com.deniscerri.ytdl.util.Extensions.enableFastScroll
import com.deniscerri.ytdl.util.UiUtil
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.snackbar.Snackbar
import junit.runner.Version
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PluginsFragment : Fragment(), PluginsAdapter.OnItemClickListener {
class PluginsFragment : Fragment(), PluginsAdapter.OnItemClickListener, PluginReleaseAdapter.OnItemClickListener {
private lateinit var recyclerView: RecyclerView
private lateinit var listAdapter: PluginsAdapter
private lateinit var releaseAdapter: PluginReleaseAdapter
private var bottomSheet: BottomSheetDialog? = null
private lateinit var settingsActivity: SettingsActivity
private lateinit var preferences: SharedPreferences
private var tmpItem: PluginItem? = null
private var plugins: List<PluginItem> = mutableListOf()
private var pluginReleases: List<PluginBase.PluginRelease> = mutableListOf()
override fun onCreateView(
inflater: LayoutInflater,
@ -57,25 +76,25 @@ class PluginsFragment : Fragment(), PluginsAdapter.OnItemClickListener {
recyclerView.adapter = listAdapter
recyclerView.enableFastScroll()
val nodeJSInstance = NodeJS.getInstance()
plugins = listOf(
PluginItem("NodeJS", nodeJSInstance.currentVersion, nodeJSInstance)
PluginItem("Python", Python),
PluginItem("FFmpeg", FFmpeg),
PluginItem("Aria2c", Aria2c),
PluginItem("NodeJS", NodeJS)
)
listAdapter.submitList(plugins)
}
override fun onCardClick(item: PluginItem) {
tmpItem = item
val bottomSheet = BottomSheetDialog(requireContext())
bottomSheet.requestWindowFeature(Window.FEATURE_NO_TITLE)
bottomSheet.setContentView(R.layout.plugin_releases_bottom_sheet)
bottomSheet.findViewById<TextView>(R.id.bottom_sheet_title)?.text = item.title
bottomSheet.findViewById<Button>(R.id.bottomsheet_import_zip)?.setOnClickListener {
tmpItem = item
bottomSheet.dismiss()
val sheet = BottomSheetDialog(requireContext())
sheet.requestWindowFeature(Window.FEATURE_NO_TITLE)
sheet.setContentView(R.layout.plugin_releases_bottom_sheet)
sheet.findViewById<TextView>(R.id.bottom_sheet_subtitle)?.text = item.title
sheet.findViewById<Button>(R.id.bottomsheet_import_zip)?.setOnClickListener {
sheet.dismiss()
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
@ -86,17 +105,39 @@ class PluginsFragment : Fragment(), PluginsAdapter.OnItemClickListener {
importPluginZipLauncher.launch(intent)
}
//TODO SHOW RELEASES LIST
val loader = sheet.findViewById<CircularProgressIndicator>(R.id.loader)
val noResults = sheet.findViewById<View>(R.id.no_results)
pluginReleases = mutableListOf()
releaseAdapter = PluginReleaseAdapter(this@PluginsFragment, requireActivity())
val releaseRecyclerView = sheet.findViewById<RecyclerView>(R.id.recyclerView)!!
releaseRecyclerView.adapter = releaseAdapter
lifecycleScope.launch {
val instance = tmpItem!!.getInstance()
instance.getReleases().apply {
pluginReleases = this.toMutableList()
releaseAdapter.submitList(pluginReleases)
releaseRecyclerView.isVisible = pluginReleases.isNotEmpty()
loader?.isVisible = false
noResults?.isVisible = pluginReleases.isEmpty()
}
}
sheet.setOnDismissListener {
bottomSheet = null
}
bottomSheet.show()
sheet.show()
val displayMetrics = DisplayMetrics()
requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics)
bottomSheet.behavior.peekHeight = displayMetrics.heightPixels
bottomSheet.window!!.setLayout(
sheet.behavior.peekHeight = displayMetrics.heightPixels
sheet.window!!.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
bottomSheet = sheet
}
private var importPluginZipLauncher = registerForActivityResult(
@ -112,15 +153,61 @@ class PluginsFragment : Fragment(), PluginsAdapter.OnItemClickListener {
tmpItem?.let { item ->
DocumentFile.fromSingleUri(requireContext(), it)?.apply {
val result = item.instance.installFromZip(requireContext(), this)
val instance = item.getInstance()
val result = instance.installFromZip(requireContext(), this)
if (result.isSuccess) {
val idx = plugins.indexOfFirst { it2 -> it2.title == item.title }
plugins[idx].version = result.getOrNull() ?: ""
listAdapter.submitList(plugins)
bottomSheet?.dismiss()
listAdapter.notifyDataSetChanged()
RuntimeManager.reInit(requireContext())
}
}
}
}
}
}
private fun deleteDownloadedVersion(item: PluginItem, version: String?) {
UiUtil.showGenericDeleteDialog(
requireContext(),
"${item.title} (${version})"
) {
val instance = item.getInstance()
val resp = instance.uninstall(requireContext())
resp.onFailure {
Snackbar.make(requireView(), it.message ?: getString(R.string.errored), Snackbar.LENGTH_LONG).show()
}
resp.onSuccess {
bottomSheet?.dismiss()
listAdapter.notifyDataSetChanged()
RuntimeManager.reInit(requireContext())
}
}
}
override fun onDeleteReleaseClick(item: PluginBase.PluginRelease) {
deleteDownloadedVersion(tmpItem!!, item.version)
}
override fun onDeleteDownloadedVersion(item: PluginItem, currentVersion: String?) {
deleteDownloadedVersion(item, currentVersion)
}
override fun onDownloadReleaseClick(item: PluginBase.PluginRelease) {
lifecycleScope.launch {
val instance = tmpItem!!.getInstance()
val file = instance.downloadRelease(requireContext(), item) {
}
val resp = instance.installFromZip(requireContext(), file!!, item.version)
resp.onFailure {
Snackbar.make(requireView(), it.message ?: getString(R.string.errored), Snackbar.LENGTH_LONG).show()
}
resp.onSuccess {
bottomSheet?.dismiss()
listAdapter.notifyDataSetChanged()
RuntimeManager.reInit(requireContext())
}
}
}
}

@ -121,22 +121,6 @@ class UpdateSettingsFragment : BaseSettingsFragment() {
true
}
//plugins
findPreference<Preference>("plugin_python")?.apply {
val instance = Python.getInstance()
summary = instance.getVersion(requireContext())
}
findPreference<Preference>("plugin_ffmpeg")?.apply {
val instance = FFmpeg.getInstance()
summary = instance.getVersion(requireContext())
}
findPreference<Preference>("plugin_aria2c")?.apply {
val instance = Aria2c.getInstance()
summary = instance.getVersion(requireContext())
}
handlePlugin(NodeJS.getInstance(), findPreference<Preference>("plugin_nodejs"))
findPreference<Preference>("reset_preferences")?.setOnPreferenceClickListener {
UiUtil.showGenericConfirmDialog(requireContext(), getString(R.string.reset), getString(R.string.reset_preferences_in_screen)) {
resetPreferences(preferences.edit(), R.xml.updating_preferences)
@ -209,41 +193,4 @@ class UpdateSettingsFragment : BaseSettingsFragment() {
}
}
private fun handlePlugin(instance: PluginBase, preference: Preference?) {
//TODO REWRITE THIS LOL
preference?.apply {
summary = instance.getVersion(requireContext())
onPreferenceClickListener = Preference.OnPreferenceClickListener {
summary = getString(R.string.loading)
val response = instance.checkForUpdates(requireContext())
if (response == null) {
Snackbar.make(requireView(), getString(R.string.failed_download), Snackbar.LENGTH_SHORT).show()
} else {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val success = instance.downloadAndInstall(requireContext(), response.downloadUrl, response.version) { progress, total ->
val downloadedMB = progress / 1048576
val totalMB = total / 1048576
lifecycleScope.launch {
withContext(Dispatchers.Main) {
summary = "${getString(R.string.downloading)} $downloadedMB MB / $totalMB MB"
}
}
}
if (success) {
RuntimeManager.reInit(requireContext())
}
}
}
}
summary = instance.getVersion(requireContext())
true
}
}
}
}

@ -66,22 +66,79 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/version"
<LinearLayout
android:layout_width="0dp"
android:layout_marginTop="5dp"
android:layout_height="wrap_content"
android:clickable="false"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="5dp"
android:scrollbars="none"
android:textSize="15sp"
android:textStyle="bold"
android:fontFamily="monospace"
tools:text="v3.12"
android:orientation="horizontal"
app:layout_constraintEnd_toStartOf="@+id/download_type"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
app:layout_constraintTop_toBottomOf="@id/title"
>
<TextView
android:id="@+id/version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="5dp"
android:scrollbars="none"
android:textSize="15sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:text="@string/not_installed" />
<TextView
android:id="@+id/downloadedChip"
style="@style/Widget.Material3.FloatingActionButton.Large.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:background="@drawable/rounded_corner"
android:backgroundTint="?attr/colorSecondary"
android:clickable="false"
android:gravity="center"
android:outlineProvider="none"
android:maxWidth="90dp"
android:maxLines="1"
android:minWidth="30dp"
android:paddingHorizontal="5dp"
android:layout_marginStart="5dp"
android:textStyle="bold"
app:cornerRadius="10dp"
android:text="@string/downloaded"
tools:visibility="gone"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bundledChip"
style="@style/Widget.Material3.FloatingActionButton.Large.Tertiary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:background="@drawable/rounded_corner"
android:backgroundTint="?attr/colorSecondary"
android:clickable="false"
android:gravity="center"
android:minWidth="30dp"
android:outlineProvider="none"
android:paddingHorizontal="5dp"
android:layout_marginStart="5dp"
android:textStyle="bold"
app:cornerRadius="10dp"
android:text="@string/bundled"
tools:visibility="gone"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/download_type"

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView android:id="@+id/log_card_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:backgroundTint="@android:color/transparent"
android:checkable="true"
android:clickable="true"
android:focusable="true"
app:checkedIcon="@null"
app:strokeColor="?attr/colorPrimary"
app:cardPreventCornerOverlap="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Avatar"
app:strokeWidth="0dp"
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:paddingHorizontal="20dp"
android:paddingVertical="10dp"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/plugin_icon"
android:layout_width="30dp"
android:layout_height="30dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:src="@drawable/ic_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_weight="0.3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Avatar" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:clickable="false"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="0.7"
app:layout_constraintStart_toEndOf="@+id/plugin_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clickable="false"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="5dp"
android:scrollbars="none"
android:textSize="17sp"
android:textStyle="bold"
tools:text="v1.0"
app:layout_constraintEnd_toStartOf="@+id/actionBtn"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/createdAt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clickable="false"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="5dp"
android:scrollbars="none"
android:textSize="15sp"
android:textStyle="bold"
android:fontFamily="monospace"
tools:text="1st Jan 2000"
app:layout_constraintEnd_toStartOf="@+id/actionBtn"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<com.google.android.material.button.MaterialButton
android:id="@+id/actionBtn"
style="?attr/materialIconButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clickable="false"
android:contentDescription="@string/preferred_download_type"
android:minHeight="0dp"
android:padding="0dp"
app:cornerRadius="10dp"
app:icon="@drawable/ic_down"
app:iconTint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

@ -4,7 +4,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<LinearLayout
@ -23,29 +22,6 @@
android:orientation="horizontal"
android:paddingTop="20dp">
<com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginEnd="20dp"
app:layout_constraintEnd_toStartOf="@+id/bottomsheet_schedule_button"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:id="@+id/shimmer_loading_title"
android:orientation="vertical">
<TextView
android:id="@+id/bottom_sheet_loading_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="2"
android:singleLine="false"
android:text="@string/loading"
android:textSize="25sp" />
</com.facebook.shimmer.ShimmerFrameLayout>
<TextView
android:id="@+id/bottom_sheet_title"
android:layout_width="0dp"
@ -94,17 +70,25 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
android:paddingBottom="85dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="70dp"
android:indeterminate="true" />
<include
layout="@layout/no_results"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="70dp"
/>
</LinearLayout>

@ -21,6 +21,9 @@
<action
android:id="@+id/action_updateSettingsFragment_to_changeLogFragment"
app:destination="@id/changeLogFragment" />
<action
android:id="@+id/action_updateSettingsFragment_to_pluginsFragment"
app:destination="@id/pluginsFragment" />
</fragment>
<fragment
android:id="@+id/downloadSettingsFragment"
@ -72,4 +75,8 @@
android:id="@+id/generateYoutubePoTokensFragment"
android:name="com.deniscerri.ytdl.ui.more.settings.advanced.generateyoutubepotokens.GenerateYoutubePoTokensFragment"
android:label="GenerateYoutubePoTokensFragment" />
<fragment
android:id="@+id/pluginsFragment"
android:name="com.deniscerri.ytdl.ui.more.settings.updating.PluginsFragment"
android:label="PluginsFragment" />
</navigation>

@ -513,4 +513,5 @@
<string name="plugins">Plugins</string>
<string name="not_installed">Not installed</string>
<string name="import_zip">Import ZIP</string>
<string name="bundled">Bundled</string>
</resources>

@ -74,34 +74,14 @@
app:icon="@drawable/ic_chapters"
app:key="changelog"
app:title="@string/changelog" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/plugins">
<Preference
app:icon="@drawable/outline_data_object_24"
app:key="plugin_python"
app:title="Python"
tools:summary="@string/not_installed" />
<Preference
app:icon="@drawable/outline_data_object_24"
app:key="plugin_ffmpeg"
app:title="FFmpeg"
tools:summary="@string/not_installed" />
<Preference
app:icon="@drawable/outline_data_object_24"
app:key="plugin_aria2c"
app:title="Aria2c"
tools:summary="@string/not_installed" />
<Preference
app:icon="@drawable/outline_data_object_24"
app:key="plugin_nodejs"
app:title="NodeJS"
tools:summary="@string/not_installed" />
app:icon="@drawable/ic_code"
app:key="plugins"
app:title="@string/plugins" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/format">
<ListPreference
android:defaultValue="filesize"

Loading…
Cancel
Save