diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4fc627917..782ee60e8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
= Build.VERSION_CODES.M) {
+ requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+
+ for (permission in requiredPermissions) {
val status = checkCallingOrSelfPermission(permission)
- if (status != PackageManager.PERMISSION_GRANTED){
+ if (status != PackageManager.PERMISSION_GRANTED) {
allPermissionProvided = false
break
}
diff --git a/data/provider/src/main/kotlin/com/flixclusive/data/provider/ProviderManager.kt b/data/provider/src/main/kotlin/com/flixclusive/data/provider/ProviderManager.kt
index 8247e608e..b2ff3a1d3 100644
--- a/data/provider/src/main/kotlin/com/flixclusive/data/provider/ProviderManager.kt
+++ b/data/provider/src/main/kotlin/com/flixclusive/data/provider/ProviderManager.kt
@@ -1,13 +1,12 @@
package com.flixclusive.data.provider
import android.content.Context
-import android.content.res.AssetManager
-import android.content.res.Resources
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshotFlow
import com.flixclusive.core.datastore.AppSettingsManager
import com.flixclusive.core.ui.common.util.showToast
import com.flixclusive.core.util.common.dispatcher.AppDispatchers
+import com.flixclusive.core.util.common.dispatcher.AppDispatchers.Companion.withIOContext
import com.flixclusive.core.util.common.dispatcher.Dispatcher
import com.flixclusive.core.util.common.dispatcher.di.ApplicationScope
import com.flixclusive.core.util.exception.safeCall
@@ -17,6 +16,7 @@ import com.flixclusive.core.util.log.warnLog
import com.flixclusive.core.util.network.fromJson
import com.flixclusive.data.provider.util.CrashHelper.getApiCrashMessage
import com.flixclusive.data.provider.util.CrashHelper.isCrashingOnGetApiMethod
+import com.flixclusive.data.provider.util.DynamicResourceLoader
import com.flixclusive.data.provider.util.NotificationUtil.notifyOnError
import com.flixclusive.data.provider.util.buildValidFilename
import com.flixclusive.data.provider.util.downloadFile
@@ -77,6 +77,8 @@ class ProviderManager @Inject constructor(
* */
private val updaterJsonMap = HashMap>()
+ private val dynamicResourceLoader = DynamicResourceLoader(context = context)
+
val workingApis = snapshotFlow {
providerDataList
.mapNotNull { data ->
@@ -293,7 +295,7 @@ class ProviderManager @Inject constructor(
* @param file Provider file
* @param providerData The provider information
*/
- @Suppress("DEPRECATION", "UNCHECKED_CAST")
+ @Suppress("UNCHECKED_CAST")
private suspend fun loadProvider(file: File, providerData: ProviderData) {
val name = file.nameWithoutExtension
val filePath = file.absolutePath
@@ -339,16 +341,12 @@ class ProviderManager @Inject constructor(
providerName = providerData.name
)
if (manifest.requiresResources) {
- // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
- val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
- val addAssetPath =
- AssetManager::class.java.getMethod("addAssetPath", String::class.java)
- addAssetPath.invoke(assets, file.absolutePath)
- providerInstance.resources = Resources(
- assets,
- context.resources.displayMetrics,
- context.resources.configuration
- )
+ withIOContext {
+ dynamicResourceLoader.load(
+ inputFile = file,
+ provider = providerInstance
+ )
+ }
}
if (providerPreference == null) {
@@ -458,7 +456,7 @@ class ProviderManager @Inject constructor(
classLoaders.values.removeIf {
it.name.equals(provider.name, true)
}
- providerApiRepository.remove(provider.name!!)
+ providerApiRepository.remove(provider.name)
providers.remove(provider.name)
if (unloadOnSettings) {
unloadProviderOnSettings(file.absolutePath)
diff --git a/data/provider/src/main/kotlin/com/flixclusive/data/provider/util/DynamicResourceLoader.kt b/data/provider/src/main/kotlin/com/flixclusive/data/provider/util/DynamicResourceLoader.kt
new file mode 100644
index 000000000..31eef0c0d
--- /dev/null
+++ b/data/provider/src/main/kotlin/com/flixclusive/data/provider/util/DynamicResourceLoader.kt
@@ -0,0 +1,226 @@
+package com.flixclusive.data.provider.util
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.content.res.Resources
+import android.os.Build
+import com.flixclusive.core.util.log.infoLog
+import com.flixclusive.provider.Provider
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+/**
+ * A utility class for dynamically loading resources from an external file.
+ * It handles both legacy (Marshmallow and below) and modern Android versions.
+ *
+ * If the device is running an Android version below Marshmallow, it copies the
+ * input file to a temporary file and appends a manifest. The said step is essential
+ * since Android 6< the `addAssetPath` method requires a path (a directory, file or ZIP)
+ * to have a manifest file or else it will assume that the path does not have any resources.
+ *
+ * An old commit code from [AOSP](https://android.googlesource.com/platform/frameworks/base/+/435acfc88917e3535462ea520b01d0868266acd2/libs/androidfw/AssetManager.cpp) (Android Open Source Project):
+ * ```java
+ * // Check that the path has an AndroidManifest.xml
+ * Asset* manifestAsset = const_cast(this)->openNonAssetInPathLocked(...);
+ * if (manifestAsset == NULL) {
+ * // This asset path does not contain any resources.
+ * // ...
+ * return false;
+ * }
+ * ```
+ *
+ * This is inspired from this [stackoverflow post](https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk)
+ *
+ * @property context The Android application context.
+ */
+@Suppress("DEPRECATION")
+internal class DynamicResourceLoader(
+ private val context: Context
+) {
+ /**
+ * Loads the resources from the input file and sets them to the provided [Provider].
+ * For Android Marshmallow and below, it manipulates the ZIP file before loading.
+ *
+ * @param inputFile The input file containing the resources to be loaded.
+ * @param provider The Provider instance to set the loaded resources.
+ */
+ fun load(inputFile: File, provider: Provider) {
+ var filePath = inputFile.absolutePath
+ if (isAndroidMarshmallowOrBelow()) {
+ val tempFile = createTempFile(inputFile)
+ val manifestFile = createManifestFile(tempFile)
+ manipulateZipFile(
+ inputFile = inputFile,
+ tempFile = tempFile,
+ manifestFile = manifestFile
+ )
+ filePath = tempFile.absolutePath
+ }
+
+ provider.resources = getDynamicResources(filePath = filePath)
+
+ if (isAndroidMarshmallowOrBelow()) {
+ cleanupArtifacts(inputFile)
+ }
+ }
+
+ /**
+ * Checks if the current Android version is Marshmallow (API 23) or below.
+ *
+ * @return True if the device is running Android Marshmallow or below, false otherwise.
+ */
+ private fun isAndroidMarshmallowOrBelow(): Boolean {
+ return Build.VERSION.SDK_INT <= Build.VERSION_CODES.M
+ }
+
+ /**
+ * Creates a new Resources instance using the provided file path.
+ *
+ * @param filePath The path to the file containing the resources.
+ * @return A new Resources instance loaded with the assets from the provided file.
+ */
+ private fun getDynamicResources(filePath: String): Resources {
+ val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
+ val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java)
+ addAssetPath.invoke(assets, filePath)
+
+ return Resources(
+ assets,
+ context.resources.displayMetrics,
+ context.resources.configuration
+ )
+ }
+
+ /**
+ * Manipulates the ZIP file for legacy Android versions.
+ * This involves copying the input to a temp file and appending a manifest.
+ *
+ * @param inputFile The original input file.
+ * @param tempFile The temporary file for manipulation.
+ * @param manifestFile The manifest file to be added.
+ */
+ private fun manipulateZipFile(
+ inputFile: File,
+ tempFile: File,
+ manifestFile: File
+ ) {
+ copyInputToTemp(
+ inputFile = inputFile,
+ tempFile = tempFile
+ )
+ appendManifestToZip(
+ tempFile = tempFile,
+ manifestFile = manifestFile
+ )
+ infoLog("ZIP file manipulation completed for legacy Android version.")
+ }
+
+ /**
+ * Cleans up temporary files created during the ZIP manipulation process.
+ *
+ * @param inputFile The original input file.
+ */
+ private fun cleanupArtifacts(inputFile: File) {
+ val tempFile = createTempFile(inputFile)
+ val manifestFile = createManifestFile(tempFile)
+ tempFile.delete()
+ manifestFile.delete()
+ infoLog("Cleanup completed.")
+ }
+
+ /**
+ * Creates a temporary file for ZIP manipulation.
+ *
+ * @param inputFile The original input file.
+ * @return A File object representing the temporary ZIP file.
+ */
+ private fun createTempFile(inputFile: File): File {
+ return File(inputFile.parent, "${inputFile.nameWithoutExtension}_temp.flx")
+ }
+
+ /**
+ * Creates a basic AndroidManifest.xml file.
+ *
+ * @param tempFile The temporary file, used to determine the parent directory.
+ * @return A File object representing the created AndroidManifest.xml.
+ */
+ private fun createManifestFile(tempFile: File): File {
+ val manifestContent = """
+
+
+
+ """.trimIndent()
+ val file = File(tempFile.parent, "AndroidManifest.xml")
+ file.writeText(manifestContent)
+ return file
+ }
+
+ /**
+ * Copies the input file to the temporary file.
+ *
+ * @param inputFile The original input file.
+ * @param tempFile The temporary file to copy to.
+ */
+ private fun copyInputToTemp(inputFile: File, tempFile: File) {
+ inputFile.copyTo(tempFile, overwrite = true)
+ }
+
+ /**
+ * Appends the AndroidManifest.xml to the temporary ZIP file.
+ *
+ * @param tempFile The temporary ZIP file.
+ * @param manifestFile The manifest file to be added.
+ */
+ private fun appendManifestToZip(tempFile: File, manifestFile: File) {
+ ZipFile(tempFile).use { zipFile ->
+ val tempOutputFile = File(tempFile.parent, "${tempFile.nameWithoutExtension}_new.flx")
+ ZipOutputStream(tempOutputFile.outputStream()).use { zipOutputStream ->
+ copyExistingEntries(zipFile, zipOutputStream)
+ addManifestToZip(zipOutputStream, manifestFile)
+ }
+ replaceOriginalTempFile(tempOutputFile, tempFile)
+ }
+ }
+
+ /**
+ * Copies existing entries from the original ZIP file to the new ZIP file.
+ *
+ * @param zipFile The original ZIP file.
+ * @param zipOutputStream The output stream of the new ZIP file.
+ */
+ private fun copyExistingEntries(zipFile: ZipFile, zipOutputStream: ZipOutputStream) {
+ for (entry in zipFile.entries()) {
+ zipOutputStream.putNextEntry(ZipEntry(entry.name))
+ zipFile.getInputStream(entry).use { input ->
+ input.copyTo(zipOutputStream)
+ }
+ zipOutputStream.closeEntry()
+ }
+ }
+
+ /**
+ * Adds the AndroidManifest.xml to the ZIP file.
+ *
+ * @param zipOutputStream The output stream of the ZIP file.
+ * @param manifestFile The manifest file to be added.
+ */
+ private fun addManifestToZip(zipOutputStream: ZipOutputStream, manifestFile: File) {
+ zipOutputStream.putNextEntry(ZipEntry("AndroidManifest.xml"))
+ manifestFile.inputStream().use { input ->
+ input.copyTo(zipOutputStream)
+ }
+ zipOutputStream.closeEntry()
+ }
+
+ /**
+ * Replaces the original temporary file with the new one.
+ *
+ * @param tempOutputFile The new temporary file to replace the original.
+ * @param tempFile The original temporary file to be replaced.
+ */
+ private fun replaceOriginalTempFile(tempOutputFile: File, tempFile: File) {
+ tempOutputFile.renameTo(tempFile)
+ }
+}
\ No newline at end of file