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