1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
package de.profpatsch.homescreen

import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper

/**
 * Everything the launcher remembers.
 *
 * Which applications exist comes from LauncherApps and which have been used
 * recently comes from UsageStatsManager; both are the system's to answer. The
 * only state that is ours is which applications have been pinned, so that is
 * all this holds.
 *
 * SQLite is in the platform (android.database.sqlite, API 1) and needs no
 * library: Room is a code generator over these same classes, and would have to
 * be unpacked from an .aar by hand. See homescreen.1, CAVEATS.
 *
 * Migrations are numbered and applied in order, exactly once. Never modify a
 * migration that has been applied; add another one. This is the same discipline
 * as the Go projects in this repository, for the same reason: the statements
 * below have already run against a database that still exists.
 */
class Db(context: Context) : SQLiteOpenHelper(context, NAME, null, VERSION) {

    companion object {
        private const val NAME = "homescreen.db"

        /** Bump when adding a migration, and add it to [MIGRATIONS]. */
        private const val VERSION = 1

        private val MIGRATIONS: List<String> = listOf(
            """
            CREATE TABLE schema_version (
              version    INTEGER PRIMARY KEY,
              applied_at INTEGER NOT NULL
            );

            -- One row per pinned application.
            --
            -- component is ComponentName.flattenToString(), which names the
            -- activity rather than the package: an application can publish more
            -- than one launcher entry, and pinning one of them should not pin
            -- its siblings.
            --
            -- added_at is when it was pinned. The grid does not sort by it --
            -- pinned applications are listed alphabetically, so that pinning a
            -- new one never moves the others -- but it is free to record and it
            -- is the only way to answer when something was pinned.
            CREATE TABLE favourite (
              component TEXT PRIMARY KEY,
              added_at  INTEGER NOT NULL
            );
            """,
        )
    }

    override fun onCreate(db: SQLiteDatabase) {
        applyMigrations(db, from = 0)
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        applyMigrations(db, from = oldVersion)
    }

    private fun applyMigrations(db: SQLiteDatabase, from: Int) {
        for (i in from until MIGRATIONS.size) {
            // execSQL runs one statement, so a migration containing several has
            // to be split. Blank fragments come from the trailing semicolon.
            for (statement in MIGRATIONS[i].split(";")) {
                if (statement.isNotBlank()) db.execSQL(statement)
            }
            db.insertWithOnConflict(
                "schema_version",
                null,
                ContentValues().apply {
                    put("version", i + 1)
                    put("applied_at", System.currentTimeMillis())
                },
                SQLiteDatabase.CONFLICT_REPLACE,
            )
        }
    }

    /** The pinned activities, as flattened ComponentNames. */
    fun favourites(): Set<String> {
        val out = mutableSetOf<String>()
        readableDatabase.rawQuery("SELECT component FROM favourite", null).use { c ->
            while (c.moveToNext()) out.add(c.getString(0))
        }
        return out
    }

    fun addFavourite(component: String) {
        writableDatabase.insertWithOnConflict(
            "favourite",
            null,
            ContentValues().apply {
                put("component", component)
                put("added_at", System.currentTimeMillis())
            },
            // Pinning something already pinned is not an error, and must not
            // overwrite the original added_at.
            SQLiteDatabase.CONFLICT_IGNORE,
        )
    }

    fun removeFavourite(component: String) {
        writableDatabase.delete("favourite", "component = ?", arrayOf(component))
    }
}