diff --git a/android/app/src/main/java/su/xash/engine/XashActivity.java b/android/app/src/main/java/su/xash/engine/XashActivity.java index df39d7f8..a35f3e9d 100644 --- a/android/app/src/main/java/su/xash/engine/XashActivity.java +++ b/android/app/src/main/java/su/xash/engine/XashActivity.java @@ -135,6 +135,14 @@ public class XashActivity extends SDLActivity { String gamelibdir = getIntent().getStringExtra("gamelibdir"); if (gamelibdir != null) nativeSetenv("XASH3D_GAMELIBDIR", gamelibdir); + String rodir = System.getenv("XASH3D_RODIR"); + if (rodir == null) { + // FIXME: we are using rodir as a supplier for downloaded game libraries + rodir = getFilesDir().getAbsolutePath() + "/gamelibs"; + nativeSetenv("XASH3D_RODIR", rodir); + } + Log.i(TAG, "XASH3D_RODIR = " + rodir); + String pakfile = getIntent().getStringExtra("pakfile"); if (pakfile != null) nativeSetenv("XASH3D_EXTRAS_PAK2", pakfile); diff --git a/android/app/src/main/java/su/xash/engine/model/Game.kt b/android/app/src/main/java/su/xash/engine/model/Game.kt index 9e763eee..a24365e4 100644 --- a/android/app/src/main/java/su/xash/engine/model/Game.kt +++ b/android/app/src/main/java/su/xash/engine/model/Game.kt @@ -6,14 +6,19 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.view.LayoutInflater +import android.widget.TextView import androidx.core.net.toUri import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import su.xash.engine.R import su.xash.engine.XashActivity import java.io.File import java.io.FileInputStream -import java.util.Arrays -import android.util.Log class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { private var iconName = "game.ico" @@ -52,7 +57,7 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { fun startEngine(ctx: Context) { val packageNames = getPackageNamesForGameDir(basedir.name) var externalGame = false - var commandLineArgs = ""; + var commandLineArgs = "" if (basedir.name != defaultGameDir) commandLineArgs += "-game ${basedir.name} " @@ -68,17 +73,18 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { } } - commandLineArgs += pref.getString("arguments", "-console -log") + commandLineArgs += pref.getString("arguments", "-console -log") ?: "" - var packageName: String? = null - var gameLibDir: String? = null if (externalGame && packageNames != null) { + var packageName: String? = null + var gameLibDir: String? = null + for (pn in packageNames) { gameLibDir = try { getGameLibDir(ctx, pn) - } catch(e: PackageManager.NameNotFoundException) { + } catch (e: PackageManager.NameNotFoundException) { null - } catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() null } @@ -99,12 +105,95 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { ctx.startActivity(intent) } show() - - return } + return + } + + launchEngine(ctx, commandLineArgs, packageName = packageName, gameLibDir = gameLibDir) + return + } + + if (packageNames == null) { + // Unknown game — try to use downloaded libraries from hlsdk-mega-build + val downloader = GameLibDownloader(ctx) + val args = commandLineArgs + + if (downloader.isDownloaded(basedir.name)) { + downloader.logExistingLibs(basedir.name) + launchEngine(ctx, args) + return + } + + val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + scope.launch { + when (val r = downloader.lookupBuild(basedir.name)) { + is GameLibDownloader.Lookup.Available -> showDownloadDialog(ctx, downloader, args) + is GameLibDownloader.Lookup.NotInManifest -> launchEngine(ctx, args) + is GameLibDownloader.Lookup.Error -> showManifestErrorDialog(ctx, args, r.cause) + } + } + return + } + + launchEngine(ctx, commandLineArgs) + } + + private fun showDownloadDialog(ctx: Context, downloader: GameLibDownloader, commandLineArgs: String) { + val view = LayoutInflater.from(ctx).inflate(R.layout.dialog_download_progress, null) + val progressBar = view.findViewById(R.id.downloadProgress) + val statusText = view.findViewById(R.id.downloadStatus) + + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.downloading_game_libs) + .setView(view) + .setCancelable(true) + .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + .create() + + dialog.show() + + val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + val job = scope.launch { + val result = downloader.download(basedir.name) { progress -> + progressBar.isIndeterminate = false + progressBar.progress = (progress * 100).toInt() + statusText.text = ctx.getString(R.string.download_progress, (progress * 100).toInt()) + } + + if (!dialog.isShowing) return@launch + + dialog.dismiss() + + if (result.isSuccess) { + launchEngine(ctx, commandLineArgs) + } else { + MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.download_failed) + .setMessage(result.exceptionOrNull()?.message + ?: ctx.getString(R.string.download_error)) + .setPositiveButton(android.R.string.ok, null) + .show() } } + dialog.setOnDismissListener { job.cancel() } + } + + private fun showManifestErrorDialog(ctx: Context, commandLineArgs: String, cause: Throwable) { + MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.manifest_error_title) + .setMessage(ctx.getString(R.string.manifest_error_message, cause.message ?: cause.javaClass.simpleName)) + .setPositiveButton(R.string.launch_anyway) { _, _ -> launchEngine(ctx, commandLineArgs) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun launchEngine( + ctx: Context, + commandLineArgs: String, + packageName: String? = null, + gameLibDir: String? = null + ) { ctx.startActivity(Intent(ctx, XashActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -113,13 +202,8 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { putExtra("usevolume", pref.getBoolean("use_volume_buttons", false)) putExtra("basedir", basedir.parent) - if (gameLibDir != null) { - putExtra("gamelibdir", gameLibDir) - } - - if (packageName != null) { - putExtra("package", packageName) - } + if (gameLibDir != null) putExtra("gamelibdir", gameLibDir) + if (packageName != null) putExtra("package", packageName) }) } @@ -152,9 +236,6 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { if (mobileHacksGames.any { it.equals(gamedir, ignoreCase = true) }) return arrayOf("su.xash.engine") - // return if (mDbEntry != null) { - // mDbEntry.getPackageName() - // } else null return null } diff --git a/android/app/src/main/java/su/xash/engine/model/GameLibDownloader.kt b/android/app/src/main/java/su/xash/engine/model/GameLibDownloader.kt new file mode 100644 index 00000000..720e3bbb --- /dev/null +++ b/android/app/src/main/java/su/xash/engine/model/GameLibDownloader.kt @@ -0,0 +1,347 @@ +package su.xash.engine.model + +import android.content.Context +import android.os.Build +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.zip.ZipInputStream +import kotlin.coroutines.coroutineContext + +class GameLibDownloader(private val context: Context) { + + private val prefs get() = context.getSharedPreferences("gamelib_metadata", Context.MODE_PRIVATE) + + // converts Android's ABI to library-suffix's arch names + fun getArch(): String? { + for (abi in Build.SUPPORTED_ABIS) { + when (abi) { + "arm64-v8a" -> return "arm64" + "armeabi-v7a" -> return "armv7l" + "x86_64" -> return "amd64" + "x86" -> return "i386" + } + } + return null + } + + fun getLibsBaseDir(): File = File(context.filesDir, "gamelibs") + + fun isDownloaded(gamedir: String): Boolean { + val dir = File(getLibsBaseDir(), gamedir) + if (dir.isDirectory && dir.list()?.isNotEmpty() == true) + return true + + // zips extract to the case used in the manifest (e.g. "CAd"); the user's + // folder name might differ in case, so do a case-insensitive lookup too. + val base = getLibsBaseDir() + val match = base.listFiles()?.firstOrNull { + it.isDirectory && it.name.equals(gamedir, ignoreCase = true) + } + return match != null && (match.list()?.isNotEmpty() == true) + } + + data class ManifestEntry( + val modKey: String, + val platformArch: String, + val filename: String, + val sha256: String + ) + + private fun manifestCacheFile(): File = File(context.cacheDir, "hlsdk-manifest.json") + + private suspend fun fetchManifest(): JSONObject? = fetchManifestOrError().first + + private suspend fun fetchManifestOrError(): Pair { + val cacheFile = manifestCacheFile() + var connection: HttpURLConnection? = null + try { + connection = URL(MANIFEST_URL).openConnection() as HttpURLConnection + connection.connectTimeout = 10000 + connection.readTimeout = 15000 + connection.instanceFollowRedirects = true + connection.connect() + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + val err = IOException("HTTP ${connection.responseCode} fetching manifest") + Log.w(TAG, "Manifest fetch failed: ${err.message}") + val cached = readCachedManifest(cacheFile) + return if (cached != null) cached to null else null to err + } + + val text = connection.inputStream.bufferedReader().use { it.readText() } + val json = JSONObject(text) + val versionError = checkManifestVersion(json) + if (versionError != null) { + Log.w(TAG, "Manifest rejected: ${versionError.message}") + return null to versionError + } + cacheFile.writeText(text) + return json to null + } catch (e: Exception) { + Log.w(TAG, "Manifest fetch failed: ${e.message}") + val cached = readCachedManifest(cacheFile) + return if (cached != null) cached to null else null to e + } finally { + connection?.disconnect() + } + } + + private fun readCachedManifest(cacheFile: File): JSONObject? { + if (!cacheFile.exists()) + return null + val json = try { + JSONObject(cacheFile.readText()) + } catch (_: Exception) { + return null + } + return if (checkManifestVersion(json) == null) json else null + } + + private fun checkManifestVersion(json: JSONObject): IOException? { + if (!json.has("version")) + return IOException("Manifest missing 'version' field") + val v = json.opt("version") + if (v !is Int || v != MANIFEST_VERSION) + return IOException("Unsupported manifest version: $v (expected $MANIFEST_VERSION)") + return null + } + + private fun lookupEntry(manifest: JSONObject, gamedir: String): ManifestEntry? { + val arch = getArch() ?: return null + val platformArch = "android-$arch" + val mods = manifest.optJSONObject("mods") ?: return null + + // Find mod key case-insensitively + var modKey: String? = null + val keys = mods.keys() + while (keys.hasNext()) { + val k = keys.next() + if (k.equals(gamedir, ignoreCase = true)) { + modKey = k + break + } + } + if (modKey == null) + return null + + val builds = mods.optJSONObject(modKey)?.optJSONObject("builds") ?: return null + val build = builds.optJSONObject(platformArch) ?: return null + val filename = build.optString("filename", "") + val sha256 = build.optString("sha256", "") + if (filename.isEmpty() || sha256.isEmpty()) + return null + + return ManifestEntry(modKey, platformArch, filename, sha256.lowercase()) + } + + sealed class Lookup { + data class Available(val entry: ManifestEntry) : Lookup() + object NotInManifest : Lookup() + data class Error(val cause: Throwable) : Lookup() + } + + suspend fun lookupBuild(gamedir: String): Lookup { + return withContext(Dispatchers.IO) { + val (manifest, error) = fetchManifestOrError() + if (manifest == null) + return@withContext Lookup.Error(error ?: IOException("Failed to fetch manifest")) + val entry = lookupEntry(manifest, gamedir) + ?: return@withContext Lookup.NotInManifest + Lookup.Available(entry) + } + } + + suspend fun isUpdateAvailable(gamedir: String): Boolean { + return withContext(Dispatchers.IO) { + val manifest = fetchManifest() ?: return@withContext false + val entry = lookupEntry(manifest, gamedir) ?: return@withContext false + val storedSha = prefs.getString("${gamedir}_sha256", null) + storedSha == null || !storedSha.equals(entry.sha256, ignoreCase = true) + } + } + + private fun urlForFilename(filename: String): String = "$RELEASE_BASE_URL/$filename" + + private suspend fun tryDownload( + url: String, + dest: File, + onProgress: (Float) -> Unit + ): Exception? { + var connection: HttpURLConnection? = null + try { + connection = URL(url).openConnection() as HttpURLConnection + connection.connectTimeout = 10000 + connection.readTimeout = 30000 + connection.instanceFollowRedirects = true + connection.connect() + + val code = connection.responseCode + if (code !in 200..299) + return IOException("HTTP $code: $url") + + val total = connection.contentLengthLong + var downloaded = 0L + + connection.inputStream.use { input -> + FileOutputStream(dest).use { output -> + val buffer = ByteArray(65536) + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read < 0) + break + output.write(buffer, 0, read) + downloaded += read + if (total > 0) { + val progress = downloaded.toFloat() / total + withContext(Dispatchers.Main) { onProgress(progress) } + } + } + } + } + + return null + } catch (e: Exception) { + return e + } finally { + connection?.disconnect() + } + } + + suspend fun download(gamedir: String, onProgress: (Float) -> Unit): Result { + return withContext(Dispatchers.IO) { + val manifest = fetchManifest() + ?: return@withContext Result.failure(IOException("Failed to fetch manifest")) + + val entry = lookupEntry(manifest, gamedir) + ?: return@withContext Result.failure(IOException("No build for '$gamedir' on ${getArch() ?: "this arch"}")) + + val url = urlForFilename(entry.filename) + val tempFile = File(context.cacheDir, entry.filename) + + try { + coroutineContext.ensureActive() + Log.i(TAG, "Downloading $url -> ${tempFile.absolutePath}") + val error = tryDownload(url, tempFile, onProgress) + if (error != null) + return@withContext Result.failure(error) + + val actualSha = computeSHA256(tempFile) + Log.i(TAG, "Downloaded ${tempFile.length()} bytes, SHA-256: $actualSha (expected ${entry.sha256})") + + if (!actualSha.equals(entry.sha256, ignoreCase = true)) + return@withContext Result.failure(IOException("SHA-256 mismatch: expected ${entry.sha256}, got $actualSha")) + + val destDir = getLibsBaseDir() + destDir.mkdirs() + val destCanon = destDir.canonicalPath + + // Remove any prior install (possibly under a differently-cased dir) so we + // don't leave behind .so files from an older build. + destDir.listFiles()?.forEach { + if (it.isDirectory && it.name.equals(entry.modKey, ignoreCase = true)) + it.deleteRecursively() + } + + Log.i(TAG, "Extracting to ${destDir.absolutePath}") + + val extractedFiles = mutableListOf() + ZipInputStream(tempFile.inputStream()).use { zip -> + var zipEntry = zip.nextEntry + while (zipEntry != null) { + coroutineContext.ensureActive() + if (!zipEntry.isDirectory) { + val outFile = File(destDir, zipEntry.name) + if (!outFile.canonicalPath.startsWith(destCanon + File.separator)) + throw SecurityException("Zip path traversal: ${zipEntry.name}") + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { zip.copyTo(it) } + // ZipInputStream doesn't restore Unix permissions; shared libraries + // need the execute bit so dlopen can mmap them with PROT_EXEC + if (outFile.name.endsWith(".so")) + outFile.setExecutable(true, false) + extractedFiles.add(" ${outFile.absolutePath} (${outFile.length()} bytes, executable: ${outFile.canExecute()})") + } + zip.closeEntry() + zipEntry = zip.nextEntry + } + } + + Log.i(TAG, "Extracted ${extractedFiles.size} files:") + extractedFiles.forEach { Log.i(TAG, it) } + + prefs.edit() + .putString("${gamedir}_sha256", entry.sha256) + .putString("${gamedir}_filename", entry.filename) + .putLong("${gamedir}_download_time", System.currentTimeMillis()) + .apply() + + Result.success(Unit) + } catch (e: Exception) { + // Best-effort: rewinding to a clean state on failure + File(getLibsBaseDir(), entry.modKey).deleteRecursively() + Result.failure(e) + } finally { + tempFile.delete() + } + } + } + + fun logExistingLibs(gamedir: String) { + val gameDir = File(getLibsBaseDir(), gamedir) + val resolved = if (gameDir.exists()) { + gameDir + } else { + getLibsBaseDir().listFiles()?.firstOrNull { + it.isDirectory && it.name.equals(gamedir, ignoreCase = true) + } + } + + if (resolved == null) { + Log.i(TAG, "Game libs dir does not exist for $gamedir") + return + } + + Log.i(TAG, "Existing game libs under ${resolved.absolutePath}:") + resolved.walkTopDown().filter { it.isFile }.forEach { + Log.i(TAG, " ${it.absolutePath} (${it.length()} bytes)") + } + val storedSha = prefs.getString("${gamedir}_sha256", null) + val downloadTime = prefs.getLong("${gamedir}_download_time", 0L) + Log.i(TAG, "Archive SHA-256: $storedSha") + if (downloadTime > 0L) + Log.i(TAG, "Downloaded at: ${java.util.Date(downloadTime)}") + } + + private fun computeSHA256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(65536) + while (true) { + val read = input.read(buffer) + if (read < 0) + break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + companion object { + private const val TAG = "GameLibDownloader" + private const val RELEASE_BASE_URL = + "https://github.com/FWGS/hlsdk-mega-build/releases/download/continuous" + private const val MANIFEST_URL = + "$RELEASE_BASE_URL/manifest.json" + private const val MANIFEST_VERSION = 1 + } +} diff --git a/android/app/src/main/res/layout/dialog_download_progress.xml b/android/app/src/main/res/layout/dialog_download_progress.xml new file mode 100644 index 00000000..3ecadb82 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_download_progress.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index c240818d..dd9f2ff9 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -30,4 +30,16 @@ Журналы сбоев Нет сохранённых журналов сбоев Удалить + Загрузка игровых библиотек + Загрузка… + Загрузка… %1$d%% + Ошибка загрузки + Произошла ошибка при загрузке игровых библиотек. + Доступно обновление + Доступна более новая версия игровых библиотек. Загрузить обновление? + Обновить + Пропустить + Не удалось связаться с сервером игровых библиотек + Не удалось загрузить манифест сборок:\n\n%1$s\n\nИгровые библиотеки сейчас недоступны для загрузки. Можно запустить движок и без них, но он, скорее всего, не найдёт игровые DLL. + Запустить всё равно diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3d82752d..66fb97ca 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -32,4 +32,16 @@ No crash logs recorded Delete Xash3D FWGS crash report + Downloading Game Libraries + Downloading… + Downloading… %1$d%% + Download Failed + An error occurred while downloading game libraries. + Update Available + A newer version of the game libraries is available. Download the update? + Update + Skip + Cannot reach game library server + Failed to download the build manifest:\n\n%1$s\n\nGame libraries cannot be downloaded right now. You can launch the engine anyway, but it likely won\'t find the game DLLs. + Launch anyway