🚀 Crash screen

* Shows app crash log
* Copy or report (share)
* Logs are saved in app's internal storage (/crash)

Resolves: #188
This commit is contained in:
Acclorite 2025-03-21 19:16:11 +02:00
parent 13da03f2aa
commit ba3f5f3b7a
14 changed files with 468 additions and 4 deletions

View file

@ -35,6 +35,7 @@
tools:ignore="DataExtractionRules" tools:ignore="DataExtractionRules"
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
<!-- Main activity -->
<activity <activity
android:name=".ui.main.MainActivity" android:name=".ui.main.MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|locale|density|uiMode" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
@ -47,6 +48,14 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Crash activity -->
<activity
android:name=".ui.crash.CrashActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
android:exported="false"
android:theme="@style/BookStory"
android:windowSoftInputMode="adjustResize"></activity>
<!-- Save app locales --> <!-- Save app locales -->
<service <service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View file

@ -8,6 +8,12 @@ package ua.acclorite.book_story
import android.app.Application import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import ua.acclorite.book_story.core.crash.CrashHandler
@HiltAndroidApp @HiltAndroidApp
class Application : Application() class Application : Application() {
override fun onCreate() {
super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
}
}

View file

@ -0,0 +1,36 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.core.crash
import android.content.Context
import android.content.Intent
import android.os.Process
import android.util.Log
import ua.acclorite.book_story.ui.crash.CrashActivity
import kotlin.system.exitProcess
private const val CRASH_TAG = "CRASH, APP"
class CrashHandler(
private val context: Context
) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, throwable: Throwable) {
val crashLog = throwable.stackTraceToString()
Log.e(CRASH_TAG, crashLog)
if (!CrashUtils.saveCrashLog(context, crashLog)) return
val intent = Intent(context, CrashActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(1)
}
}

View file

@ -0,0 +1,57 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.core.crash
import android.content.Context
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object CrashUtils {
fun saveCrashLog(context: Context, crashLog: String): Boolean {
val crashDir = File(context.filesDir, "crash")
if (!crashDir.exists()) crashDir.mkdirs()
val currentTime = LocalDateTime.now()
val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd_HH.mm.ss")
val formattedDateTime = currentTime.format(formatter)
val fileName = "crashLog_$formattedDateTime.txt"
val crashFile = File(crashDir, fileName)
val tempFile = File(crashDir, "$fileName.tmp")
try {
BufferedWriter(FileWriter(tempFile)).use { writer ->
writer.write(crashLog)
}
if (!tempFile.renameTo(crashFile)) {
throw IllegalStateException("Failed to rename .tmp file.")
}
} catch (e: Exception) {
e.printStackTrace()
tempFile.delete()
return false
}
return true
}
fun getCrashLog(context: Context): String? {
val crashDir = File(context.filesDir, "crash")
if (!crashDir.exists()) return null
val crashLogs = crashDir.listFiles() ?: return null
val crashLog = crashLogs.maxByOrNull { it.lastModified() } ?: return null
return crashLog.bufferedReader().use { reader ->
reader.readText()
}
}
}

View file

@ -29,7 +29,6 @@ import java.util.concurrent.ConcurrentLinkedQueue
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.set
private const val EPUB_TAG = "EPUB Parser" private const val EPUB_TAG = "EPUB Parser"
private typealias Source = String private typealias Source = String

View file

@ -0,0 +1,71 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.crash
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun CrashBottomBar(
copy: () -> Unit,
report: () -> Unit
) {
val windowInfo = LocalWindowInfo.current
val density = LocalDensity.current
val copyWidth = remember(windowInfo.containerSize) {
with(density) {
(windowInfo.containerSize.width.toDp() - 36.dp) * (1f / 2.5f)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(top = 18.dp, bottom = 8.dp)
.padding(horizontal = 18.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
modifier = Modifier.widthIn(
min = copyWidth
),
onClick = { copy() }
) {
StyledText(
text = stringResource(id = R.string.copy),
maxLines = 1
)
}
Button(
modifier = Modifier.weight(1f),
onClick = { report() }
) {
StyledText(
text = stringResource(id = R.string.report),
maxLines = 1
)
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.crash
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun CrashContent(
crashLog: String?,
copy: () -> Unit,
report: () -> Unit
) {
CrashScaffold(
copy = copy,
report = report
) {
CrashLayout {
if (!crashLog.isNullOrBlank()) {
item {
SelectionContainer {
StyledText(
text = crashLog,
modifier = Modifier.horizontalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.crash
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
@Composable
fun CrashLayout(
content: LazyListScope.() -> Unit
) {
LazyColumnWithScrollbar(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(18.dp)
) {
content()
}
}

View file

@ -0,0 +1,85 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.crash
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun CrashScaffold(
copy: () -> Unit,
report: () -> Unit,
content: @Composable () -> Unit
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
CrashBottomBar(
copy = copy,
report = report
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = 18.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
Icon(
imageVector = Icons.Default.BugReport,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(12.dp))
StyledText(
text = stringResource(id = R.string.crash_title),
style = MaterialTheme.typography.headlineSmall.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
Spacer(modifier = Modifier.height(8.dp))
StyledText(
text = stringResource(id = R.string.crash_description),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
Spacer(modifier = Modifier.height(18.dp))
Box(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
content()
}
}
}
}

View file

@ -0,0 +1,41 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.ui.crash
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.core.view.WindowCompat
import ua.acclorite.book_story.domain.ui.ThemeContrast
import ua.acclorite.book_story.ui.theme.BookStoryTheme
import ua.acclorite.book_story.ui.theme.Theme
class CrashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Default super
super.onCreate(savedInstanceState)
// Edge to edge
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
BookStoryTheme(
theme = Theme.DYNAMIC,
isDark = isSystemInDarkTheme(),
isPureDark = false,
themeContrast = ThemeContrast.STANDARD
) {
CrashScreen.Content()
}
}
}
}

View file

@ -0,0 +1,82 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.ui.crash
import android.content.ClipData
import android.content.Intent
import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.crash.CrashUtils
import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.presentation.core.util.LocalActivity
import ua.acclorite.book_story.presentation.core.util.launchActivity
import ua.acclorite.book_story.presentation.core.util.showToast
import ua.acclorite.book_story.presentation.crash.CrashContent
@Parcelize
object CrashScreen : Screen, Parcelable {
@Composable
override fun Content() {
val activity = LocalActivity.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val crashLog = remember {
CrashUtils.getCrashLog(activity)?.trimIndent()
}
CrashContent(
crashLog = crashLog,
copy = {
if (crashLog.isNullOrBlank()) return@CrashContent
scope.launch(Dispatchers.Main) {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Crash", crashLog))
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
activity.getString(R.string.copied)
.showToast(context = activity, longToast = false)
}
}
},
report = {
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_SUBJECT,
activity.getString(R.string.app_name)
)
putExtra(
Intent.EXTRA_TEXT,
crashLog
)
}.launchActivity(
activity = activity,
createChooser = true,
success = {
return@CrashContent
}
)
activity.getString(R.string.error_no_share_app)
.showToast(context = activity, longToast = false)
}
)
}
}

View file

@ -37,8 +37,6 @@ import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.component1
import kotlin.collections.component2
@HiltViewModel @HiltViewModel
class HistoryModel @Inject constructor( class HistoryModel @Inject constructor(

View file

@ -67,6 +67,7 @@
<string name="never">Ніколи</string> <string name="never">Ніколи</string>
<string name="undo">Скасувати</string> <string name="undo">Скасувати</string>
<string name="copy">Копіювати</string> <string name="copy">Копіювати</string>
<string name="report">Повідомити</string>
<string name="translate">Перекласти</string> <string name="translate">Перекласти</string>
<string name="dictionary">Словник</string> <string name="dictionary">Словник</string>
<string name="next">Далі</string> <string name="next">Далі</string>
@ -431,6 +432,10 @@
<string name="today">Сьогодні</string> <string name="today">Сьогодні</string>
<string name="yesterday">Вчора</string> <string name="yesterday">Вчора</string>
<!-- Crash -->
<string name="crash_title">Ой! Застосунок крашнувся.</string>
<string name="crash_description">Будь ласка, повідомте про цей збій у найзручніший для вас спосіб.</string>
<!-- Content description --> <!-- Content description -->
<string name="continue_reading_content_desc">Продовжити читання</string> <string name="continue_reading_content_desc">Продовжити читання</string>
<string name="go_back_content_desc">Назад</string> <string name="go_back_content_desc">Назад</string>

View file

@ -96,6 +96,7 @@
<string name="never">Never</string> <string name="never">Never</string>
<string name="undo">Undo</string> <string name="undo">Undo</string>
<string name="copy">Copy</string> <string name="copy">Copy</string>
<string name="report">Report</string>
<string name="translate">Translate</string> <string name="translate">Translate</string>
<string name="dictionary">Dictionary</string> <string name="dictionary">Dictionary</string>
<string name="next">Next</string> <string name="next">Next</string>
@ -566,6 +567,10 @@
<string name="today">Today</string> <string name="today">Today</string>
<string name="yesterday">Yesterday</string> <string name="yesterday">Yesterday</string>
<!-- Crash -->
<string name="crash_title">Oops! The app crashed.</string>
<string name="crash_description">Please report this crash message in the most convenient way for you.</string>
<!-- Content description --> <!-- Content description -->
<string name="continue_reading_content_desc">Continue reading</string> <string name="continue_reading_content_desc">Continue reading</string>
<string name="go_back_content_desc">Go back</string> <string name="go_back_content_desc">Go back</string>