Share Logs on Production Environment (Android)
How to implement health log export on Android—persistent DB storage, WorkManager for retention and upload, chunked upload with progress, and memory-leak prevention.
Introduction
Production apps need a way for users to share diagnostic logs with support. When a user taps “Share health logs” in Settings, the app should upload logs from the last N seconds to a server—without blocking the UI or leaking memory.
This post covers:
- Custom logging – Log to console and persist to DB in one call
- Retention – WorkManager deletes logs older than N seconds (config-driven)
- Export flow – User consent → worker uploads logs in chunks of size M (config-driven)
- Progress UI – Show upload status on screen
- Architecture – Screen → ViewModel → Repo → Use case
- Memory-leak prevention – Proper lifecycle and scope handling
1. Configuration
Define N (retention seconds) and M (chunk size) in config so they can be tuned per environment.
// LogConfig.kt
data class LogConfig(
/** Logs older than this (seconds) are deleted by retention worker */
val retentionSeconds: Long,
/** Upload logs in chunks of this size */
val uploadChunkSize: Int
)
// In your config module or BuildConfig
object AppLogConfig {
val logConfig: LogConfig = LogConfig(
retentionSeconds = 7 * 24 * 60 * 60, // 7 days
uploadChunkSize = 500
)
}
2. Data Layer: Log Entity and DAO
Use Room to store logs. Each entry has a timestamp for retention and ordering.
// LogEntity.kt
@Entity(tableName = "app_logs")
data class LogEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val level: String, // DEBUG, INFO, WARN, ERROR
val tag: String,
val message: String,
val timestamp: Long,
val throwable: String? = null
)
// LogDao.kt
@Dao
interface LogDao {
@Insert
suspend fun insert(log: LogEntity): Long
@Query("DELETE FROM app_logs WHERE timestamp < :beforeTimestamp")
suspend fun deleteOlderThan(beforeTimestamp: Long)
@Query("SELECT * FROM app_logs WHERE timestamp >= :sinceTimestamp ORDER BY timestamp ASC")
fun getLogsSince(sinceTimestamp: Long): Flow<List<LogEntity>>
@Query("SELECT * FROM app_logs WHERE timestamp >= :sinceTimestamp ORDER BY timestamp ASC")
suspend fun getLogsSinceSync(sinceTimestamp: Long): List<LogEntity>
@Query("SELECT COUNT(*) FROM app_logs WHERE timestamp >= :sinceTimestamp")
suspend fun countLogsSince(sinceTimestamp: Long): Int
}
3. Custom Logger: Console + DB
A single method logs to both Logcat and the database. All app logging goes through this.
// AppLogger.kt
class AppLogger(
private val logDao: LogDao,
private val config: LogConfig,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
fun log(level: LogLevel, tag: String, message: String, throwable: Throwable? = null) {
// Console
when (level) {
LogLevel.DEBUG -> Log.d(tag, message, throwable)
LogLevel.INFO -> Log.i(tag, message, throwable)
LogLevel.WARN -> Log.w(tag, message, throwable)
LogLevel.ERROR -> Log.e(tag, message, throwable)
}
// DB (fire-and-forget on IO)
CoroutineScope(ioDispatcher).launch {
logDao.insert(
LogEntity(
level = level.name,
tag = tag,
message = message,
timestamp = System.currentTimeMillis(),
throwable = throwable?.stackTraceToString()
)
)
}
}
fun d(tag: String, message: String) = log(LogLevel.DEBUG, tag, message)
fun i(tag: String, message: String) = log(LogLevel.INFO, tag, message)
fun w(tag: String, message: String) = log(LogLevel.WARN, tag, message)
fun e(tag: String, message: String, throwable: Throwable? = null) = log(LogLevel.ERROR, tag, message, throwable)
}
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
Note: The logger uses its own CoroutineScope with Dispatchers.IO. It does not depend on ViewModel or Activity scope, so it can be used from anywhere (repositories, use cases, workers).
4. Use Case: Upload Logs
The use case defines the contract: upload logs since a given timestamp, in chunks, and report progress.
// UploadLogsUseCase.kt
interface UploadLogsUseCase {
/**
* Uploads logs since [sinceTimestamp] in chunks.
* Emits [UploadProgress] for each chunk or completion.
*/
fun uploadLogs(sinceTimestamp: Long): Flow<UploadProgress>
}
sealed class UploadProgress {
data class InProgress(val uploadedChunks: Int, val totalChunks: Int, val totalLogs: Int) : UploadProgress()
data object Completed : UploadProgress()
data class Error(val throwable: Throwable) : UploadProgress()
}
5. Repository Implementation
The repository fetches logs from the DB, chunks them, and uploads via an API. It emits progress for each chunk.
// LogUploadRepository.kt
class LogUploadRepository(
private val logDao: LogDao,
private val logUploadApi: LogUploadApi,
private val config: LogConfig,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : UploadLogsUseCase {
override fun uploadLogs(sinceTimestamp: Long): Flow<UploadProgress> = flow {
withContext(ioDispatcher) {
val allLogs = logDao.getLogsSinceSync(sinceTimestamp)
if (allLogs.isEmpty()) {
emit(UploadProgress.Completed)
return@withContext
}
val chunks = allLogs.chunked(config.uploadChunkSize)
val totalChunks = chunks.size
chunks.forEachIndexed { index, chunk ->
val request = LogUploadRequest(
logs = chunk.map { it.toDto() },
chunkIndex = index,
totalChunks = totalChunks
)
logUploadApi.uploadLogs(request)
emit(
UploadProgress.InProgress(
uploadedChunks = index + 1,
totalChunks = totalChunks,
totalLogs = allLogs.size
)
)
}
emit(UploadProgress.Completed)
}
}.catch { e ->
emit(UploadProgress.Error(e))
}
private fun LogEntity.toDto() = LogDto(level, tag, message, timestamp, throwable)
}
// API
interface LogUploadApi {
suspend fun uploadLogs(request: LogUploadRequest)
}
data class LogUploadRequest(
val logs: List<LogDto>,
val chunkIndex: Int,
val totalChunks: Int
)
data class LogDto(val level: String, val tag: String, val message: String, val timestamp: Long, val throwable: String?)
6. Retention Worker: Delete Old Logs
WorkManager runs periodically to delete logs older than N seconds.
// LogRetentionWorker.kt
class LogRetentionWorker(
context: Context,
params: WorkerParameters,
private val logDao: LogDao,
private val config: LogConfig
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
val cutoff = System.currentTimeMillis() - (config.retentionSeconds * 1000)
logDao.deleteOlderThan(cutoff)
Result.success()
} catch (e: Exception) {
Result.failure()
}
}
class Factory(
private val logDao: LogDao,
private val config: LogConfig
) : WorkerFactory() {
override fun createWorker(context: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker {
return LogRetentionWorker(context, workerParameters, logDao, config)
}
}
}
// WorkManager configuration (Application)
// With Hilt: use @HiltWorker and @WorkerInject; no manual factory needed.
// With Koin: use DelegatingWorkerFactory to route LogRetentionWorker to your Factory:
val workManagerConfig = Configuration.Builder()
.setWorkerFactory(
DelegatingWorkerFactory().apply {
addFactory(LogRetentionWorker.Factory(logDao, config))
}
)
.build()
WorkManager.initialize(context, workManagerConfig)
// Schedule in Application or a setup module
fun scheduleLogRetention(context: Context) {
val request = PeriodicWorkRequestBuilder<LogRetentionWorker>(1, TimeUnit.DAYS)
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"log_retention",
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
7. Upload Worker: Export on User Consent
When the user taps “Share health logs”, enqueue a one-time worker that uploads logs. The worker runs in the background; the UI observes progress via a shared mechanism (e.g. WorkManager.getWorkInfosByIdLiveData or a custom progress channel).
For progress on screen, we need the UI to observe upload state. Two approaches:
Option A: Worker + Progress via Shared State
The worker writes progress to a repository that exposes Flow. The ViewModel collects this flow. The worker and ViewModel share a UploadProgressRepository that holds the current progress.
Option B: Direct ViewModel → Use Case (Recommended)
Skip the worker for the progress observation: the ViewModel calls the use case directly and collects the flow. The actual upload runs on Dispatchers.IO. When the screen is closed, the ViewModel is cleared and the coroutine is cancelled—no upload in background. If you need upload to continue in background when the user leaves the screen, use Option A with a worker that reports progress to a shared store.
Here we show Option B (simpler, progress in foreground) and then Option A (background upload with progress).
Option B: Foreground Upload (ViewModel → Use Case)
User stays on screen; upload runs in ViewModel scope; progress is observed directly.
// SettingsViewModel.kt
class SettingsViewModel(
private val uploadLogsUseCase: UploadLogsUseCase,
private val config: LogConfig
) : ViewModel() {
private val _uploadState = MutableStateFlow<UploadState>(UploadState.Idle)
val uploadState: StateFlow<UploadState> = _uploadState.asStateFlow()
fun shareHealthLogs() {
viewModelScope.launch {
val sinceTimestamp = System.currentTimeMillis() - (config.retentionSeconds * 1000)
_uploadState.value = UploadState.InProgress(0, 0, 0)
uploadLogsUseCase.uploadLogs(sinceTimestamp)
.collect { progress ->
when (progress) {
is UploadProgress.InProgress -> {
_uploadState.value = UploadState.InProgress(
progress.uploadedChunks,
progress.totalChunks,
progress.totalLogs
)
}
is UploadProgress.Completed -> _uploadState.value = UploadState.Completed
is UploadProgress.Error -> _uploadState.value = UploadState.Error(progress.throwable.message ?: "Unknown error")
}
}
}
}
fun resetUploadState() {
_uploadState.value = UploadState.Idle
}
}
sealed class UploadState {
data object Idle : UploadState()
data class InProgress(val uploadedChunks: Int, val totalChunks: Int, val totalLogs: Int) : UploadState()
data object Completed : UploadState()
data class Error(val message: String) : UploadState()
}
Option A: Background Upload with Progress
If upload must continue when the user navigates away, use a worker and a shared progress store.
// UploadProgressStore.kt - Shared between Worker and ViewModel
class UploadProgressStore {
private val _progress = MutableSharedFlow<UploadProgress>(replay = 1)
val progress: SharedFlow<UploadProgress> = _progress.asSharedFlow()
suspend fun emit(progress: UploadProgress) {
_progress.emit(progress)
}
}
// LogUploadWorker.kt
class LogUploadWorker(
context: Context,
params: WorkerParameters,
private val uploadLogsUseCase: UploadLogsUseCase,
private val progressStore: UploadProgressStore,
private val config: LogConfig
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val sinceTimestamp = System.currentTimeMillis() - (config.retentionSeconds * 1000)
try {
uploadLogsUseCase.uploadLogs(sinceTimestamp).collect { progress ->
progressStore.emit(progress)
if (progress is UploadProgress.Completed || progress is UploadProgress.Error) {
return@withContext if (progress is UploadProgress.Completed) Result.success() else Result.failure()
}
}
Result.success()
} catch (e: Exception) {
progressStore.emit(UploadProgress.Error(e))
Result.failure()
}
}
}
The ViewModel observes progressStore.progress and updates _uploadState. The worker is enqueued when the user taps “Share health logs”.
8. Screen: Progress UI
Compose example showing progress during upload.
// SettingsScreen.kt
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val uploadState by viewModel.uploadState.collectAsStateWithLifecycle()
Column(modifier = Modifier.padding(16.dp)) {
// ... other settings ...
Button(
onClick = { viewModel.shareHealthLogs() },
enabled = uploadState is UploadState.Idle || uploadState is UploadState.Completed || uploadState is UploadState.Error
) {
Text("Share health logs")
}
when (val state = uploadState) {
is UploadState.InProgress -> {
LinearProgressIndicator(
progress = { state.uploadedChunks.toFloat() / state.totalChunks },
modifier = Modifier.fillMaxWidth()
)
Text("Uploading ${state.uploadedChunks}/${state.totalChunks} chunks (${state.totalLogs} logs)")
}
is UploadState.Completed -> {
Text("Upload complete", color = Color.Green)
LaunchedEffect(Unit) {
delay(2000)
viewModel.resetUploadState()
}
}
is UploadState.Error -> {
Text("Error: ${state.message}", color = Color.Red)
}
UploadState.Idle -> { }
}
}
}
9. Layering: Screen → ViewModel → Repo → Use Case
Screen (Compose / XML)
│ observes uploadState
│ calls shareHealthLogs()
▼
ViewModel (SettingsViewModel)
│ shareHealthLogs() → uploadLogsUseCase.uploadLogs()
│ collect Flow → update _uploadState
▼
Repository (LogUploadRepository implements UploadLogsUseCase)
│ getLogsSinceSync(), chunked upload via API
▼
Data sources (LogDao, LogUploadApi)
The use case interface lives in a core/domain module. The repository implements it in the data module. ViewModels depend on UploadLogsUseCase, not the concrete repository. Tests inject a fake use case.
10. Avoiding Memory Leaks
10.1 Use viewModelScope for Upload Collection
All coroutines that collect the upload flow must run in viewModelScope. When the ViewModel is cleared, the scope is cancelled and the collection stops. No orphaned coroutines.
viewModelScope.launch {
uploadLogsUseCase.uploadLogs(sinceTimestamp).collect { ... }
}
10.2 Avoid Capturing Activity or Fragment
Never pass Activity, Fragment, or a non-application Context into the ViewModel. Use Application context or inject application-scoped dependencies.
// BAD
class MyViewModel(private val activity: Activity) : BaseStockStreamViewModel(...)
// GOOD
class SettingsViewModel(
private val uploadLogsUseCase: UploadLogsUseCase,
private val config: LogConfig
) : ViewModel()
10.3 AppLogger Scope
AppLogger uses CoroutineScope(ioDispatcher).launch. That scope is not tied to any lifecycle. Each log is a fire-and-forget job. To avoid unbounded growth, ensure the scope is either:
- Application-scoped – A single
CoroutineScopefor the whole app, cancelled only on process death, or - Supervised – Use
SupervisorJobso one failing insert does not cancel others
// Application-scoped logger
class AppLogger(
private val logDao: LogDao,
private val applicationScope: CoroutineScope // Injected; e.g. ProcessLifecycleOwner scope
) {
fun log(...) {
Log.d(tag, message, throwable)
applicationScope.launch(Dispatchers.IO) {
logDao.insert(...)
}
}
}
10.4 Worker and Progress Store
If using UploadProgressStore with a worker: the store should be a singleton (e.g. in DI). The ViewModel collects from it. When the ViewModel is cleared, the collection is cancelled. The store itself does not hold references to the ViewModel or screen. Workers hold no references to UI.
10.5 Reset State on Idle
When upload completes or errors, reset state after a delay or on user action. Avoid holding large UploadState objects longer than needed.
fun resetUploadState() {
_uploadState.value = UploadState.Idle
}
11. Summary
| Topic | Approach |
|---|---|
| Logging | AppLogger logs to console and DB; single entry point |
| Retention | WorkManager LogRetentionWorker deletes logs older than N seconds (config) |
| Export | User taps “Share health logs” → ViewModel calls UploadLogsUseCase or enqueues LogUploadWorker |
| Chunking | Upload in chunks of size M (config); repository chunks and uploads sequentially |
| Progress | Flow<UploadProgress> emitted per chunk; ViewModel collects and exposes StateFlow<UploadState> |
| Architecture | Screen → ViewModel → Repo → Use case |
| Memory leaks | Use viewModelScope; avoid Activity/Fragment in ViewModel; application-scoped logger scope; workers hold no UI refs |
With this setup, users can share health logs from Settings, see upload progress, and the app retains logs only for the configured period while avoiding common memory-leak pitfalls.