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
4 changes: 4 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- MANAGE_EXTERNAL_STORAGE: when granted, replaces SAF with direct java.io.File POSIX
access for orders-of-magnitude lower write latency. Play Store policy explicitly
permits productivity file-editing apps. SAF remains the fallback if not granted. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- SAF (Storage Access Framework) grants are given at runtime via ACTION_OPEN_DOCUMENT_TREE
and stored as persistable URI permissions — no manifest storage permissions needed. -->

Expand Down
10 changes: 10 additions & 0 deletions androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ class MainActivity : ComponentActivity() {
}
}

override fun onStop() {
super.onStop()
// Flush any pending write-behind pages to SAF before the process may be killed.
val app = application as? SteleKitApplication ?: return
lifecycleScope.launch {
try { app.fileSystem.flushPendingWrites() }
catch (e: Exception) { Log.w(TAG, "onStop write-behind flush failed", e) }
}
}

override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
val repos = graphManager?.activeRepositorySet?.value ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import dev.stapler.stelekit.git.CredentialStore
import dev.stapler.stelekit.platform.PlatformFileSystem
import dev.stapler.stelekit.platform.PlatformSettings
import dev.stapler.stelekit.platform.SteleKitContext
import dev.stapler.stelekit.platform.WriteBehindQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking

class SteleKitApplication : Application() {

Expand Down Expand Up @@ -40,6 +42,20 @@ class SteleKitApplication : Application() {
DriverFactory.setContext(this)
CredentialStore.init(this)
fileSystem = PlatformFileSystem().apply { init(applicationContext) }
// Activate write-behind when MANAGE_EXTERNAL_STORAGE is not granted.
// Direct access (when granted) is faster than write-behind and makes it unnecessary.
if (android.os.Build.VERSION.SDK_INT >= 30 &&
!android.os.Environment.isExternalStorageManager()
) {
val queueFile = java.io.File(filesDir, "write_behind_queue.txt")
fileSystem.setWriteBehindQueue(WriteBehindQueue(queueFile))
// Startup recovery: flush any pages left dirty from a previous session.
// Runs synchronously before the graph loads to guarantee consistency.
runBlocking {
try { fileSystem.flushPendingWrites() }
catch (e: Exception) { Log.w(TAG, "Startup write-behind flush failed", e) }
}
}
graphManager = GraphManager(
platformSettings = PlatformSettings(),
driverFactory = DriverFactory(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
package dev.stapler.stelekit.db

import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.coroutines.runBlocking

/**
* Applies performance PRAGMAs in [onConfigure] via [SupportSQLiteDatabase.query] (rawQuery path).
*
* Requery's [RequerySQLiteOpenHelperFactory] restricts [SupportSQLiteDatabase.execSQL] for
* statements that return a result set (like `PRAGMA journal_mode=WAL`), throwing
* "Queries can be performed using SQLiteDatabase query or rawQuery methods only." The rawQuery
* path ([query]) is unrestricted and correctly executes SET-type PRAGMAs — SQLite runs the
* PRAGMA and returns the new value as a cursor, which we discard by calling [close].
*
* [onConfigure] fires before schema creation, ensuring WAL is in effect for all DDL.
*/
private class WalConfiguredCallback(
schema: app.cash.sqldelight.db.SqlSchema<app.cash.sqldelight.db.QueryResult.Value<Unit>>,
) : AndroidSqliteDriver.Callback(schema) {
override fun onConfigure(db: SupportSQLiteDatabase) {
super.onConfigure(db) // preserves foreign-key enforcement and other AndroidSqliteDriver defaults
// rawQuery path: Requery allows query/rawQuery for all statement types including SET-PRAGMAs.
// WAL mode: concurrent reads during writes; persistent across connections once set.
// synchronous=NORMAL: fsync on WAL checkpoint only, safe with WAL.
// busy_timeout: retry for up to 10 s on SQLITE_BUSY before surfacing the error.
// wal_autocheckpoint=4000: reduce checkpoint frequency on write-heavy workloads (default 1000).
// temp_store=MEMORY: keep temp tables in RAM, not on Android storage.
// cache_size=-8000: 8 MB page cache; reduces repeated reads for large graphs (1000+ pages).
listOf(
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=10000",
"PRAGMA wal_autocheckpoint=4000",
"PRAGMA temp_store=MEMORY",
"PRAGMA cache_size=-8000",
).forEach { pragma ->
try { db.query(pragma).close() } catch (_: Exception) { }
}
}
}

actual class DriverFactory actual constructor() {
companion object {
Expand Down Expand Up @@ -38,26 +74,15 @@ actual class DriverFactory actual constructor() {

// AndroidSqliteDriver handles schema creation (fresh installs) and numbered .sqm
// migrations (via SQLiteOpenHelper.onUpgrade) automatically.
// WalConfiguredCallback applies PRAGMAs in onConfigure via rawQuery (Requery-safe).
val driver = AndroidSqliteDriver(
schema = SteleDatabase.Schema.synchronous(),
context = context,
name = dbName,
factory = RequerySQLiteOpenHelperFactory()
factory = RequerySQLiteOpenHelperFactory(),
callback = WalConfiguredCallback(SteleDatabase.Schema.synchronous()),
)

// WAL mode: allows concurrent reads while a write is in progress, reducing SQLITE_BUSY.
// busy_timeout: retry for up to 10 seconds before surfacing SQLITE_BUSY to the caller.
try { driver.execute(null, "PRAGMA journal_mode=WAL;", 0) } catch (_: Exception) { }
try { driver.execute(null, "PRAGMA synchronous=NORMAL;", 0) } catch (_: Exception) { }
try { driver.execute(null, "PRAGMA busy_timeout=10000;", 0) } catch (_: Exception) { }
// Reduce WAL checkpoint frequency to avoid blocking writes during auto-checkpoint.
// Default=1000 pages triggers frequent checkpoints on write-heavy workloads; 4000 amortizes cost.
// temp_store=MEMORY: keeps temp tables in RAM instead of hitting Android's storage.
// cache_size=-8000: 8MB page cache reduces repeated reads for large graphs (1000+ pages).
try { driver.execute(null, "PRAGMA wal_autocheckpoint=4000;", 0) } catch (_: Exception) { }
try { driver.execute(null, "PRAGMA temp_store=MEMORY;", 0) } catch (_: Exception) { }
try { driver.execute(null, "PRAGMA cache_size=-8000;", 0) } catch (_: Exception) { }

// Apply incremental DDL migrations (idempotent, hash-tracked).
runBlocking { MigrationRunner.applyAll(driver) }

Expand Down
Loading
Loading