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).

This commit is contained in:
Alibek Omarov
2026-05-10 00:32:29 +05:00
parent e5d6f66f61
commit 3c07680263
8 changed files with 214 additions and 41 deletions

View File

@@ -95,5 +95,9 @@
<intent> <intent>
<action android:name="su.xash.engine.MOD" /> <action android:name="su.xash.engine.MOD" />
</intent> </intent>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -43,26 +43,36 @@ class MainActivity : AppCompatActivity() {
} }
private fun showPendingCrashReport() { private fun showPendingCrashReport() {
val pending = CrashReports.pendingFile(this) val pending = CrashReports.pendingStacktrace(this)
if (!pending.exists() || pending.length() == 0L) if (!pending.exists() || pending.length() == 0L)
return return
val historyDir = CrashReports.historyDir(this).apply { mkdirs() } val historyDir = CrashReports.historyDir(this).apply { mkdirs() }
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val archived = File(historyDir, "crash-$ts.log") val entryDir = File(historyDir, "crash-$ts").apply { mkdirs() }
if (!pending.renameTo(archived)) {
// fall back to in-place read if move failed; still consume the file
archived.writeText(pending.readText())
pending.delete()
}
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) AlertDialog.Builder(this)
.setTitle(R.string.crash_dialog_title) .setTitle(R.string.crash_dialog_title)
.setView(CrashReports.buildContentView(this, content)) .setView(CrashReports.buildContentView(this, entry.summary()))
.setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(this, content) } .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(this, entry) }
.setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(this, archived) } .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(this, entry) }
.setNegativeButton(R.string.crash_dismiss, null) .setNegativeButton(R.string.crash_dismiss, null)
.show() .show()
} }
private fun moveOrCopy(src: File, dst: File) {
if (!src.exists())
return
if (src.renameTo(dst))
return
dst.writeText(src.readText())
src.delete()
}
} }

View File

@@ -1,6 +1,7 @@
package su.xash.engine; package su.xash.engine;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.pm.ActivityInfo; import android.content.pm.ActivityInfo;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.os.Build; import android.os.Build;
@@ -14,6 +15,7 @@ import android.view.WindowManager;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import su.xash.engine.util.AndroidBug5497Workaround; import su.xash.engine.util.AndroidBug5497Workaround;
import su.xash.engine.util.CrashReports;
import java.io.File; import java.io.File;
import java.util.Arrays; import java.util.Arrays;
@@ -121,6 +123,45 @@ public class XashActivity extends SDLActivity {
return getWindow().superDispatchKeyEvent(event); 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? // TODO: REMOVE LATER, temporary launchers support?
@Override @Override
protected String[] getArguments() { protected String[] getArguments() {
@@ -128,6 +169,8 @@ public class XashActivity extends SDLActivity {
crashDir.mkdirs(); crashDir.mkdirs();
nativeSetenv("XASH3D_CRASH_DIR", crashDir.getAbsolutePath()); nativeSetenv("XASH3D_CRASH_DIR", crashDir.getAbsolutePath());
recordLaunchInfo();
String gamedir = getIntent().getStringExtra("gamedir"); String gamedir = getIntent().getStringExtra("gamedir");
if (gamedir == null) gamedir = "valve"; if (gamedir == null) gamedir = "valve";
nativeSetenv("XASH3D_GAME", gamedir); nativeSetenv("XASH3D_GAME", gamedir);

View File

@@ -6,7 +6,6 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import su.xash.engine.R import su.xash.engine.R
import su.xash.engine.util.CrashReports import su.xash.engine.util.CrashReports
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -26,9 +25,12 @@ class CrashLogsFragment : PreferenceFragmentCompat() {
val ctx = requireContext() val ctx = requireContext()
preferenceScreen.removeAll() 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 { preferenceScreen.addPreference(Preference(ctx).apply {
setTitle(R.string.crash_logs_empty) setTitle(R.string.crash_logs_empty)
isSelectable = false isSelectable = false
@@ -37,28 +39,27 @@ class CrashLogsFragment : PreferenceFragmentCompat() {
} }
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
files.forEach { file -> dirs.forEach { dir ->
preferenceScreen.addPreference(Preference(ctx).apply { preferenceScreen.addPreference(Preference(ctx).apply {
title = fmt.format(Date(file.lastModified())) title = fmt.format(Date(dir.lastModified()))
summary = file.name summary = dir.name
setOnPreferenceClickListener { setOnPreferenceClickListener {
showCrashLog(file) showCrashLog(CrashReports.Entry(dir))
true true
} }
}) })
} }
} }
private fun showCrashLog(file: File) { private fun showCrashLog(entry: CrashReports.Entry) {
val ctx = requireContext() val ctx = requireContext()
val content = file.readText()
AlertDialog.Builder(ctx) AlertDialog.Builder(ctx)
.setTitle(file.name) .setTitle(entry.name)
.setView(CrashReports.buildContentView(ctx, content)) .setView(CrashReports.buildContentView(ctx, entry.summary()))
.setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(ctx, content) } .setPositiveButton(R.string.crash_send_to_developers) { _, _ -> CrashReports.sendByEmail(ctx, entry) }
.setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(ctx, file) } .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(ctx, entry) }
.setNegativeButton(R.string.crash_log_delete) { _, _ -> .setNegativeButton(R.string.crash_log_delete) { _, _ ->
file.delete() entry.dir.deleteRecursively()
populate() populate()
} }
.show() .show()

View File

@@ -4,14 +4,20 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.Build
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import su.xash.engine.BuildConfig import su.xash.engine.BuildConfig
import su.xash.engine.R import su.xash.engine.R
import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object CrashReports { object CrashReports {
private const val PREFS = "crash_reports" private const val PREFS = "crash_reports"
@@ -20,7 +26,37 @@ object CrashReports {
private const val D = "9c8d9e8c97bf9988988cd1989e86" 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<File> = 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") fun historyDir(ctx: Context): File = File(ctx.filesDir, "crashes/history")
// wipe everything on app update; otherwise drop logs older than 30 days // wipe everything on app update; otherwise drop logs older than 30 days
@@ -30,28 +66,102 @@ object CrashReports {
val currentVersion = BuildConfig.VERSION_CODE val currentVersion = BuildConfig.VERSION_CODE
if (lastVersion != currentVersion) { if (lastVersion != currentVersion) {
historyDir(ctx).listFiles()?.forEach { it.delete() } historyDir(ctx).deleteRecursively()
pendingFile(ctx).delete() pendingStacktrace(ctx).delete()
pendingSysinfo(ctx).delete()
pendingIntent(ctx).delete()
prefs.edit().putInt(KEY_LAST_VERSION, currentVersion).apply() prefs.edit().putInt(KEY_LAST_VERSION, currentVersion).apply()
return return
} }
val cutoff = System.currentTimeMillis() - MAX_AGE_MS val cutoff = System.currentTimeMillis() - MAX_AGE_MS
historyDir(ctx).listFiles()?.forEach { f -> historyDir(ctx).listFiles()?.forEach { entry ->
if (f.lastModified() < cutoff) f.delete() 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 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_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 { fun buildContentView(ctx: Context, content: String): View {
@@ -66,11 +176,10 @@ object CrashReports {
return ScrollView(ctx).apply { addView(text) } return ScrollView(ctx).apply { addView(text) }
} }
fun share(ctx: Context, file: File) { fun share(ctx: Context, entry: Entry) {
val authority = "${BuildConfig.APPLICATION_ID}.fileprovider" val uri = zipUri(ctx, entry)
val uri = FileProvider.getUriForFile(ctx, authority, file)
val intent = Intent(Intent.ACTION_SEND).apply { 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_SUBJECT, ctx.getString(R.string.crash_email_subject))
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

View File

@@ -30,6 +30,9 @@
<string name="crash_logs">Журналы сбоев</string> <string name="crash_logs">Журналы сбоев</string>
<string name="crash_logs_empty">Нет сохранённых журналов сбоев</string> <string name="crash_logs_empty">Нет сохранённых журналов сбоев</string>
<string name="crash_log_delete">Удалить</string> <string name="crash_log_delete">Удалить</string>
<string name="crash_email_body">К письму прикреплены подробности сбоя. Опишите, пожалуйста, как можно подробнее, что вы делали, когда игра вылетела — любая мелочь поможет нам исправить ошибку!</string>
<string name="crash_no_mail_app_title">Почтовое приложение не найдено</string>
<string name="crash_no_mail_app_message">На этом устройстве нет почтового клиента. Установите его (или воспользуйтесь кнопкой «Поделиться», чтобы отправить отчёт другим способом) и попробуйте снова.</string>
<string name="downloading_game_libs">Загрузка игровых библиотек</string> <string name="downloading_game_libs">Загрузка игровых библиотек</string>
<string name="downloading">Загрузка…</string> <string name="downloading">Загрузка…</string>
<string name="download_progress">Загрузка… %1$d%%</string> <string name="download_progress">Загрузка… %1$d%%</string>

View File

@@ -32,6 +32,9 @@
<string name="crash_logs_empty">No crash logs recorded</string> <string name="crash_logs_empty">No crash logs recorded</string>
<string name="crash_log_delete">Delete</string> <string name="crash_log_delete">Delete</string>
<string name="crash_email_subject" translatable="false">Xash3D FWGS crash report</string> <string name="crash_email_subject" translatable="false">Xash3D FWGS crash report</string>
<string name="crash_email_body">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!</string>
<string name="crash_no_mail_app_title">No mail app found</string>
<string name="crash_no_mail_app_message">No email client is installed on this device. Install one (or use Share to send the report another way) and try again.</string>
<string name="downloading_game_libs">Downloading Game Libraries</string> <string name="downloading_game_libs">Downloading Game Libraries</string>
<string name="downloading">Downloading…</string> <string name="downloading">Downloading…</string>
<string name="download_progress">Downloading… %1$d%%</string> <string name="download_progress">Downloading… %1$d%%</string>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<files-path name="crashes" path="crashes/" /> <cache-path name="crash_zips" path="crashes/" />
</paths> </paths>