Skip to content

Commit

Permalink
fix(provider-ui): fix dynamic resource loading for android marshmallo…
Browse files Browse the repository at this point in the history
…w and below.

Holy sh- this took me a day to find the fix. Heck u AOSP!

Anyway, I might post DynamicResourceLoader as a gist.
  • Loading branch information
rhenwinch committed Aug 28, 2024
1 parent 282397f commit e360649
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 16 deletions.
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />

<uses-feature
android:name="android.hardware.touchscreen"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ fun Context.hasAllPermissionGranted(): Boolean {
requiredPermissions.add(Manifest.permission.POST_NOTIFICATIONS)
}

for (permission in requiredPermissions){
if (Build.VERSION.SDK_INT >= 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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -77,6 +77,8 @@ class ProviderManager @Inject constructor(
* */
private val updaterJsonMap = HashMap<String, List<ProviderData>>()

private val dynamicResourceLoader = DynamicResourceLoader(context = context)

val workingApis = snapshotFlow {
providerDataList
.mapNotNull { data ->
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AssetManager*>(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 = """
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
""".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)
}
}

0 comments on commit e360649

Please sign in to comment.