Skip to content

Commit 9d9157a

Browse files
committed
feat: in-app logcat viewer
closes ssalggnikool#358
1 parent b497102 commit 9d9157a

11 files changed

Lines changed: 287 additions & 0 deletions

File tree

composeApp/src/androidMain/kotlin/paige/navic/di/PlatformModule.android.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.koin.dsl.module
88
import paige.navic.data.database.CacheDatabase
99
import paige.navic.data.database.DownloadDatabase
1010
import paige.navic.domain.manager.ConnectivityManager
11+
import paige.navic.domain.manager.LogManager
1112
import paige.navic.domain.manager.ShareManager
1213
import paige.navic.domain.manager.StorageManager
1314
import paige.navic.domain.repositories.PlayerStateRepository
@@ -60,4 +61,5 @@ actual val platformModule = module {
6061
singleOf(::ShareManager)
6162
singleOf(::StorageManager)
6263
singleOf(::ConnectivityManager)
64+
singleOf(::LogManager)
6365
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package paige.navic.domain.manager
2+
3+
import androidx.compose.runtime.mutableStateListOf
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.Job
7+
import kotlinx.coroutines.SupervisorJob
8+
import kotlinx.coroutines.isActive
9+
import kotlinx.coroutines.launch
10+
import paige.navic.domain.parser.LogLine
11+
import paige.navic.domain.parser.LogLineParser
12+
import java.io.InputStreamReader
13+
14+
private const val LOG_SIZE = 500
15+
16+
actual class LogManager {
17+
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
18+
private var readerJob: Job? = null
19+
20+
private val _logs = mutableStateListOf<LogLine>()
21+
actual val logs: List<LogLine> = _logs
22+
23+
actual fun startStreaming() {
24+
stopStreaming()
25+
_logs.clear()
26+
readerJob = scope.launch {
27+
val process = Runtime.getRuntime().exec(arrayOf("logcat", "--format=tag"))
28+
val reader = InputStreamReader(process.inputStream).buffered()
29+
30+
while (isActive) {
31+
val nextLine = reader.readLine() ?: break
32+
val parsedLine = LogLineParser.parseString(nextLine)
33+
_logs.add(parsedLine)
34+
if (_logs.size > LOG_SIZE) {
35+
_logs.removeAt(0)
36+
}
37+
}
38+
39+
reader.close()
40+
process.destroy()
41+
}
42+
}
43+
44+
actual fun stopStreaming() {
45+
readerJob?.cancel()
46+
readerJob = null
47+
}
48+
49+
actual fun clearLogs() {
50+
Runtime.getRuntime().exec(arrayOf("logcat", "-c"))
51+
_logs.clear()
52+
}
53+
}

composeApp/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
<string name="title_cellular">Cellular</string>
5656
<string name="title_select_lyrics">Select Lyrics</string>
5757
<string name="title_advanced">Advanced Transcoding</string>
58+
<string name="title_logs">Logs</string>
5859

5960
<string name="subtitle_appearance">Themes, colours, layout</string>
6061
<string name="subtitle_now_playing">Customise the fullscreen now playing view</string>

composeApp/src/commonMain/kotlin/paige/navic/App.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import paige.navic.ui.screens.settings.SettingsAppearanceScreen
8585
import paige.navic.ui.screens.settings.SettingsCustomHeadersScreen
8686
import paige.navic.ui.screens.settings.SettingsDataStorageScreen
8787
import paige.navic.ui.screens.settings.SettingsDeveloperScreen
88+
import paige.navic.ui.screens.settings.SettingsLogsScreen
8889
import paige.navic.ui.screens.settings.SettingsNowPlayingScreen
8990
import paige.navic.ui.screens.settings.SettingsPlaybackScreen
9091
import paige.navic.ui.screens.settings.SettingsScreen
@@ -340,5 +341,8 @@ private fun entryProvider(
340341
entry<Screen.Settings.StreamingQuality> {
341342
SettingsStreamingQualityScreen()
342343
}
344+
entry<Screen.Settings.Logs> {
345+
SettingsLogsScreen()
346+
}
343347
}
344348
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package paige.navic.domain.manager
2+
3+
import paige.navic.domain.parser.LogLine
4+
5+
expect class LogManager {
6+
val logs: List<LogLine>
7+
fun startStreaming()
8+
fun stopStreaming()
9+
fun clearLogs()
10+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package paige.navic.domain.parser
2+
3+
import androidx.compose.material3.MaterialTheme
4+
import androidx.compose.runtime.Composable
5+
import paige.navic.ui.theme.warning
6+
7+
data class LogLine(
8+
val type: LogLineType,
9+
val text: String,
10+
val rawText: String
11+
)
12+
13+
enum class LogLineType {
14+
Info,
15+
Warning,
16+
Error,
17+
Debug,
18+
Verbose;
19+
20+
@Composable
21+
fun backgroundColor() = when (this) {
22+
Info -> MaterialTheme.colorScheme.primary
23+
Warning -> MaterialTheme.colorScheme.warning
24+
Error -> MaterialTheme.colorScheme.error
25+
Debug -> MaterialTheme.colorScheme.secondary
26+
Verbose -> MaterialTheme.colorScheme.secondary
27+
}
28+
29+
@Composable
30+
fun contentColor() = when (this) {
31+
Info -> MaterialTheme.colorScheme.onPrimary
32+
Warning -> MaterialTheme.colorScheme.surface
33+
Error -> MaterialTheme.colorScheme.onError
34+
Debug -> MaterialTheme.colorScheme.onSecondary
35+
Verbose -> MaterialTheme.colorScheme.onSecondary
36+
}
37+
38+
companion object {
39+
fun fromChar(char: Char?) = when (char) {
40+
'I' -> Info
41+
'W' -> Warning
42+
'E' -> Error
43+
'D' -> Debug
44+
'V' -> Verbose
45+
else -> Info
46+
}
47+
}
48+
}
49+
50+
object LogLineParser {
51+
fun parseString(rawText: String) = LogLine(
52+
text = rawText.drop(2),
53+
rawText = rawText,
54+
type = LogLineType.fromChar(rawText.firstOrNull())
55+
)
56+
}

composeApp/src/commonMain/kotlin/paige/navic/ui/navigation/Screen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,8 @@ sealed interface Screen : NavKey {
144144
@Immutable
145145
@Serializable
146146
data object StreamingQuality : Settings
147+
@Immutable
148+
@Serializable
149+
data object Logs : Settings
147150
}
148151
}

composeApp/src/commonMain/kotlin/paige/navic/ui/screens/settings/DeveloperScreen.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import navic.composeapp.generated.resources.option_custom_headers
2929
import navic.composeapp.generated.resources.subtitle_check_for_updates
3030
import navic.composeapp.generated.resources.title_confirm
3131
import navic.composeapp.generated.resources.title_developer
32+
import navic.composeapp.generated.resources.title_logs
3233
import org.jetbrains.compose.resources.stringResource
3334
import org.koin.compose.koinInject
3435
import paige.navic.LocalNavStack
@@ -90,6 +91,20 @@ fun SettingsDeveloperScreen() {
9091
Text(stringResource(Res.string.option_custom_headers))
9192
Icon(Icons.Outlined.ChevronForward, null)
9293
}
94+
if (platformContext.platformType == PlatformType.Android) {
95+
FormRow(
96+
onClick = dropUnlessResumed {
97+
backStack.lastOrNull()?.let {
98+
if (it is Screen.Settings.Developer) {
99+
backStack.add(Screen.Settings.Logs)
100+
}
101+
}
102+
}
103+
) {
104+
Text(stringResource(Res.string.title_logs))
105+
Icon(Icons.Outlined.ChevronForward, null)
106+
}
107+
}
93108
}
94109
Form {
95110
FormRow(onClick = {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package paige.navic.ui.screens.settings
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.horizontalScroll
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.layout.size
9+
import androidx.compose.foundation.lazy.LazyColumn
10+
import androidx.compose.foundation.lazy.items
11+
import androidx.compose.foundation.lazy.rememberLazyListState
12+
import androidx.compose.foundation.rememberScrollState
13+
import androidx.compose.material3.Icon
14+
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
15+
import androidx.compose.material3.MaterialTheme
16+
import androidx.compose.material3.Scaffold
17+
import androidx.compose.material3.Surface
18+
import androidx.compose.material3.Text
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.runtime.DisposableEffect
22+
import androidx.compose.runtime.LaunchedEffect
23+
import androidx.compose.ui.Alignment
24+
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.draw.clip
26+
import androidx.compose.ui.platform.LocalClipboardManager
27+
import androidx.compose.ui.text.AnnotatedString
28+
import androidx.compose.ui.text.font.FontFamily
29+
import androidx.compose.ui.unit.dp
30+
import androidx.compose.ui.unit.sp
31+
import navic.composeapp.generated.resources.Res
32+
import navic.composeapp.generated.resources.action_delete
33+
import navic.composeapp.generated.resources.title_logs
34+
import org.jetbrains.compose.resources.stringResource
35+
import org.koin.compose.koinInject
36+
import paige.navic.domain.manager.LogManager
37+
import paige.navic.domain.parser.LogLine
38+
import paige.navic.icons.Icons
39+
import paige.navic.icons.outlined.Delete
40+
import paige.navic.ui.components.layouts.NestedTopBar
41+
import paige.navic.ui.components.layouts.TopBarButton
42+
43+
@Composable
44+
fun SettingsLogsScreen() {
45+
val logManager = koinInject<LogManager>()
46+
val logs = logManager.logs
47+
val listState = rememberLazyListState()
48+
49+
DisposableEffect(Unit) {
50+
logManager.startStreaming()
51+
onDispose {
52+
logManager.stopStreaming()
53+
}
54+
}
55+
56+
LaunchedEffect(logs.size) {
57+
if (logs.lastIndex > 0) {
58+
listState.requestScrollToItem(logs.lastIndex)
59+
}
60+
}
61+
62+
Scaffold(
63+
topBar = {
64+
NestedTopBar(
65+
title = { Text(stringResource(Res.string.title_logs)) },
66+
actions = {
67+
TopBarButton(
68+
onClick = { logManager.clearLogs() },
69+
enabled = logs.isNotEmpty()
70+
) {
71+
Icon(Icons.Outlined.Delete, stringResource(Res.string.action_delete))
72+
}
73+
}
74+
)
75+
}
76+
) { innerPadding ->
77+
CompositionLocalProvider(
78+
LocalMinimumInteractiveComponentSize provides 0.dp
79+
) {
80+
LazyColumn(
81+
modifier = Modifier.horizontalScroll(rememberScrollState()),
82+
state = listState,
83+
contentPadding = innerPadding
84+
) {
85+
items(logs) { line ->
86+
LogLineRow(line = line)
87+
}
88+
}
89+
}
90+
}
91+
}
92+
93+
@Composable
94+
private fun LogLineRow(
95+
line: LogLine
96+
) {
97+
@Suppress("DEPRECATION")
98+
val clipboardManager = LocalClipboardManager.current
99+
Surface(
100+
onClick = {
101+
clipboardManager.setText(AnnotatedString(line.rawText))
102+
}
103+
) {
104+
Row(
105+
verticalAlignment = Alignment.CenterVertically
106+
) {
107+
Box(
108+
modifier = Modifier
109+
.padding(horizontal = 3.dp, vertical = 1.5.dp)
110+
.size(22.dp)
111+
.clip(MaterialTheme.shapes.extraSmall)
112+
.background(line.type.backgroundColor()),
113+
contentAlignment = Alignment.Center
114+
) {
115+
Text(
116+
text = line.type.name.first().toString(),
117+
fontSize = 12.sp,
118+
color = line.type.contentColor()
119+
)
120+
}
121+
122+
Text(
123+
text = line.text,
124+
fontFamily = FontFamily.Monospace,
125+
fontSize = 12.sp,
126+
maxLines = 1
127+
)
128+
}
129+
}
130+
}

composeApp/src/iosMain/kotlin/paige/navic/di/PlatformModule.ios.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import org.koin.dsl.module
99
import paige.navic.data.database.CacheDatabase
1010
import paige.navic.data.database.DownloadDatabase
1111
import paige.navic.domain.manager.ConnectivityManager
12+
import paige.navic.domain.manager.LogManager
1213
import paige.navic.domain.manager.ShareManager
1314
import paige.navic.domain.manager.StorageManager
1415
import paige.navic.domain.repositories.PlayerStateRepository
@@ -68,6 +69,7 @@ actual val platformModule = module {
6869
single<CoilPlatformContext> { CoilPlatformContext.INSTANCE }
6970
singleOf(::StorageManager)
7071
singleOf(::ConnectivityManager)
72+
singleOf(::LogManager)
7173
}
7274

7375
@OptIn(ExperimentalForeignApi::class)

0 commit comments

Comments
 (0)