Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
af103aa
Fix typo: serizliation -> serialization in Gradle configuration
AgentKush Jan 21, 2026
388ed31
Fix: Implement info menu action in ReaderMenuProvider
AgentKush Jan 21, 2026
67e2bc9
Fix: Add documentation for page count mismatch handling in ReaderView…
AgentKush Jan 21, 2026
0733353
Fix: Replace internal PlatformRegistry API with OkHttp.initialize
AgentKush Jan 21, 2026
129bef3
Implement TODO items: onTrimMemory and TIP spacing
AgentKush Jan 21, 2026
84f7698
Implement adaptive prefetch queue limit based on RAM, network, and po…
AgentKush Jan 21, 2026
aafde60
Localize WebView proxy unsupported error message
AgentKush Jan 21, 2026
0df96fa
Clean up stale TODO/FIXME comments and improve code documentation
AgentKush Jan 21, 2026
17e87f1
Implement adblock CSS element hiding and domain modifiers
AgentKush Jan 21, 2026
e4ef5bc
Implement directory sharing support for local manga
AgentKush Jan 21, 2026
f95e139
Improve UX for missing chapters and clean up remaining TODOs
AgentKush Jan 21, 2026
ac2e165
Update kotatsu-parsers to v1.2
AgentKush Jan 22, 2026
42510b3
Add configurable JS timeout for WebView evaluation
AgentKush Jan 22, 2026
74dba9f
Add configurable compression format options to BitmapWrapper
AgentKush Jan 22, 2026
c906f70
Add persistent blacklist with TTL to MirrorSwitcher
AgentKush Jan 22, 2026
f4dc2ec
Add related manga support for external plugins
AgentKush Jan 22, 2026
4088fb9
Add exponential backoff retry to RateLimitInterceptor
AgentKush Jan 22, 2026
1d56aa7
Add persistent blacklist for image proxy interceptors
AgentKush Jan 22, 2026
e529452
Add configurable TTL for memory content caches
AgentKush Jan 22, 2026
098c31b
Add configurable parallel chapter checking for tracker
AgentKush Jan 22, 2026
0c9ebd9
feat: Implement Scrobbler Offline Queue (#5)
AgentKush Jan 22, 2026
f2f8190
feat: Implement Network Quality Adaptive Behavior (#6)
AgentKush Jan 22, 2026
65a0879
feat: Implement DNS Prefetching for Common Domains (#7)
AgentKush Jan 22, 2026
e2f97b5
feat: Implement Download Resume Support (#8)
AgentKush Jan 22, 2026
cd4df25
chore: Update Java target to 17 and improve DNS prefetch
AgentKush Jan 22, 2026
a44909c
feat: Database Query Optimization (#9)
AgentKush Jan 22, 2026
bb33f23
Improvement #10: Enhanced App Update Check
AgentKush Jan 22, 2026
3b00220
Improvement #11: Source Health Monitor
AgentKush Jan 22, 2026
639ddfa
Add fallbackToDestructiveMigration for incompatible database schemas
AgentKush Jan 22, 2026
6449afd
Fix memory leak in CaptchaHandler.onError()
AgentKush Jan 22, 2026
284df7c
Update kotatsu-parsers to latest commit (b5b4d2cd43)
AgentKush Jan 22, 2026
c939d74
Fix VersionId parsing for nightly builds
AgentKush Jan 22, 2026
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: 3 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@
<action android:name="${applicationId}.action.REPORT_ERROR" />
</intent-filter>
</receiver>
<receiver
android:name="org.koitharu.kotatsu.core.github.AppUpdateDismissReceiver"
android:exported="false" />

<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import okhttp3.internal.platform.PlatformRegistry
import okhttp3.OkHttp
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
Expand All @@ -26,6 +26,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.network.DnsPrefetchManager
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
Expand Down Expand Up @@ -61,6 +62,9 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var workScheduleManager: WorkScheduleManager

@Inject
lateinit var dnsPrefetchManager: DnsPrefetchManager

@Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>

Expand All @@ -75,7 +79,7 @@ open class BaseApp : Application(), Configuration.Provider {

override fun onCreate() {
super.onCreate()
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
OkHttp.initialize(this)
if (ACRA.isACRASenderServiceProcess()) {
return
}
Expand All @@ -94,6 +98,7 @@ open class BaseApp : Application(), Configuration.Provider {
localStorageChanges.collect(localMangaIndexProvider.get())
}
workScheduleManager.init()
dnsPrefetchManager.initialize()
}

override fun attachBaseContext(base: Context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
Expand All @@ -11,16 +12,41 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
* In-memory cache for manga content with configurable TTL.
*
* Cache TTL settings can be configured in AppSettings:
* - Details cache: default 5 minutes (configurable 1-60 min)
* - Pages cache: default 10 minutes (configurable 1-120 min)
* - Related manga cache: default 10 minutes (configurable 1-120 min)
*
* Note: Changes to TTL settings require app restart to take effect.
*/
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
class MemoryContentCache @Inject constructor(
application: Application,
settings: AppSettings,
) : ComponentCallbacks2 {

private val isLowRam = application.isLowRamDevice()

private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(
maxSize = if (isLowRam) 1 else 4,
lifetime = settings.cacheDetailsTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(
maxSize = if (isLowRam) 1 else 4,
lifetime = settings.cachePagesTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(
maxSize = if (isLowRam) 1 else 3,
lifetime = settings.cacheRelatedTtlMinutes.toLong(),
timeUnit = TimeUnit.MINUTES,
)

init {
application.registerComponentCallbacks(this)
Expand Down
236 changes: 236 additions & 0 deletions app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabaseOptimizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package org.koitharu.kotatsu.core.db

import android.content.Context
import android.util.Log
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton

/**
* Database query optimizer that provides runtime optimizations and diagnostics.
*
* Features:
* - Periodic ANALYZE to update query planner statistics
* - VACUUM for database compaction (when database is idle)
* - Query plan analysis for debugging slow queries
* - Index usage statistics
*/
@Singleton
class DatabaseOptimizer @Inject constructor(
@ApplicationContext private val context: Context,
private val database: MangaDatabase,
) {

/**
* Run ANALYZE to update SQLite's query planner statistics.
* Should be called periodically (e.g., once per app session or daily).
*/
suspend fun analyzeDatabase() = withContext(Dispatchers.IO) {
try {
database.openHelper.writableDatabase.execSQL("ANALYZE")
logDebug { "Database ANALYZE completed successfully" }
} catch (e: Exception) {
Log.e(TAG, "Failed to analyze database", e)
}
}

/**
* Run VACUUM to compact the database and reclaim unused space.
* Should be called infrequently (e.g., weekly or when database size is large).
* Note: This operation can be slow and locks the database.
*/
suspend fun vacuumDatabase() = withContext(Dispatchers.IO) {
try {
database.openHelper.writableDatabase.execSQL("VACUUM")
logDebug { "Database VACUUM completed successfully" }
} catch (e: Exception) {
Log.e(TAG, "Failed to vacuum database", e)
}
}

/**
* Get the current database file size in bytes.
*/
fun getDatabaseSize(): Long {
return try {
context.getDatabasePath("kotatsu-db").length()
} catch (e: Exception) {
-1L
}
}

/**
* Get database statistics including page count, free pages, etc.
*/
suspend fun getDatabaseStats(): DatabaseStats = withContext(Dispatchers.IO) {
try {
val db = database.openHelper.readableDatabase

val pageCount = queryPragmaInt(db, "page_count")
val pageSize = queryPragmaInt(db, "page_size")
val freeListCount = queryPragmaInt(db, "freelist_count")

DatabaseStats(
sizeBytes = getDatabaseSize(),
pageCount = pageCount,
pageSize = pageSize,
freePages = freeListCount,
usedPages = pageCount - freeListCount,
fragmentationPercent = if (pageCount > 0) {
(freeListCount.toFloat() / pageCount * 100).coerceIn(0f, 100f)
} else 0f,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to get database stats", e)
DatabaseStats()
}
}

/**
* Explain a query plan for debugging purposes.
* Only available in debug builds.
*/
suspend fun explainQueryPlan(query: String): List<String> = withContext(Dispatchers.IO) {
if (!BuildConfig.DEBUG) {
return@withContext emptyList()
}

try {
val db = database.openHelper.readableDatabase
val results = mutableListOf<String>()

db.query("EXPLAIN QUERY PLAN $query").use { cursor ->
while (cursor.moveToNext()) {
val detail = cursor.getString(cursor.getColumnIndexOrThrow("detail"))
results.add(detail)
}
}

results
} catch (e: Exception) {
Log.e(TAG, "Failed to explain query plan", e)
emptyList()
}
}

/**
* Get a list of all indexes in the database.
*/
suspend fun getIndexList(): List<IndexInfo> = withContext(Dispatchers.IO) {
try {
val db = database.openHelper.readableDatabase
val indexes = mutableListOf<IndexInfo>()

// Get all tables
db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%'").use { tablesCursor ->
while (tablesCursor.moveToNext()) {
val tableName = tablesCursor.getString(0)

// Get indexes for each table
db.query("PRAGMA index_list('$tableName')").use { indexCursor ->
while (indexCursor.moveToNext()) {
val indexName = indexCursor.getString(indexCursor.getColumnIndexOrThrow("name"))
val isUnique = indexCursor.getInt(indexCursor.getColumnIndexOrThrow("unique")) == 1

// Get columns in the index
val columns = mutableListOf<String>()
db.query("PRAGMA index_info('$indexName')").use { colCursor ->
while (colCursor.moveToNext()) {
columns.add(colCursor.getString(colCursor.getColumnIndexOrThrow("name")))
}
}

indexes.add(IndexInfo(
name = indexName,
tableName = tableName,
columns = columns,
isUnique = isUnique,
))
}
}
}
}

indexes
} catch (e: Exception) {
Log.e(TAG, "Failed to get index list", e)
emptyList()
}
}

/**
* Check database integrity.
*/
suspend fun checkIntegrity(): Boolean = withContext(Dispatchers.IO) {
try {
val db = database.openHelper.readableDatabase
db.query("PRAGMA integrity_check").use { cursor ->
if (cursor.moveToFirst()) {
val result = cursor.getString(0)
return@withContext result == "ok"
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to check database integrity", e)
false
}
}

/**
* Optimize database for better performance.
* Combines ANALYZE and optional VACUUM based on fragmentation.
*/
suspend fun optimize(forceVacuum: Boolean = false) = withContext(Dispatchers.IO) {
// Always run ANALYZE
analyzeDatabase()

// Only VACUUM if fragmentation is high or forced
val stats = getDatabaseStats()
if (forceVacuum || stats.fragmentationPercent > VACUUM_THRESHOLD_PERCENT) {
logDebug { "Running VACUUM due to ${stats.fragmentationPercent}% fragmentation" }
vacuumDatabase()
}
}

private fun queryPragmaInt(db: SupportSQLiteDatabase, pragma: String): Int {
return db.query("PRAGMA $pragma").use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}

private inline fun logDebug(message: () -> String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message())
}
}

data class DatabaseStats(
val sizeBytes: Long = 0,
val pageCount: Int = 0,
val pageSize: Int = 0,
val freePages: Int = 0,
val usedPages: Int = 0,
val fragmentationPercent: Float = 0f,
) {
val sizeMb: Float
get() = sizeBytes / (1024f * 1024f)
}

data class IndexInfo(
val name: String,
val tableName: String,
val columns: List<String>,
val isUnique: Boolean,
)

companion object {
private const val TAG = "DatabaseOptimizer"
private const val VACUUM_THRESHOLD_PERCENT = 20f
}
}
Loading