From 463ed05340b6387a82fc51a8acd72d7759f516c4 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sat, 16 May 2026 12:20:55 +0500 Subject: [PATCH] android: add auto-updater to the launcher --- android/.gitignore | 3 +- android/app/build.gradle.kts | 15 ++ android/app/src/debug/res/values/strings.xml | 1 + android/app/src/main/AndroidManifest.xml | 12 +- .../main/java/su/xash/engine/MainActivity.kt | 109 +++++++++- .../java/su/xash/engine/model/AppUpdater.kt | 205 ++++++++++++++++++ .../main/java/su/xash/engine/model/Game.kt | 50 +---- .../su/xash/engine/model/GameLibDownloader.kt | 14 +- .../engine/model/InstallStatusReceiver.kt | 42 ++++ .../engine/ui/settings/CrashLogsFragment.kt | 3 +- .../java/su/xash/engine/util/CrashReports.kt | 17 -- .../su/xash/engine/util/DownloadProgress.kt | 69 ++++++ .../java/su/xash/engine/util/MonospaceText.kt | 19 ++ .../app/src/main/res/values-ru/strings.xml | 14 +- android/app/src/main/res/values/strings.xml | 14 +- .../res/xml/{provider_paths.xml => paths.xml} | 0 16 files changed, 518 insertions(+), 69 deletions(-) create mode 100644 android/app/src/main/java/su/xash/engine/model/AppUpdater.kt create mode 100644 android/app/src/main/java/su/xash/engine/model/InstallStatusReceiver.kt create mode 100644 android/app/src/main/java/su/xash/engine/util/DownloadProgress.kt create mode 100644 android/app/src/main/java/su/xash/engine/util/MonospaceText.kt rename android/app/src/main/res/xml/{provider_paths.xml => paths.xml} (100%) diff --git a/android/.gitignore b/android/.gitignore index ee4ba493..bd916d7f 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,4 +11,5 @@ local.properties release/ *.hprof .vscode/ -*.bak \ No newline at end of file +*.bak +.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e56f2d7..972bc309 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,6 +20,8 @@ extensions.configure { minSdk = 21 targetSdk = 35 + buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") + externalNativeBuild { val engineRoot = projectDir.parentFile.parent @@ -58,6 +60,15 @@ extensions.configure { buildConfig = true } + signingConfigs { + create("androidDebugKey") { + storeFile = File(projectDir.parentFile, "debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + lint { abortOnError = false } @@ -91,6 +102,7 @@ extensions.configure { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("boolean", "ENABLE_AUTO_UPDATE", "false") } release { @@ -99,6 +111,7 @@ extensions.configure { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("boolean", "ENABLE_AUTO_UPDATE", "false") } register("asan") { @@ -108,6 +121,8 @@ extensions.configure { register("continuous") { initWith(getByName("release")) applicationIdSuffix = ".test" + buildConfigField("boolean", "ENABLE_AUTO_UPDATE", "true") + signingConfig = signingConfigs.getByName("androidDebugKey") } } } diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml index b3ef5b7f..bec892b4 100644 --- a/android/app/src/debug/res/values/strings.xml +++ b/android/app/src/debug/res/values/strings.xml @@ -1,4 +1,5 @@ Xash3D FWGS (Test) + su.xash.engine.test.documents diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 547b6819..8aac1b58 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,8 @@ + + + android:resource="@xml/paths" /> + + + + + + = info.buildNum) + return@launch + val changelog = updater.fetchChangelog(BuildConfig.GIT_HASH, info.tagName) + showEngineUpdateDialog(updater, info.buildNum, changelog, prefs) + } + } + + private fun showEngineUpdateDialog( + updater: AppUpdater, + remoteBuildNum: Int, + changelog: List?, + prefs: android.content.SharedPreferences, + ) { + val builder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.engine_update_available) + .setMessage(getString(R.string.engine_update_message, remoteBuildNum)) + .setPositiveButton(R.string.engine_update_download) { _, _ -> + showEngineDownloadDialog(updater) + } + .setNegativeButton(R.string.engine_update_later) { _, _ -> + prefs.edit().putInt(KEY_DISMISSED_BUILDNUM, remoteBuildNum).apply() + } + + if (!changelog.isNullOrEmpty()) { + val text = buildString { + append(getString(R.string.engine_update_changelog_header)) + val shown = changelog.take(CHANGELOG_MAX_LINES) + for (c in shown) + append("\n• ").append(c.subject) + val extra = changelog.size - shown.size + if (extra > 0) + append("\n").append(getString(R.string.engine_update_changelog_more, extra)) + } + builder.setView(monospaceTextView(this, text)) + } + + builder.show() + } + + private fun showEngineDownloadDialog(updater: AppUpdater) { + if (!updater.canInstall()) { + promptForInstallPermission() + return + } + showDownloadProgressDialog( + ctx = this, + titleRes = R.string.engine_update_downloading, + cancelable = true, + scope = lifecycleScope, + download = { onProgress -> updater.downloadAndInstall(onProgress) }, + ) + } + + private fun promptForInstallPermission() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.engine_update_permission_needed) + .setMessage(R.string.engine_update_permission_message) + .setPositiveButton(R.string.engine_update_open_settings) { _, _ -> + val packageIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + "package:$packageName".toUri()) + try { + startActivity(packageIntent) + } catch (_: ActivityNotFoundException) { + try { + startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)) + } catch (_: ActivityNotFoundException) { + // no settings screen — nothing more we can do + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() } override fun onSupportNavigateUp(): Boolean { @@ -58,7 +157,7 @@ class MainActivity : AppCompatActivity() { val entry = CrashReports.Entry(entryDir) AlertDialog.Builder(this) .setTitle(R.string.crash_dialog_title) - .setView(CrashReports.buildContentView(this, entry.summary())) + .setView(monospaceTextView(this, entry.summary())) .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(this, entry) } .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(this, entry) } .setNegativeButton(R.string.crash_dismiss, null) @@ -75,4 +174,12 @@ class MainActivity : AppCompatActivity() { dst.writeText(src.readText()) src.delete() } + + companion object { + private const val CHANGELOG_MAX_LINES = 15 + private const val UPDATE_PREFS = "app_updater" + private const val KEY_LAST_CHECK = "last_check_ms" + private const val KEY_DISMISSED_BUILDNUM = "dismissed_buildnum" + private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L + } } diff --git a/android/app/src/main/java/su/xash/engine/model/AppUpdater.kt b/android/app/src/main/java/su/xash/engine/model/AppUpdater.kt new file mode 100644 index 00000000..b49921a8 --- /dev/null +++ b/android/app/src/main/java/su/xash/engine/model/AppUpdater.kt @@ -0,0 +1,205 @@ +package su.xash.engine.model + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject +import su.xash.engine.BuildConfig +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import kotlin.coroutines.coroutineContext + +class AppUpdater(private val context: Context) { + + data class UpdateInfo(val buildNum: Int, val tagName: String) + data class CommitInfo(val sha: String, val subject: String) + + fun canInstall(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + context.packageManager.canRequestPackageInstalls() + + suspend fun checkForUpdate(): UpdateInfo? { + if (!BuildConfig.ENABLE_AUTO_UPDATE) return null + return withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + connection = URL(RELEASE_API_URL).openConnection() as HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.setRequestProperty("Accept", "application/vnd.github+json") + connection.connect() + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Release API check failed: HTTP ${connection.responseCode}") + return@withContext null + } + + val release = JSONObject(connection.inputStream.bufferedReader().readText()) + val body = release.optString("body", "") + val tagName = release.optString("tag_name").ifEmpty { TAG_CONTINUOUS } + + // buildnum is days-since-2015-04-01, same metric as VERSION_CODE / 10000 + val remote = BUILDNUM_REGEX.find(body)?.groupValues?.get(1)?.toIntOrNull() + val localDays = BuildConfig.VERSION_CODE / 10000 + Log.i(TAG, "Remote buildnum: $remote (tag=$tagName), local: $localDays") + + if (remote != null && remote - localDays >= STALENESS_DAYS) + UpdateInfo(remote, tagName) + else + null + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + Log.w(TAG, "Update check failed: ${e.message}") + null + } catch (e: JSONException) { + Log.w(TAG, "Update check parse failed: ${e.message}") + null + } finally { + connection?.disconnect() + } + } + } + + suspend fun fetchChangelog(fromRef: String, toRef: String): List? { + if (fromRef.isEmpty() || toRef.isEmpty()) + return null + return withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + connection = URL("$COMPARE_API_BASE/$fromRef...$toRef").openConnection() as HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.setRequestProperty("Accept", "application/vnd.github+json") + connection.connect() + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Compare API failed: HTTP ${connection.responseCode}") + return@withContext null + } + + val json = JSONObject(connection.inputStream.bufferedReader().readText()) + val commits = json.optJSONArray("commits") ?: return@withContext null + val result = ArrayList(commits.length()) + for (i in 0 until commits.length()) { + val c = commits.getJSONObject(i) + val sha = c.optString("sha").ifEmpty { continue } + val msg = c.optJSONObject("commit")?.optString("message") ?: continue + val subject = msg.substringBefore('\n').trim() + if (subject.isNotEmpty()) + result.add(CommitInfo(sha, subject)) + } + // GitHub returns oldest first + result.reverse() + result + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + Log.w(TAG, "Changelog fetch failed: ${e.message}") + null + } catch (e: JSONException) { + Log.w(TAG, "Changelog parse failed: ${e.message}") + null + } finally { + connection?.disconnect() + } + } + } + + suspend fun downloadAndInstall(onProgress: (Long, Long) -> Unit): Result { + return withContext(Dispatchers.IO) { + val tempFile = File(context.cacheDir, "xash3d-fwgs-update.apk") + var connection: HttpURLConnection? = null + try { + connection = URL(APK_URL).openConnection() as HttpURLConnection + connection.connectTimeout = 10000 + connection.readTimeout = 30000 + connection.instanceFollowRedirects = true + connection.connect() + + if (connection.responseCode != HttpURLConnection.HTTP_OK) + return@withContext Result.failure(IOException("HTTP ${connection.responseCode}")) + + val total = connection.contentLengthLong + var downloaded = 0L + var lastEmit = 0L + + connection.inputStream.use { input -> + FileOutputStream(tempFile).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 + val now = System.currentTimeMillis() + if (now - lastEmit >= PROGRESS_INTERVAL_MS) { + lastEmit = now + withContext(Dispatchers.Main) { onProgress(downloaded, total) } + } + } + } + } + withContext(Dispatchers.Main) { onProgress(downloaded, total) } + + Log.i(TAG, "Downloaded APK: ${tempFile.length()} bytes -> ${tempFile.absolutePath}") + + triggerInstall(tempFile) + Result.success(Unit) + } catch (e: CancellationException) { + tempFile.delete() + throw e + } catch (e: IOException) { + tempFile.delete() + Result.failure(e) + } finally { + connection?.disconnect() + } + } + } + + private fun triggerInstall(apk: File) { + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val sessionId = installer.createSession(params) + installer.openSession(sessionId).use { session -> + session.openWrite("base.apk", 0, apk.length()).use { out -> + apk.inputStream().use { it.copyTo(out) } + session.fsync(out) + } + val statusIntent = Intent(INSTALL_ACTION).setPackage(context.packageName) + val piFlags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + val pi = PendingIntent.getBroadcast(context, sessionId, statusIntent, piFlags) + session.commit(pi.intentSender) + } + } + + companion object { + private const val TAG = "AppUpdater" + private const val STALENESS_DAYS = 3 + private const val PROGRESS_INTERVAL_MS = 100L + private const val TAG_CONTINUOUS = "continuous" + private const val INSTALL_ACTION = "su.xash.engine.INSTALL_RESULT" + private const val APK_URL = + "https://github.com/FWGS/xash3d-fwgs/releases/download/continuous/xash3d-fwgs-android.apk" + private const val RELEASE_API_URL = + "https://api.github.com/repos/FWGS/xash3d-fwgs/releases/tags/continuous" + private const val COMPARE_API_BASE = + "https://api.github.com/repos/FWGS/xash3d-fwgs/compare" + private val BUILDNUM_REGEX = Regex("""buildnum\s+(\d+)""") + } +} 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 a24365e4..91f83ae8 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,17 +6,15 @@ 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 su.xash.engine.util.showDownloadProgressDialog import java.io.File import java.io.FileInputStream @@ -139,44 +137,14 @@ class Game(val ctx: Context, val basedir: File, val gameInfoFile: File) { } 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() } + showDownloadProgressDialog( + ctx = ctx, + titleRes = R.string.downloading_game_libs, + cancelable = true, + scope = CoroutineScope(Dispatchers.Main + SupervisorJob()), + download = { onProgress -> downloader.download(basedir.name, onProgress) }, + onSuccess = { launchEngine(ctx, commandLineArgs) }, + ) } private fun showManifestErrorDialog(ctx: Context, commandLineArgs: String, cause: Throwable) { 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 index 952dd945..a25e0959 100644 --- a/android/app/src/main/java/su/xash/engine/model/GameLibDownloader.kt +++ b/android/app/src/main/java/su/xash/engine/model/GameLibDownloader.kt @@ -199,7 +199,7 @@ class GameLibDownloader(private val context: Context) { private suspend fun tryDownload( url: String, dest: File, - onProgress: (Float) -> Unit + onProgress: (Long, Long) -> Unit ): Exception? { var connection: HttpURLConnection? = null try { @@ -215,6 +215,7 @@ class GameLibDownloader(private val context: Context) { val total = connection.contentLengthLong var downloaded = 0L + var lastEmit = 0L connection.inputStream.use { input -> FileOutputStream(dest).use { output -> @@ -226,13 +227,15 @@ class GameLibDownloader(private val context: Context) { break output.write(buffer, 0, read) downloaded += read - if (total > 0) { - val progress = downloaded.toFloat() / total - withContext(Dispatchers.Main) { onProgress(progress) } + val now = System.currentTimeMillis() + if (now - lastEmit >= PROGRESS_INTERVAL_MS) { + lastEmit = now + withContext(Dispatchers.Main) { onProgress(downloaded, total) } } } } } + withContext(Dispatchers.Main) { onProgress(downloaded, total) } return null } catch (e: Exception) { @@ -242,7 +245,7 @@ class GameLibDownloader(private val context: Context) { } } - suspend fun download(gamedir: String, onProgress: (Float) -> Unit): Result { + suspend fun download(gamedir: String, onProgress: (Long, Long) -> Unit): Result { return withContext(Dispatchers.IO) { val manifest = fetchManifest() ?: return@withContext Result.failure(IOException("Failed to fetch manifest")) @@ -364,6 +367,7 @@ class GameLibDownloader(private val context: Context) { companion object { private const val TAG = "GameLibDownloader" + private const val PROGRESS_INTERVAL_MS = 100L private const val RELEASE_BASE_URL = "https://github.com/FWGS/hlsdk-mega-build/releases/download/continuous" private const val MANIFEST_URL = diff --git a/android/app/src/main/java/su/xash/engine/model/InstallStatusReceiver.kt b/android/app/src/main/java/su/xash/engine/model/InstallStatusReceiver.kt new file mode 100644 index 00000000..a50cfdcd --- /dev/null +++ b/android/app/src/main/java/su/xash/engine/model/InstallStatusReceiver.kt @@ -0,0 +1,42 @@ +package su.xash.engine.model + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.util.Log +import android.widget.Toast +import su.xash.engine.R + +class InstallStatusReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirm: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + confirm?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + confirm?.let { context.startActivity(it) } + } + PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Install succeeded") + else -> { + val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE).orEmpty() + Log.w(TAG, "Install status $status: $msg") + Toast.makeText( + context, + context.getString(R.string.engine_install_failed, msg), + Toast.LENGTH_LONG, + ).show() + } + } + } + + companion object { + private const val TAG = "InstallStatus" + } +} diff --git a/android/app/src/main/java/su/xash/engine/ui/settings/CrashLogsFragment.kt b/android/app/src/main/java/su/xash/engine/ui/settings/CrashLogsFragment.kt index 88f4fe70..5621bef6 100644 --- a/android/app/src/main/java/su/xash/engine/ui/settings/CrashLogsFragment.kt +++ b/android/app/src/main/java/su/xash/engine/ui/settings/CrashLogsFragment.kt @@ -6,6 +6,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import su.xash.engine.R import su.xash.engine.util.CrashReports +import su.xash.engine.util.monospaceTextView import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -55,7 +56,7 @@ class CrashLogsFragment : PreferenceFragmentCompat() { val ctx = requireContext() AlertDialog.Builder(ctx) .setTitle(entry.name) - .setView(CrashReports.buildContentView(ctx, entry.summary())) + .setView(monospaceTextView(ctx, entry.summary())) .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(ctx, entry) } .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(ctx, entry) } .setNegativeButton(R.string.crash_log_delete) { _, _ -> diff --git a/android/app/src/main/java/su/xash/engine/util/CrashReports.kt b/android/app/src/main/java/su/xash/engine/util/CrashReports.kt index 4e8894b0..90e9856b 100644 --- a/android/app/src/main/java/su/xash/engine/util/CrashReports.kt +++ b/android/app/src/main/java/su/xash/engine/util/CrashReports.kt @@ -2,13 +2,8 @@ package su.xash.engine.util import android.content.Context import android.content.Intent -import android.graphics.Typeface import android.net.Uri import android.os.Build -import android.util.TypedValue -import android.view.View -import android.widget.ScrollView -import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.FileProvider import su.xash.engine.BuildConfig @@ -164,18 +159,6 @@ object CrashReports { ctx.startActivity(chooser) } - fun buildContentView(ctx: Context, content: String): View { - val pad = (16 * ctx.resources.displayMetrics.density).toInt() - val text = TextView(ctx).apply { - text = content - typeface = Typeface.MONOSPACE - setTextIsSelectable(true) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - setPadding(pad, pad, pad, pad) - } - return ScrollView(ctx).apply { addView(text) } - } - fun share(ctx: Context, entry: Entry) { val uri = zipUri(ctx, entry) val intent = Intent(Intent.ACTION_SEND).apply { diff --git a/android/app/src/main/java/su/xash/engine/util/DownloadProgress.kt b/android/app/src/main/java/su/xash/engine/util/DownloadProgress.kt new file mode 100644 index 00000000..3467a20f --- /dev/null +++ b/android/app/src/main/java/su/xash/engine/util/DownloadProgress.kt @@ -0,0 +1,69 @@ +package su.xash.engine.util + +import android.content.Context +import android.text.format.Formatter +import android.view.LayoutInflater +import android.widget.TextView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import su.xash.engine.R + +fun showDownloadProgressDialog( + ctx: Context, + titleRes: Int, + cancelable: Boolean, + scope: CoroutineScope, + download: suspend ((Long, Long) -> Unit) -> Result, + onSuccess: (() -> Unit)? = null, +) { + 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(titleRes) + .setView(view) + .setCancelable(cancelable) + .apply { + if (cancelable) + setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + } + .create() + + dialog.show() + + val job = scope.launch { + val result = download { downloaded, total -> + val downloadedStr = Formatter.formatShortFileSize(ctx, downloaded) + if (total > 0) { + progressBar.isIndeterminate = false + progressBar.progress = (downloaded * 100 / total).toInt() + val totalStr = Formatter.formatShortFileSize(ctx, total) + statusText.text = ctx.getString(R.string.download_progress, downloadedStr, totalStr) + } else { + statusText.text = ctx.getString(R.string.download_progress_unknown, downloadedStr) + } + } + + if (!dialog.isShowing) + return@launch + + dialog.dismiss() + + if (result.isSuccess) { + onSuccess?.invoke() + } 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() + } + } + + if (cancelable) + dialog.setOnDismissListener { job.cancel() } +} diff --git a/android/app/src/main/java/su/xash/engine/util/MonospaceText.kt b/android/app/src/main/java/su/xash/engine/util/MonospaceText.kt new file mode 100644 index 00000000..91fb6337 --- /dev/null +++ b/android/app/src/main/java/su/xash/engine/util/MonospaceText.kt @@ -0,0 +1,19 @@ +package su.xash.engine.util + +import android.content.Context +import android.graphics.Typeface +import android.util.TypedValue +import android.view.View +import android.widget.ScrollView +import android.widget.TextView + +fun monospaceTextView(ctx: Context, content: String): View { + val pad = (16 * ctx.resources.displayMetrics.density).toInt() + val text = TextView(ctx).apply { + text = content + typeface = Typeface.MONOSPACE + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setPadding(pad, pad, pad, pad) + } + return ScrollView(ctx).apply { addView(text) } +} diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 5cfeb2b8..fb22545f 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -35,7 +35,8 @@ На этом устройстве нет почтового клиента. Установите его (или воспользуйтесь кнопкой «Поделиться», чтобы отправить отчёт другим способом) и попробуйте снова. Загрузка игровых библиотек Загрузка… - Загрузка… %1$d%% + Загрузка… %1$s / %2$s + Загрузка… %1$s Ошибка загрузки Произошла ошибка при загрузке игровых библиотек. Доступно обновление @@ -49,4 +50,15 @@ Ветка Коммит Загружено + Доступно обновление движка + Доступна сборка %1$d. Обновить? + Изменения с вашей сборки: + + ещё %1$d коммитов + Обновить + Позже + Загрузка обновления + Нужно разрешение + Чтобы установить обновление, разрешите Xash3D FWGS устанавливать неизвестные приложения в настройках системы. + Открыть настройки + Не удалось установить обновление: %1$s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index dd5be181..f75f3205 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -37,7 +37,8 @@ No email client is installed on this device. Install one (or use Share to send the report another way) and try again. Downloading Game Libraries Downloading… - Downloading… %1$d%% + Downloading… %1$s / %2$s + Downloading… %1$s Download Failed An error occurred while downloading game libraries. Update Available @@ -51,4 +52,15 @@ Branch Commit Downloaded + Engine Update Available + Build %1$d is available. Update now? + Changes since your build: + + %1$d more commits + Update + Later + Downloading Update + Permission Required + To install the update, allow Xash3D FWGS to install unknown apps in system settings. + Open settings + Update install failed: %1$s diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/paths.xml similarity index 100% rename from android/app/src/main/res/xml/provider_paths.xml rename to android/app/src/main/res/xml/paths.xml