diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1135efa..b5f05b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { @@ -66,6 +67,7 @@ android { buildFeatures { compose = true resValues = true + buildConfig = true } packaging { resources { @@ -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) } diff --git a/app/src/main/java/com/greenart7c3/morganite/CustomHttpServer.kt b/app/src/main/java/com/greenart7c3/morganite/CustomHttpServer.kt index c1a2f52..f508597 100644 --- a/app/src/main/java/com/greenart7c3/morganite/CustomHttpServer.kt +++ b/app/src/main/java/com/greenart7c3/morganite/CustomHttpServer.kt @@ -4,8 +4,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.util.Log import androidx.core.content.ContextCompat +import com.greenart7c3.morganite.logs.MorganiteLog import com.greenart7c3.morganite.models.SettingsManager import com.greenart7c3.morganite.service.FileStore import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -99,17 +99,17 @@ class CustomHttpServer( private val torStatusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Log.d(Morganite.TAG, "Received Broadcast: ${intent?.action}") + MorganiteLog.d(Morganite.TAG, "Received Broadcast: ${intent?.action}") when (intent?.action) { TorService.ACTION_STATUS -> { val status = intent.getStringExtra(TorService.EXTRA_STATUS) ?: TorService.STATUS_OFF - Log.d(Morganite.TAG, "Tor connection status: $status") + MorganiteLog.d(Morganite.TAG, "Tor connection status: $status") torStatus.value = status updateClients() } TorService.ACTION_ERROR -> { val error = intent.getStringExtra(Intent.EXTRA_TEXT) - Log.e(Morganite.TAG, "Tor connection error: $error") + MorganiteLog.e(Morganite.TAG, "Tor connection error: $error") } } } @@ -134,7 +134,7 @@ class CustomHttpServer( // is brought up on demand by ensureTorReady(). We only need to tear // it down here when the user turns the setting off. if (!it.useTor && (torStatus.value != TorService.STATUS_OFF)) { - Log.d(Morganite.TAG, "Tor disabled in settings, stopping service...") + MorganiteLog.d(Morganite.TAG, "Tor disabled in settings, stopping service...") torIdleJob?.cancel() stopTor() } @@ -145,7 +145,7 @@ class CustomHttpServer( private fun updateClients() { val settings = settingsManager.settings.value - Log.d(Morganite.TAG, "Updating clients. useTor: ${settings.useTor}, status: ${torStatus.value}") + MorganiteLog.d(Morganite.TAG, "Updating clients. useTor: ${settings.useTor}, status: ${torStatus.value}") // Capture the clients we are about to replace so their idle connection // pools and dispatcher threads can be released afterwards. Without this, @@ -166,7 +166,7 @@ class CustomHttpServer( .build() rootClient = if (settings.useTorForAllUrls) { - Log.d(Morganite.TAG, "Routing all traffic through Tor proxy") + MorganiteLog.d(Morganite.TAG, "Routing all traffic through Tor proxy") torClient } else { OkHttpClient.Builder().build() @@ -196,11 +196,11 @@ class CustomHttpServer( suspend fun start() { if (::server.isInitialized) { - Log.d(Morganite.TAG, "Server already initialized. Starting") + MorganiteLog.d(Morganite.TAG, "Server already initialized. Starting") server.startSuspend() return } - Log.d(Morganite.TAG, "Starting CustomHttpServer") + MorganiteLog.d(Morganite.TAG, "Starting CustomHttpServer") // Tor is started lazily on the first tor-routed request (see ensureTorReady). updateClients() server = startKtorHttpServer() @@ -209,14 +209,14 @@ class CustomHttpServer( } private fun startTor() { - Log.d(Morganite.TAG, "Starting Tor Service via Intent") + MorganiteLog.d(Morganite.TAG, "Starting Tor Service via Intent") val intent = Intent(Morganite.instance, TorService::class.java) intent.action = TorService.ACTION_START Morganite.instance.startService(intent) } private fun stopTor() { - Log.d(Morganite.TAG, "Stopping Tor Service via Intent") + MorganiteLog.d(Morganite.TAG, "Stopping Tor Service via Intent") val intent = Intent(Morganite.instance, TorService::class.java) intent.action = TorService.ACTION_STOP Morganite.instance.startService(intent) @@ -241,7 +241,7 @@ class CustomHttpServer( true } if (ready == null) { - Log.w(Morganite.TAG, "Tor did not become ready within ${torBootstrapTimeoutMs}ms") + MorganiteLog.w(Morganite.TAG, "Tor did not become ready within ${torBootstrapTimeoutMs}ms") } } } @@ -252,14 +252,14 @@ class CustomHttpServer( torIdleJob = Morganite.instance.scope.launch { delay(torIdleTimeoutMs) if (torUsers.get() == 0 && torStatus.value != TorService.STATUS_OFF) { - Log.d(Morganite.TAG, "Tor idle for ${torIdleTimeoutMs}ms, stopping it") + MorganiteLog.d(Morganite.TAG, "Tor idle for ${torIdleTimeoutMs}ms, stopping it") stopTor() } } } suspend fun stop() { - Log.d(Morganite.TAG, "Stopping CustomHttpServer") + MorganiteLog.d(Morganite.TAG, "Stopping CustomHttpServer") server.stopSuspend() nostrClient.disconnect() torIdleJob?.cancel() @@ -275,7 +275,7 @@ class CustomHttpServer( } suspend fun fetchInboxRelays(pubkey: String): List { - Log.d(Morganite.TAG, "Fetching inbox relays for $pubkey") + MorganiteLog.d(Morganite.TAG, "Fetching inbox relays for $pubkey") val event = nostrClient.downloadFirstEvent( filters = fallbackRelays.associateWith { listOf( @@ -289,12 +289,12 @@ class CustomHttpServer( ) val relays = (event as? AdvertisedRelayListEvent)?.readRelaysNorm() ?: emptyList() - Log.d(Morganite.TAG, "Found ${relays.size} inbox relays for $pubkey") + MorganiteLog.d(Morganite.TAG, "Found ${relays.size} inbox relays for $pubkey") return relays } suspend fun fetchAuthorServers(pubkey: String): List { - Log.d(Morganite.TAG, "Fetching author servers for $pubkey") + MorganiteLog.d(Morganite.TAG, "Fetching author servers for $pubkey") // The relay websocket is routed through Tor when the setting is on, so make // sure Tor is up first and count this as an active tor user. val needsTor = settingsManager.settings.value.useTor @@ -321,13 +321,13 @@ class CustomHttpServer( ) val servers = (event as? BlossomServersEvent)?.servers() ?: emptyList() - Log.d(Morganite.TAG, "Found ${servers.size} servers for $pubkey") + MorganiteLog.d(Morganite.TAG, "Found ${servers.size} servers for $pubkey") return servers } finally { // Close the relay socket once no lookup is still using it, so it does // not stay connected (and keep reconnecting) in the background. if (activeAuthorLookups.decrementAndGet() == 0) { - Log.d(Morganite.TAG, "No author lookups in flight, disconnecting nostr client") + MorganiteLog.d(Morganite.TAG, "No author lookups in flight, disconnecting nostr client") nostrClient.disconnect() } if (needsTor && torUsers.decrementAndGet() == 0) { @@ -343,7 +343,7 @@ class CustomHttpServer( ): Boolean { val url = buildUrl(server, hash, extension) val useTor = url.contains(".onion") || settingsManager.settings.value.useTorForAllUrls - Log.d(Morganite.TAG, "Attempting to fetch and save from $url (Use Tor: $useTor)") + MorganiteLog.d(Morganite.TAG, "Attempting to fetch and save from $url (Use Tor: $useTor)") val client = if (useTor) { torClient @@ -374,7 +374,7 @@ class CustomHttpServer( return try { client.newCall(Request.Builder().url(url).build()).execute().use { response -> if (!response.isSuccessful) { - Log.d(Morganite.TAG, "Fetch failed from $url: ${response.code}") + MorganiteLog.d(Morganite.TAG, "Fetch failed from $url: ${response.code}") return false } @@ -390,16 +390,16 @@ class CustomHttpServer( } fileStore.moveFile(tempFile, hash) - Log.d(Morganite.TAG, "Successfully saved $hash from $url") + MorganiteLog.d(Morganite.TAG, "Successfully saved $hash from $url") true } catch (e: Exception) { - Log.e(Morganite.TAG, "Error while saving from $url", e) + MorganiteLog.e(Morganite.TAG, "Error while saving from $url", e) if (tempFile.exists()) tempFile.delete() false } } } catch (e: Exception) { - Log.e(Morganite.TAG, "Network error fetching from $url", e) + MorganiteLog.e(Morganite.TAG, "Network error fetching from $url", e) false } } @@ -412,7 +412,7 @@ class CustomHttpServer( ): Boolean { val url = buildUrl(server, hash, extension) val useTor = url.contains(".onion") || settingsManager.settings.value.useTorForAllUrls - Log.d(Morganite.TAG, "Attempting to fetch and stream from $url (Use Tor: $useTor)") + MorganiteLog.d(Morganite.TAG, "Attempting to fetch and stream from $url (Use Tor: $useTor)") val client = if (useTor) { torClient @@ -444,7 +444,7 @@ class CustomHttpServer( return try { client.newCall(Request.Builder().url(url).build()).execute().use { response -> if (!response.isSuccessful) { - Log.d(Morganite.TAG, "Fetch failed from $url: ${response.code}") + MorganiteLog.d(Morganite.TAG, "Fetch failed from $url: ${response.code}") return false // Try next server } @@ -477,10 +477,10 @@ class CustomHttpServer( // Finalize fileStore.moveFile(tempFile, hash) - Log.d(Morganite.TAG, "Successfully streamed and saved $hash from $url") + MorganiteLog.d(Morganite.TAG, "Successfully streamed and saved $hash from $url") true // Signal SUCCESS to the loop } catch (e: Exception) { - Log.e(Morganite.TAG, "Error while streaming from $url", e) + MorganiteLog.e(Morganite.TAG, "Error while streaming from $url", e) if (tempFile.exists()) tempFile.delete() // If the user (the client) disconnected, throw to stop everything @@ -491,7 +491,7 @@ class CustomHttpServer( } } } catch (e: Exception) { - Log.e(Morganite.TAG, "Network error fetching from $url", e) + MorganiteLog.e(Morganite.TAG, "Network error fetching from $url", e) false // Connection error, return false to try next server } } @@ -499,12 +499,12 @@ class CustomHttpServer( fun startMonitoring() { server.application.monitor.subscribe(ApplicationStarted) { isRunning.value = true - Log.d(Morganite.TAG, "Server started") + MorganiteLog.d(Morganite.TAG, "Server started") } server.application.monitor.subscribe(ApplicationStopped) { isRunning.value = false - Log.d(Morganite.TAG, "Server stopped") + MorganiteLog.d(Morganite.TAG, "Server stopped") } } @@ -544,10 +544,10 @@ class CustomHttpServer( get { val path = call.request.path() // e.g., "/b1674...f553.pdf" - Log.d(Morganite.TAG, "GET request: $path") + MorganiteLog.d(Morganite.TAG, "GET request: $path") val regex = Regex("([0-9a-f]{64})(\\.[a-z0-9]+)?") val match = regex.find(path) ?: run { - Log.d(Morganite.TAG, "Invalid SHA-256 hash in path: $path") + MorganiteLog.d(Morganite.TAG, "Invalid SHA-256 hash in path: $path") return@get call.respond(HttpStatusCode.BadRequest, "Invalid SHA-256 hash") } @@ -561,13 +561,13 @@ class CustomHttpServer( val hashETag = "\"$hash\"" if (clientETag == hashETag) { - Log.d(Morganite.TAG, "Serving $hash (Not Modified)") + MorganiteLog.d(Morganite.TAG, "Serving $hash (Not Modified)") call.respond(HttpStatusCode.NotModified) return@get } val mimeType = fileStore.detectMimeType(file) - Log.d(Morganite.TAG, "Serving $hash from local storage ($mimeType)") + MorganiteLog.d(Morganite.TAG, "Serving $hash from local storage ($mimeType)") call.respondFile(file) { call.response.headers.appendIfAbsent(HttpHeaders.ContentType, mimeType) call.response.headers.appendIfAbsent(HttpHeaders.ETag, hash) @@ -579,11 +579,11 @@ class CustomHttpServer( val xsServers = call.request.queryParameters.getAll("xs") ?: emptyList() val authorPubkeys = call.request.queryParameters.getAll("as") ?: emptyList() - Log.d(Morganite.TAG, "$hash not found locally. Attempting proxy (xs: ${xsServers.size}, as: ${authorPubkeys.size})") + MorganiteLog.d(Morganite.TAG, "$hash not found locally. Attempting proxy (xs: ${xsServers.size}, as: ${authorPubkeys.size})") // Attempt retrieval from xs hints for (server in xsServers) { - Log.d(Morganite.TAG, "Trying xs hint server: $server") + MorganiteLog.d(Morganite.TAG, "Trying xs hint server: $server") val success = tryFetchAndStream(server, hash, extension, call) if (success) return@get // Exit the route on first success } @@ -592,22 +592,22 @@ class CustomHttpServer( for (pubkey in authorPubkeys) { val servers = fetchAuthorServers(pubkey) // BUD-03 kind:10063 for (server in servers) { - Log.d(Morganite.TAG, "Trying author server: $server for $pubkey") + MorganiteLog.d(Morganite.TAG, "Trying author server: $server for $pubkey") val success = tryFetchAndStream(server, hash, extension, call) if (success) return@get // Exit the route on first success } } - Log.d(Morganite.TAG, "Resource $hash not found on any server") + MorganiteLog.d(Morganite.TAG, "Resource $hash not found on any server") call.respond(HttpStatusCode.NotFound) } head { val path = call.request.path() - Log.d(Morganite.TAG, "HEAD request: $path") + MorganiteLog.d(Morganite.TAG, "HEAD request: $path") val regex = Regex("([0-9a-f]{64})(\\.[a-z0-9]+)?") val match = regex.find(path) ?: run { - Log.d(Morganite.TAG, "Invalid SHA-256 hash in path: $path") + MorganiteLog.d(Morganite.TAG, "Invalid SHA-256 hash in path: $path") call.respond(HttpStatusCode.BadRequest) return@head } @@ -621,10 +621,10 @@ class CustomHttpServer( val xsServers = call.request.queryParameters.getAll("xs") ?: emptyList() val authorPubkeys = call.request.queryParameters.getAll("as") ?: emptyList() - Log.d(Morganite.TAG, "$hash not found locally for HEAD. Attempting proxy (xs: ${xsServers.size}, as: ${authorPubkeys.size})") + MorganiteLog.d(Morganite.TAG, "$hash not found locally for HEAD. Attempting proxy (xs: ${xsServers.size}, as: ${authorPubkeys.size})") for (server in xsServers) { - Log.d(Morganite.TAG, "Trying xs hint server: $server") + MorganiteLog.d(Morganite.TAG, "Trying xs hint server: $server") if (tryFetchAndSave(server, hash, extension)) { file = fileStore.getFileByHash(hash) break @@ -636,7 +636,7 @@ class CustomHttpServer( val servers = fetchAuthorServers(pubkey) var found = false for (server in servers) { - Log.d(Morganite.TAG, "Trying author server: $server for $pubkey") + MorganiteLog.d(Morganite.TAG, "Trying author server: $server for $pubkey") if (tryFetchAndSave(server, hash, extension)) { file = fileStore.getFileByHash(hash) found = true @@ -649,13 +649,13 @@ class CustomHttpServer( } if (file == null || !file.exists()) { - Log.d(Morganite.TAG, "File not found for hash: $hash") + MorganiteLog.d(Morganite.TAG, "File not found for hash: $hash") call.respond(HttpStatusCode.NotFound) return@head } val mimeType = fileStore.detectMimeType(file) - Log.d(Morganite.TAG, "HEAD response for $hash: $mimeType, ${file.length()} bytes") + MorganiteLog.d(Morganite.TAG, "HEAD response for $hash: $mimeType, ${file.length()} bytes") call.response.status(HttpStatusCode.OK) call.response.headers.appendIfAbsent(HttpHeaders.ContentType, mimeType) diff --git a/app/src/main/java/com/greenart7c3/morganite/MainActivity.kt b/app/src/main/java/com/greenart7c3/morganite/MainActivity.kt index dfe178e..a04dbf2 100644 --- a/app/src/main/java/com/greenart7c3/morganite/MainActivity.kt +++ b/app/src/main/java/com/greenart7c3/morganite/MainActivity.kt @@ -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 @@ -41,16 +40,6 @@ 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() @@ -58,8 +47,8 @@ class MainActivity : ComponentActivity() { MorganiteTheme { val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), - onResult = { isGranted: Boolean -> - Log.d("MainActivity", "Permission granted: $isGranted") + onResult = { _: Boolean -> + } ) @@ -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 diff --git a/app/src/main/java/com/greenart7c3/morganite/Morganite.kt b/app/src/main/java/com/greenart7c3/morganite/Morganite.kt index dab20c7..4485a39 100644 --- a/app/src/main/java/com/greenart7c3/morganite/Morganite.kt +++ b/app/src/main/java/com/greenart7c3/morganite/Morganite.kt @@ -2,34 +2,58 @@ 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>(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> 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 { @@ -37,41 +61,17 @@ class Morganite: Application() { } } - @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(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 { @@ -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 diff --git a/app/src/main/java/com/greenart7c3/morganite/logs/LogCleanupWorker.kt b/app/src/main/java/com/greenart7c3/morganite/logs/LogCleanupWorker.kt new file mode 100644 index 0000000..7e31f2c --- /dev/null +++ b/app/src/main/java/com/greenart7c3/morganite/logs/LogCleanupWorker.kt @@ -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 + } +} diff --git a/app/src/main/java/com/greenart7c3/morganite/logs/LogDao.kt b/app/src/main/java/com/greenart7c3/morganite/logs/LogDao.kt new file mode 100644 index 0000000..017178d --- /dev/null +++ b/app/src/main/java/com/greenart7c3/morganite/logs/LogDao.kt @@ -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> + + @Query("DELETE FROM logs WHERE timestamp < :cutoff") + suspend fun deleteOlderThan(cutoff: Long) +} diff --git a/app/src/main/java/com/greenart7c3/morganite/logs/LogDatabase.kt b/app/src/main/java/com/greenart7c3/morganite/logs/LogDatabase.kt new file mode 100644 index 0000000..6e37be3 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/morganite/logs/LogDatabase.kt @@ -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 } + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/morganite/logs/LogEntry.kt b/app/src/main/java/com/greenart7c3/morganite/logs/LogEntry.kt new file mode 100644 index 0000000..c3bb549 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/morganite/logs/LogEntry.kt @@ -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, +) diff --git a/app/src/main/java/com/greenart7c3/morganite/logs/MorganiteLog.kt b/app/src/main/java/com/greenart7c3/morganite/logs/MorganiteLog.kt new file mode 100644 index 0000000..30a6782 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/morganite/logs/MorganiteLog.kt @@ -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) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b84717b..e3cd042 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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" }