feat: Implement crash reporting system with CrashLogsScreen and integration in ProfileScreen

This commit is contained in:
k1ngsterr1
2026-01-25 02:33:56 +05:00
parent 766ab84f8c
commit c8214cdfa3
7 changed files with 878 additions and 1 deletions

View File

@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".RosettaApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View File

@@ -48,6 +48,7 @@ import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen
import com.rosetta.messenger.ui.settings.ThemeScreen
import com.rosetta.messenger.ui.settings.UpdatesScreen
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import java.text.SimpleDateFormat
@@ -510,6 +511,7 @@ fun MainScreen(
var showSafetyScreen by remember { mutableStateOf(false) }
var showBackupScreen by remember { mutableStateOf(false) }
var showLogsScreen by remember { mutableStateOf(false) }
var showCrashLogsScreen by remember { mutableStateOf(false) }
// ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
@@ -538,7 +540,7 @@ fun MainScreen(
androidx.compose.animation.AnimatedVisibility(
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen,
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
@@ -780,6 +782,10 @@ fun MainScreen(
showProfileScreen = false
showLogsScreen = true
},
onNavigateToCrashLogs = {
showProfileScreen = false
showCrashLogsScreen = true
},
viewModel = profileViewModel,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
@@ -807,6 +813,21 @@ fun MainScreen(
}
}
androidx.compose.animation.AnimatedVisibility(
visible = showCrashLogsScreen,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
if (showCrashLogsScreen) {
CrashLogsScreen(
onBackClick = {
showCrashLogsScreen = false
showProfileScreen = true
}
)
}
}
androidx.compose.animation.AnimatedVisibility(
visible = showOtherProfileScreen,
enter = fadeIn(animationSpec = tween(300)),

View File

@@ -0,0 +1,38 @@
package com.rosetta.messenger
import android.app.Application
import android.util.Log
import com.rosetta.messenger.utils.CrashReportManager
/**
* Application класс для инициализации глобальных компонентов приложения
*/
class RosettaApplication : Application() {
companion object {
private const val TAG = "RosettaApplication"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Application starting...")
// Инициализируем crash reporter
initCrashReporting()
Log.d(TAG, "Application initialized successfully")
}
/**
* Инициализация системы сбора crash reports
*/
private fun initCrashReporting() {
try {
CrashReportManager.init(this)
Log.d(TAG, "Crash reporting initialized")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize crash reporting", e)
}
}
}

View File

@@ -0,0 +1,337 @@
package com.rosetta.messenger.ui.crashlogs
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.utils.CrashReportManager
import java.text.SimpleDateFormat
import java.util.*
/**
* Экран для просмотра crash logs
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashLogsScreen(
onBackClick: () -> Unit
) {
val context = LocalContext.current
var crashReports by remember { mutableStateOf<List<CrashReportManager.CrashReport>>(emptyList()) }
var selectedReport by remember { mutableStateOf<CrashReportManager.CrashReport?>(null) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<String?>(null) }
// Загружаем crash reports
LaunchedEffect(Unit) {
crashReports = CrashReportManager.getCrashReports(context)
}
// Функция для обновления списка
fun refreshReports() {
crashReports = CrashReportManager.getCrashReports(context)
}
if (selectedReport != null) {
// Показываем детали краша
CrashDetailScreen(
crashReport = selectedReport!!,
onBackClick = { selectedReport = null },
onDelete = {
CrashReportManager.deleteCrashReport(context, selectedReport!!.fileName)
refreshReports()
selectedReport = null
}
)
} else {
// Список крашей
Scaffold(
topBar = {
TopAppBar(
title = { Text("Crash Logs") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (crashReports.isNotEmpty()) {
IconButton(onClick = { showDeleteAllDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete All")
}
}
}
)
}
) { paddingValues ->
if (crashReports.isEmpty()) {
// Пустое состояние
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.BugReport,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
Text(
text = "No crash reports",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = "Great! Your app is running smoothly",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}
} else {
// Список crash reports
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(crashReports) { report ->
CrashReportItem(
crashReport = report,
onClick = { selectedReport = report },
onDelete = { showDeleteDialog = report.fileName }
)
}
}
}
}
// Диалог удаления всех
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Delete All Crash Reports?") },
text = { Text("This will permanently delete all ${crashReports.size} crash reports.") },
confirmButton = {
TextButton(
onClick = {
CrashReportManager.deleteAllCrashReports(context)
refreshReports()
showDeleteAllDialog = false
}
) {
Text("Delete All")
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Cancel")
}
}
)
}
// Диалог удаления одного
if (showDeleteDialog != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = null },
title = { Text("Delete Crash Report?") },
text = { Text("This will permanently delete this crash report.") },
confirmButton = {
TextButton(
onClick = {
CrashReportManager.deleteCrashReport(context, showDeleteDialog!!)
refreshReports()
showDeleteDialog = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = null }) {
Text("Cancel")
}
}
)
}
}
}
/**
* Элемент списка с crash report
*/
@Composable
private fun CrashReportItem(
crashReport: CrashReportManager.CrashReport,
onClick: () -> Unit,
onDelete: () -> Unit
) {
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy, HH:mm:ss", Locale.getDefault()) }
val exceptionType = remember {
crashReport.content.lines()
.find { it.startsWith("Exception Type:") }
?.substringAfter("Exception Type: ")
?.substringAfterLast(".")
?: "Unknown"
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.BugReport,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = exceptionType,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Text(
text = dateFormat.format(Date(crashReport.timestamp)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
/**
* Детальный просмотр crash report
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CrashDetailScreen(
crashReport: CrashReportManager.CrashReport,
onBackClick: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Crash Details") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { /* TODO: Share */ }) {
Icon(Icons.Default.Share, contentDescription = "Share")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(MaterialTheme.colorScheme.surface),
contentPadding = PaddingValues(16.dp)
) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = crashReport.content,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 18.sp
)
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Crash Report?") },
text = { Text("This will permanently delete this crash report.") },
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
onDelete()
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
}
}

View File

@@ -148,6 +148,7 @@ fun ProfileScreen(
onNavigateToTheme: () -> Unit = {},
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
onNavigateToCrashLogs: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null
@@ -425,6 +426,14 @@ fun ProfileScreen(
title = "Safety",
onClick = onNavigateToSafety,
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramSettingsItem(
icon = TablerIcons.Bug,
title = "Crash Logs",
onClick = onNavigateToCrashLogs,
isDarkTheme = isDarkTheme,
showDivider = biometricAvailable is BiometricAvailability.Available
)

View File

@@ -0,0 +1,215 @@
package com.rosetta.messenger.utils
import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.*
/**
* Менеджер для сохранения crash reports в локальное хранилище
*/
class CrashReportManager private constructor(private val context: Context) : Thread.UncaughtExceptionHandler {
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
companion object {
private const val TAG = "CrashReportManager"
private const val CRASH_DIR = "crash_reports"
private const val MAX_CRASH_FILES = 50 // Максимум файлов с крашами
@Volatile
private var INSTANCE: CrashReportManager? = null
/**
* Инициализация crash reporter
*/
fun init(context: Context) {
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANCE = CrashReportManager(context.applicationContext)
Thread.setDefaultUncaughtExceptionHandler(INSTANCE)
Log.d(TAG, "Crash reporter initialized")
}
}
}
}
/**
* Получить список всех crash reports
*/
fun getCrashReports(context: Context): List<CrashReport> {
val crashDir = File(context.filesDir, CRASH_DIR)
if (!crashDir.exists()) return emptyList()
return crashDir.listFiles()
?.filter { it.extension == "txt" }
?.sortedByDescending { it.lastModified() }
?.map { file ->
CrashReport(
fileName = file.name,
timestamp = file.lastModified(),
content = file.readText()
)
} ?: emptyList()
}
/**
* Удалить старые crash reports
*/
fun deleteCrashReport(context: Context, fileName: String): Boolean {
val crashDir = File(context.filesDir, CRASH_DIR)
val file = File(crashDir, fileName)
return if (file.exists()) {
file.delete()
} else {
false
}
}
/**
* Удалить все crash reports
*/
fun deleteAllCrashReports(context: Context) {
val crashDir = File(context.filesDir, CRASH_DIR)
if (crashDir.exists()) {
crashDir.listFiles()?.forEach { it.delete() }
}
}
}
override fun uncaughtException(thread: Thread, throwable: Throwable) {
try {
saveCrashReport(thread, throwable)
} catch (e: Exception) {
Log.e(TAG, "Error saving crash report", e)
}
// Вызываем дефолтный handler (чтобы система тоже обработала краш)
defaultHandler?.uncaughtException(thread, throwable)
}
/**
* Сохранить crash report в файл
*/
private fun saveCrashReport(thread: Thread, throwable: Throwable) {
val timestamp = dateFormat.format(Date())
val fileName = "crash_$timestamp.txt"
val crashDir = File(context.filesDir, CRASH_DIR)
if (!crashDir.exists()) {
crashDir.mkdirs()
}
val crashFile = File(crashDir, fileName)
val report = buildCrashReport(thread, throwable)
crashFile.writeText(report)
Log.d(TAG, "Crash report saved: $fileName")
// Удаляем старые файлы если их слишком много
cleanupOldCrashFiles(crashDir)
}
/**
* Собрать информацию о краше
*/
private fun buildCrashReport(thread: Thread, throwable: Throwable): String {
val report = StringBuilder()
// Заголовок
report.append("=== CRASH REPORT ===\n\n")
// Время краша
report.append("Timestamp: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())}\n\n")
// Информация о приложении
report.append("=== App Info ===\n")
report.append("Package: ${context.packageName}\n")
try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
report.append("Version: ${packageInfo.versionName} (${packageInfo.longVersionCode})\n")
} catch (e: Exception) {
report.append("Version: Unknown\n")
}
report.append("\n")
// Информация об устройстве
report.append("=== Device Info ===\n")
report.append("Manufacturer: ${Build.MANUFACTURER}\n")
report.append("Model: ${Build.MODEL}\n")
report.append("Android Version: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
report.append("Device: ${Build.DEVICE}\n")
report.append("Board: ${Build.BOARD}\n")
report.append("\n")
// Информация о треде
report.append("=== Thread Info ===\n")
report.append("Thread: ${thread.name}\n")
report.append("Thread ID: ${thread.id}\n")
report.append("\n")
// Stack trace
report.append("=== Exception ===\n")
report.append("Exception Type: ${throwable.javaClass.name}\n")
report.append("Message: ${throwable.message ?: "No message"}\n\n")
// Полный stack trace
report.append("=== Stack Trace ===\n")
val stringWriter = StringWriter()
val printWriter = PrintWriter(stringWriter)
throwable.printStackTrace(printWriter)
report.append(stringWriter.toString())
// Cause (если есть)
var cause = throwable.cause
var causeLevel = 1
while (cause != null) {
report.append("\n=== Caused by (level $causeLevel) ===\n")
report.append("Exception Type: ${cause.javaClass.name}\n")
report.append("Message: ${cause.message ?: "No message"}\n\n")
val causeWriter = StringWriter()
val causePrintWriter = PrintWriter(causeWriter)
cause.printStackTrace(causePrintWriter)
report.append(causeWriter.toString())
cause = cause.cause
causeLevel++
// Предотвращаем бесконечный цикл
if (causeLevel > 10) break
}
return report.toString()
}
/**
* Удалить старые crash файлы если их слишком много
*/
private fun cleanupOldCrashFiles(crashDir: File) {
val files = crashDir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
if (files.size > MAX_CRASH_FILES) {
files.drop(MAX_CRASH_FILES).forEach { file ->
file.delete()
Log.d(TAG, "Deleted old crash file: ${file.name}")
}
}
}
/**
* Data class для представления crash report
*/
data class CrashReport(
val fileName: String,
val timestamp: Long,
val content: String
)
}