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 @@
-
+