839 lines
34 KiB
Kotlin
839 lines
34 KiB
Kotlin
package com.looker.droidify.database
|
|
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.database.Cursor
|
|
import android.database.sqlite.SQLiteDatabase
|
|
import android.database.sqlite.SQLiteOpenHelper
|
|
import android.os.CancellationSignal
|
|
import androidx.core.database.sqlite.transaction
|
|
import com.fasterxml.jackson.core.JsonGenerator
|
|
import com.fasterxml.jackson.core.JsonParser
|
|
import com.looker.droidify.database.table.DatabaseHelper
|
|
import com.looker.droidify.database.table.Table
|
|
import com.looker.droidify.datastore.model.SortOrder
|
|
import com.looker.droidify.model.InstalledItem
|
|
import com.looker.droidify.model.Product
|
|
import com.looker.droidify.model.ProductItem
|
|
import com.looker.droidify.model.Repository
|
|
import com.looker.droidify.utility.common.extension.Json
|
|
import com.looker.droidify.utility.common.extension.asSequence
|
|
import com.looker.droidify.utility.common.extension.firstOrNull
|
|
import com.looker.droidify.utility.common.extension.parseDictionary
|
|
import com.looker.droidify.utility.common.extension.writeDictionary
|
|
import com.looker.droidify.utility.serialization.product
|
|
import com.looker.droidify.utility.serialization.productItem
|
|
import com.looker.droidify.utility.serialization.repository
|
|
import com.looker.droidify.utility.serialization.serialize
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.channels.awaitClose
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.Flow
|
|
import kotlinx.coroutines.flow.callbackFlow
|
|
import kotlinx.coroutines.flow.emitAll
|
|
import kotlinx.coroutines.flow.flowOf
|
|
import kotlinx.coroutines.flow.flowOn
|
|
import kotlinx.coroutines.flow.map
|
|
import kotlinx.coroutines.flow.onCompletion
|
|
import kotlinx.coroutines.flow.onEach
|
|
import kotlinx.coroutines.withContext
|
|
import java.io.ByteArrayOutputStream
|
|
import kotlin.collections.component1
|
|
import kotlin.collections.component2
|
|
import kotlin.collections.set
|
|
|
|
object Database {
|
|
fun init(context: Context): Boolean {
|
|
val helper = DatabaseHelper(context)
|
|
db = helper.writableDatabase
|
|
RepositoryAdapter.removeDuplicates()
|
|
return helper.created || helper.updated
|
|
}
|
|
|
|
private lateinit var db: SQLiteDatabase
|
|
|
|
object Schema {
|
|
object Repository : Table {
|
|
const val ROW_ID = "_id"
|
|
const val ROW_ENABLED = "enabled"
|
|
const val ROW_DELETED = "deleted"
|
|
const val ROW_DATA = "data"
|
|
|
|
override val memory = false
|
|
override val innerName = "repository"
|
|
override val createTable = """
|
|
$ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
$ROW_ENABLED INTEGER NOT NULL,
|
|
$ROW_DELETED INTEGER NOT NULL,
|
|
$ROW_DATA BLOB NOT NULL
|
|
"""
|
|
}
|
|
|
|
object Product : Table {
|
|
const val ROW_REPOSITORY_ID = "repository_id"
|
|
const val ROW_PACKAGE_NAME = "package_name"
|
|
const val ROW_NAME = "name"
|
|
const val ROW_SUMMARY = "summary"
|
|
const val ROW_DESCRIPTION = "description"
|
|
const val ROW_ADDED = "added"
|
|
const val ROW_UPDATED = "updated"
|
|
const val ROW_VERSION_CODE = "version_code"
|
|
const val ROW_SIGNATURES = "signatures"
|
|
const val ROW_COMPATIBLE = "compatible"
|
|
const val ROW_DATA = "data"
|
|
const val ROW_DATA_ITEM = "data_item"
|
|
|
|
override val memory = false
|
|
override val innerName = "product"
|
|
override val createTable = """
|
|
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
|
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
|
$ROW_NAME TEXT NOT NULL,
|
|
$ROW_SUMMARY TEXT NOT NULL,
|
|
$ROW_DESCRIPTION TEXT NOT NULL,
|
|
$ROW_ADDED INTEGER NOT NULL,
|
|
$ROW_UPDATED INTEGER NOT NULL,
|
|
$ROW_VERSION_CODE INTEGER NOT NULL,
|
|
$ROW_SIGNATURES TEXT NOT NULL,
|
|
$ROW_COMPATIBLE INTEGER NOT NULL,
|
|
$ROW_DATA BLOB NOT NULL,
|
|
$ROW_DATA_ITEM BLOB NOT NULL,
|
|
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME)
|
|
"""
|
|
override val createIndex = ROW_PACKAGE_NAME
|
|
}
|
|
|
|
object Category : Table {
|
|
const val ROW_REPOSITORY_ID = "repository_id"
|
|
const val ROW_PACKAGE_NAME = "package_name"
|
|
const val ROW_NAME = "name"
|
|
|
|
override val memory = false
|
|
override val innerName = "category"
|
|
override val createTable = """
|
|
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
|
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
|
$ROW_NAME TEXT NOT NULL,
|
|
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME)
|
|
"""
|
|
override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME"
|
|
}
|
|
|
|
object Installed : Table {
|
|
const val ROW_PACKAGE_NAME = "package_name"
|
|
const val ROW_VERSION = "version"
|
|
const val ROW_VERSION_CODE = "version_code"
|
|
const val ROW_SIGNATURE = "signature"
|
|
|
|
override val memory = true
|
|
override val innerName = "installed"
|
|
override val createTable = """
|
|
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
|
$ROW_VERSION TEXT NOT NULL,
|
|
$ROW_VERSION_CODE INTEGER NOT NULL,
|
|
$ROW_SIGNATURE TEXT NOT NULL
|
|
"""
|
|
}
|
|
|
|
object Lock : Table {
|
|
const val ROW_PACKAGE_NAME = "package_name"
|
|
const val ROW_VERSION_CODE = "version_code"
|
|
|
|
override val memory = true
|
|
override val innerName = "lock"
|
|
override val createTable = """
|
|
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
|
$ROW_VERSION_CODE INTEGER NOT NULL
|
|
"""
|
|
}
|
|
|
|
object Synthetic {
|
|
const val ROW_CAN_UPDATE = "can_update"
|
|
const val ROW_MATCH_RANK = "match_rank"
|
|
}
|
|
}
|
|
|
|
sealed class Subject {
|
|
data object Repositories : Subject()
|
|
data class Repository(val id: Long) : Subject()
|
|
data object Products : Subject()
|
|
}
|
|
|
|
private val observers = mutableMapOf<Subject, MutableSet<() -> Unit>>()
|
|
|
|
private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit =
|
|
{ register, observer ->
|
|
synchronized(observers) {
|
|
val set = observers[subject] ?: run {
|
|
val set = mutableSetOf<() -> Unit>()
|
|
observers[subject] = set
|
|
set
|
|
}
|
|
if (register) {
|
|
set += observer
|
|
} else {
|
|
set -= observer
|
|
}
|
|
}
|
|
}
|
|
|
|
fun flowCollection(subject: Subject): Flow<Unit> = callbackFlow {
|
|
val callback: () -> Unit = { trySend(Unit) }
|
|
val dataObservable = dataObservable(subject)
|
|
dataObservable(true, callback)
|
|
|
|
awaitClose { dataObservable(false, callback) }
|
|
}.flowOn(Dispatchers.IO)
|
|
|
|
private fun notifyChanged(vararg subjects: Subject) {
|
|
synchronized(observers) {
|
|
subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() }
|
|
}
|
|
}
|
|
|
|
private fun SQLiteDatabase.insertOrReplace(
|
|
replace: Boolean,
|
|
table: String,
|
|
contentValues: ContentValues,
|
|
): Long {
|
|
return if (replace) {
|
|
replace(table, null, contentValues)
|
|
} else {
|
|
insert(
|
|
table,
|
|
null,
|
|
contentValues,
|
|
)
|
|
}
|
|
}
|
|
|
|
fun SQLiteDatabase.query(
|
|
table: String,
|
|
columns: Array<String>? = null,
|
|
selection: Pair<String, Array<String>>? = null,
|
|
orderBy: String? = null,
|
|
signal: CancellationSignal? = null,
|
|
): Cursor {
|
|
return query(
|
|
false,
|
|
table,
|
|
columns,
|
|
selection?.first,
|
|
selection?.second,
|
|
null,
|
|
null,
|
|
orderBy,
|
|
null,
|
|
signal,
|
|
)
|
|
}
|
|
|
|
private fun Cursor.observable(subject: Subject): ObservableCursor {
|
|
return ObservableCursor(this, dataObservable(subject))
|
|
}
|
|
|
|
fun <T> ByteArray.jsonParse(callback: (JsonParser) -> T): T {
|
|
return Json.factory.createParser(this).use { it.parseDictionary(callback) }
|
|
}
|
|
|
|
fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray {
|
|
val outputStream = ByteArrayOutputStream()
|
|
Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) }
|
|
return outputStream.toByteArray()
|
|
}
|
|
|
|
object RepositoryAdapter {
|
|
internal fun putWithoutNotification(
|
|
repository: Repository,
|
|
shouldReplace: Boolean,
|
|
database: SQLiteDatabase,
|
|
): Long {
|
|
return database.insertOrReplace(
|
|
shouldReplace,
|
|
Schema.Repository.name,
|
|
ContentValues().apply {
|
|
if (shouldReplace) {
|
|
put(Schema.Repository.ROW_ID, repository.id)
|
|
}
|
|
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
|
|
put(Schema.Repository.ROW_DELETED, 0)
|
|
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
|
|
},
|
|
)
|
|
}
|
|
|
|
fun put(repository: Repository, database: SQLiteDatabase = db): Repository {
|
|
val shouldReplace = repository.id >= 0L
|
|
val newId = putWithoutNotification(repository, shouldReplace, database)
|
|
val id = if (shouldReplace) repository.id else newId
|
|
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
|
return if (newId != repository.id) repository.copy(id = newId) else repository
|
|
}
|
|
|
|
fun removeDuplicates() {
|
|
db.transaction {
|
|
val all = getAll()
|
|
val different = all.distinctBy { it.address }
|
|
val duplicates = all - different.toSet()
|
|
duplicates.forEach {
|
|
markAsDeleted(it.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun getStream(id: Long): Flow<Repository?> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
|
.map { get(id) }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
fun get(id: Long): Repository? {
|
|
return db.query(
|
|
Schema.Repository.name,
|
|
selection = Pair(
|
|
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
|
|
arrayOf(id.toString()),
|
|
),
|
|
).use { it.firstOrNull()?.let(::transform) }
|
|
}
|
|
|
|
fun getAllStream(): Flow<List<Repository>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
|
.map { getAll() }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
fun getEnabledStream(): Flow<List<Repository>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
|
.map { getEnabled() }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
private suspend fun getEnabled(): List<Repository> = withContext(Dispatchers.IO) {
|
|
db.query(
|
|
Schema.Repository.name,
|
|
selection = Pair(
|
|
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
|
|
"${Schema.Repository.ROW_DELETED} == 0",
|
|
emptyArray(),
|
|
),
|
|
signal = null,
|
|
).use { it.asSequence().map(::transform).toList() }
|
|
}
|
|
|
|
fun getAll(): List<Repository> {
|
|
return db.query(
|
|
Schema.Repository.name,
|
|
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
|
signal = null,
|
|
).use { it.asSequence().map(::transform).toList() }
|
|
}
|
|
|
|
fun getAllRemovedStream(): Flow<Map<Long, Boolean>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
|
.map { getAllDisabledDeleted() }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
private fun getAllDisabledDeleted(): Map<Long, Boolean> {
|
|
return db.query(
|
|
Schema.Repository.name,
|
|
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
|
|
selection = Pair(
|
|
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
|
|
"${Schema.Repository.ROW_DELETED} != 0",
|
|
emptyArray(),
|
|
),
|
|
signal = null,
|
|
).use { parentCursor ->
|
|
parentCursor.asSequence().associate {
|
|
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
|
|
val isDeletedIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED)
|
|
it.getLong(idIndex) to (it.getInt(isDeletedIndex) != 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun markAsDeleted(id: Long) {
|
|
db.update(
|
|
Schema.Repository.name,
|
|
ContentValues().apply {
|
|
put(Schema.Repository.ROW_DELETED, 1)
|
|
},
|
|
"${Schema.Repository.ROW_ID} = ?",
|
|
arrayOf(id.toString()),
|
|
)
|
|
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
|
}
|
|
|
|
fun cleanup(removedRepos: Map<Long, Boolean>) {
|
|
val result = removedRepos.map { (id, isDeleted) ->
|
|
val idsString = id.toString()
|
|
val productsCount = db.delete(
|
|
Schema.Product.name,
|
|
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
|
|
null,
|
|
)
|
|
val categoriesCount = db.delete(
|
|
Schema.Category.name,
|
|
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
|
|
null,
|
|
)
|
|
if (isDeleted) {
|
|
db.delete(
|
|
Schema.Repository.name,
|
|
"${Schema.Repository.ROW_ID} IN ($id)",
|
|
null,
|
|
)
|
|
}
|
|
productsCount != 0 || categoriesCount != 0
|
|
}
|
|
if (result.any { it }) {
|
|
notifyChanged(Subject.Products)
|
|
}
|
|
}
|
|
|
|
fun importRepos(list: List<Repository>) {
|
|
db.transaction {
|
|
val currentAddresses = getAll().map { it.address }
|
|
val newRepos = list
|
|
.filter { it.address !in currentAddresses }
|
|
newRepos.forEach { put(it) }
|
|
removeDuplicates()
|
|
}
|
|
}
|
|
|
|
fun query(signal: CancellationSignal?): Cursor {
|
|
return db.query(
|
|
Schema.Repository.name,
|
|
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
|
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
|
|
signal = signal,
|
|
).observable(Subject.Repositories)
|
|
}
|
|
|
|
fun transform(cursor: Cursor): Repository {
|
|
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA))
|
|
.jsonParse {
|
|
it.repository().apply {
|
|
this.id =
|
|
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
object ProductAdapter {
|
|
|
|
fun getStream(packageName: String): Flow<List<Product>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
|
.map { get(packageName, null) }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
suspend fun getUpdates(skipSignatureCheck: Boolean): List<ProductItem> =
|
|
withContext(Dispatchers.IO) {
|
|
query(
|
|
installed = true,
|
|
updates = true,
|
|
searchQuery = "",
|
|
skipSignatureCheck = skipSignatureCheck,
|
|
section = ProductItem.Section.All,
|
|
order = SortOrder.NAME,
|
|
signal = null,
|
|
).use {
|
|
it.asSequence()
|
|
.map(ProductAdapter::transformItem)
|
|
.toList()
|
|
}
|
|
}
|
|
|
|
fun getUpdatesStream(skipSignatureCheck: Boolean): Flow<List<ProductItem>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
|
// Crashes due to immediate retrieval of data?
|
|
.onEach { delay(50) }
|
|
.map { getUpdates(skipSignatureCheck) }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
fun getAll(): List<Product> {
|
|
return db.query(
|
|
Schema.Product.name,
|
|
columns = arrayOf(
|
|
Schema.Product.ROW_REPOSITORY_ID,
|
|
Schema.Product.ROW_DESCRIPTION,
|
|
Schema.Product.ROW_DATA,
|
|
),
|
|
selection = null,
|
|
signal = null,
|
|
).use { it.asSequence().map(::transform).toList() }
|
|
}
|
|
|
|
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
|
|
return db.query(
|
|
Schema.Product.name,
|
|
columns = arrayOf(
|
|
Schema.Product.ROW_REPOSITORY_ID,
|
|
Schema.Product.ROW_DESCRIPTION,
|
|
Schema.Product.ROW_DATA,
|
|
),
|
|
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
|
signal = signal,
|
|
).use { it.asSequence().map(::transform).toList() }
|
|
}
|
|
|
|
fun getCountStream(repositoryId: Long): Flow<Int> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
|
.map { getCount(repositoryId) }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
private fun getCount(repositoryId: Long): Int {
|
|
return db.query(
|
|
Schema.Product.name,
|
|
columns = arrayOf("COUNT (*)"),
|
|
selection = Pair(
|
|
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
|
arrayOf(repositoryId.toString()),
|
|
),
|
|
).use { it.firstOrNull()?.getInt(0) ?: 0 }
|
|
}
|
|
|
|
fun query(
|
|
installed: Boolean,
|
|
updates: Boolean,
|
|
skipSignatureCheck: Boolean = false,
|
|
searchQuery: String,
|
|
section: ProductItem.Section,
|
|
order: SortOrder,
|
|
signal: CancellationSignal?,
|
|
): Cursor {
|
|
val builder = QueryBuilder()
|
|
|
|
val signatureMatches = if (skipSignatureCheck) "1"
|
|
else """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
|
|
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
|
|
product.${Schema.Product.ROW_SIGNATURES} != ''"""
|
|
|
|
builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID},
|
|
product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME},
|
|
product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION},
|
|
(COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND
|
|
product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} >
|
|
COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
|
|
AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE},
|
|
product.${Schema.Product.ROW_DATA_ITEM},"""
|
|
|
|
if (searchQuery.isNotEmpty()) {
|
|
builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR
|
|
product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) |
|
|
((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) |
|
|
(product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},"""
|
|
builder %= List(4) { "%$searchQuery%" }
|
|
} else {
|
|
builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK},"
|
|
}
|
|
|
|
builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND
|
|
(installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
|
|
PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product"""
|
|
builder += """JOIN ${Schema.Repository.name} AS repository
|
|
ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}"""
|
|
builder += """LEFT JOIN ${Schema.Lock.name} AS lock
|
|
ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}"""
|
|
|
|
if (!installed && !updates) {
|
|
builder += "LEFT"
|
|
}
|
|
builder += """JOIN ${Schema.Installed.name} AS installed
|
|
ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}"""
|
|
|
|
if (section is ProductItem.Section.Category) {
|
|
builder += """JOIN ${Schema.Category.name} AS category
|
|
ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}"""
|
|
}
|
|
|
|
builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
|
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
|
|
|
if (section is ProductItem.Section.Category) {
|
|
builder += "AND category.${Schema.Category.ROW_NAME} = ?"
|
|
builder %= section.name
|
|
} else if (section is ProductItem.Section.Repository) {
|
|
builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?"
|
|
builder %= section.id.toString()
|
|
}
|
|
|
|
if (searchQuery.isNotEmpty()) {
|
|
builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0"""
|
|
}
|
|
|
|
builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1"
|
|
|
|
if (updates) {
|
|
builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}"
|
|
}
|
|
builder += "ORDER BY"
|
|
|
|
if (searchQuery.isNotEmpty()) {
|
|
builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,"""
|
|
}
|
|
|
|
when (order) {
|
|
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
|
|
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
|
|
else -> Unit
|
|
}::class
|
|
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
|
|
|
|
return builder.query(db, signal).observable(Subject.Products)
|
|
}
|
|
|
|
private fun transform(cursor: Cursor): Product {
|
|
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA))
|
|
.jsonParse {
|
|
it.product().apply {
|
|
this.repositoryId = cursor
|
|
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
|
this.description = cursor
|
|
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION))
|
|
}
|
|
}
|
|
}
|
|
|
|
fun transformPackageName(cursor: Cursor): String {
|
|
return cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
|
}
|
|
|
|
fun transformItem(cursor: Cursor): ProductItem {
|
|
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
|
|
.jsonParse {
|
|
it.productItem().apply {
|
|
this.repositoryId = cursor
|
|
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
|
this.packageName = cursor
|
|
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
|
this.name = cursor
|
|
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME))
|
|
this.summary = cursor
|
|
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY))
|
|
this.installedVersion = cursor
|
|
.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION))
|
|
.orEmpty()
|
|
this.compatible = cursor
|
|
.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0
|
|
this.canUpdate = cursor
|
|
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0
|
|
this.matchRank = cursor
|
|
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
object CategoryAdapter {
|
|
|
|
fun getAllStream(): Flow<Set<String>> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
|
.map { getAll() }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
private suspend fun getAll(): Set<String> = withContext(Dispatchers.IO) {
|
|
val builder = QueryBuilder()
|
|
|
|
builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME}
|
|
FROM ${Schema.Category.name} AS category
|
|
JOIN ${Schema.Repository.name} AS repository
|
|
ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}
|
|
WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
|
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
|
|
|
builder.query(db, null).use { cursor ->
|
|
cursor.asSequence().map {
|
|
it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME))
|
|
}.toSet()
|
|
}
|
|
}
|
|
}
|
|
|
|
object InstalledAdapter {
|
|
|
|
fun getStream(packageName: String): Flow<InstalledItem?> = flowOf(Unit)
|
|
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
|
.map { get(packageName, null) }
|
|
.flowOn(Dispatchers.IO)
|
|
|
|
fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
|
|
return db.query(
|
|
Schema.Installed.name,
|
|
columns = arrayOf(
|
|
Schema.Installed.ROW_PACKAGE_NAME,
|
|
Schema.Installed.ROW_VERSION,
|
|
Schema.Installed.ROW_VERSION_CODE,
|
|
Schema.Installed.ROW_SIGNATURE,
|
|
),
|
|
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
|
signal = signal,
|
|
).use { it.firstOrNull()?.let(::transform) }
|
|
}
|
|
|
|
private fun put(installedItem: InstalledItem, notify: Boolean) {
|
|
db.insertOrReplace(
|
|
true,
|
|
Schema.Installed.name,
|
|
ContentValues().apply {
|
|
put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName)
|
|
put(Schema.Installed.ROW_VERSION, installedItem.version)
|
|
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
|
|
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
|
|
},
|
|
)
|
|
if (notify) {
|
|
notifyChanged(Subject.Products)
|
|
}
|
|
}
|
|
|
|
fun put(installedItem: InstalledItem) = put(installedItem, true)
|
|
|
|
fun putAll(installedItems: List<InstalledItem>) {
|
|
db.transaction {
|
|
db.delete(Schema.Installed.name, null, null)
|
|
installedItems.forEach { put(it, false) }
|
|
}
|
|
}
|
|
|
|
fun delete(packageName: String) {
|
|
val count = db.delete(
|
|
Schema.Installed.name,
|
|
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
|
|
arrayOf(packageName),
|
|
)
|
|
if (count > 0) {
|
|
notifyChanged(Subject.Products)
|
|
}
|
|
}
|
|
|
|
private fun transform(cursor: Cursor): InstalledItem {
|
|
return InstalledItem(
|
|
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
|
|
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
|
|
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
|
|
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)),
|
|
)
|
|
}
|
|
}
|
|
|
|
object LockAdapter {
|
|
private fun put(lock: Pair<String, Long>, notify: Boolean) {
|
|
db.insertOrReplace(
|
|
true,
|
|
Schema.Lock.name,
|
|
ContentValues().apply {
|
|
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
|
|
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
|
|
},
|
|
)
|
|
if (notify) {
|
|
notifyChanged(Subject.Products)
|
|
}
|
|
}
|
|
|
|
fun put(lock: Pair<String, Long>) = put(lock, true)
|
|
|
|
fun putAll(locks: List<Pair<String, Long>>) {
|
|
db.transaction {
|
|
db.delete(Schema.Lock.name, null, null)
|
|
locks.forEach { put(it, false) }
|
|
}
|
|
}
|
|
|
|
fun delete(packageName: String) {
|
|
db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
|
|
notifyChanged(Subject.Products)
|
|
}
|
|
}
|
|
|
|
object UpdaterAdapter {
|
|
private val Table.temporaryName: String
|
|
get() = "${name}_temporary"
|
|
|
|
fun createTemporaryTable() {
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
|
db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName))
|
|
db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName))
|
|
}
|
|
|
|
fun putTemporary(products: List<Product>) {
|
|
db.transaction {
|
|
for (product in products) {
|
|
// Format signatures like ".signature1.signature2." for easier select
|
|
val signatures = product.signatures.joinToString { ".$it" }
|
|
.let { if (it.isNotEmpty()) "$it." else "" }
|
|
db.insertOrReplace(
|
|
true,
|
|
Schema.Product.temporaryName,
|
|
ContentValues().apply {
|
|
put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId)
|
|
put(Schema.Product.ROW_PACKAGE_NAME, product.packageName)
|
|
put(Schema.Product.ROW_NAME, product.name)
|
|
put(Schema.Product.ROW_SUMMARY, product.summary)
|
|
put(Schema.Product.ROW_DESCRIPTION, product.description)
|
|
put(Schema.Product.ROW_ADDED, product.added)
|
|
put(Schema.Product.ROW_UPDATED, product.updated)
|
|
put(Schema.Product.ROW_VERSION_CODE, product.versionCode)
|
|
put(Schema.Product.ROW_SIGNATURES, signatures)
|
|
put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0)
|
|
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
|
|
put(
|
|
Schema.Product.ROW_DATA_ITEM,
|
|
jsonGenerate(product.item()::serialize),
|
|
)
|
|
},
|
|
)
|
|
for (category in product.categories) {
|
|
db.insertOrReplace(
|
|
true,
|
|
Schema.Category.temporaryName,
|
|
ContentValues().apply {
|
|
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
|
|
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
|
|
put(Schema.Category.ROW_NAME, category)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun finishTemporary(repository: Repository, success: Boolean) {
|
|
if (success) {
|
|
db.transaction {
|
|
db.delete(
|
|
Schema.Product.name,
|
|
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
|
arrayOf(repository.id.toString()),
|
|
)
|
|
db.delete(
|
|
Schema.Category.name,
|
|
"${Schema.Category.ROW_REPOSITORY_ID} = ?",
|
|
arrayOf(repository.id.toString()),
|
|
)
|
|
db.execSQL(
|
|
"INSERT INTO ${Schema.Product.name} SELECT * " +
|
|
"FROM ${Schema.Product.temporaryName}",
|
|
)
|
|
db.execSQL(
|
|
"INSERT INTO ${Schema.Category.name} SELECT * " +
|
|
"FROM ${Schema.Category.temporaryName}",
|
|
)
|
|
RepositoryAdapter.putWithoutNotification(repository, true, db)
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
|
}
|
|
notifyChanged(
|
|
Subject.Repositories,
|
|
Subject.Repository(repository.id),
|
|
Subject.Products,
|
|
)
|
|
} else {
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
|
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
|
}
|
|
}
|
|
}
|
|
}
|