package nya.kitsunyan.foxydroid.database import android.annotation.SuppressLint 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 com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import io.reactivex.rxjava3.core.Observable import nya.kitsunyan.foxydroid.entity.InstalledItem import nya.kitsunyan.foxydroid.entity.Product import nya.kitsunyan.foxydroid.entity.ProductItem import nya.kitsunyan.foxydroid.entity.Repository import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.json.* import java.io.ByteArrayOutputStream object Database { fun init(context: Context): Boolean { val helper = Helper(context) db = helper.writableDatabase if (helper.created) { for (repository in Repository.defaultRepositories) { RepositoryAdapter.put(repository) } } return helper.created || helper.updated } private lateinit var db: SQLiteDatabase private interface Table { val memory: Boolean val innerName: String val createTable: String val createIndex: String? get() = null val databasePrefix: String get() = if (memory) "memory." else "" val name: String get() = "$databasePrefix$innerName" fun formatCreateTable(name: String): String { return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})" } val createIndexPairFormatted: Pair? get() = createIndex?.let { Pair("CREATE INDEX ${innerName}_index ON $innerName ($it)", "CREATE INDEX ${name}_index ON $innerName ($it)") } } private 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_VERSION_CODE = "version_code" const val ROW_SIGNATURE = "signature" 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_VERSION_CODE INTEGER NOT NULL, $ROW_SIGNATURE 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" } } private class Helper(context: Context): SQLiteOpenHelper(context, "foxydroid", null, 1) { var created = false private set var updated = false private set override fun onCreate(db: SQLiteDatabase) = Unit override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) private fun onVersionChange(db: SQLiteDatabase) { handleTables(db, true, Schema.Product, Schema.Category) this.updated = true } override fun onOpen(db: SQLiteDatabase) { val create = handleTables(db, false, Schema.Repository) val updated = handleTables(db, create, Schema.Product, Schema.Category) db.execSQL("ATTACH DATABASE ':memory:' AS memory") handleTables(db, false, Schema.Installed, Schema.Lock) handleIndexes(db, Schema.Repository, Schema.Product, Schema.Category, Schema.Installed, Schema.Lock) dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) this.created = this.created || create this.updated = this.updated || create || updated } } private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { val shouldRecreate = recreate || tables.any { val sql = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("sql"), selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))) .use { it.firstOrNull()?.getString(0) }.orEmpty() it.formatCreateTable(it.innerName) != sql } return shouldRecreate && run { val shouldVacuum = tables.map { db.execSQL("DROP TABLE IF EXISTS ${it.name}") db.execSQL(it.formatCreateTable(it.name)) !it.memory } if (shouldVacuum.any { it }) { db.execSQL("VACUUM") } true } } private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { val shouldVacuum = tables.map { val sqls = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName))) .use { it.asSequence().mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }.toList() } .filter { !it.first.startsWith("sqlite_") } val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty() createIndexes.map { it.first } != sqls.map { it.second } && run { for (name in sqls.map { it.first }) { db.execSQL("DROP INDEX IF EXISTS $name") } for (createIndexPair in createIndexes) { db.execSQL(createIndexPair.second) } !it.memory } } if (shouldVacuum.any { it }) { db.execSQL("VACUUM") } } private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { val tables = db.query("sqlite_master", columns = arrayOf("name"), selection = Pair("type = ?", arrayOf("table"))) .use { it.asSequence().mapNotNull { it.getString(0) }.toList() } .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name } if (tables.isNotEmpty()) { for (table in tables) { db.execSQL("DROP TABLE IF EXISTS $table") } db.execSQL("VACUUM") } } sealed class Subject { object Repositories: Subject() data class Repository(val id: Long): Subject() object Products: Subject() } private val observers = mutableMapOf 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 observable(subject: Subject): Observable { return Observable.create { val callback: () -> Unit = { it.onNext(Unit) } val dataObservable = dataObservable(subject) dataObservable(true, callback) it.setCancellable { dataObservable(false, callback) } } } 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) } private fun SQLiteDatabase.query(table: String, columns: Array? = null, selection: Pair>? = 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)) } private fun ByteArray.jsonParse(callback: (JsonParser) -> T): T { return Json.factory.createParser(this).use { it.parseDictionary(callback) } } private 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): Long { return db.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): Repository { val shouldReplace = repository.id >= 0L val newId = putWithoutNotification(repository, shouldReplace) 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 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 getAll(signal: CancellationSignal?): List { return db.query(Schema.Repository.name, selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), signal = signal).use { it.asSequence().map(::transform).toList() } } fun getAllDisabledDeleted(signal: CancellationSignal?): Set> { 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 = signal).use { it.asSequence().map { Pair(it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)), it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0) }.toSet() } } 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(pairs: Set>) { val result = pairs.windowed(10, 10, true).map { val idsString = it.joinToString(separator = ", ") { it.first.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) val deleteIdsString = it.asSequence().filter { it.second } .joinToString(separator = ", ") { it.first.toString() } if (deleteIdsString.isNotEmpty()) { db.delete(Schema.Repository.name, "${Schema.Repository.ROW_ID} IN ($deleteIdsString)", null) } productsCount != 0 || categoriesCount != 0 } if (result.any { it }) { notifyChanged(Subject.Products) } } fun query(signal: CancellationSignal?): Cursor { return db.query(Schema.Repository.name, selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), signal = signal).observable(Subject.Repositories) } fun transform(cursor: Cursor): Repository { return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA)) .jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), it) } } } object ProductAdapter { fun get(packageName: String, signal: CancellationSignal?): List { return db.query(Schema.Product.name, columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DATA), selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), signal = signal).use { it.asSequence().map(::transform).toList() } } 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 } } @SuppressLint("Recycle") fun query(installed: Boolean, updates: Boolean, searchQuery: String, category: String, signal: CancellationSignal?): Cursor { val builder = QueryBuilder() 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 product.${Schema.Product.ROW_SIGNATURE} = installed.${Schema.Installed.ROW_SIGNATURE} AND product.${Schema.Product.ROW_SIGNATURE} != '') AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE}, product.${Schema.Product.ROW_DATA_ITEM}, MAX((product.${Schema.Product.ROW_COMPATIBLE} << 32) | 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 (category.isNotEmpty()) { 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 (category.isNotEmpty()) { builder += "AND category.${Schema.Category.ROW_NAME} = ?" builder %= category } if (searchQuery.isNotEmpty()) { builder += """AND (product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ? OR product.${Schema.Product.ROW_NAME} LIKE ? OR product.${Schema.Product.ROW_SUMMARY} LIKE ?)""" builder %= List(3) { "%$searchQuery%" } } builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" if (updates) { builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}" } builder += "ORDER BY 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.getColumnIndex(Schema.Product.ROW_DATA)) .jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), it) } } fun transformItem(cursor: Cursor): ProductItem { return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM)) .jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)), cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)).orEmpty(), cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0, cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, it) } } } object CategoryAdapter { fun getAll(signal: CancellationSignal?): Set { 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""" return builder.query(db, signal).use { it.asSequence() .map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() } } } object InstalledAdapter { 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) { db.beginTransaction() try { db.delete(Schema.Installed.name, null, null) installedItems.forEach { put(it, false) } db.setTransactionSuccessful() } finally { db.endTransaction() } } fun delete(packageName: String) { db.delete(Schema.Installed.name, "${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) notifyChanged(Subject.Products) } private fun transform(cursor: Cursor): InstalledItem { return InstalledItem(cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)), cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)), cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)), cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE))) } } object LockAdapter { private fun put(lock: Pair, 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) = put(lock, true) fun putAll(locks: List>) { db.beginTransaction() try { db.delete(Schema.Lock.name, null, null) locks.forEach { put(it, false) } db.setTransactionSuccessful() } finally { db.endTransaction() } } 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) { db.beginTransaction() try { for (product in products) { 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_VERSION_CODE, product.versionCode) put(Schema.Product.ROW_SIGNATURE, product.signature) 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) }) } } db.setTransactionSuccessful() } finally { db.endTransaction() } } fun finishTemporary(repository: Repository, success: Boolean) { if (success) { db.beginTransaction() try { 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.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") db.setTransactionSuccessful() } finally { db.endTransaction() } if (success) { 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}") } } } }