From 3c07680263322b76cbb57489936932a09ddf7db5 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sun, 10 May 2026 00:32:29 +0500 Subject: [PATCH] android: pack crash log to ZIP file, put system and intent info alongside it. Properly handle if multple email clients are installed on device (tested with gmail, outlook and k-9). --- android/app/src/main/AndroidManifest.xml | 4 + .../main/java/su/xash/engine/MainActivity.kt | 32 ++-- .../java/su/xash/engine/XashActivity.java | 43 ++++++ .../engine/ui/settings/CrashLogsFragment.kt | 29 ++-- .../java/su/xash/engine/util/CrashReports.kt | 139 ++++++++++++++++-- .../app/src/main/res/values-ru/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/xml/provider_paths.xml | 2 +- 8 files changed, 214 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f8f0515a..547b6819 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -95,5 +95,9 @@ + + + + diff --git a/android/app/src/main/java/su/xash/engine/MainActivity.kt b/android/app/src/main/java/su/xash/engine/MainActivity.kt index cac47a6c..3acd4cfd 100644 --- a/android/app/src/main/java/su/xash/engine/MainActivity.kt +++ b/android/app/src/main/java/su/xash/engine/MainActivity.kt @@ -43,26 +43,36 @@ class MainActivity : AppCompatActivity() { } private fun showPendingCrashReport() { - val pending = CrashReports.pendingFile(this) + val pending = CrashReports.pendingStacktrace(this) if (!pending.exists() || pending.length() == 0L) return val historyDir = CrashReports.historyDir(this).apply { mkdirs() } val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val archived = File(historyDir, "crash-$ts.log") - if (!pending.renameTo(archived)) { - // fall back to in-place read if move failed; still consume the file - archived.writeText(pending.readText()) - pending.delete() - } + val entryDir = File(historyDir, "crash-$ts").apply { mkdirs() } - val content = archived.readText() + moveOrCopy(pending, File(entryDir, CrashReports.STACKTRACE_NAME)) + moveOrCopy(CrashReports.pendingSysinfo(this), File(entryDir, CrashReports.SYSINFO_NAME)) + moveOrCopy(CrashReports.pendingIntent(this), File(entryDir, CrashReports.INTENT_NAME)) + + val entry = CrashReports.Entry(entryDir) AlertDialog.Builder(this) .setTitle(R.string.crash_dialog_title) - .setView(CrashReports.buildContentView(this, content)) - .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(this, content) } - .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(this, archived) } + .setView(CrashReports.buildContentView(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) .show() } + + private fun moveOrCopy(src: File, dst: File) { + if (!src.exists()) + return + + if (src.renameTo(dst)) + return + + dst.writeText(src.readText()) + src.delete() + } } 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 a35f3e9d..6c7145b9 100644 --- a/android/app/src/main/java/su/xash/engine/XashActivity.java +++ b/android/app/src/main/java/su/xash/engine/XashActivity.java @@ -1,6 +1,7 @@ package su.xash.engine; import android.annotation.SuppressLint; +import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.res.AssetManager; import android.os.Build; @@ -14,6 +15,7 @@ import android.view.WindowManager; import org.libsdl.app.SDLActivity; import su.xash.engine.util.AndroidBug5497Workaround; +import su.xash.engine.util.CrashReports; import java.io.File; import java.util.Arrays; @@ -121,6 +123,45 @@ public class XashActivity extends SDLActivity { return getWindow().superDispatchKeyEvent(event); } + private static void appendStringExtra(StringBuilder sb, Intent intent, String key) { + String value = intent.getStringExtra(key); + if (value != null) + sb.append(" ").append(key).append(" = ").append(value).append('\n'); + } + + // record intent info, so that it could be consumed later for crash reporting + private void recordLaunchInfo() { + // do not overwrite current launch info with pending crash log, shouldn't happen but might + File pendingCrash = new File(getFilesDir(), "crashes/" + CrashReports.STACKTRACE_NAME); + if (pendingCrash.exists() && pendingCrash.length() > 0) + return; + + // write Android version, fingerprint, supported abis, etc + CrashReports.writeSystemInfo(this); + + // now create intent info and pass it to crash reporting + Intent intent = getIntent(); + if (intent == null) + return; + StringBuilder sb = new StringBuilder(); + sb.append("Action: ").append(intent.getAction()).append('\n'); + sb.append("Data: ").append(intent.getDataString()).append('\n'); + sb.append("Calling package: ").append(getCallingPackage()).append('\n'); + sb.append("Extras:\n"); + // only write intent extras that we care about + appendStringExtra(sb, intent, "gamedir"); + appendStringExtra(sb, intent, "gamelibdir"); + appendStringExtra(sb, intent, "pakfile"); + appendStringExtra(sb, intent, "basedir"); + appendStringExtra(sb, intent, "package"); + appendStringExtra(sb, intent, "argv"); + sb.append(" usevolume = ").append(intent.getBooleanExtra("usevolume", false)).append('\n'); + String[] env = intent.getStringArrayExtra("env"); + if (env != null) + sb.append(" env = ").append(Arrays.toString(env)).append('\n'); + CrashReports.writeIntentInfo(this, sb.toString()); + } + // TODO: REMOVE LATER, temporary launchers support? @Override protected String[] getArguments() { @@ -128,6 +169,8 @@ public class XashActivity extends SDLActivity { crashDir.mkdirs(); nativeSetenv("XASH3D_CRASH_DIR", crashDir.getAbsolutePath()); + recordLaunchInfo(); + String gamedir = getIntent().getStringExtra("gamedir"); if (gamedir == null) gamedir = "valve"; nativeSetenv("XASH3D_GAME", gamedir); 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 b1163903..88f4fe70 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,7 +6,6 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import su.xash.engine.R import su.xash.engine.util.CrashReports -import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -26,9 +25,12 @@ class CrashLogsFragment : PreferenceFragmentCompat() { val ctx = requireContext() preferenceScreen.removeAll() - val files = CrashReports.historyDir(ctx).listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList() + val dirs = CrashReports.historyDir(ctx).listFiles() + ?.filter { it.isDirectory } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() - if (files.isEmpty()) { + if (dirs.isEmpty()) { preferenceScreen.addPreference(Preference(ctx).apply { setTitle(R.string.crash_logs_empty) isSelectable = false @@ -37,28 +39,27 @@ class CrashLogsFragment : PreferenceFragmentCompat() { } val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) - files.forEach { file -> + dirs.forEach { dir -> preferenceScreen.addPreference(Preference(ctx).apply { - title = fmt.format(Date(file.lastModified())) - summary = file.name + title = fmt.format(Date(dir.lastModified())) + summary = dir.name setOnPreferenceClickListener { - showCrashLog(file) + showCrashLog(CrashReports.Entry(dir)) true } }) } } - private fun showCrashLog(file: File) { + private fun showCrashLog(entry: CrashReports.Entry) { val ctx = requireContext() - val content = file.readText() AlertDialog.Builder(ctx) - .setTitle(file.name) - .setView(CrashReports.buildContentView(ctx, content)) - .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(ctx, content) } - .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(ctx, file) } + .setTitle(entry.name) + .setView(CrashReports.buildContentView(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) { _, _ -> - file.delete() + entry.dir.deleteRecursively() populate() } .show() 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 ed37ab8f..4e8894b0 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 @@ -4,14 +4,20 @@ 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 import su.xash.engine.R +import java.io.BufferedOutputStream import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream object CrashReports { private const val PREFS = "crash_reports" @@ -20,7 +26,37 @@ object CrashReports { private const val D = "9c8d9e8c97bf9988988cd1989e86" - fun pendingFile(ctx: Context): File = File(ctx.filesDir, "crashes/crash.log") + const val STACKTRACE_NAME = "crash.log" + const val SYSINFO_NAME = "sysinfo.txt" + const val INTENT_NAME = "intent.txt" + + class Entry(val dir: File) { + val name: String get() = dir.name + val timestamp: Long get() = dir.lastModified() + val stacktrace: File get() = File(dir, STACKTRACE_NAME) + val sysinfo: File get() = File(dir, SYSINFO_NAME) + val intent: File get() = File(dir, INTENT_NAME) + + fun attachments(): List = listOf(stacktrace, sysinfo, intent).filter { it.exists() && it.length() > 0 } + + fun summary(): String = buildString { + if (stacktrace.exists()) + append(stacktrace.readText()) + + if (sysinfo.exists()) { + append("\n--- System info ---\n").append(sysinfo.readText()) + } + + if (intent.exists()) { + append("\n--- XashActivity intent ---\n").append(intent.readText()) + } + } + } + + fun pendingDir(ctx: Context): File = File(ctx.filesDir, "crashes") + fun pendingStacktrace(ctx: Context): File = File(pendingDir(ctx), STACKTRACE_NAME) + fun pendingSysinfo(ctx: Context): File = File(pendingDir(ctx), SYSINFO_NAME) + fun pendingIntent(ctx: Context): File = File(pendingDir(ctx), INTENT_NAME) fun historyDir(ctx: Context): File = File(ctx.filesDir, "crashes/history") // wipe everything on app update; otherwise drop logs older than 30 days @@ -30,28 +66,102 @@ object CrashReports { val currentVersion = BuildConfig.VERSION_CODE if (lastVersion != currentVersion) { - historyDir(ctx).listFiles()?.forEach { it.delete() } - pendingFile(ctx).delete() + historyDir(ctx).deleteRecursively() + pendingStacktrace(ctx).delete() + pendingSysinfo(ctx).delete() + pendingIntent(ctx).delete() prefs.edit().putInt(KEY_LAST_VERSION, currentVersion).apply() return } val cutoff = System.currentTimeMillis() - MAX_AGE_MS - historyDir(ctx).listFiles()?.forEach { f -> - if (f.lastModified() < cutoff) f.delete() + historyDir(ctx).listFiles()?.forEach { entry -> + if (entry.lastModified() < cutoff) + entry.deleteRecursively() } } - fun sendByEmail(ctx: Context, content: String) { + @JvmStatic + fun writeSystemInfo(ctx: Context) { + val text = buildString { + append("App version: ").append(BuildConfig.VERSION_NAME).append(" (code ").append(BuildConfig.VERSION_CODE).append(")\n") + append("Application ID: ").append(BuildConfig.APPLICATION_ID).append('\n') + append("Android: ").append(Build.VERSION.RELEASE).append(" (SDK ").append(Build.VERSION.SDK_INT).append(")\n") + append("Manufacturer: ").append(Build.MANUFACTURER).append('\n') + append("Brand: ").append(Build.BRAND).append('\n') + append("Model: ").append(Build.MODEL).append('\n') + append("Device: ").append(Build.DEVICE).append('\n') + append("Product: ").append(Build.PRODUCT).append('\n') + append("Hardware: ").append(Build.HARDWARE).append('\n') + append("Fingerprint: ").append(Build.FINGERPRINT).append('\n') + append("Supported ABIs: ").append(Build.SUPPORTED_ABIS.joinToString(", ")).append('\n') + } + + runCatching { + pendingDir(ctx).mkdirs() + pendingSysinfo(ctx).writeText(text) + } + } + + @JvmStatic + fun writeIntentInfo(ctx: Context, text: String) { + runCatching { + pendingDir(ctx).mkdirs() + pendingIntent(ctx).writeText(text) + } + } + + private fun zipUri(ctx: Context, entry: Entry): Uri { + val zipDir = File(ctx.cacheDir, "crashes").apply { mkdirs() } + val zip = File(zipDir, "${entry.name}.zip") + ZipOutputStream(BufferedOutputStream(FileOutputStream(zip))).use { zos -> + entry.attachments().forEach { f -> + zos.putNextEntry(ZipEntry(f.name)) + f.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + } + } + val authority = "${BuildConfig.APPLICATION_ID}.fileprovider" + return FileProvider.getUriForFile(ctx, authority, zip) + } + + fun sendByEmail(ctx: Context, entry: Entry) { val addr = D.chunked(2) { (it.toString().toInt(16) xor 0xFF).toChar() }.joinToString("") - val intent = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.fromParts("mailto", addr, null) + + val mailtoProbe = Intent(Intent.ACTION_SENDTO, Uri.fromParts("mailto", addr, null)) + val mailApps = ctx.packageManager.queryIntentActivities(mailtoProbe, 0) + if (mailApps.isEmpty()) { + AlertDialog.Builder(ctx) + .setTitle(R.string.crash_no_mail_app_title) + .setMessage(R.string.crash_no_mail_app_message) + .setPositiveButton(android.R.string.ok, null) + .show() + return + } + + val uri = zipUri(ctx, entry) + val baseSend = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_EMAIL, arrayOf(addr)) putExtra(Intent.EXTRA_SUBJECT, ctx.getString(R.string.crash_email_subject)) - putExtra(Intent.EXTRA_TEXT, content) + putExtra(Intent.EXTRA_TEXT, ctx.getString(R.string.crash_email_body)) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - if (intent.resolveActivity(ctx.packageManager) != null) { - ctx.startActivity(intent) + + val targeted = mailApps.map { ri -> + Intent(baseSend).apply { setPackage(ri.activityInfo.packageName) } } + + if (targeted.size == 1) { + ctx.startActivity(targeted[0]) + return + } + + // Multiple mail apps — show a chooser limited to them, no other share targets + val chooser = Intent.createChooser(targeted[0], ctx.getString(R.string.crash_send_to_developers)) + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, targeted.drop(1).toTypedArray()) + ctx.startActivity(chooser) } fun buildContentView(ctx: Context, content: String): View { @@ -66,11 +176,10 @@ object CrashReports { return ScrollView(ctx).apply { addView(text) } } - fun share(ctx: Context, file: File) { - val authority = "${BuildConfig.APPLICATION_ID}.fileprovider" - val uri = FileProvider.getUriForFile(ctx, authority, file) + fun share(ctx: Context, entry: Entry) { + val uri = zipUri(ctx, entry) val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" + type = "application/zip" putExtra(Intent.EXTRA_SUBJECT, ctx.getString(R.string.crash_email_subject)) putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index bdaaf7a9..5cfeb2b8 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -30,6 +30,9 @@ Журналы сбоев Нет сохранённых журналов сбоев Удалить + К письму прикреплены подробности сбоя. Опишите, пожалуйста, как можно подробнее, что вы делали, когда игра вылетела — любая мелочь поможет нам исправить ошибку! + Почтовое приложение не найдено + На этом устройстве нет почтового клиента. Установите его (или воспользуйтесь кнопкой «Поделиться», чтобы отправить отчёт другим способом) и попробуйте снова. Загрузка игровых библиотек Загрузка… Загрузка… %1$d%% diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 928e6179..dd5be181 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -32,6 +32,9 @@ No crash logs recorded Delete Xash3D FWGS crash report + Crash details are attached. Please describe in as much detail as possible what you were doing when the game crashed — every little thing helps us fix it! + No mail app found + 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%% diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml index c6a2b50b..19ee4816 100644 --- a/android/app/src/main/res/xml/provider_paths.xml +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -1,4 +1,4 @@ - +