🚀 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:
parent
13da03f2aa
commit
ba3f5f3b7a
14 changed files with 468 additions and 4 deletions
|
|
@ -35,6 +35,7 @@
|
|||
tools:ignore="DataExtractionRules"
|
||||
tools:targetApi="tiramisu">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
|
||||
|
|
@ -47,6 +48,14 @@
|
|||
</intent-filter>
|
||||
</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 -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ package ua.acclorite.book_story
|
|||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import ua.acclorite.book_story.core.crash.CrashHandler
|
||||
|
||||
@HiltAndroidApp
|
||||
class Application : Application()
|
||||
class Application : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
|||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.set
|
||||
|
||||
private const val EPUB_TAG = "EPUB Parser"
|
||||
private typealias Source = String
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,8 +37,6 @@ import java.util.Calendar
|
|||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryModel @Inject constructor(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
<string name="never">Ніколи</string>
|
||||
<string name="undo">Скасувати</string>
|
||||
<string name="copy">Копіювати</string>
|
||||
<string name="report">Повідомити</string>
|
||||
<string name="translate">Перекласти</string>
|
||||
<string name="dictionary">Словник</string>
|
||||
<string name="next">Далі</string>
|
||||
|
|
@ -431,6 +432,10 @@
|
|||
<string name="today">Сьогодні</string>
|
||||
<string name="yesterday">Вчора</string>
|
||||
|
||||
<!-- Crash -->
|
||||
<string name="crash_title">Ой! Застосунок крашнувся.</string>
|
||||
<string name="crash_description">Будь ласка, повідомте про цей збій у найзручніший для вас спосіб.</string>
|
||||
|
||||
<!-- Content description -->
|
||||
<string name="continue_reading_content_desc">Продовжити читання</string>
|
||||
<string name="go_back_content_desc">Назад</string>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
<string name="never">Never</string>
|
||||
<string name="undo">Undo</string>
|
||||
<string name="copy">Copy</string>
|
||||
<string name="report">Report</string>
|
||||
<string name="translate">Translate</string>
|
||||
<string name="dictionary">Dictionary</string>
|
||||
<string name="next">Next</string>
|
||||
|
|
@ -566,6 +567,10 @@
|
|||
<string name="today">Today</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 -->
|
||||
<string name="continue_reading_content_desc">Continue reading</string>
|
||||
<string name="go_back_content_desc">Go back</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue