diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 25112818..5fcf21d7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -68,6 +68,15 @@
+
+
+
CrashReports.sendByEmail(this, content) }
+ .setNeutralButton(R.string.crash_share) { _, _ -> CrashReports.share(this, archived) }
+ .setNegativeButton(R.string.crash_dismiss, null)
+ .show()
+ }
}
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 fa8b698e..df39d7f8 100644
--- a/android/app/src/main/java/su/xash/engine/XashActivity.java
+++ b/android/app/src/main/java/su/xash/engine/XashActivity.java
@@ -15,6 +15,7 @@ import org.libsdl.app.SDLActivity;
import su.xash.engine.util.AndroidBug5497Workaround;
+import java.io.File;
import java.util.Arrays;
import java.util.List;
@@ -123,6 +124,10 @@ public class XashActivity extends SDLActivity {
// TODO: REMOVE LATER, temporary launchers support?
@Override
protected String[] getArguments() {
+ File crashDir = new File(getFilesDir(), "crashes");
+ crashDir.mkdirs();
+ nativeSetenv("XASH3D_CRASH_DIR", crashDir.getAbsolutePath());
+
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/AppSettingsPreferenceFragment.kt b/android/app/src/main/java/su/xash/engine/ui/settings/AppSettingsPreferenceFragment.kt
index 3d61560f..8bfd23fb 100644
--- a/android/app/src/main/java/su/xash/engine/ui/settings/AppSettingsPreferenceFragment.kt
+++ b/android/app/src/main/java/su/xash/engine/ui/settings/AppSettingsPreferenceFragment.kt
@@ -1,6 +1,8 @@
package su.xash.engine.ui.settings
import android.os.Bundle
+import androidx.navigation.fragment.findNavController
+import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import su.xash.engine.R
@@ -8,5 +10,10 @@ class AppSettingsPreferenceFragment() : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.sharedPreferencesName = "app_preferences";
setPreferencesFromResource(R.xml.app_preferences, rootKey);
+
+ findPreference("crash_logs")?.setOnPreferenceClickListener {
+ findNavController().navigate(R.id.action_appSettingsFragment_to_crashLogsFragment)
+ true
+ }
}
}
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
new file mode 100644
index 00000000..b1163903
--- /dev/null
+++ b/android/app/src/main/java/su/xash/engine/ui/settings/CrashLogsFragment.kt
@@ -0,0 +1,66 @@
+package su.xash.engine.ui.settings
+
+import android.os.Bundle
+import androidx.appcompat.app.AlertDialog
+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
+
+class CrashLogsFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
+ populate()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ populate()
+ }
+
+ private fun populate() {
+ val ctx = requireContext()
+ preferenceScreen.removeAll()
+
+ val files = CrashReports.historyDir(ctx).listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
+
+ if (files.isEmpty()) {
+ preferenceScreen.addPreference(Preference(ctx).apply {
+ setTitle(R.string.crash_logs_empty)
+ isSelectable = false
+ })
+ return
+ }
+
+ val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+ files.forEach { file ->
+ preferenceScreen.addPreference(Preference(ctx).apply {
+ title = fmt.format(Date(file.lastModified()))
+ summary = file.name
+ setOnPreferenceClickListener {
+ showCrashLog(file)
+ true
+ }
+ })
+ }
+ }
+
+ private fun showCrashLog(file: File) {
+ 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) }
+ .setNegativeButton(R.string.crash_log_delete) { _, _ ->
+ file.delete()
+ 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
new file mode 100644
index 00000000..ed37ab8f
--- /dev/null
+++ b/android/app/src/main/java/su/xash/engine/util/CrashReports.kt
@@ -0,0 +1,80 @@
+package su.xash.engine.util
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Typeface
+import android.net.Uri
+import android.util.TypedValue
+import android.view.View
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.core.content.FileProvider
+import su.xash.engine.BuildConfig
+import su.xash.engine.R
+import java.io.File
+
+object CrashReports {
+ private const val PREFS = "crash_reports"
+ private const val KEY_LAST_VERSION = "last_version_code"
+ private const val MAX_AGE_MS = 30L * 24L * 60L * 60L * 1000L // 30 days
+
+ private const val D = "9c8d9e8c97bf9988988cd1989e86"
+
+ fun pendingFile(ctx: Context): File = File(ctx.filesDir, "crashes/crash.log")
+ fun historyDir(ctx: Context): File = File(ctx.filesDir, "crashes/history")
+
+ // wipe everything on app update; otherwise drop logs older than 30 days
+ fun prune(ctx: Context) {
+ val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val lastVersion = prefs.getInt(KEY_LAST_VERSION, -1)
+ val currentVersion = BuildConfig.VERSION_CODE
+
+ if (lastVersion != currentVersion) {
+ historyDir(ctx).listFiles()?.forEach { it.delete() }
+ pendingFile(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()
+ }
+ }
+
+ fun sendByEmail(ctx: Context, content: String) {
+ 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)
+ putExtra(Intent.EXTRA_SUBJECT, ctx.getString(R.string.crash_email_subject))
+ putExtra(Intent.EXTRA_TEXT, content)
+ }
+ if (intent.resolveActivity(ctx.packageManager) != null) {
+ ctx.startActivity(intent)
+ }
+ }
+
+ 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, file: File) {
+ val authority = "${BuildConfig.APPLICATION_ID}.fileprovider"
+ val uri = FileProvider.getUriForFile(ctx, authority, file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, ctx.getString(R.string.crash_email_subject))
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ ctx.startActivity(Intent.createChooser(intent, ctx.getString(R.string.crash_share)))
+ }
+}
diff --git a/android/app/src/main/res/navigation/nav_graph.xml b/android/app/src/main/res/navigation/nav_graph.xml
index f24610bc..2e4dbf98 100644
--- a/android/app/src/main/res/navigation/nav_graph.xml
+++ b/android/app/src/main/res/navigation/nav_graph.xml
@@ -22,5 +22,13 @@
+ android:label="@string/app_settings">
+
+
+
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 8171d755..c240818d 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -23,4 +23,11 @@
Требуется APK с игровыми библиотеками
Чтобы запустить эту игру, нужно установить дополнительный APK
Скачать
+ Xash3D FWGS аварийно завершился в прошлый раз
+ Отправить разработчикам
+ Поделиться
+ Закрыть
+ Журналы сбоев
+ Нет сохранённых журналов сбоев
+ Удалить
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6cd8bf40..3d82752d 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -24,4 +24,12 @@
Game code APK required
To launch this game, you need additional APK installed
Download
+ Xash3D FWGS crashed last time
+ Send to developers
+ Share
+ Dismiss
+ Crash logs
+ No crash logs recorded
+ Delete
+ Xash3D FWGS crash report
diff --git a/android/app/src/main/res/xml/app_preferences.xml b/android/app/src/main/res/xml/app_preferences.xml
index b5d94e96..bbaaf241 100644
--- a/android/app/src/main/res/xml/app_preferences.xml
+++ b/android/app/src/main/res/xml/app_preferences.xml
@@ -11,4 +11,8 @@
app:key="use_icons"
app:layout="@layout/switch_preference"
app:title="@string/preferences_use_icons" />
+
+
diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 00000000..c6a2b50b
--- /dev/null
+++ b/android/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+