mirror of
https://github.com/FWGS/xash3d-fwgs.git
synced 2026-08-05 03:24:56 +08:00
android: implement game library downloading
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<LinearProgressIndicator>(R.id.downloadProgress)
|
||||
val statusText = view.findViewById<TextView>(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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<JSONObject?, Throwable?> {
|
||||
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<Unit> {
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
22
android/app/src/main/res/layout/dialog_download_progress.xml
Normal file
22
android/app/src/main/res/layout/dialog_download_progress.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/downloadStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/downloading" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/downloadProgress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="true" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -30,4 +30,16 @@
|
||||
<string name="crash_logs">Журналы сбоев</string>
|
||||
<string name="crash_logs_empty">Нет сохранённых журналов сбоев</string>
|
||||
<string name="crash_log_delete">Удалить</string>
|
||||
<string name="downloading_game_libs">Загрузка игровых библиотек</string>
|
||||
<string name="downloading">Загрузка…</string>
|
||||
<string name="download_progress">Загрузка… %1$d%%</string>
|
||||
<string name="download_failed">Ошибка загрузки</string>
|
||||
<string name="download_error">Произошла ошибка при загрузке игровых библиотек.</string>
|
||||
<string name="update_available">Доступно обновление</string>
|
||||
<string name="update_available_message">Доступна более новая версия игровых библиотек. Загрузить обновление?</string>
|
||||
<string name="update_download">Обновить</string>
|
||||
<string name="update_skip">Пропустить</string>
|
||||
<string name="manifest_error_title">Не удалось связаться с сервером игровых библиотек</string>
|
||||
<string name="manifest_error_message">Не удалось загрузить манифест сборок:\n\n%1$s\n\nИгровые библиотеки сейчас недоступны для загрузки. Можно запустить движок и без них, но он, скорее всего, не найдёт игровые DLL.</string>
|
||||
<string name="launch_anyway">Запустить всё равно</string>
|
||||
</resources>
|
||||
|
||||
@@ -32,4 +32,16 @@
|
||||
<string name="crash_logs_empty">No crash logs recorded</string>
|
||||
<string name="crash_log_delete">Delete</string>
|
||||
<string name="crash_email_subject" translatable="false">Xash3D FWGS crash report</string>
|
||||
<string name="downloading_game_libs">Downloading Game Libraries</string>
|
||||
<string name="downloading">Downloading…</string>
|
||||
<string name="download_progress">Downloading… %1$d%%</string>
|
||||
<string name="download_failed">Download Failed</string>
|
||||
<string name="download_error">An error occurred while downloading game libraries.</string>
|
||||
<string name="update_available">Update Available</string>
|
||||
<string name="update_available_message">A newer version of the game libraries is available. Download the update?</string>
|
||||
<string name="update_download">Update</string>
|
||||
<string name="update_skip">Skip</string>
|
||||
<string name="manifest_error_title">Cannot reach game library server</string>
|
||||
<string name="manifest_error_message">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.</string>
|
||||
<string name="launch_anyway">Launch anyway</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user