Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.gradle.ktlint) version (libs.versions.ktlint)
alias(libs.plugins.ksp)
}

android {
Expand Down Expand Up @@ -66,6 +67,7 @@ android {
buildFeatures {
compose = true
resValues = true
buildConfig = true
}
packaging {
resources {
Expand Down Expand Up @@ -108,4 +110,8 @@ dependencies {
implementation(libs.tika.core)
implementation(libs.quartz)
implementation(libs.tor.android)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.work.runtime.ktx)
}
92 changes: 46 additions & 46 deletions app/src/main/java/com/greenart7c3/morganite/CustomHttpServer.kt

Large diffs are not rendered by default.

17 changes: 3 additions & 14 deletions app/src/main/java/com/greenart7c3/morganite/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.greenart7c3.morganite

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
Expand Down Expand Up @@ -41,25 +40,15 @@ import kotlinx.coroutines.launch
import java.text.DecimalFormat

class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
Morganite.instance.startLogStream()
}

override fun onStop() {
Morganite.instance.stopLogStream()
super.onStop()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MorganiteTheme {
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted: Boolean ->
Log.d("MainActivity", "Permission granted: $isGranted")
onResult = { _: Boolean ->

}
)

Expand All @@ -70,7 +59,7 @@ class MainActivity : ComponentActivity() {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val isRunning by Morganite.instance.httpServer.isRunning.collectAsStateWithLifecycle()
val settings by Morganite.instance.settingsManager.settings.collectAsStateWithLifecycle()
val logStream by Morganite.instance.logStream.collectAsStateWithLifecycle()
val logStream by Morganite.instance.logs.collectAsStateWithLifecycle()

Column(
modifier = Modifier
Expand Down
87 changes: 44 additions & 43 deletions app/src/main/java/com/greenart7c3/morganite/Morganite.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,76 @@ package com.greenart7c3.morganite

import android.app.Application
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.greenart7c3.morganite.logs.LogCleanupWorker
import com.greenart7c3.morganite.logs.LogDatabase
import com.greenart7c3.morganite.logs.LogEntry
import com.greenart7c3.morganite.logs.MorganiteLog
import com.greenart7c3.morganite.models.SettingsManager
import com.greenart7c3.morganite.service.AndroidFileStore
import com.greenart7c3.morganite.service.HttpServerService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit

class Morganite: Application() {
class Morganite : Application() {
lateinit var httpServer: CustomHttpServer
lateinit var settingsManager: SettingsManager
lateinit var logDatabase: LogDatabase
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val logStream = MutableStateFlow<List<String>>(emptyList())

private var logStreamJob: Job? = null
private var logStreamProcess: Process? = null
private val timeFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)

/**
* Last [LOG_VIEWER_LIMIT] log lines from the local database, oldest first so
* the UI can auto-scroll to the most recent entry.
*/
val logs: StateFlow<List<String>> by lazy {
logDatabase.logDao().recent(LOG_VIEWER_LIMIT).map { entries ->
entries.asReversed().map { it.format() }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

override fun onCreate() {
super.onCreate()

Log.d(TAG, "onCreate")

instance = this
logDatabase = LogDatabase.getInstance(this)

MorganiteLog.d(TAG, "onCreate")

settingsManager = SettingsManager(this)
scheduleLogCleanup()
startService()
httpServer = CustomHttpServer(AndroidFileStore(this), settingsManager)
scope.launch {
httpServer.start()
}
}

@Synchronized
fun startLogStream() {
if (logStreamJob?.isActive == true) return
logStreamJob = scope.launch(Dispatchers.IO) {
try {
Runtime.getRuntime().exec(arrayOf("logcat", "-c"))
// Filter at the logcat level so only Morganite-tagged lines are
// delivered to this process. Without "$TAG:V *:S" we would receive
// every log line from every app on the device and scan each one,
// waking the CPU constantly the whole time the UI is visible.
val process = Runtime.getRuntime().exec(arrayOf("logcat", "-v", "time", "$TAG:V", "*:S"))
logStreamProcess = process
process.inputStream.bufferedReader().use { reader ->
while (true) {
val line = reader.readLine() ?: break
if (line.contains(TAG)) {
logStream.value = (logStream.value + line).takeLast(100)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start log stream", e)
} finally {
logStreamProcess = null
}
}
private fun scheduleLogCleanup() {
val request = PeriodicWorkRequestBuilder<LogCleanupWorker>(1, TimeUnit.HOURS).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
LogCleanupWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}

@Synchronized
fun stopLogStream() {
logStreamProcess?.destroy()
logStreamProcess = null
logStreamJob?.cancel()
logStreamJob = null
}
private fun LogEntry.format(): String =
"${timeFormat.format(Date(timestamp))} $level/$tag: $message"

fun startService() {
try {
Expand All @@ -80,12 +80,13 @@ class Morganite: Application() {
Intent(this, HttpServerService::class.java),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to start HttpServerService", e)
MorganiteLog.e(TAG, "Failed to start HttpServerService", e)
}
}

companion object {
const val TAG = "Morganite"
const val LOG_VIEWER_LIMIT = 500

lateinit var instance: Morganite
private set
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.greenart7c3.morganite.logs

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters

/**
* Periodically deletes log entries older than [LOG_RETENTION_MS] (1 week).
* Scheduled as unique periodic work from [com.greenart7c3.morganite.Morganite].
*/
class LogCleanupWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val cutoff = System.currentTimeMillis() - LOG_RETENTION_MS
LogDatabase.getInstance(applicationContext).logDao().deleteOlderThan(cutoff)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}

companion object {
const val WORK_NAME = "log_cleanup"
const val LOG_RETENTION_MS = 7L * 24 * 60 * 60 * 1000
}
}
18 changes: 18 additions & 0 deletions app/src/main/java/com/greenart7c3/morganite/logs/LogDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.greenart7c3.morganite.logs

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface LogDao {
@Insert
suspend fun insert(entry: LogEntry)

@Query("SELECT * FROM logs ORDER BY id DESC LIMIT :limit")
fun recent(limit: Int): Flow<List<LogEntry>>

@Query("DELETE FROM logs WHERE timestamp < :cutoff")
suspend fun deleteOlderThan(cutoff: Long)
}
29 changes: 29 additions & 0 deletions app/src/main/java/com/greenart7c3/morganite/logs/LogDatabase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.greenart7c3.morganite.logs

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [LogEntry::class], version = 1, exportSchema = false)
abstract class LogDatabase : RoomDatabase() {
abstract fun logDao(): LogDao

companion object {
@Volatile
private var instance: LogDatabase? = null

fun getInstance(context: Context): LogDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
LogDatabase::class.java,
"logs.db",
)
.fallbackToDestructiveMigration(true)
.build()
.also { instance = it }
}
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/greenart7c3/morganite/logs/LogEntry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.greenart7c3.morganite.logs

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "logs")
data class LogEntry(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val timestamp: Long,
val level: String,
val tag: String,
val message: String,
)
55 changes: 55 additions & 0 deletions app/src/main/java/com/greenart7c3/morganite/logs/MorganiteLog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.greenart7c3.morganite.logs

import android.util.Log
import com.greenart7c3.morganite.BuildConfig
import com.greenart7c3.morganite.Morganite
import kotlinx.coroutines.launch

/**
* Logging entry point for the app. Persists every log line to the local Room
* database (the source of truth for the in-app log viewer) and, in debug builds
* only, also forwards to [android.util.Log] so `adb logcat` keeps working.
*/
object MorganiteLog {
fun d(tag: String, message: String) = log("D", tag, message, null)

fun i(tag: String, message: String) = log("I", tag, message, null)

fun w(tag: String, message: String, throwable: Throwable? = null) = log("W", tag, message, throwable)

fun e(tag: String, message: String, throwable: Throwable? = null) = log("E", tag, message, throwable)

private fun log(level: String, tag: String, message: String, throwable: Throwable?) {
if (BuildConfig.DEBUG) {
when (level) {
"E" -> Log.e(tag, message, throwable)
"W" -> Log.w(tag, message, throwable)
"I" -> Log.i(tag, message)
else -> Log.d(tag, message)
}
}

val fullMessage = if (throwable != null) {
"$message\n${Log.getStackTraceString(throwable)}"
} else {
message
}

val app = Morganite.instance
val dao = LogDatabase.getInstance(app).logDao()
app.scope.launch {
try {
dao.insert(
LogEntry(
timestamp = System.currentTimeMillis(),
level = level,
tag = tag,
message = fullMessage,
),
)
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e(tag, "Failed to persist log entry", e)
}
}
}
}
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ okhttp = "5.3.2"
tikaCore = "3.2.3"
quartz = "1.05.1"
torAndroid = "0.4.8.22"
room = "2.8.4"
ksp = "2.3.9"
work = "2.10.5"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
Expand All @@ -39,8 +42,13 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
tika-core = { module = "org.apache.tika:tika-core", version.ref = "tikaCore" }
quartz = { module = "com.vitorpamplona.quartz:quartz-android", version.ref = "quartz" }
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
gradle-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "ktlint" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Loading