Android live debugging

Live edit an Android app database without restarting the app

Add a small debug-only helper to your app and edit its SQLite database from the browser while the app keeps running.

Open the viewer

What you get

Live edit works like Android Studio's Database Inspector, but from your browser. SQLite Viewer sends each change to a tiny ContentProvider inside your debug app, which runs it on the app's own database. The app keeps running, and nothing has to be stopped, copied or restarted.

Debug builds only. The helper lets anyone with USB debugging access run write statements on the database, so register it only in src/debug and never ship it in a release build.

Set it up

  1. Add the helper class. Download SqliteViewerProvider.kt (or copy the code below) into your app module, for example app/src/main/java/com/example/debughelper/. Change the package line to match the folder.
  2. Register it for debug builds only. Create app/src/debug/AndroidManifest.xml with the provider entry below. Because it lives in the debug source set, release builds never contain the entry and nothing is exposed.
  3. Optional: refresh Room screens. Room only notices changes made through its own connection. Set the afterWrite hook once so your LiveData and Flow screens update right after a live edit.
  4. Run the debug build. Install your debug build on the phone and open the app once so Android registers the provider.
  5. Turn on Live edit. In SQLite Viewer choose Android, connect your phone, open the app database and tick Live edit. A green LIVE EDIT badge appears. Now every row edit, insert, delete and SQL statement you run is also applied to the running app.

Debug manifest

<?xml version="1.0" encoding="utf-8"?>
<!-- src/debug/AndroidManifest.xml  (debug builds only) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <provider
            android:name="com.example.debughelper.SqliteViewerProvider"
            android:authorities="${applicationId}.sqliteviewer"
            android:exported="true" />
    </application>
</manifest>

Optional Room hook

// In your Application class (optional, only if you use Room LiveData / Flow)
override fun onCreate() {
    super.onCreate()
    if (BuildConfig.DEBUG) {
        SqliteViewerProvider.afterWrite = {
            AppDatabase.getInstance(this).invalidationTracker.refreshVersionsAsync()
        }
    }
}

The helper (SqliteViewerProvider.kt)

package com.example.debughelper // use any package, for example your app's own

import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import org.json.JSONArray

/**
 * DEBUG BUILDS ONLY.
 *
 * Lets SQLite Viewer (https://sqliteviewer.codemasterlk.com) edit this app's databases
 * while the app keeps running. The class can live in src/main, but it is only registered
 * in src/debug/AndroidManifest.xml, so release builds never expose it.
 */
class SqliteViewerProvider : ContentProvider() {

    companion object {
        /** Optional: called after every successful live edit, e.g. to refresh Room observers. */
        @JvmStatic
        var afterWrite: (() -> Unit)? = null

        private val allowedStatement = Regex("""^(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER)\b""", RegexOption.IGNORE_CASE)
        private val leadingNoise = Regex("""^(\s|--[^\n]*\n|/\*.*?\*/)*""", RegexOption.DOT_MATCHES_ALL)
        private val safeName = Regex("""^[A-Za-z0-9._-]+$""")
    }

    private val handles = HashMap<String, SQLiteDatabase>()

    override fun onCreate(): Boolean = true

    override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
        val answer = try {
            when (method) {
                "ping" -> "OK:1"
                "batch" -> "OK:" + runBatch(extras?.getString("db"), arg)
                else -> "ERR:unknown method $method"
            }
        } catch (e: Throwable) {
            "ERR:" + (e.message ?: e.javaClass.simpleName).replace('\n', ' ')
        }
        return Bundle().apply { putString("r", answer) }
    }

    @Synchronized
    private fun database(name: String): SQLiteDatabase {
        handles[name]?.let { if (it.isOpen) return it }

        val file = context!!.getDatabasePath(name)
        require(file.exists()) { "database not found: $name" }

        return SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READWRITE)
            .also { handles[name] = it }
    }

    private fun runBatch(name: String?, base64: String?): Int {
        require(name != null && safeName.matches(name)) { "invalid database name" }
        require(!base64.isNullOrEmpty()) { "no statements" }

        val statements = JSONArray(String(Base64.decode(base64, Base64.DEFAULT), Charsets.UTF_8))
        val db = database(name)

        db.beginTransaction()
        try {
            for (i in 0 until statements.length()) {
                val sql = statements.getString(i)
                require(allowedStatement.containsMatchIn(sql.replaceFirst(leadingNoise, ""))) {
                    "statement type not allowed"
                }
                db.execSQL(sql)
            }
            db.setTransactionSuccessful()
        } finally {
            db.endTransaction()
        }

        afterWrite?.invoke()
        return statements.length()
    }

    // Not used: the viewer talks to this provider only through call().
    override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? = null
    override fun getType(uri: Uri): String? = null
    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
}

What it can and cannot do

Troubleshooting