diff --git a/CRASH_REPORTS_SYSTEM.md b/CRASH_REPORTS_SYSTEM.md new file mode 100644 index 0000000..159b061 --- /dev/null +++ b/CRASH_REPORTS_SYSTEM.md @@ -0,0 +1,256 @@ +# Система Crash Reports для Rosetta Android + +## 📋 Обзор + +Реализована система автоматического сохранения crash reports в приложении Rosetta Android. Теперь при каждом краше приложения информация о нем будет сохраняться в локальное хранилище для последующего анализа. + +## 🎯 Функциональность + +### Автоматический сбор + +- **Отлов крашей**: Все необработанные исключения автоматически перехватываются +- **Детальная информация**: Сохраняется полная информация о краше: + - Timestamp (дата и время) + - Информация о приложении (package, версия) + - Информация об устройстве (модель, Android версия) + - Thread информация + - Exception type и message + - Полный stack trace + - Вложенные causes (до 10 уровней) + +### Управление логами + +- **Просмотр**: Удобный UI для просмотра всех крашей +- **Детали**: Подробный просмотр каждого краша с возможностью копирования +- **Удаление**: Можно удалить отдельный краш или все разом +- **Автоочистка**: Хранится максимум 50 последних крашей + +## 📁 Файловая структура + +``` +rosetta-android/app/src/main/java/com/rosetta/messenger/ +├── RosettaApplication.kt # Application класс с инициализацией +├── utils/ +│ └── CrashReportManager.kt # Основной класс управления крашами +└── ui/ + └── crashlogs/ + └── CrashLogsScreen.kt # UI для просмотра логов +``` + +## 🚀 Как использовать + +### 1. Просмотр Crash Logs + +В приложении: + +1. Откройте **Профиль** (Settings) +2. Нажмите на **Crash Logs** +3. Увидите список всех крашей +4. Нажмите на краш для просмотра деталей + +### 2. Программный доступ + +```kotlin +// Получить список всех крашей +val crashes = CrashReportManager.getCrashReports(context) + +// Удалить конкретный краш +CrashReportManager.deleteCrashReport(context, fileName) + +// Удалить все краши +CrashReportManager.deleteAllCrashReports(context) +``` + +### 3. Тестирование системы + +Для проверки работы crash reporter можно добавить тестовый краш: + +```kotlin +// В любом месте приложения +Button(onClick = { + throw RuntimeException("Test crash for debugging") +}) { + Text("Test Crash") +} +``` + +## 📝 Формат Crash Report + +Пример сохраненного crash report: + +``` +=== CRASH REPORT === + +Timestamp: 2026-01-25 14:30:45 + +=== App Info === +Package: com.rosetta.messenger +Version: 1.0.0 (1) + +=== Device Info === +Manufacturer: Samsung +Model: SM-G991B +Android Version: 13 (API 33) +Device: o1s +Board: s5e9925 + +=== Thread Info === +Thread: main +Thread ID: 2 + +=== Exception === +Exception Type: java.lang.NullPointerException +Message: Attempt to invoke virtual method on null object + +=== Stack Trace === +java.lang.NullPointerException: Attempt to invoke virtual method on null object + at com.rosetta.messenger.ui.MainActivity.onCreate(MainActivity.kt:123) + at android.app.Activity.performCreate(Activity.java:8051) + ... + +=== Caused by (level 1) === +... +``` + +## 🔧 Настройки + +В [CrashReportManager.kt](rosetta-android/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt): + +```kotlin +private const val MAX_CRASH_FILES = 50 // Максимум файлов +private const val CRASH_DIR = "crash_reports" // Директория хранения +``` + +## 💾 Хранение данных + +- **Расположение**: `/data/data/com.rosetta.messenger/files/crash_reports/` +- **Формат файлов**: `crash_YYYY-MM-DD_HH-mm-ss.txt` +- **Автоочистка**: Старые файлы удаляются при превышении лимита + +## 🎨 UI Features + +### Список крашей + +- ❌ Красная иконка bug для каждого краша +- 📅 Дата и время краша +- 🔍 Тип исключения +- 🗑️ Кнопка удаления для каждого + +### Детальный просмотр + +- 📄 Полный текст crash report +- ✂️ Возможность выделения и копирования текста +- 🔙 Навигация назад +- 🗑️ Удаление краша + +### Пустое состояние + +Если нет крашей, показывается дружелюбное сообщение: + +``` +🐛 No crash reports +Great! Your app is running smoothly +``` + +## 🔒 Безопасность и Privacy + +- ✅ Данные хранятся только локально +- ✅ Не отправляются на сторонние серверы +- ✅ Пользователь контролирует удаление +- ✅ Автоматическая ротация старых логов + +## 🛠️ Техническая реализация + +### Инициализация + +Crash reporter автоматически инициализируется в `RosettaApplication.onCreate()`: + +```kotlin +class RosettaApplication : Application() { + override fun onCreate() { + super.onCreate() + CrashReportManager.init(this) + } +} +``` + +### Обработка крашей + +`CrashReportManager` реализует `Thread.UncaughtExceptionHandler`: + +- Перехватывает необработанные исключения +- Сохраняет детальную информацию +- Передает управление дефолтному handler (для нормального завершения) + +## 📊 Мониторинг + +### Что отслеживать: + +1. **Частота крашей**: Как часто происходят краши +2. **Тип исключений**: Какие типы ошибок встречаются +3. **Места крашей**: В каких частях кода происходят проблемы +4. **Устройства**: На каких устройствах чаще крашится + +### Аналитика + +Регулярно проверяйте Crash Logs раздел для: + +- Выявления паттернов +- Приоритизации багов +- Улучшения стабильности + +## 🚧 Дальнейшие улучшения + +Возможные доработки: + +1. **Export**: Экспорт крашей в файл для отправки разработчикам +2. **Filtering**: Фильтрация по типу исключения, дате +3. **Statistics**: Статистика по типам крашей +4. **Auto-report**: Опциональная отправка на сервер (с согласия) +5. **Symbols**: Интеграция с символами для более читаемых stack traces + +## 🐛 Debug режим + +В debug режиме рекомендуется добавить быстрый доступ к Crash Logs: + +```kotlin +// В ProfileScreen или DebugMenu +if (BuildConfig.DEBUG) { + Button(onClick = { + throw RuntimeException("Test crash") + }) { + Text("Trigger Test Crash") + } +} +``` + +## ✅ Чеклист интеграции + +- [x] CrashReportManager создан +- [x] RosettaApplication настроен +- [x] AndroidManifest обновлен +- [x] UI для просмотра создан +- [x] Навигация добавлена +- [x] Автоочистка настроена + +## 📞 Получение крашей от пользователей + +Если пользователь сообщает о краше: + +1. Попросите открыть Profile → Crash Logs +2. Найти краш по дате/времени +3. Скопировать текст краша +4. Отправить разработчикам + +## 🎯 Best Practices + +1. **Регулярная проверка**: Просматривайте краши минимум раз в неделю +2. **Приоритеты**: Сначала исправляйте частые краши +3. **Тестирование**: После исправления проверяйте что краш не воспроизводится +4. **Документация**: Документируйте причины и решения крашей + +--- + +**Статус**: ✅ Готово к использованию +**Версия**: 1.0 +**Дата**: 25 января 2026 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b508ec8..691f05f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ Unit +) { + val context = LocalContext.current + var crashReports by remember { mutableStateOf>(emptyList()) } + var selectedReport by remember { mutableStateOf(null) } + var showDeleteAllDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(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") + } + } + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 5355622..a16b76d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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 ) diff --git a/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt b/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt new file mode 100644 index 0000000..38be667 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/CrashReportManager.kt @@ -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 { + 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 + ) +}