diff --git a/app/build.gradle b/app/build.gradle index 07f11f4b..36e5cfc7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 58e8d253..ddfdfa91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ ()) - 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() 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(pythonLocation.exePath!!, ytdlpPath!!.absolutePath) + request.buildCommand() + val fullCommand = mutableListOf(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 } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/YTDLUpdater.kt b/app/src/main/java/com/deniscerri/ytdl/core/YTDLUpdater.kt index d582e67b..456f2266 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/YTDLUpdater.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/YTDLUpdater.kt @@ -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() diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/Aria2c.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/Aria2c.kt index 253dece1..2a5345ac 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/plugins/Aria2c.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/Aria2c.kt @@ -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() = "" } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/FFmpeg.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/FFmpeg.kt index 519ac578..7a839677 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/plugins/FFmpeg.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/FFmpeg.kt @@ -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() = "" } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/NodeJS.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/NodeJS.kt index 9f153b70..c6dc4b31 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/plugins/NodeJS.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/NodeJS.kt @@ -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() = "" } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/PluginBase.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/PluginBase.kt index 350746a5..49f08356 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/plugins/PluginBase.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/PluginBase.kt @@ -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 { 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 { + 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 { + suspend fun getReleases() : List { +// +// 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>(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] diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/Python.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/Python.kt index 0e4e39f9..5cd26f2e 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/plugins/Python.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/Python.kt @@ -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() = "" } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/core/plugins/QuickJS.kt b/app/src/main/java/com/deniscerri/ytdl/core/plugins/QuickJS.kt new file mode 100644 index 00000000..7452f46e --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/core/plugins/QuickJS.kt @@ -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() = "" +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/database/models/PluginItem.kt b/app/src/main/java/com/deniscerri/ytdl/database/models/PluginItem.kt index f8807ca2..804fc49a 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/models/PluginItem.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/models/PluginItem.kt @@ -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 -) \ No newline at end of file + val plugin: PluginBase +) { + fun getInstance(): PluginBase = plugin.getInstance() +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginReleaseAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginReleaseAdapter.kt new file mode 100644 index 00000000..97a511dc --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginReleaseAdapter.kt @@ -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(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(R.id.title).text = item.version + card.findViewById(R.id.createdAt).text = item.createdAt + + val actionBtn = card.findViewById(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 = object : DiffUtil.ItemCallback() { + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginsAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginsAdapter.kt index 0911ce0d..7a751f46 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginsAdapter.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/PluginsAdapter.kt @@ -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(R.id.title).text = it.title - card.findViewById(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(R.id.version).text = currentVersion + + card.findViewById(R.id.downloadedChip).isVisible = isDownloaded + card.findViewById(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 } } } diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PluginsFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PluginsFragment.kt index 6e82398b..8ec2512a 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PluginsFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/updating/PluginsFragment.kt @@ -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 = mutableListOf() + private var pluginReleases: List = 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(R.id.bottom_sheet_title)?.text = item.title - bottomSheet.findViewById