package com.ea.ironmonkey import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream /** * Shown on the launch AFTER a crash, never during one. * * The native handler (crash_handler.cpp) can only write a file - a signal * handler runs on an already-broken process where JNI and Activities are not * legal. So it drops `crash_pending.txt` and dies; this screen is what the * tester sees next time they open the game. * * Everything it produces stays on the device unless the tester presses send. * There is no backend and no automatic upload - see BETA_TELEMETRY_PLAN.md. * * Built with Compose/Material3 and its own dark colour scheme rather than the * game's `Theme.AppCompat.NoActionBar`: this screen must render correctly no * matter what the rest of the app's theming does, and it is the one screen a * tester sees when everything else has already gone wrong. */ class CrashReportActivity : ComponentActivity() { companion object { private const val TAG = "CrashReport" private const val PENDING_NAME = "crash_pending.txt" /** Reports live here: Android/data//files/crashes - reachable over USB, no permission needed. */ fun crashDir(activity: android.app.Activity): File = File(activity.getExternalFilesDir(null), "crashes") /** The file the native handler writes. Null if there is nothing waiting. */ fun pendingReport(activity: android.app.Activity): File? = File(crashDir(activity), PENDING_NAME).takeIf { it.isFile && it.length() > 0 } } private var bundle: File? = null override fun onCreate(savedInstanceState: Bundle?) { // Android 15 draws every app edge to edge whether it asks or not, so // the choice is between handling insets or having text sit under the // status bar. Declaring it explicitly and letting Scaffold apply the // padding is the supported way; the first version did neither and the // content ran under the system bars. enableEdgeToEdge() super.onCreate(savedInstanceState) val pending = pendingReport(this) if (pending == null) { // Nothing to report - never block the tester on an empty screen. finish() return } // Rename out of the way FIRST, before anything that could fail. The // native handler always writes the same fixed name (it cannot safely // format a timestamp inside a signal handler), so leaving it in place // would let the next crash overwrite a report not yet sent. val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val kept = File(crashDir(this), "crash-$stamp.txt") if (!pending.renameTo(kept)) { Log.w(TAG, "could not rename $pending - reporting it in place") } val report = if (kept.isFile) kept else pending val details = buildString { append(deviceSummary()) append("\n\n") append(runCatching { report.readText() }.getOrElse { "(could not read the report: $it)" }) } bundle = runCatching { zipReport(report, details, stamp) } .onFailure { Log.w(TAG, "could not build the zip", it) } .getOrNull() setContent { MaterialTheme(colorScheme = darkColorScheme()) { CrashReportScreen( details = details, bundlePath = bundle?.absolutePath, canSend = bundle != null, onSend = ::share, onContinue = ::startGame, ) } } } private fun deviceSummary(): String = buildString { append("device: ${Build.MANUFACTURER} ${Build.MODEL}\n") append("android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n") append("soc: ${Build.HARDWARE}\n") append("abis: ${Build.SUPPORTED_ABIS.joinToString(", ")}\n") append("app: ${appVersion()}") } private fun appVersion(): String = runCatching { val p = packageManager.getPackageInfo(packageName, 0) "${p.versionName} (${p.longVersionCode})" }.getOrElse { "(unknown)" } private fun zipReport(report: File, details: String, stamp: String): File { val out = File(crashDir(this), "crash-$stamp.zip") ZipOutputStream(out.outputStream().buffered()).use { zip -> zip.putNextEntry(ZipEntry("crash.txt")) zip.write(details.toByteArray()) zip.closeEntry() if (report.isFile) { zip.putNextEntry(ZipEntry(report.name)) report.inputStream().use { it.copyTo(zip) } zip.closeEntry() } } return out } private fun share() { val file = bundle ?: return val uri = runCatching { FileProvider.getUriForFile(this, "$packageName.crashreports", file) }.getOrElse { Log.w(TAG, "FileProvider failed for $file", it) return } val send = Intent(Intent.ACTION_SEND).apply { type = "application/zip" putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_SUBJECT, "NFSMW arm64 - отчёт о сбое") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(send, "Отправить отчёт")) } private fun startGame() { startActivity(Intent(this, GameActivityMain::class.java)) finish() } } @Composable private fun CrashReportScreen( details: String, bundlePath: String?, canSend: Boolean, onSend: () -> Unit, onContinue: () -> Unit, ) { // Scaffold's innerPadding carries the system-bar insets, so nothing ends up // under the status bar or the gesture handle. Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(16.dp) ) { Text( text = "Игра аварийно завершилась", style = MaterialTheme.typography.headlineSmall, ) Text( // Say plainly what is in the file before offering to send it. // The tester is the one sending it; they should know what it // contains. text = "Отчёт сохранён на устройстве. В нём модель телефона, версия Android, " + "версия сборки и технические данные о сбое. Личных данных и игрового " + "аккаунта в нём нет.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp), ) if (bundlePath != null) { Text( text = bundlePath, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), ) } Card(modifier = Modifier .fillMaxWidth() .padding(top = 16.dp) .weight(1f) ) { Text( text = details, fontSize = 11.sp, lineHeight = 15.sp, modifier = Modifier .verticalScroll(rememberScrollState()) .padding(12.dp), ) } Row( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { OutlinedButton(onClick = onContinue) { Text("Продолжить") } Button(onClick = onSend, enabled = canSend) { Text("Отправить отчёт") } } } } }