Skip to content

Commit 853fee1

Browse files
authored
Merge pull request #31 from SmilingPixel/feat/logging_system_0610
feat: Enhance custom Logger with error handling and persistence features
2 parents dd80443 + 5d4cff1 commit 853fee1

16 files changed

Lines changed: 649 additions & 31 deletions

File tree

composeApp/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ kotlin {
5252
androidMain.dependencies {
5353
implementation(compose.preview)
5454
implementation(libs.androidx.activity.compose)
55+
implementation(libs.androidx.core.ktx)
5556
}
5657
commonMain.dependencies {
5758
implementation(compose.runtime)

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@
2424
<category android:name="android.intent.category.LAUNCHER" />
2525
</intent-filter>
2626
</activity>
27+
<provider
28+
android:name="androidx.core.content.FileProvider"
29+
android:authorities="${applicationId}.fileprovider"
30+
android:exported="false"
31+
android:grantUriPermissions="true">
32+
<meta-data
33+
android:name="android.support.FILE_PROVIDER_PATHS"
34+
android:resource="@xml/log_export_paths" />
35+
</provider>
2736
</application>
2837

29-
</manifest>
38+
</manifest>

composeApp/src/androidMain/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ Key components found in this module:
99
- **Networking:** Android-native networking components, like the Ktor `okhttp` client engine.
1010
- **System Integrations:** Access to Google Play Services for things like Google Drive Auth and Cloud Sync features.
1111
- **Platform APIs:** Specific implementations requiring `Context` or Android application framework resources.
12+
13+
## Build and Manifest Notes
14+
Android compilation depends on AndroidX Core KTX because Android log export uses `FileProvider` to share generated log files safely with other apps. Keep the `FileProvider` declaration in `AndroidManifest.xml` and the matching paths resource in `res/xml/log_export_paths.xml` in sync with the logger export implementation.
15+
16+
To validate Android-specific changes from the repository root, run:
17+
18+
```bash
19+
./gradlew assembleDebug
20+
```

composeApp/src/androidMain/kotlin/io/github/smiling_pixel/sync/AutoSync.android.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package io.github.smiling_pixel.sync
22

3-
import android.util.Log
43
import io.github.smiling_pixel.database.DiaryRepository
54
import io.github.smiling_pixel.client.getCloudDriveClient
65
import io.github.smiling_pixel.preference.getSettingsRepository
6+
import io.github.smiling_pixel.util.Logger
7+
import io.github.smiling_pixel.util.e
78
import kotlinx.coroutines.CoroutineScope
89
import kotlinx.coroutines.CancellationException
910
import kotlinx.coroutines.Dispatchers
@@ -37,11 +38,7 @@ actual fun startAutoSync(repo: DiaryRepository): Job? {
3738
// Do not treat normal coroutine cancellation as a sync failure.
3839
throw e
3940
} catch (e: Exception) {
40-
Log.e(
41-
AUTO_SYNC_LOG_TAG,
42-
AUTO_SYNC_ERROR_MESSAGE,
43-
e,
44-
)
41+
Logger.e(AUTO_SYNC_LOG_TAG, AUTO_SYNC_ERROR_MESSAGE, e)
4542
nextDelayMs = (nextDelayMs * 2).coerceAtMost(AUTO_SYNC_MAX_BACKOFF_MS)
4643
}
4744
}
Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,134 @@
11
package io.github.smiling_pixel.util
22

3+
import android.content.Intent
4+
import androidx.core.content.FileProvider
35
import android.util.Log
6+
import io.github.smiling_pixel.preference.AndroidContextProvider
7+
import java.io.File
8+
import java.text.SimpleDateFormat
9+
import java.util.Date
10+
import java.util.Locale
411

512
actual object Logger {
6-
actual fun log(level: LogLevel, tag: String, message: String) {
13+
private const val MAX_LOG_BYTES = 1_048_576L
14+
private const val LOG_FILE_NAME = "markday.log"
15+
private const val PREVIOUS_LOG_FILE_NAME = "markday.previous.log"
16+
17+
@Volatile
18+
private var minLogLevel: LogLevel = LogLevel.ERROR
19+
@Volatile
20+
private var isPersistenceEnabled: Boolean = false
21+
private val lock = Any()
22+
23+
actual fun setLogLevel(level: LogLevel) {
24+
minLogLevel = level
25+
}
26+
27+
actual fun getLogLevel(): LogLevel = minLogLevel
28+
29+
actual fun setPersistenceEnabled(enabled: Boolean) {
30+
isPersistenceEnabled = enabled
31+
}
32+
33+
actual fun isPersistenceEnabled(): Boolean = isPersistenceEnabled
34+
35+
actual fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?) {
36+
if (level.ordinal < minLogLevel.ordinal) return
737
when (level) {
8-
LogLevel.DEBUG -> Log.d(tag, message)
9-
LogLevel.INFO -> Log.i(tag, message)
10-
LogLevel.WARN -> Log.w(tag, message)
11-
LogLevel.ERROR -> Log.e(tag, message)
38+
LogLevel.DEBUG -> Log.d(tag, message, throwable)
39+
LogLevel.INFO -> Log.i(tag, message, throwable)
40+
LogLevel.WARN -> Log.w(tag, message, throwable)
41+
LogLevel.ERROR -> Log.e(tag, message, throwable)
42+
}
43+
if (isPersistenceEnabled) {
44+
persist(level, tag, message, throwable)
45+
}
46+
}
47+
48+
actual suspend fun exportPersistedLogs(): LogExportResult {
49+
return try {
50+
val context = AndroidContextProvider.context
51+
val content = synchronized(lock) { readPersistedLogs() }
52+
if (content.isBlank()) {
53+
return LogExportResult.NoLogs
54+
}
55+
56+
val exportDir = File(context.cacheDir, "log_exports").also { it.mkdirs() }
57+
val exportFile = File(exportDir, timestampedExportFileName())
58+
exportFile.writeText(content)
59+
60+
val uri = FileProvider.getUriForFile(
61+
context,
62+
"${context.packageName}.fileprovider",
63+
exportFile
64+
)
65+
val sendIntent = Intent(Intent.ACTION_SEND).apply {
66+
type = "text/plain"
67+
putExtra(Intent.EXTRA_STREAM, uri)
68+
putExtra(Intent.EXTRA_SUBJECT, "MarkDay logs")
69+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
70+
}
71+
val chooser = Intent.createChooser(sendIntent, "Export MarkDay logs").apply {
72+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
73+
}
74+
context.startActivity(chooser)
75+
76+
LogExportResult.Success(exportFile.name, "Android share sheet")
77+
} catch (e: Exception) {
78+
LogExportResult.Failure(e.message ?: "Unable to export logs.")
79+
}
80+
}
81+
82+
actual suspend fun clearPersistedLogs() {
83+
synchronized(lock) {
84+
logFile().delete()
85+
previousLogFile().delete()
86+
}
87+
}
88+
89+
private fun persist(level: LogLevel, tag: String, message: String, throwable: Throwable?) {
90+
try {
91+
synchronized(lock) {
92+
val file = logFile()
93+
file.parentFile?.mkdirs()
94+
if (file.length() >= MAX_LOG_BYTES) {
95+
previousLogFile().delete()
96+
if (!file.renameTo(previousLogFile())) {
97+
file.writeText("")
98+
}
99+
}
100+
file.appendText(formatLine(level, tag, message, throwable))
101+
}
102+
} catch (e: Exception) {
103+
// Logging must never fail the app or recursively log logger-internal errors.
104+
}
105+
}
106+
107+
private fun readPersistedLogs(): String {
108+
val previous = previousLogFile().takeIf { it.exists() }?.readText().orEmpty()
109+
val current = logFile().takeIf { it.exists() }?.readText().orEmpty()
110+
return previous + current
111+
}
112+
113+
private fun formatLine(level: LogLevel, tag: String, message: String, throwable: Throwable?): String {
114+
val fullMessage = if (throwable != null) {
115+
"$message\n${throwable.stackTraceToString()}"
116+
} else {
117+
message
12118
}
119+
return "${timestamp()} [$level] $tag: $fullMessage\n"
120+
}
121+
122+
private fun timestamp(): String = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US).format(Date())
123+
124+
private fun timestampedExportFileName(): String {
125+
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
126+
return "markday-log-$timestamp.txt"
13127
}
128+
129+
private fun logsDir(): File = File(AndroidContextProvider.context.filesDir, "logs")
130+
131+
private fun logFile(): File = File(logsDir(), LOG_FILE_NAME)
132+
133+
private fun previousLogFile(): File = File(logsDir(), PREVIOUS_LOG_FILE_NAME)
14134
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<paths xmlns:android="http://schemas.android.com/apk/res/android">
3+
<cache-path name="log_exports" path="log_exports/" />
4+
</paths>

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import io.github.smiling_pixel.screens.InsightsScreen
4747
import io.github.smiling_pixel.screens.MomentsScreen
4848
import io.github.smiling_pixel.screens.ProfileScreen
4949
import io.github.smiling_pixel.screens.SettingsScreen
50+
import io.github.smiling_pixel.util.Logger
5051
import coil3.compose.setSingletonImageLoaderFactory
5152

5253
@Serializable
@@ -84,11 +85,18 @@ fun App(
8485
val settingsRepository = remember { getSettingsRepository() }
8586
val themeMode by settingsRepository.themeMode.collectAsState(initial = null)
8687
val isPureBlackEnabled by settingsRepository.isPureBlackEnabled.collectAsState(initial = null)
88+
val logLevel by settingsRepository.logLevel.collectAsState(initial = null)
89+
val isLogPersistenceEnabled by settingsRepository.isLogPersistenceEnabled.collectAsState(initial = null)
8790

88-
if (themeMode == null || isPureBlackEnabled == null) {
91+
if (themeMode == null || isPureBlackEnabled == null || logLevel == null || isLogPersistenceEnabled == null) {
8992
return
9093
}
9194

95+
LaunchedEffect(logLevel, isLogPersistenceEnabled) {
96+
Logger.setLogLevel(logLevel!!)
97+
Logger.setPersistenceEnabled(isLogPersistenceEnabled!!)
98+
}
99+
92100
val useDarkTheme = themeMode == ThemeMode.DARK || (themeMode == ThemeMode.SYSTEM && isSystemInDarkTheme())
93101

94102
MarkDayTheme(

composeApp/src/commonMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package io.github.smiling_pixel.preference
22

33
import io.github.smiling_pixel.theme.ThemeMode
4+
import io.github.smiling_pixel.util.LogLevel
45
import kotlinx.coroutines.flow.Flow
56

7+
/**
8+
* Provides persisted user settings for the application.
9+
*/
610
interface SettingsRepository {
711
/**
812
* A flow emitting the current [ThemeMode] setting of the application.
@@ -33,6 +37,33 @@ interface SettingsRepository {
3337

3438
val cloudSyncDeletionTombstonesJson: Flow<String?>
3539
suspend fun setCloudSyncDeletionTombstonesJson(value: String?)
40+
41+
/**
42+
* A flow emitting the minimum log level used by the application logger.
43+
*/
44+
val logLevel: Flow<LogLevel>
45+
46+
/**
47+
* Updates the minimum log level used by the application logger.
48+
*
49+
* @param level The minimum log severity to emit.
50+
*/
51+
suspend fun setLogLevel(level: LogLevel)
52+
53+
/**
54+
* A flow emitting whether filtered logs should be persisted.
55+
*/
56+
val isLogPersistenceEnabled: Flow<Boolean>
57+
58+
/**
59+
* Updates whether filtered logs should be persisted.
60+
*
61+
* @param enabled Whether log persistence should be enabled.
62+
*/
63+
suspend fun setLogPersistenceEnabled(enabled: Boolean)
3664
}
3765

66+
/**
67+
* Returns the platform-specific settings repository.
68+
*/
3869
expect fun getSettingsRepository(): SettingsRepository

0 commit comments

Comments
 (0)