diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f1b5f39..740758f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -32,7 +32,7 @@ android {
minSdk = 28
targetSdk = 35
versionCode = ciBuildNumber
- versionName = "1.1.1-ci.$ciBuildNumber"
+ versionName = "1.1.2-ci.$ciBuildNumber.for-maliang"
ndk {
abiFilters += "arm64-v8a"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f36dbc1..50bb0e3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,8 +29,10 @@
-
+
+
+
@@ -90,10 +92,10 @@
android:name="com.bepinex.android.StubActivity"
android:taskAffinity="${applicationId}.game"
android:theme="@style/UnityThemeSelector"
- android:screenOrientation="landscape"
android:process=":game"
android:launchMode="standard"
- android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale|layoutDirection|density"
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
+ android:resizeableActivity="false"
android:hardwareAccelerated="true"
android:exported="false" />
diff --git a/app/src/main/cpp/fusion/src/fusion.cpp b/app/src/main/cpp/fusion/src/fusion.cpp
index 4c0c76e..c876926 100644
--- a/app/src/main/cpp/fusion/src/fusion.cpp
+++ b/app/src/main/cpp/fusion/src/fusion.cpp
@@ -137,8 +137,11 @@ static bool stage_fusion_config(const FusionConfig &config)
patchedIl2CppPath.c_str(),
1024 * 1024); // 1 MB code cave, load AFTER libunity
if (!poolHandle) {
- LOGE("allocate_setup_injected_noload failed — falling back to original il2cpp path");
- il2cppPath = gameIl2cppPath;
+ // Loading an unpatched image can leave Dobby branches pointing into a
+ // non-writable ELF and later abort inside il2cpp. Stop here with a
+ // diagnostic instead of continuing in an unsafe mixed state.
+ LOGE("allocate_setup_injected_noload failed — refusing unsafe il2cpp fallback");
+ return false;
} else {
il2cppPath = patchedIl2CppPath;
LOGI("Patched il2cpp ELF written (load deferred until after libunity)");
@@ -198,7 +201,10 @@ static int il2cpp_init_hook(char *domain_name)
/* 4. Start CoreCLR + BepInEx */
__android_log_write(ANDROID_LOG_ERROR, "FusionB", "calling dotnet_start_runtime...");
- dotnet_start_runtime();
+ if (!dotnet_start_runtime()) {
+ __android_log_write(ANDROID_LOG_ERROR, "FusionB", "dotnet_start_runtime FAILED");
+ return result;
+ }
__android_log_write(ANDROID_LOG_ERROR, "FusionB", "hook returning");
return result;
diff --git a/app/src/main/java/com/bepinex/android/BepInExPaths.kt b/app/src/main/java/com/bepinex/android/BepInExPaths.kt
index f223e3d..8470be2 100644
--- a/app/src/main/java/com/bepinex/android/BepInExPaths.kt
+++ b/app/src/main/java/com/bepinex/android/BepInExPaths.kt
@@ -93,7 +93,12 @@ object BepInExPaths {
fun isBepInExExtracted(packageName: String): Boolean =
File(getCoreDir(packageName), "BepInEx.Core.dll").exists()
- /** Check if dotnet runtime has been extracted for a game */
+ /**
+ * Check if the managed .NET runtime has been extracted for a game.
+ * Native CoreCLR (`libcoreclr.so` and siblings) ships in jniLibs and is
+ * loaded next to libmain.so, so it is not part of this directory.
+ */
fun isDotnetExtracted(filesDir: File, packageName: String): Boolean =
- File(getDotnetDir(filesDir, packageName), "System.Private.CoreLib.dll").exists()
+ File(getDotnetDir(filesDir, packageName), ".launcher-dotnet-ready").isFile &&
+ File(getDotnetDir(filesDir, packageName), "System.Private.CoreLib.dll").isFile
}
diff --git a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt
index b8c19e2..d9f11c1 100644
--- a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt
+++ b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt
@@ -222,6 +222,7 @@ class BootstrapActivity : Activity() {
// 7. Start the registered stub via InstrumentationHooks
updateProgress(getString(R.string.bootstrap_status_launching), "", 95)
try {
+ ensureLaunchDisplay()
val launcherClass = gameContext.classLoader.loadClass(launcherClassName)
BepInExLog.i("Starting game launcher: ${launcherClass.name}")
initializeFusion(null, null)
@@ -247,6 +248,22 @@ class BootstrapActivity : Activity() {
}
}
+ /**
+ * ActivityThread crashes deep inside ContextImpl when a task is launched
+ * with an invalid display id (common in clones, secondary users and OEM
+ * split-screen implementations). Fail before Unity is touched so the
+ * launcher can report a useful error and the process stays recoverable.
+ */
+ private fun ensureLaunchDisplay() {
+ val manager = getSystemService(Context.DISPLAY_SERVICE) as? android.hardware.display.DisplayManager
+ ?: throw IllegalStateException("Display service is unavailable")
+ val display = manager.getDisplay(android.view.Display.DEFAULT_DISPLAY)
+ ?: throw IllegalStateException(
+ "No default display is available. Exit split-screen/clone mode and retry."
+ )
+ BepInExLog.i("Launch display: id=${display.displayId}, state=${display.state}")
+ }
+
// Launcher onCreate hook
private fun installLauncherOnCreateHook(
diff --git a/app/src/main/java/com/bepinex/android/DebugCrashCollector.kt b/app/src/main/java/com/bepinex/android/DebugCrashCollector.kt
index 620d189..873498b 100644
--- a/app/src/main/java/com/bepinex/android/DebugCrashCollector.kt
+++ b/app/src/main/java/com/bepinex/android/DebugCrashCollector.kt
@@ -17,6 +17,7 @@ object DebugCrashCollector {
"com.LanPiaoPiao.PlantsVsZombiesRHMod"
)
private const val MAX_FILE_BYTES = 32L * 1024L * 1024L
+ private const val MAX_LOG_FILES = 64
fun collect(context: Context) {
try {
@@ -60,8 +61,9 @@ object DebugCrashCollector {
)
TARGET_PACKAGES.forEach { packageName ->
val gameRoot = BepInExPaths.getGameRootDir(packageName)
- gameRoot.walkTopDown()
+ gameRoot.walkTopDown().maxDepth(8)
.filter { it.isFile && it.length() <= MAX_FILE_BYTES && names.contains(it.name) }
+ .take(MAX_LOG_FILES)
.forEach { source ->
val relative = source.relativeTo(gameRoot).path.replace(File.separatorChar, '_')
copyIfPresent(source, File(snapshot, "${packageName}_$relative"))
diff --git a/app/src/main/java/com/bepinex/android/FileExtractor.kt b/app/src/main/java/com/bepinex/android/FileExtractor.kt
index 40a47c3..729b4ee 100644
--- a/app/src/main/java/com/bepinex/android/FileExtractor.kt
+++ b/app/src/main/java/com/bepinex/android/FileExtractor.kt
@@ -6,6 +6,7 @@ import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import java.security.MessageDigest
import java.util.UUID
+import java.io.IOException
/**
* Extracts BepInEx and .NET runtime from APK assets to per-game directories.
@@ -145,9 +146,14 @@ class FileExtractor(private val context: Context) {
*/
fun extractDotnetIfNeeded(packageName: String, onProgress: (String) -> Unit = {}) {
val dotnetDir = BepInExPaths.getDotnetDir(context.filesDir, packageName)
- val marker = File(dotnetDir, "System.Private.CoreLib.dll")
+ val marker = File(dotnetDir, ".launcher-dotnet-ready")
+ // Native CoreCLR is packaged in jniLibs; dotnet.zip contains the
+ // managed TPA assemblies only. Do not require native files inside the
+ // extracted directory or every valid installation would be rejected.
+ val required = listOf("System.Private.CoreLib.dll")
- if (marker.exists()) {
+ if (marker.isFile && required.all { File(dotnetDir, it).isFile } &&
+ dotnetDir.walkTopDown().count { it.isFile && it.extension.equals("dll", true) } >= 100) {
BepInExLog.i("dotnet already extracted for $packageName")
return
}
@@ -159,7 +165,23 @@ class FileExtractor(private val context: Context) {
onProgress("Extracting .NET runtime for $packageName...")
BepInExLog.i("Extracting dotnet.zip → ${dotnetDir.absolutePath}")
- extractZip("dotnet.zip", dotnetDir)
+ val staging = File(dotnetDir.parentFile, ".dotnet-${UUID.randomUUID()}")
+ try {
+ extractZip("dotnet.zip", staging)
+ check(required.all { File(staging, it).isFile } &&
+ staging.walkTopDown().count { it.isFile && it.extension.equals("dll", true) } >= 100) {
+ "Incomplete .NET runtime: missing ${required.filterNot { File(staging, it).isFile }}"
+ }
+ File(staging, ".launcher-dotnet-ready").writeText(
+ "version=1\nfiles=${staging.walkTopDown().count { it.isFile }}\n"
+ )
+ if (dotnetDir.exists() && !dotnetDir.deleteRecursively()) {
+ throw IOException("Cannot replace incomplete dotnet directory")
+ }
+ check(staging.renameTo(dotnetDir)) { "Cannot install extracted dotnet runtime" }
+ } finally {
+ if (staging.exists()) staging.deleteRecursively()
+ }
BepInExLog.i("dotnet extracted: ${dotnetDir.absolutePath}")
}
@@ -229,6 +251,10 @@ class FileExtractor(private val context: Context) {
while (entry != null) {
// Normalize path separators: Windows-created zips may use \ instead of /
val normalizedName = entry.name.replace('\\', '/')
+ require(normalizedName.isNotEmpty() && !normalizedName.startsWith("/") &&
+ normalizedName.split('/').none { it == ".." }) {
+ "Unsafe ZIP entry: ${entry.name}"
+ }
val outFile = File(destDir, normalizedName)
if (entry.isDirectory || normalizedName.endsWith("/")) {
if (!outFile.mkdirs() && !outFile.exists()) {
diff --git a/app/src/main/java/com/bepinex/android/GameDetector.kt b/app/src/main/java/com/bepinex/android/GameDetector.kt
index b0f36bf..c3a064e 100644
--- a/app/src/main/java/com/bepinex/android/GameDetector.kt
+++ b/app/src/main/java/com/bepinex/android/GameDetector.kt
@@ -19,12 +19,28 @@ object GameDetector {
private const val TAG = "GameDetector"
- /** Only packages in this list are shown as supported games. */
- private val SUPPORTED_PACKAGES = setOf(
+ /**
+ * Only packages in this list are shown as supported games.
+ * The same names are declared in the manifest `` block.
+ */
+ internal val SUPPORTED_PACKAGES = setOf(
"com.LanPiaoPiao.PlantsVsZombiesRH",
"com.LanPiaoPiao.PlantsVsZombiesRHMod"
)
+ /** True when Android 11+ package visibility already reveals a supported game. */
+ fun supportedPackageVisible(context: Context): Boolean {
+ val pm = context.packageManager
+ return SUPPORTED_PACKAGES.any { packageName ->
+ try {
+ pm.getApplicationInfo(packageName, 0)
+ true
+ } catch (_: PackageManager.NameNotFoundException) {
+ false
+ }
+ }
+ }
+
const val PVZ_LAUNCHER_ACTIVITY = "com.unity3d.player.UnityPlayerActivity"
/** ABI directories inside APKs (lib//libil2cpp.so). */
diff --git a/app/src/main/java/com/bepinex/android/InstalledAppsPermission.kt b/app/src/main/java/com/bepinex/android/InstalledAppsPermission.kt
new file mode 100644
index 0000000..c94f7bd
--- /dev/null
+++ b/app/src/main/java/com/bepinex/android/InstalledAppsPermission.kt
@@ -0,0 +1,187 @@
+package com.bepinex.android
+
+import android.app.Activity
+import android.app.AppOpsManager
+import android.content.ActivityNotFoundException
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.PermissionInfo
+import android.net.Uri
+import android.os.Build
+import android.os.Process
+import android.provider.Settings
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+
+/**
+ * Vendor "read installed apps" permission.
+ *
+ * Xiaomi exposes [PERMISSION] as a dangerous runtime permission. Honor MagicOS
+ * keeps the same name as an AppOps toggle and ignores [Activity.requestPermissions],
+ * so those devices open the system permission page instead.
+ */
+object InstalledAppsPermission {
+
+ const val PERMISSION = "com.android.permission.GET_INSTALLED_APPS"
+
+ private const val OP_GET_INSTALLED_APPS = 10022
+ private val OP_NAMES = arrayOf("android:get_installed_apps", "GET_INSTALLED_APPS")
+
+ enum class Request {
+ NotNeeded,
+ RuntimeDialog,
+ Settings
+ }
+
+ fun needsRequest(context: Context): Boolean = !isGranted(context)
+
+ fun isGranted(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return true
+ if (context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED) return true
+ if (isAppOpsAllowed(context)) return true
+ if (GameDetector.supportedPackageVisible(context)) return true
+ if (!isHonor() && permissionInfo(context) == null) return true
+ return false
+ }
+
+ fun request(
+ activity: Activity,
+ runtimeLauncher: ActivityResultLauncher,
+ settingsLauncher: ActivityResultLauncher,
+ allowSettings: Boolean
+ ): Request {
+ if (!needsRequest(activity)) return Request.NotNeeded
+ logStatus(activity)
+ if (canRequestRuntime(activity) && !isHonor()) {
+ try {
+ runtimeLauncher.launch(PERMISSION)
+ return Request.RuntimeDialog
+ } catch (error: Exception) {
+ BepInExLog.e("GET_INSTALLED_APPS requestPermissions failed", error)
+ }
+ }
+ if (!allowSettings) return Request.NotNeeded
+ return if (openSettings(activity, settingsLauncher)) Request.Settings else Request.NotNeeded
+ }
+
+ private fun openSettings(
+ activity: Activity,
+ settingsLauncher: ActivityResultLauncher
+ ): Boolean {
+ val candidates = listOf(
+ ComponentName(
+ "com.hihonor.permissionmanager",
+ "com.hihonor.permissionmanager.ui.SingleAppActivity"
+ ),
+ ComponentName(
+ "com.hihonor.systemmanager",
+ "com.hihonor.permissionmanager.ui.SingleAppActivity"
+ ),
+ ComponentName(
+ "com.huawei.systemmanager",
+ "com.huawei.permissionmanager.ui.SingleAppActivity"
+ )
+ )
+ for (component in candidates) {
+ val intent = Intent().apply {
+ this.component = component
+ putExtra("packageName", activity.packageName)
+ putExtra("packagename", activity.packageName)
+ }
+ try {
+ settingsLauncher.launch(intent)
+ BepInExLog.i("Opened app list settings: ${component.packageName}/${component.className}")
+ showSettingsHint(activity)
+ return true
+ } catch (error: ActivityNotFoundException) {
+ BepInExLog.i("App list settings activity missing: ${component.className}")
+ } catch (error: SecurityException) {
+ BepInExLog.w("App list settings activity blocked: ${component.className}: ${error.message}")
+ }
+ }
+
+ val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", activity.packageName, null)
+ }
+ return try {
+ settingsLauncher.launch(details)
+ BepInExLog.i("Opened app details for app list permission")
+ showSettingsHint(activity)
+ true
+ } catch (error: Exception) {
+ BepInExLog.e("Failed to open app details for app list permission", error)
+ false
+ }
+ }
+
+ private fun showSettingsHint(activity: Activity) {
+ Toast.makeText(
+ activity,
+ activity.getString(R.string.applist_permission_settings_hint),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ private fun canRequestRuntime(context: Context): Boolean {
+ val info = permissionInfo(context) ?: return false
+ return info.protection == PermissionInfo.PROTECTION_DANGEROUS
+ }
+
+ private fun permissionInfo(context: Context): PermissionInfo? {
+ return try {
+ context.packageManager.getPermissionInfo(PERMISSION, 0)
+ } catch (_: PackageManager.NameNotFoundException) {
+ null
+ }
+ }
+
+ private fun isHonor(): Boolean {
+ val manufacturer = Build.MANUFACTURER.lowercase()
+ val brand = Build.BRAND.lowercase()
+ return manufacturer.contains("honor") || brand.contains("honor")
+ }
+
+ private fun isAppOpsAllowed(context: Context): Boolean {
+ val mode = appOpsMode(context)
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+
+ private fun appOpsMode(context: Context): Int? {
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return null
+ val uid = Process.myUid()
+ val packageName = context.packageName
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ for (op in OP_NAMES) {
+ val mode = try {
+ appOps.unsafeCheckOpNoThrow(op, uid, packageName)
+ } catch (_: Exception) {
+ null
+ }
+ if (mode == AppOpsManager.MODE_ALLOWED || mode == AppOpsManager.MODE_IGNORED) return mode
+ }
+ }
+ return try {
+ val method = AppOpsManager::class.java.getMethod(
+ "checkOpNoThrow",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ String::class.java
+ )
+ method.invoke(appOps, OP_GET_INSTALLED_APPS, uid, packageName) as? Int
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun logStatus(context: Context) {
+ val protection = permissionInfo(context)?.protection
+ BepInExLog.i(
+ "App list permission: manufacturer=${Build.MANUFACTURER} brand=${Build.BRAND} " +
+ "protection=$protection honor=${isHonor()} " +
+ "self=${context.checkSelfPermission(PERMISSION)} appOps=${appOpsMode(context)} " +
+ "visible=${GameDetector.supportedPackageVisible(context)}"
+ )
+ }
+}
diff --git a/app/src/main/java/com/bepinex/android/MainActivity.kt b/app/src/main/java/com/bepinex/android/MainActivity.kt
index 50aad5a..474c0ff 100644
--- a/app/src/main/java/com/bepinex/android/MainActivity.kt
+++ b/app/src/main/java/com/bepinex/android/MainActivity.kt
@@ -2,7 +2,6 @@ package com.bepinex.android
import android.content.Context
import android.content.Intent
-import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
@@ -121,21 +120,15 @@ class MainActivity : ComponentActivity() {
private val installedAppsPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
- BepInExLog.i("GET_INSTALLED_APPS granted=$granted")
- appListPermissionGranted = granted
- val alsoStorage = requestStorageAfterAppList
- requestStorageAfterAppList = false
- GameDetector.invalidateCache()
- startGameDetection(askAppListPermission = false)
- if (alsoStorage && !storagePermissionGranted) {
- requestStoragePermission()
- }
+ BepInExLog.i("GET_INSTALLED_APPS callback granted=$granted")
+ onAppListPermissionFinished()
}
- companion object {
- private const val GET_INSTALLED_APPS_PERMISSION =
- "com.android.permission.GET_INSTALLED_APPS"
+ private val installedAppsSettingsLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { onAppListPermissionFinished() }
+ companion object {
/** Saved across activity recreations (e.g. language switch) */
private var savedPackageName: String? = null
private var savedPagerPage = 0
@@ -214,11 +207,25 @@ class MainActivity : ComponentActivity() {
private fun completeOnboarding() {
AppSettings.setOnboardingCompleted(this, true)
showOnboarding = false
- if (requestAppListPermission(thenRequestStorage = !storagePermissionGranted)) return
- if (!storagePermissionGranted) {
- checkStoragePermission(requestIfMissing = true)
- } else if (detectedGames.isEmpty() && !isScanning) {
- startGameDetection(askAppListPermission = false)
+ when (requestAppListPermission(
+ thenRequestStorage = !storagePermissionGranted,
+ allowSettings = true
+ )) {
+ InstalledAppsPermission.Request.RuntimeDialog -> return
+ InstalledAppsPermission.Request.Settings -> {
+ if (detectedGames.isEmpty() && !isScanning) {
+ startGameDetection(askAppListPermission = false)
+ }
+ return
+ }
+ InstalledAppsPermission.Request.NotNeeded -> {
+ requestStorageAfterAppList = false
+ if (!storagePermissionGranted) {
+ checkStoragePermission(requestIfMissing = true)
+ } else if (detectedGames.isEmpty() && !isScanning) {
+ startGameDetection(askAppListPermission = false)
+ }
+ }
}
}
@@ -337,16 +344,34 @@ class MainActivity : ComponentActivity() {
}
}
- private fun needsAppListPermission(): Boolean =
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
- checkSelfPermission(GET_INSTALLED_APPS_PERMISSION) != PackageManager.PERMISSION_GRANTED
+ private fun needsAppListPermission(): Boolean = InstalledAppsPermission.needsRequest(this)
- private fun requestAppListPermission(thenRequestStorage: Boolean = false): Boolean {
- if (!needsAppListPermission()) return false
+ private fun requestAppListPermission(
+ thenRequestStorage: Boolean = false,
+ allowSettings: Boolean = true
+ ): InstalledAppsPermission.Request {
+ if (!needsAppListPermission()) {
+ appListPermissionGranted = true
+ return InstalledAppsPermission.Request.NotNeeded
+ }
requestStorageAfterAppList = thenRequestStorage
- BepInExLog.i("Requesting GET_INSTALLED_APPS")
- installedAppsPermissionLauncher.launch(GET_INSTALLED_APPS_PERMISSION)
- return true
+ return InstalledAppsPermission.request(
+ activity = this,
+ runtimeLauncher = installedAppsPermissionLauncher,
+ settingsLauncher = installedAppsSettingsLauncher,
+ allowSettings = allowSettings
+ )
+ }
+
+ private fun onAppListPermissionFinished() {
+ appListPermissionGranted = !needsAppListPermission()
+ val alsoStorage = requestStorageAfterAppList
+ requestStorageAfterAppList = false
+ GameDetector.invalidateCache()
+ startGameDetection(askAppListPermission = false)
+ if (alsoStorage && !storagePermissionGranted) {
+ requestStoragePermission()
+ }
}
override fun onNewIntent(intent: Intent) {
@@ -368,7 +393,9 @@ class MainActivity : ComponentActivity() {
}
val permissionChanged = permissionNow != storagePermissionGranted
storagePermissionGranted = permissionNow
- appListPermissionGranted = !needsAppListPermission()
+ val appListNow = !needsAppListPermission()
+ val appListBecameGranted = appListNow && !appListPermissionGranted
+ appListPermissionGranted = appListNow
if (permissionChanged && permissionNow) {
startGameDetection(askAppListPermission = !showOnboarding)
}
@@ -388,6 +415,10 @@ class MainActivity : ComponentActivity() {
maybeReportGameCrash(game.packageName)
}
}
+ if (appListBecameGranted && !permissionChanged && !isScanning) {
+ GameDetector.invalidateCache()
+ startGameDetection(askAppListPermission = false)
+ }
selectedGame?.let { ensureFramework(it.packageName) }
}
@@ -443,7 +474,13 @@ class MainActivity : ComponentActivity() {
// Game detection
private fun startGameDetection(askAppListPermission: Boolean = true) {
- if (askAppListPermission && requestAppListPermission()) return
+ if (askAppListPermission) {
+ when (requestAppListPermission(allowSettings = false)) {
+ InstalledAppsPermission.Request.RuntimeDialog -> return
+ InstalledAppsPermission.Request.Settings,
+ InstalledAppsPermission.Request.NotNeeded -> Unit
+ }
+ }
scope.launch {
isScanning = true
diff --git a/app/src/main/java/com/bepinex/android/fusion/CustomContextWrapper.kt b/app/src/main/java/com/bepinex/android/fusion/CustomContextWrapper.kt
index 569a939..54c3145 100644
--- a/app/src/main/java/com/bepinex/android/fusion/CustomContextWrapper.kt
+++ b/app/src/main/java/com/bepinex/android/fusion/CustomContextWrapper.kt
@@ -3,6 +3,8 @@ package com.bepinex.android.fusion
import android.content.Context
import android.content.ContextWrapper
import android.content.SharedPreferences
+import android.app.Activity
+import android.hardware.display.DisplayManager
import android.os.Build
import android.view.Display
import java.io.File
@@ -11,7 +13,8 @@ import java.io.File
class CustomContextWrapper(
gameContext: Context,
private val filesContext: Context,
- private val windowContext: Context
+ private val windowContext: Context,
+ private val ownerActivity: Activity
) : ContextWrapper(gameContext) {
init {
applicationInfo.dataDir = filesContext.applicationInfo.dataDir
@@ -30,16 +33,28 @@ class CustomContextWrapper(
override fun getSystemService(name: String): Any? = windowContext.getSystemService(name)
override fun getDisplay(): Display? {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- val display = windowContext.display
- if (display != null) return display
- val activityDisplay = (getBaseContext() as? android.app.Activity)?.display
- if (activityDisplay != null) return activityDisplay
- val dm = windowContext.getSystemService(Context.DISPLAY_SERVICE) as? android.hardware.display.DisplayManager
- return dm?.getDisplay(android.view.Display.DEFAULT_DISPLAY)
+ // Activity.getDisplay() and ContextWrapper.getDisplay() both forward to
+ // the base context. attachBaseContext installs this wrapper as that base,
+ // so calling the activity or super here recurses until the stack overflows.
+ displayOf(windowContext)?.let { return it }
+ val manager = windowContext.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
+ return manager?.getDisplay(Display.DEFAULT_DISPLAY)
+ }
+
+ private fun displayOf(start: Context): Display? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
+ var current: Context? = start
+ val seen = HashSet()
+ while (current != null && seen.add(current)) {
+ if (current === this || current === ownerActivity) return null
+ if (current !is ContextWrapper) return current.display
+ val base = current.baseContext
+ if (base == null || base === current || base === this || base === ownerActivity) return null
+ current = base
}
return null
}
+
override fun getApplicationContext(): Context = filesContext.applicationContext
override fun getObbDir(): File? = filesContext.obbDir
override fun getObbDirs(): Array = filesContext.obbDirs
diff --git a/app/src/main/java/com/bepinex/android/fusion/GameContextWrapper.java b/app/src/main/java/com/bepinex/android/fusion/GameContextWrapper.java
index e5ce813..a986f84 100644
--- a/app/src/main/java/com/bepinex/android/fusion/GameContextWrapper.java
+++ b/app/src/main/java/com/bepinex/android/fusion/GameContextWrapper.java
@@ -4,7 +4,6 @@
import android.content.ContextWrapper;
import android.content.SharedPreferences;
import android.content.res.Resources;
-import android.os.Build;
import android.view.Display;
import androidx.annotation.Nullable;
@@ -90,10 +89,12 @@ public File getExternalFilesDir(String type) {
@Override
public Display getDisplay() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- return this.fusionContext.getDisplay();
- }
- return null;
+ Display display = this.fusionContext.getDisplay();
+ if (display != null) return display;
+ android.hardware.display.DisplayManager manager =
+ (android.hardware.display.DisplayManager) this.fusionContext
+ .getSystemService(Context.DISPLAY_SERVICE);
+ return manager != null ? manager.getDisplay(Display.DEFAULT_DISPLAY) : null;
}
@Override
diff --git a/app/src/main/java/com/bepinex/android/fusion/UnityPlayerHooks.kt b/app/src/main/java/com/bepinex/android/fusion/UnityPlayerHooks.kt
index 98ac1ff..bf8addf 100644
--- a/app/src/main/java/com/bepinex/android/fusion/UnityPlayerHooks.kt
+++ b/app/src/main/java/com/bepinex/android/fusion/UnityPlayerHooks.kt
@@ -2,6 +2,7 @@ package com.bepinex.android.fusion
import android.app.Activity
import android.content.Context
+import android.content.ContextWrapper
import android.graphics.Color
import android.os.Looper
import android.view.Gravity
@@ -233,9 +234,9 @@ object UnityPlayerHooks {
val activity = frame.thisObject as? Activity ?: return
if (activity.javaClass.classLoader !== gameClassLoader || frame.args.isEmpty()) return
val original = frame.args[0] as? Context ?: return
- if (original is CustomContextWrapper) return
+ if (wrapsCustomContext(original)) return
frame.args[0] = CustomContextWrapper(
- gameContext, fusionApplication.applicationContext, original
+ gameContext, fusionApplication.applicationContext, original, activity
)
BepInExLog.i("attachBaseContext wrapped ${activity.javaClass.name}")
}
@@ -264,6 +265,16 @@ object UnityPlayerHooks {
}
}
+ private fun wrapsCustomContext(context: Context): Boolean {
+ var current: Context? = context
+ val seen = HashSet()
+ while (current != null && seen.add(current)) {
+ if (current is CustomContextWrapper) return true
+ current = (current as? ContextWrapper)?.baseContext
+ }
+ return false
+ }
+
private fun showLoadingOverlay(activity: Activity, statusText: String): View? {
var overlay: View? = null
diff --git a/app/src/main/java/com/bepinex/android/market/MarketInstaller.kt b/app/src/main/java/com/bepinex/android/market/MarketInstaller.kt
index 026ebe5..ffa05bd 100644
--- a/app/src/main/java/com/bepinex/android/market/MarketInstaller.kt
+++ b/app/src/main/java/com/bepinex/android/market/MarketInstaller.kt
@@ -27,6 +27,7 @@ object MarketInstaller {
private const val WORK_DIR = "market-download"
private const val MAX_REDIRECTS = 8
private const val MAX_NESTED_ARCHIVES = 3
+ private const val MAX_SCAN_FILES = 10_000
data class Progress(
val phase: Phase,
@@ -113,7 +114,11 @@ object MarketInstaller {
private fun scanPreparedFiles(scanRoot: File, mod: MarketMod): PreparedKind {
if (!scanRoot.exists()) return PreparedKind.NoMatch
- val files = scanRoot.walkTopDown().filter { it.isFile }.toList()
+ val files = scanRoot.walkTopDown()
+ .maxDepth(8)
+ .filter { it.isFile }
+ .take(MAX_SCAN_FILES)
+ .toList()
if (files.any { it.name.equals("modpack.json", ignoreCase = true) }) {
return PreparedKind.Modpack(scanRoot)
}
diff --git a/app/src/main/java/com/bepinex/android/modpack/ModpackManager.kt b/app/src/main/java/com/bepinex/android/modpack/ModpackManager.kt
index f8ebf74..9018a25 100644
--- a/app/src/main/java/com/bepinex/android/modpack/ModpackManager.kt
+++ b/app/src/main/java/com/bepinex/android/modpack/ModpackManager.kt
@@ -112,6 +112,7 @@ class ModpackManager {
companion object {
const val MODPACK_EXTENSION = "rhp"
const val MODPACK_MIME_TYPE = "application/octet-stream"
+ private const val MAX_PLUGIN_FILES = 10_000
private val SUPPORTED_MODPACK_EXTENSIONS = setOf("rhp", "zip")
private const val MODS_KEY = "mods"
@@ -363,6 +364,7 @@ class ModpackManager {
// Android paths are case-sensitive and File.extension preserves
// the original case. Treat .dll/.DLL/.Dll as the same mod type.
.filter { it.isFile && it.extension.equals("dll", ignoreCase = true) }
+ .take(MAX_PLUGIN_FILES)
.toList()
} else {
emptyList()
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 9df8eda..9cb24a2 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -324,6 +324,7 @@
启动器需要读取已安装应用列表来发现融合版是否已安装。
授予应用列表权限
应用列表权限已授予
+ 请在该应用的权限里开启「读取应用列表」。
第一次启动请稍等
首次启动会准备运行环境,可能下载文件。保持网络畅通,不要切走应用。
崩溃后请分享日志
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a2caa0d..96fa295 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -340,6 +340,7 @@
The launcher also needs to read the installed app list to find Fusion.
Grant app list access
App list access is granted
+ In this app\'s permissions, allow reading the installed app list.
The first launch takes a moment
The first start prepares the runtime and may download files. Keep the network on and stay in the app.
Share logs after a crash
diff --git a/ci-version.txt b/ci-version.txt
index 0751c1b..33f752b 100644
--- a/ci-version.txt
+++ b/ci-version.txt
@@ -1 +1 @@
-572
+583