feat: Implement crash reporting system with CrashLogsScreen and integration in ProfileScreen
This commit is contained in:
256
CRASH_REPORTS_SYSTEM.md
Normal file
256
CRASH_REPORTS_SYSTEM.md
Normal file
@@ -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
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".RosettaApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import com.rosetta.messenger.ui.settings.ProfileScreen
|
|||||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||||
import com.rosetta.messenger.ui.settings.ThemeScreen
|
import com.rosetta.messenger.ui.settings.ThemeScreen
|
||||||
import com.rosetta.messenger.ui.settings.UpdatesScreen
|
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.splash.SplashScreen
|
||||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -510,6 +511,7 @@ fun MainScreen(
|
|||||||
var showSafetyScreen by remember { mutableStateOf(false) }
|
var showSafetyScreen by remember { mutableStateOf(false) }
|
||||||
var showBackupScreen by remember { mutableStateOf(false) }
|
var showBackupScreen by remember { mutableStateOf(false) }
|
||||||
var showLogsScreen by remember { mutableStateOf(false) }
|
var showLogsScreen by remember { mutableStateOf(false) }
|
||||||
|
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// ProfileViewModel для логов
|
// ProfileViewModel для логов
|
||||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
||||||
@@ -538,7 +540,7 @@ fun MainScreen(
|
|||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
||||||
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
||||||
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen,
|
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen,
|
||||||
enter = fadeIn(animationSpec = tween(300)),
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
exit = fadeOut(animationSpec = tween(200))
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
) {
|
) {
|
||||||
@@ -780,6 +782,10 @@ fun MainScreen(
|
|||||||
showProfileScreen = false
|
showProfileScreen = false
|
||||||
showLogsScreen = true
|
showLogsScreen = true
|
||||||
},
|
},
|
||||||
|
onNavigateToCrashLogs = {
|
||||||
|
showProfileScreen = false
|
||||||
|
showCrashLogsScreen = true
|
||||||
|
},
|
||||||
viewModel = profileViewModel,
|
viewModel = profileViewModel,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
|
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(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = showOtherProfileScreen,
|
visible = showOtherProfileScreen,
|
||||||
enter = fadeIn(animationSpec = tween(300)),
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,7 @@ fun ProfileScreen(
|
|||||||
onNavigateToTheme: () -> Unit = {},
|
onNavigateToTheme: () -> Unit = {},
|
||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
onNavigateToLogs: () -> Unit = {},
|
onNavigateToLogs: () -> Unit = {},
|
||||||
|
onNavigateToCrashLogs: () -> Unit = {},
|
||||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
dialogDao: com.rosetta.messenger.database.DialogDao? = null
|
dialogDao: com.rosetta.messenger.database.DialogDao? = null
|
||||||
@@ -425,6 +426,14 @@ fun ProfileScreen(
|
|||||||
title = "Safety",
|
title = "Safety",
|
||||||
onClick = onNavigateToSafety,
|
onClick = onNavigateToSafety,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = true
|
||||||
|
)
|
||||||
|
|
||||||
|
TelegramSettingsItem(
|
||||||
|
icon = TablerIcons.Bug,
|
||||||
|
title = "Crash Logs",
|
||||||
|
onClick = onNavigateToCrashLogs,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
showDivider = biometricAvailable is BiometricAvailability.Available
|
showDivider = biometricAvailable is BiometricAvailability.Available
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user