Merge branch 'master' into installer-improvements

This commit is contained in:
machiav3lli
2022-01-04 23:34:54 +01:00
committed by GitHub
57 changed files with 1487 additions and 1316 deletions

View File

@ -1,18 +1,41 @@
package com.looker.droidify
object Common {
const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
const val NOTIFICATION_CHANNEL_UPDATES = "updates"
const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
const val NOTIFICATION_CHANNEL_INSTALLER = "installed"
const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
const val NOTIFICATION_CHANNEL_UPDATES = "updates"
const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
const val NOTIFICATION_CHANNEL_INSTALLER = "installed"
const val NOTIFICATION_ID_SYNCING = 1
const val NOTIFICATION_ID_UPDATES = 2
const val NOTIFICATION_ID_DOWNLOADING = 3
const val NOTIFICATION_ID_INSTALLER = 4
const val NOTIFICATION_ID_SYNCING = 1
const val NOTIFICATION_ID_UPDATES = 2
const val NOTIFICATION_ID_DOWNLOADING = 3
const val NOTIFICATION_ID_INSTALLER = 4
const val PREFS_LANGUAGE = "languages"
const val PREFS_LANGUAGE_DEFAULT = "system"
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"
const val ROW_VERSION = "version"
const val ROW_SIGNATURE = "signature"
const val ROW_ID = "_id"
const val ROW_ENABLED = "enabled"
const val ROW_DELETED = "deleted"
const val ROW_CAN_UPDATE = "can_update"
const val ROW_MATCH_RANK = "match_rank"
const val ROW_REPOSITORY_NAME = "repository"
const val ROW_PRODUCT_NAME = "product"
const val ROW_CATEGORY_NAME = "category"
const val ROW_INSTALLED_NAME = "memory_installed"
const val ROW_LOCK_NAME = "memory_lock"
const val JOB_ID_SYNC = 1
}
const val JOB_ID_SYNC = 1
const val PREFS_LANGUAGE = "languages"
const val PREFS_LANGUAGE_DEFAULT = "system"

View File

@ -5,12 +5,13 @@ import android.app.Application
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.*
import android.os.BatteryManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.looker.droidify.content.Cache
import com.looker.droidify.content.Preferences
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.database.Database
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.network.CoilDownloader
import com.looker.droidify.network.Downloader
@ -21,27 +22,30 @@ import com.looker.droidify.utility.Utils.toInstalledItem
import com.looker.droidify.utility.extension.android.Android
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.net.InetSocketAddress
import java.net.Proxy
import kotlin.time.Duration.Companion.hours
@Suppress("unused")
class MainApplication : Application(), ImageLoaderFactory {
lateinit var db: DatabaseX
override fun onCreate() {
super.onCreate()
val databaseUpdated = Database.init(this)
db = DatabaseX.getInstance(applicationContext)
Preferences.init(this)
ProductPreferences.init(this)
RepositoryUpdater.init()
RepositoryUpdater.init(this)
listenApplications()
listenPreferences()
if (databaseUpdated) {
/*if (databaseUpdated) {
forceSyncAll()
}
}*/
Cache.cleanup(this)
updateSyncJob(false)
@ -66,9 +70,9 @@ class MainApplication : Application(), ImageLoaderFactory {
null
}
if (packageInfo != null) {
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
db.installedDao.put(packageInfo.toInstalledItem())
} else {
Database.InstalledAdapter.delete(packageName)
db.installedDao.delete(packageName)
}
}
}
@ -82,7 +86,7 @@ class MainApplication : Application(), ImageLoaderFactory {
val installedItems =
packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag)
.map { it.toInstalledItem() }
Database.InstalledAdapter.putAll(installedItems)
db.installedDao.put(*installedItems.toTypedArray())
}
private fun listenPreferences() {
@ -125,40 +129,67 @@ class MainApplication : Application(), ImageLoaderFactory {
private fun updateSyncJob(force: Boolean) {
val jobScheduler = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
val reschedule = force || !jobScheduler.allPendingJobs.any { it.id == Common.JOB_ID_SYNC }
val reschedule = force || !jobScheduler.allPendingJobs.any { it.id == JOB_ID_SYNC }
if (reschedule) {
val autoSync = Preferences[Preferences.Key.AutoSync]
when (autoSync) {
Preferences.AutoSync.Never -> {
jobScheduler.cancel(Common.JOB_ID_SYNC)
is Preferences.AutoSync.Never -> {
jobScheduler.cancel(JOB_ID_SYNC)
}
Preferences.AutoSync.Wifi, Preferences.AutoSync.Always -> {
val period = 12 * 60 * 60 * 1000L // 12 hours
val wifiOnly = autoSync == Preferences.AutoSync.Wifi
jobScheduler.schedule(JobInfo
.Builder(
Common.JOB_ID_SYNC,
ComponentName(this, SyncService.Job::class.java)
is Preferences.AutoSync.Wifi -> {
autoSync(
jobScheduler = jobScheduler,
connectionType = JobInfo.NETWORK_TYPE_UNMETERED
)
}
is Preferences.AutoSync.WifiBattery -> {
if (isCharging(this)) {
autoSync(
jobScheduler = jobScheduler,
connectionType = JobInfo.NETWORK_TYPE_UNMETERED
)
.setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY)
.apply {
if (Android.sdk(26)) {
setRequiresBatteryNotLow(true)
setRequiresStorageNotLow(true)
}
if (Android.sdk(24)) {
setPeriodic(period, JobInfo.getMinFlexMillis())
} else {
setPeriodic(period)
}
}
.build())
}
Unit
}
is Preferences.AutoSync.Always -> {
autoSync(
jobScheduler = jobScheduler,
connectionType = JobInfo.NETWORK_TYPE_ANY
)
}
}::class.java
}
}
private fun autoSync(jobScheduler: JobScheduler, connectionType: Int) {
val period = 12.hours.inWholeMilliseconds
jobScheduler.schedule(
JobInfo
.Builder(
JOB_ID_SYNC,
ComponentName(this, SyncService.Job::class.java)
)
.setRequiredNetworkType(connectionType)
.apply {
if (Android.sdk(26)) {
setRequiresBatteryNotLow(true)
setRequiresStorageNotLow(true)
}
if (Android.sdk(24)) setPeriodic(period, JobInfo.getMinFlexMillis())
else setPeriodic(period)
}
.build()
)
}
private fun isCharging(context: Context): Boolean {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val plugged = intent!!.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1)
return plugged == BatteryManager.BATTERY_PLUGGED_AC
|| plugged == BatteryManager.BATTERY_PLUGGED_USB
|| plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS
}
private fun updateProxy() {
val type = Preferences[Preferences.Key.ProxyType].proxyType
val host = Preferences[Preferences.Key.ProxyHost]
@ -176,14 +207,14 @@ class MainApplication : Application(), ImageLoaderFactory {
}
}
}
val proxy = socketAddress?.let { Proxy(type, socketAddress) }
val proxy = socketAddress?.let { Proxy(type, it) }
Downloader.proxy = proxy
}
private fun forceSyncAll() {
Database.RepositoryAdapter.getAll(null).forEach {
db.repositoryDao.all.mapNotNull { it.trueData }.forEach {
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
db.repositoryDao.put(it.copy(lastModified = "", entityTag = ""))
}
}
Connection(SyncService::class.java, onBind = { connection, binder ->

View File

@ -3,8 +3,8 @@ package com.looker.droidify.content
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import com.looker.droidify.Common.PREFS_LANGUAGE
import com.looker.droidify.Common.PREFS_LANGUAGE_DEFAULT
import com.looker.droidify.PREFS_LANGUAGE
import com.looker.droidify.PREFS_LANGUAGE_DEFAULT
import com.looker.droidify.R
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.utility.extension.android.Android
@ -18,8 +18,8 @@ import java.net.Proxy
object Preferences {
private lateinit var preferences: SharedPreferences
private val _subject = MutableSharedFlow<Key<*>>()
val subject = _subject.asSharedFlow()
private val mutableSubject = MutableSharedFlow<Key<*>>()
val subject = mutableSubject.asSharedFlow()
private val keys = sequenceOf(
Key.Language,
@ -39,12 +39,14 @@ object Preferences {
fun init(context: Context) {
preferences =
context.getSharedPreferences("${context.packageName}_preferences",
Context.MODE_PRIVATE)
context.getSharedPreferences(
"${context.packageName}_preferences",
Context.MODE_PRIVATE
)
preferences.registerOnSharedPreferenceChangeListener { _, keyString ->
CoroutineScope(Dispatchers.Default).launch {
keys[keyString]?.let {
_subject.emit(it)
mutableSubject.emit(it)
}
}
}
@ -167,10 +169,11 @@ object Preferences {
sealed class AutoSync(override val valueString: String) : Enumeration<AutoSync> {
override val values: List<AutoSync>
get() = listOf(Never, Wifi, Always)
get() = listOf(Never, Wifi, WifiBattery, Always)
object Never : AutoSync("never")
object Wifi : AutoSync("wifi")
object WifiBattery : AutoSync("wifi-battery")
object Always : AutoSync("always")
}

View File

@ -2,7 +2,8 @@ package com.looker.droidify.content
import android.content.Context
import android.content.SharedPreferences
import com.looker.droidify.database.Database
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.database.Lock
import com.looker.droidify.entity.ProductPreference
import com.looker.droidify.utility.extension.json.Json
import com.looker.droidify.utility.extension.json.parseDictionary
@ -21,17 +22,30 @@ object ProductPreferences {
private lateinit var preferences: SharedPreferences
private val mutableSubject = MutableSharedFlow<Pair<String, Long?>>()
private val subject = mutableSubject.asSharedFlow()
lateinit var db: DatabaseX
fun init(context: Context) {
db = DatabaseX.getInstance(context)
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
Database.LockAdapter.putAll(preferences.all.keys
.mapNotNull { packageName ->
this[packageName].databaseVersionCode?.let { Pair(packageName, it) }
})
db.lockDao.insert(*preferences.all.keys
.mapNotNull { pName ->
this[pName].databaseVersionCode?.let {
Lock().apply {
package_name = pName
version_code = it
}
}
}
.toTypedArray()
)
CoroutineScope(Dispatchers.Default).launch {
subject.collect { (packageName, versionCode) ->
if (versionCode != null) Database.LockAdapter.put(Pair(packageName, versionCode))
else Database.LockAdapter.delete(packageName)
if (versionCode != null) db.lockDao.insert(Lock().apply {
package_name = packageName
version_code = versionCode
}
)
else db.lockDao.delete(packageName)
}
}
}

View File

@ -87,9 +87,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = activeRequests[id]!!.request
val db = DatabaseX.getInstance(requireContext())
return QueryLoader(requireContext()) {
when (request) {
is Request.ProductsAvailable -> Database.ProductAdapter
is Request.ProductsAvailable -> db.productDao
.query(
installed = false,
updates = false,
@ -98,7 +99,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
order = request.order,
signal = it
)
is Request.ProductsInstalled -> Database.ProductAdapter
is Request.ProductsInstalled -> db.productDao
.query(
installed = true,
updates = false,
@ -107,7 +108,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
order = request.order,
signal = it
)
is Request.ProductsUpdates -> Database.ProductAdapter
is Request.ProductsUpdates -> db.productDao
.query(
installed = true,
updates = true,
@ -116,7 +117,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
order = request.order,
signal = it
)
is Request.Repositories -> Database.RepositoryAdapter.query(it)
is Request.Repositories -> db.repositoryDao.allCursor
}
}
}

View File

@ -1,57 +1,272 @@
package com.looker.droidify.database
import android.database.SQLException
import android.database.Cursor
import android.os.CancellationSignal
import androidx.room.*
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.looker.droidify.*
import com.looker.droidify.entity.ProductItem
import io.reactivex.rxjava3.core.Flowable
@Dao
interface RepositoryDao {
interface BaseDao<T> {
@Insert
@Throws(SQLException::class)
fun insert(vararg repository: Repository)
fun insert(vararg product: T)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertReplace(vararg product: T)
@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(vararg repository: Repository?)
fun update(vararg obj: T): Int
fun put(repository: Repository) {
if (repository.id >= 0L) update(repository) else insert(repository)
@Delete
fun delete(obj: T)
}
@Dao
interface RepositoryDao : BaseDao<Repository> {
@get:Query("SELECT COUNT(_id) FROM repository")
val count: Int
fun put(repository: com.looker.droidify.entity.Repository): com.looker.droidify.entity.Repository {
repository.let {
val dbRepo = Repository().apply {
if (it.id >= 0L) id = it.id
enabled = if (it.enabled) 1 else 0
deleted = false
data = it
}
val newId = if (it.id > 0L) update(dbRepo).toLong() else returnInsert(dbRepo)
return if (newId != repository.id) repository.copy(id = newId) else repository
}
}
@Insert
fun returnInsert(product: Repository): Long
@Query("SELECT * FROM repository WHERE _id = :id and deleted == 0")
fun get(id: Long): Repository?
@get:Query("SELECT * FROM repository WHERE deleted == 0 ORDER BY _id ASC")
val allCursor: Cursor
@get:Query("SELECT * FROM repository WHERE deleted == 0 ORDER BY _id ASC")
val all: List<Repository>
@get:Query("SELECT * FROM repository WHERE deleted == 0 ORDER BY _id ASC")
val allFlowable: Flowable<List<Repository>>
@get:Query("SELECT _id, deleted FROM repository WHERE deleted != 0 and enabled == 0 ORDER BY _id ASC")
val allDisabledDeleted: List<Repository.IdAndDeleted>
@Delete
fun delete(repository: Repository)
@Query("DELETE FROM repository WHERE _id = :id")
fun deleteById(vararg id: Long): Int
// TODO optimize
@Update(onConflict = OnConflictStrategy.REPLACE)
fun markAsDeleted(id: Long) {
update(get(id).apply { this?.deleted = 1 })
get(id).apply { this?.deleted = true }?.let { update(it) }
}
}
@Dao
interface ProductDao {
interface ProductDao : BaseDao<Product> {
@Query("SELECT COUNT(*) FROM product WHERE repository_id = :id")
fun countForRepository(id: Long): Long
@Query("SELECT * FROM product WHERE package_name = :packageName")
fun get(packageName: String): Product?
fun get(packageName: String): List<Product?>
@Query("DELETE FROM product WHERE repository_id = :id")
fun deleteById(vararg id: Long): Int
@RawQuery
fun query(
query: SupportSQLiteQuery
): Cursor
// TODO optimize and simplify
@Transaction
fun query(
installed: Boolean, updates: Boolean, searchQuery: String,
section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal?
): Cursor {
val builder = QueryBuilder()
val signatureMatches = """installed.${ROW_SIGNATURE} IS NOT NULL AND
product.${ROW_SIGNATURES} LIKE ('%.' || installed.${ROW_SIGNATURE} || '.%') AND
product.${ROW_SIGNATURES} != ''"""
builder += """SELECT product.rowid AS _id, product.${ROW_REPOSITORY_ID},
product.${ROW_PACKAGE_NAME}, product.${ROW_NAME},
product.${ROW_SUMMARY}, installed.${ROW_VERSION},
(COALESCE(lock.${ROW_VERSION_CODE}, -1) NOT IN (0, product.${ROW_VERSION_CODE}) AND
product.${ROW_COMPATIBLE} != 0 AND product.${ROW_VERSION_CODE} >
COALESCE(installed.${ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
AS ${ROW_CAN_UPDATE}, product.${ROW_COMPATIBLE},
product.${ROW_DATA_ITEM},"""
if (searchQuery.isNotEmpty()) {
builder += """(((product.${ROW_NAME} LIKE ? OR
product.${ROW_SUMMARY} LIKE ?) * 7) |
((product.${ROW_PACKAGE_NAME} LIKE ?) * 3) |
(product.${ROW_DESCRIPTION} LIKE ?)) AS ${ROW_MATCH_RANK},"""
builder %= List(4) { "%$searchQuery%" }
} else {
builder += "0 AS ${ROW_MATCH_RANK},"
}
builder += """MAX((product.${ROW_COMPATIBLE} AND
(installed.${ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
PRINTF('%016X', product.${ROW_VERSION_CODE})) FROM $ROW_PRODUCT_NAME AS product"""
builder += """JOIN $ROW_REPOSITORY_NAME AS repository
ON product.${ROW_REPOSITORY_ID} = repository.${ROW_ID}"""
builder += """LEFT JOIN $ROW_LOCK_NAME AS lock
ON product.${ROW_PACKAGE_NAME} = lock.${ROW_PACKAGE_NAME}"""
if (!installed && !updates) {
builder += "LEFT"
}
builder += """JOIN $ROW_INSTALLED_NAME AS installed
ON product.${ROW_PACKAGE_NAME} = installed.${ROW_PACKAGE_NAME}"""
if (section is ProductItem.Section.Category) {
builder += """JOIN $ROW_CATEGORY_NAME AS category
ON product.${ROW_PACKAGE_NAME} = category.${ROW_PACKAGE_NAME}"""
}
builder += """WHERE repository.${ROW_ENABLED} != 0 AND
repository.${ROW_DELETED} == 0"""
if (section is ProductItem.Section.Category) {
builder += "AND category.${ROW_NAME} = ?"
builder %= section.name
} else if (section is ProductItem.Section.Repository) {
builder += "AND product.${ROW_REPOSITORY_ID} = ?"
builder %= section.id.toString()
}
if (searchQuery.isNotEmpty()) {
builder += """AND $ROW_MATCH_RANK > 0"""
}
builder += "GROUP BY product.${ROW_PACKAGE_NAME} HAVING 1"
if (updates) {
builder += "AND $ROW_CAN_UPDATE"
}
builder += "ORDER BY"
if (searchQuery.isNotEmpty()) {
builder += """$ROW_MATCH_RANK DESC,"""
}
when (order) {
ProductItem.Order.NAME -> Unit
ProductItem.Order.DATE_ADDED -> builder += "product.${ROW_ADDED} DESC,"
ProductItem.Order.LAST_UPDATE -> builder += "product.${ROW_UPDATED} DESC,"
}::class
builder += "product.${ROW_NAME} COLLATE LOCALIZED ASC"
return query(SimpleSQLiteQuery(builder.build()))
}
@RawQuery
fun queryList(
query: SupportSQLiteQuery
): List<Product>
// TODO optimize and simplify
@Transaction
fun queryList(
installed: Boolean, updates: Boolean, searchQuery: String,
section: ProductItem.Section, order: ProductItem.Order
): List<Product> {
val builder = QueryBuilder()
val signatureMatches = """installed.${ROW_SIGNATURE} IS NOT NULL AND
product.${ROW_SIGNATURES} LIKE ('%.' || installed.${ROW_SIGNATURE} || '.%') AND
product.${ROW_SIGNATURES} != ''"""
builder += """SELECT product.rowid AS _id, product.${ROW_REPOSITORY_ID},
product.${ROW_PACKAGE_NAME}, product.${ROW_NAME},
product.${ROW_SUMMARY}, installed.${ROW_VERSION},
(COALESCE(lock.${ROW_VERSION_CODE}, -1) NOT IN (0, product.${ROW_VERSION_CODE}) AND
product.${ROW_COMPATIBLE} != 0 AND product.${ROW_VERSION_CODE} >
COALESCE(installed.${ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
AS ${ROW_CAN_UPDATE}, product.${ROW_COMPATIBLE},
product.${ROW_DATA_ITEM},"""
if (searchQuery.isNotEmpty()) {
builder += """(((product.${ROW_NAME} LIKE ? OR
product.${ROW_SUMMARY} LIKE ?) * 7) |
((product.${ROW_PACKAGE_NAME} LIKE ?) * 3) |
(product.${ROW_DESCRIPTION} LIKE ?)) AS ${ROW_MATCH_RANK},"""
builder %= List(4) { "%$searchQuery%" }
} else {
builder += "0 AS ${ROW_MATCH_RANK},"
}
builder += """MAX((product.${ROW_COMPATIBLE} AND
(installed.${ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
PRINTF('%016X', product.${ROW_VERSION_CODE})) FROM $ROW_PRODUCT_NAME AS product"""
builder += """JOIN $ROW_REPOSITORY_NAME AS repository
ON product.${ROW_REPOSITORY_ID} = repository.${ROW_ID}"""
builder += """LEFT JOIN $ROW_LOCK_NAME AS lock
ON product.${ROW_PACKAGE_NAME} = lock.${ROW_PACKAGE_NAME}"""
if (!installed && !updates) {
builder += "LEFT"
}
builder += """JOIN $ROW_INSTALLED_NAME AS installed
ON product.${ROW_PACKAGE_NAME} = installed.${ROW_PACKAGE_NAME}"""
if (section is ProductItem.Section.Category) {
builder += """JOIN $ROW_CATEGORY_NAME AS category
ON product.${ROW_PACKAGE_NAME} = category.${ROW_PACKAGE_NAME}"""
}
builder += """WHERE repository.${ROW_ENABLED} != 0 AND
repository.${ROW_DELETED} == 0"""
if (section is ProductItem.Section.Category) {
builder += "AND category.${ROW_NAME} = ?"
builder %= section.name
} else if (section is ProductItem.Section.Repository) {
builder += "AND product.${ROW_REPOSITORY_ID} = ?"
builder %= section.id.toString()
}
if (searchQuery.isNotEmpty()) {
builder += """AND $ROW_MATCH_RANK > 0"""
}
builder += "GROUP BY product.${ROW_PACKAGE_NAME} HAVING 1"
if (updates) {
builder += "AND $ROW_CAN_UPDATE"
}
builder += "ORDER BY"
if (searchQuery.isNotEmpty()) {
builder += """$ROW_MATCH_RANK DESC,"""
}
when (order) {
ProductItem.Order.NAME -> Unit
ProductItem.Order.DATE_ADDED -> builder += "product.${ROW_ADDED} DESC,"
ProductItem.Order.LAST_UPDATE -> builder += "product.${ROW_UPDATED} DESC,"
}::class
builder += "product.${ROW_NAME} COLLATE LOCALIZED ASC"
return queryList(SimpleSQLiteQuery(builder.build()))
}
}
@Dao
interface CategoryDao {
@Query(
interface CategoryDao : BaseDao<Category> {
@get:Query(
"""SELECT DISTINCT category.name
FROM category AS category
JOIN repository AS repository
@ -59,31 +274,85 @@ interface CategoryDao {
WHERE repository.enabled != 0 AND
repository.deleted == 0"""
)
fun getAll(): List<String>
val allNames: List<String>
@Query("DELETE FROM category WHERE repository_id = :id")
fun deleteById(vararg id: Long): Int
}
@Dao
interface InstalledDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Throws(SQLException::class)
fun insert(vararg installed: Installed)
interface InstalledDao : BaseDao<Installed> {
fun put(vararg isntalled: com.looker.droidify.entity.InstalledItem) {
isntalled.forEach {
insertReplace(Installed(it.packageName).apply {
version = it.version
version_code = it.versionCode
signature = it.signature
})
}
}
@Query("SELECT * FROM installed WHERE package_name = :packageName")
fun get(packageName: String): Installed?
@Query("SELECT * FROM memory_installed WHERE package_name = :packageName")
fun get(packageName: String): Cursor
@Query("DELETE FROM installed WHERE package_name = :packageName")
@Query("DELETE FROM memory_installed WHERE package_name = :packageName")
fun delete(packageName: String)
}
@Dao
interface LockDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Throws(SQLException::class)
fun insert(vararg lock: Lock)
@Query("DELETE FROM lock WHERE package_name = :packageName")
interface LockDao : BaseDao<Lock> {
@Query("DELETE FROM memory_lock WHERE package_name = :packageName")
fun delete(packageName: String)
}
@Dao
interface ProductTempDao : BaseDao<ProductTemp> {
@get:Query("SELECT * FROM temporary_product")
val all: Array<ProductTemp>
@Query("DELETE FROM temporary_product")
fun emptyTable()
@Insert
fun insertCategory(vararg product: CategoryTemp)
@Transaction
fun putTemporary(products: List<com.looker.droidify.entity.Product>) {
products.forEach {
val signatures = it.signatures.joinToString { ".$it" }
.let { if (it.isNotEmpty()) "$it." else "" }
insert(it.let {
ProductTemp().apply {
repository_id = it.repositoryId
package_name = it.packageName
name = it.name
summary = it.summary
description = it.description
added = it.added
updated = it.updated
version_code = it.versionCode
this.signatures = signatures
compatible = if (it.compatible) 1 else 0
data = it
data_item = it.item()
}
})
it.categories.forEach { category ->
insertCategory(CategoryTemp().apply {
repository_id = it.repositoryId
package_name = it.packageName
name = category
})
}
}
}
}
@Dao
interface CategoryTempDao : BaseDao<CategoryTemp> {
@get:Query("SELECT * FROM temporary_category")
val all: Array<CategoryTemp>
@Query("DELETE FROM temporary_category")
fun emptyTable()
}

View File

@ -1,828 +0,0 @@
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 com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.entity.InstalledItem
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.android.firstOrNull
import com.looker.droidify.utility.extension.json.Json
import com.looker.droidify.utility.extension.json.parseDictionary
import com.looker.droidify.utility.extension.json.writeDictionary
import io.reactivex.rxjava3.core.Observable
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<String, String>?
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_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"
}
}
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", 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.inTransaction()) {
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.inTransaction()) {
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")
}
if (!db.inTransaction()) {
db.execSQL("VACUUM")
}
}
}
sealed class Subject {
object Repositories : Subject()
data class Repository(val id: Long) : Subject()
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 observable(subject: Subject): Observable<Unit> {
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<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 {
// Done in put
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))
})
}
// Done
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
}
// Done
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) }
}
// Done
// MAYBE signal has to be considered
fun getAll(signal: CancellationSignal?): List<Repository> {
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal
).use { it.asSequence().map(::transform).toList() }
}
// Done Pair<Long,Int> instead
// MAYBE signal has to be considered
fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<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 = signal
).use {
it.asSequence().map {
Pair(
it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)),
it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0
)
}.toSet()
}
}
// Done
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)
}
// Done
fun cleanup(pairs: Set<Pair<Long, Boolean>>) {
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)
}
}
// get the cursor in the specific table. Unnecessary with Room
fun query(signal: CancellationSignal?): Cursor {
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal
).observable(Subject.Repositories)
}
// Unnecessary with Room
fun transform(cursor: Cursor): Repository {
return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA))
.jsonParse {
Repository.deserialize(it).apply {
this.id = cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID))
}
}
}
}
object ProductAdapter {
// Done
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() }
}
// Done
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 }
}
// Complex left to wiring phase
fun query(
installed: Boolean, updates: Boolean, searchQuery: String,
section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal?,
): Cursor {
val builder = QueryBuilder()
val signatureMatches = """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) {
ProductItem.Order.NAME -> Unit
ProductItem.Order.DATE_ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
ProductItem.Order.LAST_UPDATE -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
}::class
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
return builder.query(db, signal).observable(Subject.Products)
}
// Unnecessary with Room
private fun transform(cursor: Cursor): Product {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA))
.jsonParse {
Product.deserialize(it).apply {
this.repositoryId = cursor
.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID))
this.description = cursor
.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION))
}
}
}
// Unnecessary with Room
fun transformItem(cursor: Cursor): ProductItem {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM))
.jsonParse {
ProductItem.deserialize(it).apply {
this.repositoryId = cursor
.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID))
this.packageName = cursor
.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME))
this.name = cursor
.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME))
this.summary = cursor
.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY))
this.installedVersion = cursor
.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION))
.orEmpty()
this.compatible = cursor
.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0
this.canUpdate = cursor
.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0
this.matchRank = cursor
.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK))
}
}
}
}
object CategoryAdapter {
// Done
fun getAll(signal: CancellationSignal?): Set<String> {
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 {
// Done
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) }
}
// Done in insert
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)
}
}
// Done in insert
fun put(installedItem: InstalledItem) = put(installedItem, true)
// Done in insert
fun putAll(installedItems: List<InstalledItem>) {
db.beginTransaction()
try {
db.delete(Schema.Installed.name, null, null)
installedItems.forEach { put(it, false) }
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
// Done
fun delete(packageName: String) {
val count = db.delete(
Schema.Installed.name,
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
arrayOf(packageName)
)
if (count > 0) {
notifyChanged(Subject.Products)
}
}
// Unnecessary with Room
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 {
// Done in insert (Lock object instead of pair)
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)
}
}
// Done in insert (Lock object instead of pair)
fun put(lock: Pair<String, Long>) = put(lock, true)
// Done in insert (Lock object instead of pair)
fun putAll(locks: List<Pair<String, Long>>) {
db.beginTransaction()
try {
db.delete(Schema.Lock.name, null, null)
locks.forEach { put(it, false) }
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
// Done
fun delete(packageName: String) {
db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
notifyChanged(Subject.Products)
}
}
// TODO add temporary tables
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.beginTransaction()
try {
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)
})
}
}
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}")
}
}
}
}

View File

@ -5,12 +5,15 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.looker.droidify.entity.Repository.Companion.defaultRepositories
@Database(
entities = [
Repository::class,
Product::class,
ProductTemp::class,
Category::class,
CategoryTemp::class,
Installed::class,
Lock::class
], version = 1
@ -19,7 +22,9 @@ import androidx.room.TypeConverters
abstract class DatabaseX : RoomDatabase() {
abstract val repositoryDao: RepositoryDao
abstract val productDao: ProductDao
abstract val productTempDao: ProductTempDao
abstract val categoryDao: CategoryDao
abstract val categoryTempDao: CategoryTempDao
abstract val installedDao: InstalledDao
abstract val lockDao: LockDao
@ -39,6 +44,11 @@ abstract class DatabaseX : RoomDatabase() {
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
INSTANCE?.let { instance ->
if (instance.repositoryDao.count == 0) defaultRepositories.forEach {
instance.repositoryDao.put(it)
}
}
}
return INSTANCE!!
}
@ -46,17 +56,33 @@ abstract class DatabaseX : RoomDatabase() {
}
fun cleanUp(pairs: Set<Pair<Long, Boolean>>) {
val result = pairs.windowed(10, 10, true).map {
val ids = it.map { it.first }.toLongArray()
val productsCount = productDao.deleteById(*ids)
val categoriesCount = categoryDao.deleteById(*ids)
val deleteIds = it.filter { it.second }.map { it.first }.toLongArray()
repositoryDao.deleteById(*deleteIds)
productsCount != 0 || categoriesCount != 0
runInTransaction {
val result = pairs.windowed(10, 10, true).map {
val ids = it.map { it.first }.toLongArray()
val productsCount = productDao.deleteById(*ids)
val categoriesCount = categoryDao.deleteById(*ids)
val deleteIds = it.filter { it.second }.map { it.first }.toLongArray()
repositoryDao.deleteById(*deleteIds)
productsCount != 0 || categoriesCount != 0
}
}
// Use live objects and observers instead
// TODO Use live objects and observers instead
/*if (result.any { it }) {
com.looker.droidify.database.Database.notifyChanged(com.looker.droidify.database.Database.Subject.Products)
}*/
}
fun finishTemporary(repository: com.looker.droidify.entity.Repository, success: Boolean) {
runInTransaction {
if (success) {
productDao.deleteById(repository.id)
categoryDao.deleteById(repository.id)
productDao.insert(*(productTempDao.all))
categoryDao.insert(*(categoryTempDao.all))
repositoryDao.put(repository)
}
productTempDao.emptyTable()
categoryTempDao.emptyTable()
}
}
}

View File

@ -33,6 +33,8 @@ class QueryBuilder {
this.arguments += arguments
}
fun build() = builder.toString()
fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor {
val query = builder.toString()
val arguments = arguments.toTypedArray()
@ -47,4 +49,4 @@ class QueryBuilder {
}
return db.rawQuery(query, arguments, signal)
}
}
}

View File

@ -4,11 +4,10 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import com.looker.droidify.database.Database.jsonGenerate
import com.looker.droidify.database.Database.jsonParse
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.utility.jsonGenerate
import com.looker.droidify.utility.jsonParse
@Entity
class Repository {
@ -17,63 +16,72 @@ class Repository {
var id: Long = 0
var enabled = 0
var deleted = 0
var deleted = false
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var data: Repository? = null
val trueData: Repository?
get() = data?.copy(id = id)
class IdAndDeleted {
@ColumnInfo(name = "_id")
var id = 0L
var deleted = 0
var deleted = false
}
}
@Entity(primaryKeys = ["repository_id", "package_name"])
class Product {
var repository_id: Long = 0
@Entity(tableName = "product", primaryKeys = ["repository_id", "package_name"])
open class Product {
var repository_id = 0L
var package_name = ""
var name = ""
var summary = ""
var description = ""
var added = 0
var updated = 0
var version_code = 0
var added = 0L
var updated = 0L
var version_code = 0L
var signatures = ""
var compatible = 0
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var data: Product? = null
var data: com.looker.droidify.entity.Product? = null
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var data_item: ProductItem? = null
}
@Entity(primaryKeys = ["repository_id", "package_name", "name"])
class Category {
@Entity(tableName = "temporary_product")
class ProductTemp : Product()
@Entity(tableName = "category", primaryKeys = ["repository_id", "package_name", "name"])
open class Category {
var repository_id: Long = 0
var package_name = ""
var name = ""
}
@Entity
class Installed {
@Entity(tableName = "temporary_category")
class CategoryTemp : Category()
@Entity(tableName = "memory_installed")
class Installed(pName: String = "") {
@PrimaryKey
var package_name = ""
var package_name = pName
var version = ""
var version_code = 0
var version_code = 0L
var signature = ""
}
@Entity
@Entity(tableName = "memory_lock")
class Lock {
@PrimaryKey
var package_name = ""
var version_code = 0
var version_code = 0L
}
object Converters {
@ -87,11 +95,12 @@ object Converters {
@TypeConverter
@JvmStatic
fun toProduct(byteArray: ByteArray) = byteArray.jsonParse { Product.deserialize(it) }
fun toProduct(byteArray: ByteArray) =
byteArray.jsonParse { com.looker.droidify.entity.Product.deserialize(it) }
@TypeConverter
@JvmStatic
fun toByteArray(product: Product) = jsonGenerate(product::serialize)
fun toByteArray(product: com.looker.droidify.entity.Product) = jsonGenerate(product::serialize)
@TypeConverter
@JvmStatic

View File

@ -153,9 +153,9 @@ object IndexV1Parser {
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
valueAsString
)
it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) {
val locale = it.key
it.dictionary("localized") -> forEachKey { keyToken ->
if (keyToken.token == JsonToken.START_OBJECT) {
val locale = keyToken.key
var name = ""
var summary = ""
var description = ""

View File

@ -3,7 +3,7 @@ package com.looker.droidify.index
import android.content.Context
import android.net.Uri
import com.looker.droidify.content.Cache
import com.looker.droidify.database.Database
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Release
import com.looker.droidify.entity.Repository
@ -59,29 +59,28 @@ object RepositoryUpdater {
private val updaterLock = Any()
private val cleanupLock = Any()
lateinit var db: DatabaseX
fun init() {
fun init(context: Context) {
db = DatabaseX.getInstance(context)
var lastDisabled = setOf<Long>()
Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
//.concatWith(Database.observable(Database.Subject.Repositories)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle {
RxUtils.querySingle {
Database.RepositoryAdapter.getAllDisabledDeleted(
it
)
db.repositoryDao.allDisabledDeleted
}
}
.forEach { it ->
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet()
val newDisabled = it.asSequence().filter { !it.deleted }.map { it.id }.toSet()
val disabled = newDisabled - lastDisabled
lastDisabled = newDisabled
val deleted = it.asSequence().filter { it.second }.map { it.first }.toSet()
val deleted = it.asSequence().filter { it.deleted }.map { it.id }.toSet()
if (disabled.isNotEmpty() || deleted.isNotEmpty()) {
val pairs = (disabled.asSequence().map { Pair(it, false) } +
deleted.asSequence().map { Pair(it, true) }).toSet()
synchronized(cleanupLock) { Database.RepositoryAdapter.cleanup(pairs) }
synchronized(cleanupLock) { db.cleanUp(pairs) }
}
}
}
@ -193,12 +192,14 @@ object RepositoryUpdater {
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit,
): Boolean {
var rollback = true
val db = DatabaseX.getInstance(context)
return synchronized(updaterLock) {
try {
val jarFile = JarFile(file, true)
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
val total = indexEntry.size
Database.UpdaterAdapter.createTemporaryTable()
db.productTempDao.emptyTable()
db.categoryTempDao.emptyTable()
val features = context.packageManager.systemAvailableFeatures
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
@ -231,7 +232,7 @@ object RepositoryUpdater {
}
products += transformProduct(product, features, unstable)
if (products.size >= 50) {
Database.UpdaterAdapter.putTemporary(products)
db.productTempDao.putTemporary(products)
products.clear()
}
}
@ -249,7 +250,7 @@ object RepositoryUpdater {
throw InterruptedException()
}
if (products.isNotEmpty()) {
Database.UpdaterAdapter.putTemporary(products)
db.productTempDao.putTemporary(products)
products.clear()
}
Pair(changedRepository, certificateFromIndex)
@ -334,7 +335,7 @@ object RepositoryUpdater {
progress.toLong(),
totalCount.toLong()
)
Database.UpdaterAdapter.putTemporary(products
db.productTempDao.putTemporary(products
.map { transformProduct(it, features, unstable) })
}
}
@ -407,7 +408,7 @@ object RepositoryUpdater {
}
callback(Stage.COMMIT, 0, null)
synchronized(cleanupLock) {
Database.UpdaterAdapter.finishTemporary(
db.finishTemporary(
commitRepository,
true
)
@ -423,7 +424,7 @@ object RepositoryUpdater {
} finally {
file.delete()
if (rollback) {
Database.UpdaterAdapter.finishTemporary(repository, false)
db.finishTemporary(repository, false)
}
}
}

View File

@ -11,8 +11,8 @@ import android.net.Uri
import android.os.IBinder
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_INSTALLER
import com.looker.droidify.Common.NOTIFICATION_ID_INSTALLER
import com.looker.droidify.NOTIFICATION_CHANNEL_INSTALLER
import com.looker.droidify.NOTIFICATION_ID_INSTALLER
import com.looker.droidify.MainActivity
import com.looker.droidify.R
import com.looker.droidify.utility.Utils

View File

@ -17,7 +17,6 @@ import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.EditRepositoryBinding
import com.looker.droidify.entity.Repository
import com.looker.droidify.network.Downloader
@ -154,7 +153,7 @@ class EditRepositoryFragment() : ScreenFragment() {
}
if (savedInstanceState == null) {
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
val repository = repositoryId?.let { screenActivity.db.repositoryDao.get(it)?.trueData }
if (repository == null) {
val clipboardManager =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@ -233,7 +232,7 @@ class EditRepositoryFragment() : ScreenFragment() {
}
lifecycleScope.launch {
val list = Database.RepositoryAdapter.getAll(null)
val list = screenActivity.db.repositoryDao.all.mapNotNull { it.trueData }
takenAddresses = list.asSequence().filter { it.id != repositoryId }
.flatMap { (it.mirrors + it.address).asSequence() }
.map { it.withoutKnownPath }.toSet()
@ -449,10 +448,10 @@ class EditRepositoryFragment() : ScreenFragment() {
MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager)
invalidateState()
} else {
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
val repository = repositoryId?.let { screenActivity.db.repositoryDao.get(it)?.trueData }
?.edit(address, fingerprint, authentication)
?: Repository.newRepository(address, fingerprint, authentication)
val changedRepository = Database.RepositoryAdapter.put(repository)
val changedRepository = screenActivity.db.repositoryDao.put(repository)
if (repositoryId == null && changedRepository.enabled) {
binder.sync(changedRepository)
}

View File

@ -9,11 +9,11 @@ import com.google.android.material.card.MaterialCardView
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.droidify.entity.Repository
import com.looker.droidify.utility.extension.resources.clear
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.resources.inflate
import com.looker.droidify.utility.getRepository
import com.looker.droidify.widget.CursorRecyclerAdapter
class RepositoriesAdapter(
@ -45,7 +45,7 @@ class RepositoriesAdapter(
}
private fun getRepository(position: Int): Repository {
return Database.RepositoryAdapter.transform(moveTo(position))
return moveTo(position).getRepository()
}
override fun onCreateViewHolder(

View File

@ -12,7 +12,6 @@ import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.widget.NestedScrollView
import androidx.lifecycle.lifecycleScope
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.TitleTextItemBinding
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
@ -99,7 +98,7 @@ class RepositoryFragment() : ScreenFragment() {
}
private fun updateRepositoryView() {
val repository = Database.RepositoryAdapter.get(repositoryId)
val repository = screenActivity.db.repositoryDao.get(repositoryId)?.trueData
val layout = layout!!
layout.removeAllViews()
if (repository == null) {
@ -125,7 +124,7 @@ class RepositoryFragment() : ScreenFragment() {
if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) {
layout.addTitleText(
R.string.number_of_applications,
Database.ProductAdapter.getCount(repository.id).toString()
screenActivity.db.productDao.countForRepository(repository.id).toString()
)
}
} else {

View File

@ -11,6 +11,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.circularreveal.CircularRevealFrameLayout
import com.looker.droidify.MainApplication
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.CursorOwner
@ -26,6 +27,9 @@ abstract class ScreenActivity : AppCompatActivity() {
private const val STATE_FRAGMENT_STACK = "fragmentStack"
}
val db
get() = (application as MainApplication).db
sealed class SpecialIntent {
object Updates : SpecialIntent()
class Install(val packageName: String?, val status: Int?, val promptIntent: Intent?) : SpecialIntent()

View File

@ -19,7 +19,7 @@ import androidx.viewpager2.widget.ViewPager2
import coil.load
import com.google.android.material.imageview.ShapeableImageView
import com.looker.droidify.R
import com.looker.droidify.database.Database
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Repository
import com.looker.droidify.graphics.PaddingDrawable
@ -68,6 +68,7 @@ class ScreenshotsFragment() : DialogFragment() {
val window = dialog.window
val decorView = window?.decorView
val db = DatabaseX.getInstance(requireContext())
if (window != null) {
WindowCompat.setDecorFitsSystemWindows(window, false)
@ -132,13 +133,17 @@ class ScreenshotsFragment() : DialogFragment() {
var restored = false
productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
//.concatWith(Database.observable(Database.Subject.Products)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
.flatMapSingle {
RxUtils.querySingle {
db.productDao.get(packageName).mapNotNull { it?.data }
}
}
.map { it ->
Pair(
it.find { it.repositoryId == repositoryId },
Database.RepositoryAdapter.get(repositoryId)
db.repositoryDao.get(repositoryId)?.trueData
)
}
.observeOn(AndroidSchedulers.mainThread())

View File

@ -33,7 +33,6 @@ import com.looker.droidify.utility.Utils.languagesList
import com.looker.droidify.utility.Utils.translateLocale
import com.looker.droidify.utility.extension.resources.*
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class SettingsFragment : ScreenFragment() {
@ -105,6 +104,7 @@ class SettingsFragment : ScreenFragment() {
when (it) {
Preferences.AutoSync.Never -> getString(R.string.never)
Preferences.AutoSync.Wifi -> getString(R.string.only_on_wifi)
Preferences.AutoSync.WifiBattery -> getString(R.string.only_on_wifi_and_battery)
Preferences.AutoSync.Always -> getString(R.string.always)
}
}

View File

@ -19,7 +19,6 @@ import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.TabsToolbarBinding
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.service.Connection
@ -235,9 +234,9 @@ class TabsFragment : ScreenFragment() {
}
categoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
//.concatWith(Database.observable(Database.Subject.Products)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.CategoryAdapter.getAll(it) } }
.flatMapSingle { RxUtils.querySingle { screenActivity.db.categoryDao.allNames } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
setSectionsAndUpdate(
@ -246,9 +245,9 @@ class TabsFragment : ScreenFragment() {
)
}
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
//.concatWith(Database.observable(Database.Subject.Repositories)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
.flatMapSingle { RxUtils.querySingle { screenActivity.db.repositoryDao.all.mapNotNull { it.trueData } } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { it ->
setSectionsAndUpdate(null, it.asSequence().filter { it.enabled }

View File

@ -7,12 +7,7 @@ import android.content.Intent
import android.net.Uri
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common.NOTIFICATION_ID_DOWNLOADING
import com.looker.droidify.Common.NOTIFICATION_ID_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_DOWNLOADING
import com.looker.droidify.MainActivity
import com.looker.droidify.R
import com.looker.droidify.*
import com.looker.droidify.content.Cache
import com.looker.droidify.entity.Release
import com.looker.droidify.entity.Repository

View File

@ -12,15 +12,9 @@ import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common.NOTIFICATION_ID_UPDATES
import com.looker.droidify.Common.NOTIFICATION_ID_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_UPDATES
import com.looker.droidify.MainActivity
import com.looker.droidify.R
import com.looker.droidify.*
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.Database
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.index.RepositoryUpdater
@ -31,6 +25,7 @@ import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.android.notificationManager
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.text.formatSize
import com.looker.droidify.utility.getProductItem
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
@ -104,7 +99,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
fun sync(request: SyncRequest) {
val ids = Database.RepositoryAdapter.getAll(null)
val ids = db.repositoryDao.all.mapNotNull { it.trueData }
.asSequence().filter { it.enabled }.map { it.id }.toList()
sync(ids, request)
}
@ -130,7 +125,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
Database.RepositoryAdapter.put(repository.enable(enabled))
db.repositoryDao.put(repository.enable(enabled))
if (enabled) {
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
tasks += Task(repository.id, true)
@ -149,10 +144,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
}
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
val repository = db.repositoryDao.get(repositoryId)?.trueData
return repository != null && run {
setEnabled(repository, false)
Database.RepositoryAdapter.markAsDeleted(repository.id)
db.repositoryDao.markAsDeleted(repository.id)
true
}
}
@ -160,10 +155,12 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
lateinit var db: DatabaseX
override fun onCreate() {
super.onCreate()
db = DatabaseX.getInstance(applicationContext)
if (Android.sdk(26)) {
NotificationChannel(
NOTIFICATION_CHANNEL_SYNCING,
@ -337,7 +334,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
val repository = Database.RepositoryAdapter.get(task.repositoryId)
val repository = db.repositoryDao.get(task.repositoryId)?.trueData
if (repository != null && repository.enabled) {
val lastStarted = started
val newStarted =
@ -382,7 +379,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
} else if (started != Started.NO) {
val disposable = RxUtils
.querySingle { it ->
Database.ProductAdapter
db.productDao
.query(
installed = true,
updates = true,
@ -392,7 +389,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
signal = it
)
.use {
it.asSequence().map(Database.ProductAdapter::transformItem)
it.asSequence().map { it.getProductItem() }
.toList()
}
}

View File

@ -2,15 +2,12 @@ package com.looker.droidify.ui.activities
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.os.Bundle
import android.view.*
import android.view.inputmethod.InputMethodManager
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
@ -20,11 +17,10 @@ import androidx.navigation.ui.setupWithNavController
import com.google.android.material.appbar.MaterialToolbar
import com.looker.droidify.BuildConfig
import com.looker.droidify.ContextWrapperX
import com.looker.droidify.MainApplication
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.database.QueryLoader
import com.looker.droidify.databinding.ActivityMainXBinding
import com.looker.droidify.installer.AppInstaller
import com.looker.droidify.screen.*
@ -37,7 +33,7 @@ import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.text.nullIfEmpty
import kotlinx.coroutines.launch
class MainActivityX : AppCompatActivity(), LoaderManager.LoaderCallbacks<Cursor> {
class MainActivityX : AppCompatActivity() {
companion object {
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
@ -67,6 +63,9 @@ class MainActivityX : AppCompatActivity(), LoaderManager.LoaderCallbacks<Cursor>
}
})
val db
get() = (application as MainApplication).db
lateinit var cursorOwner: CursorOwner
private set
@ -230,79 +229,4 @@ class MainActivityX : AppCompatActivity(), LoaderManager.LoaderCallbacks<Cursor>
}
syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment)
}
fun attachCursorOwner(callback: CursorOwner.Callback, request: CursorOwner.Request) {
val oldActiveRequest = viewModel.activeRequests[request.id]
if (oldActiveRequest?.callback != null &&
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
) {
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
}
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
callback.onCursorData(request, oldActiveRequest.cursor)
oldActiveRequest.cursor
} else {
null
}
viewModel.activeRequests[request.id] = CursorOwner.ActiveRequest(request, callback, cursor)
if (cursor == null) {
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
}
}
fun detachCursorOwner(callback: CursorOwner.Callback) {
for (id in viewModel.activeRequests.keys) {
val activeRequest = viewModel.activeRequests[id]!!
if (activeRequest.callback == callback) {
viewModel.activeRequests[id] = activeRequest.copy(callback = null)
}
}
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = viewModel.activeRequests[id]!!.request
return QueryLoader(this) {
when (request) {
is CursorOwner.Request.ProductsAvailable -> Database.ProductAdapter
.query(
installed = false,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is CursorOwner.Request.ProductsInstalled -> Database.ProductAdapter
.query(
installed = true,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is CursorOwner.Request.ProductsUpdates -> Database.ProductAdapter
.query(
installed = true,
updates = true,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is CursorOwner.Request.Repositories -> Database.RepositoryAdapter.query(it)
}
}
}
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
val activeRequest = viewModel.activeRequests[loader.id]
if (activeRequest != null) {
viewModel.activeRequests[loader.id] = activeRequest.copy(cursor = data)
activeRequest.callback?.onCursorData(activeRequest.request, data)
}
}
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
}

View File

@ -16,13 +16,13 @@ import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.Database
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.network.CoilDownloader
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.nullIfEmpty
import com.looker.droidify.utility.getProductItem
import com.looker.droidify.widget.CursorRecyclerAdapter
class AppListAdapter(private val onClick: (ProductItem) -> Unit) :
@ -113,7 +113,7 @@ class AppListAdapter(private val onClick: (ProductItem) -> Unit) :
}
private fun getProductItem(position: Int): ProductItem {
return Database.ProductAdapter.transformItem(moveTo(position))
return moveTo(position).getProductItem()
}
override fun onCreateViewHolder(

View File

@ -17,7 +17,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.R
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.database.Database
import com.looker.droidify.entity.*
import com.looker.droidify.installer.AppInstaller
import com.looker.droidify.screen.MessageDialog
@ -32,15 +31,17 @@ import com.looker.droidify.utility.Utils.rootInstallerEnabled
import com.looker.droidify.utility.Utils.startUpdate
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.text.trimAfter
import com.looker.droidify.utility.getInstalledItem
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
import kotlin.collections.ArrayList
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
companion object {
@ -129,12 +130,16 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
var first = true
productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
//.concatWith(Database.observable(Database.Subject.Products)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
.flatMapSingle {
RxUtils.querySingle {
screenActivity.db.productDao.get(packageName).mapNotNull { it?.data }
}
}
.flatMapSingle { products ->
RxUtils
.querySingle { Database.RepositoryAdapter.getAll(it) }
.querySingle { screenActivity.db.repositoryDao.all.mapNotNull { it.trueData } }
.map { it ->
it.asSequence().map { Pair(it.id, it) }.toMap()
.let {
@ -151,7 +156,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
}
.flatMapSingle { products ->
RxUtils
.querySingle { Nullable(Database.InstalledAdapter.get(packageName, it)) }
.querySingle { Nullable(screenActivity.db.installedDao.get(packageName).getInstalledItem()) }
.map { Pair(products, it) }
}
.observeOn(AndroidSchedulers.mainThread())
@ -452,15 +457,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
} else Unit
}
AppDetailAdapter.Action.SHARE -> {
val sendIntent: Intent = Intent().apply {
this.action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
"https://www.f-droid.org/packages/${products[0].first.packageName}/"
)
type = "text/plain"
}
startActivity(Intent.createChooser(sendIntent, null))
shareIntent(packageName, products[0].first.name)
}
}::class
}
@ -478,6 +475,21 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
}
}
private fun shareIntent(packageName: String, appName: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
val extraText = if (Android.sdk(24)) {
"https://www.f-droid.org/${resources.configuration.locales[0].language}/packages/${packageName}/"
} else "https://www.f-droid.org/${resources.configuration.locale.language}/packages/${packageName}/"
shareIntent.type = "text/plain"
shareIntent.putExtra(Intent.EXTRA_TITLE, appName)
shareIntent.putExtra(Intent.EXTRA_SUBJECT, appName)
shareIntent.putExtra(Intent.EXTRA_TEXT, extraText)
startActivity(Intent.createChooser(shareIntent, "Where to Send?"))
}
override fun onPreferenceChanged(preference: ProductPreference) {
lifecycleScope.launch { updateButtons(preference) }
}

View File

@ -13,7 +13,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.screen.BaseFragment
import com.looker.droidify.ui.adapters.AppListAdapter
@ -78,10 +77,10 @@ class AppListFragment() : BaseFragment(), CursorOwner.Callback {
screenActivity.cursorOwner.attach(this, viewModel.request(source))
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
//.concatWith(Database.observable(Database.Subject.Repositories)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
.map { list -> list.asSequence().map { Pair(it.id, it) }.toMap() }
.flatMapSingle { RxUtils.querySingle { screenActivity.db.repositoryDao.all.mapNotNull { it.trueData } } }
.map { it.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (recyclerView?.adapter as? AppListAdapter)?.repositories = it }
}

View File

@ -5,34 +5,32 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.looker.droidify.R
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.FragmentExploreXBinding
import com.looker.droidify.entity.Repository
import com.looker.droidify.ui.adapters.AppListAdapter
import com.looker.droidify.ui.viewmodels.MainNavFragmentViewModelX
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.widget.RecyclerFastScroller
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ExploreFragment : MainNavFragmentX(), CursorOwner.Callback {
override val viewModel: MainNavFragmentViewModelX by viewModels()
override lateinit var viewModel: MainNavFragmentViewModelX
private lateinit var binding: FragmentExploreXBinding
override val source = Source.AVAILABLE
private var repositoriesDisposable: Disposable? = null
private var repositories: Map<Long, Repository> = mapOf()
override fun onCreateView(
inflater: LayoutInflater,
@ -42,6 +40,9 @@ class ExploreFragment : MainNavFragmentX(), CursorOwner.Callback {
super.onCreate(savedInstanceState)
binding = FragmentExploreXBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
val viewModelFactory = MainNavFragmentViewModelX.Factory(mainActivityX.db)
viewModel = ViewModelProvider(this, viewModelFactory)
.get(MainNavFragmentViewModelX::class.java)
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(context)
@ -58,22 +59,13 @@ class ExploreFragment : MainNavFragmentX(), CursorOwner.Callback {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainActivityX.attachCursorOwner(this, viewModel.request(source))
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
viewModel.fillList(source)
viewModel.db.repositoryDao.allFlowable
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
.flatMapSingle { list -> RxUtils.querySingle { list.mapNotNull { it.trueData } } }
.map { list -> list.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (binding.recyclerView.adapter as? AppListAdapter)?.repositories = it }
}
override fun onDestroyView() {
super.onDestroyView()
mainActivityX.detachCursorOwner(this)
repositoriesDisposable?.dispose()
repositoriesDisposable = null
.subscribe { repositories = it }
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {

View File

@ -5,34 +5,36 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.looker.droidify.R
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.FragmentInstalledXBinding
import com.looker.droidify.ui.adapters.AppListAdapter
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.ui.items.HAppItem
import com.looker.droidify.ui.items.VAppItem
import com.looker.droidify.ui.viewmodels.MainNavFragmentViewModelX
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.widget.RecyclerFastScroller
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class InstalledFragment : MainNavFragmentX(), CursorOwner.Callback {
override val viewModel: MainNavFragmentViewModelX by viewModels()
override lateinit var viewModel: MainNavFragmentViewModelX
private lateinit var binding: FragmentInstalledXBinding
private val installedItemAdapter = ItemAdapter<VAppItem>()
private var installedFastAdapter: FastAdapter<VAppItem>? = null
private val updatedItemAdapter = ItemAdapter<HAppItem>()
private var updatedFastAdapter: FastAdapter<HAppItem>? = null
override val source = Source.INSTALLED
private var repositoriesDisposable: Disposable? = null
private var repositories: Map<Long, Repository> = mapOf()
override fun onCreateView(
inflater: LayoutInflater,
@ -42,12 +44,26 @@ class InstalledFragment : MainNavFragmentX(), CursorOwner.Callback {
super.onCreate(savedInstanceState)
binding = FragmentInstalledXBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
val viewModelFactory = MainNavFragmentViewModelX.Factory(mainActivityX.db)
viewModel = ViewModelProvider(this, viewModelFactory)
.get(MainNavFragmentViewModelX::class.java)
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(context)
installedFastAdapter = FastAdapter.with(installedItemAdapter)
installedFastAdapter?.setHasStableIds(true)
binding.installedRecycler.apply {
layoutManager = LinearLayoutManager(requireContext())
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
adapter = AppListAdapter { mainActivityX.navigateProduct(it.packageName) }
adapter = installedFastAdapter
RecyclerFastScroller(this)
}
updatedFastAdapter = FastAdapter.with(updatedItemAdapter)
updatedFastAdapter?.setHasStableIds(true)
binding.updatedRecycler.apply {
layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
adapter = updatedFastAdapter
RecyclerFastScroller(this)
}
return binding.root
@ -56,28 +72,26 @@ class InstalledFragment : MainNavFragmentX(), CursorOwner.Callback {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainActivityX.attachCursorOwner(this, viewModel.request(source))
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
viewModel.fillList(source)
viewModel.db.repositoryDao.allFlowable
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
.flatMapSingle { list -> RxUtils.querySingle { list.mapNotNull { it.trueData } } }
.map { list -> list.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (binding.recyclerView.adapter as? AppListAdapter)?.repositories = it }
}
override fun onDestroyView() {
super.onDestroyView()
mainActivityX.detachCursorOwner(this)
repositoriesDisposable?.dispose()
repositoriesDisposable = null
.subscribe { repositories = it }
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
// TODO create app list out of cursor and use those on the different RecycleViews
(binding.recyclerView.adapter as? AppListAdapter)?.apply {
this.cursor = cursor
// TODO get a list instead of the cursor
// TODO use LiveData and observers instead of listeners
val appItemList: List<ProductItem> = listOf()
installedItemAdapter.set(appItemList
.map { VAppItem(it, repositories[it.repositoryId]) }
)
updatedItemAdapter.set(appItemList.filter { it.canUpdate }
.map { HAppItem(it, repositories[it.repositoryId]) }
)
/*
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
emptyText = when {
@ -88,6 +102,6 @@ class InstalledFragment : MainNavFragmentX(), CursorOwner.Callback {
}
}
}
}
*/
}
}

View File

@ -5,34 +5,36 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.looker.droidify.R
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.Database
import com.looker.droidify.databinding.FragmentLatestXBinding
import com.looker.droidify.ui.adapters.AppListAdapter
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.ui.items.HAppItem
import com.looker.droidify.ui.items.VAppItem
import com.looker.droidify.ui.viewmodels.MainNavFragmentViewModelX
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.widget.RecyclerFastScroller
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class LatestFragment : MainNavFragmentX(), CursorOwner.Callback {
override val viewModel: MainNavFragmentViewModelX by viewModels()
override lateinit var viewModel: MainNavFragmentViewModelX
private lateinit var binding: FragmentLatestXBinding
override val source = Source.UPDATES
private val updatedItemAdapter = ItemAdapter<VAppItem>()
private var updatedFastAdapter: FastAdapter<VAppItem>? = null
private val newItemAdapter = ItemAdapter<HAppItem>()
private var newFastAdapter: FastAdapter<HAppItem>? = null
private var repositoriesDisposable: Disposable? = null
override val source = Source.AVAILABLE
private var repositories: Map<Long, Repository> = mapOf()
override fun onCreateView(
inflater: LayoutInflater,
@ -42,13 +44,26 @@ class LatestFragment : MainNavFragmentX(), CursorOwner.Callback {
super.onCreate(savedInstanceState)
binding = FragmentLatestXBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
val viewModelFactory = MainNavFragmentViewModelX.Factory(mainActivityX.db)
viewModel = ViewModelProvider(this, viewModelFactory)
.get(MainNavFragmentViewModelX::class.java)
binding.recyclerView.apply {
id = android.R.id.list
layoutManager = LinearLayoutManager(context)
updatedFastAdapter = FastAdapter.with(updatedItemAdapter)
updatedFastAdapter?.setHasStableIds(true)
binding.updatedRecycler.apply {
layoutManager = LinearLayoutManager(requireContext())
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
adapter = AppListAdapter { mainActivityX.navigateProduct(it.packageName) }
adapter = updatedFastAdapter
RecyclerFastScroller(this)
}
newFastAdapter = FastAdapter.with(newItemAdapter)
newFastAdapter?.setHasStableIds(true)
binding.newRecycler.apply {
layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
adapter = newFastAdapter
RecyclerFastScroller(this)
}
return binding.root
@ -57,28 +72,26 @@ class LatestFragment : MainNavFragmentX(), CursorOwner.Callback {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainActivityX.attachCursorOwner(this, viewModel.request(source))
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
viewModel.fillList(source)
viewModel.db.repositoryDao.allFlowable
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
.flatMapSingle { list -> RxUtils.querySingle { list.mapNotNull { it.trueData } } }
.map { list -> list.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (binding.recyclerView.adapter as? AppListAdapter)?.repositories = it }
}
override fun onDestroyView() {
super.onDestroyView()
mainActivityX.detachCursorOwner(this)
repositoriesDisposable?.dispose()
repositoriesDisposable = null
.subscribe { repositories = it }
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
// TODO create app list out of cursor and use those on the different RecycleViews
(binding.recyclerView.adapter as? AppListAdapter)?.apply {
this.cursor = cursor
// TODO get a list instead of the cursor
// TODO use LiveData and observers instead of listeners
val appItemList: List<ProductItem> = listOf()
updatedItemAdapter.set(appItemList // .filter { !it.hasOneRelease }
.map { VAppItem(it, repositories[it.repositoryId]) }
)
newItemAdapter.set(appItemList // .filter { it.hasOneRelease }
.map { HAppItem(it, repositories[it.repositoryId]) }
)
/*
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
emptyText = when {
@ -89,6 +102,6 @@ class LatestFragment : MainNavFragmentX(), CursorOwner.Callback {
}
}
}
}
*/
}
}

View File

@ -10,7 +10,7 @@ import com.looker.droidify.ui.viewmodels.MainNavFragmentViewModelX
abstract class MainNavFragmentX : Fragment(), CursorOwner.Callback {
val mainActivityX: MainActivityX
get() = requireActivity() as MainActivityX
abstract val viewModel: MainNavFragmentViewModelX
abstract var viewModel: MainNavFragmentViewModelX
abstract val source: Source
open fun onBackPressed(): Boolean = false
@ -18,7 +18,7 @@ abstract class MainNavFragmentX : Fragment(), CursorOwner.Callback {
internal fun setSearchQuery(searchQuery: String) {
viewModel.setSearchQuery(searchQuery) {
if (view != null) {
mainActivityX.attachCursorOwner(this, viewModel.request(source))
viewModel.fillList(source)
}
}
}
@ -26,7 +26,7 @@ abstract class MainNavFragmentX : Fragment(), CursorOwner.Callback {
internal fun setSection(section: ProductItem.Section) {
viewModel.setSection(section) {
if (view != null) {
mainActivityX.attachCursorOwner(this, viewModel.request(source))
viewModel.fillList(source)
}
}
}
@ -34,7 +34,7 @@ abstract class MainNavFragmentX : Fragment(), CursorOwner.Callback {
internal fun setOrder(order: ProductItem.Order) {
viewModel.setOrder(order) {
if (view != null) {
mainActivityX.attachCursorOwner(this, viewModel.request(source))
viewModel.fillList(source)
}
}
}
@ -44,4 +44,37 @@ enum class Source(val titleResId: Int, val sections: Boolean, val order: Boolean
AVAILABLE(R.string.available, true, true),
INSTALLED(R.string.installed, false, true),
UPDATES(R.string.updates, false, false)
}
sealed class Request {
internal abstract val id: Int
data class ProductsAvailable(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order,
) : Request() {
override val id: Int
get() = 1
}
data class ProductsInstalled(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order,
) : Request() {
override val id: Int
get() = 2
}
data class ProductsUpdates(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order,
) : Request() {
override val id: Int
get() = 3
}
object Repositories : Request() {
override val id: Int
get() = 4
}
}

View File

@ -0,0 +1,42 @@
package com.looker.droidify.ui.items
import android.view.LayoutInflater
import android.view.ViewGroup
import coil.load
import coil.transform.RoundedCornersTransformation
import com.looker.droidify.R
import com.looker.droidify.databinding.ItemAppHorizXBinding
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.network.CoilDownloader
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.resources.toPx
import com.mikepenz.fastadapter.binding.AbstractBindingItem
class HAppItem(val item: ProductItem, val repository: Repository?) :
AbstractBindingItem<ItemAppHorizXBinding>() {
override val type: Int
get() = R.id.fastadapter_item
override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?)
: ItemAppHorizXBinding = ItemAppHorizXBinding.inflate(inflater, parent, false)
override fun bindView(binding: ItemAppHorizXBinding, payloads: List<Any>) {
val (progressIcon, defaultIcon) = Utils.getDefaultApplicationIcons(binding.icon.context)
binding.name.text = item.name
repository?.let {
binding.icon.load(
CoilDownloader.createIconUri(
binding.icon, item.packageName,
item.icon, item.metadataIcon, repository
)
) {
transformations(RoundedCornersTransformation(4.toPx))
placeholder(progressIcon)
error(defaultIcon)
}
}
binding.version.text = if (item.canUpdate) item.version else item.installedVersion
}
}

View File

@ -0,0 +1,79 @@
package com.looker.droidify.ui.items
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import coil.load
import coil.transform.RoundedCornersTransformation
import com.looker.droidify.R
import com.looker.droidify.databinding.ItemAppVerticalXBinding
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.network.CoilDownloader
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.utility.extension.resources.toPx
import com.looker.droidify.utility.extension.text.nullIfEmpty
import com.mikepenz.fastadapter.binding.AbstractBindingItem
class VAppItem(val item: ProductItem, val repository: Repository?) :
AbstractBindingItem<ItemAppVerticalXBinding>() {
override val type: Int
get() = R.id.fastadapter_item
override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?)
: ItemAppVerticalXBinding = ItemAppVerticalXBinding.inflate(inflater, parent, false)
override fun bindView(binding: ItemAppVerticalXBinding, payloads: List<Any>) {
val (progressIcon, defaultIcon) = Utils.getDefaultApplicationIcons(binding.icon.context)
binding.name.text = item.name
binding.summary.text =
if (item.name == item.summary) "" else item.summary
binding.summary.visibility =
if (binding.summary.text.isNotEmpty()) View.VISIBLE else View.GONE
repository?.let {
binding.icon.load(
CoilDownloader.createIconUri(
binding.icon, item.packageName,
item.icon, item.metadataIcon, it
)
) {
transformations(RoundedCornersTransformation(4.toPx))
placeholder(progressIcon)
error(defaultIcon)
}
}
binding.status.apply {
if (item.canUpdate) {
text = item.version
if (background == null) {
background =
ResourcesCompat.getDrawable(
binding.root.resources,
R.drawable.background_border,
context.theme
)
resources.sizeScaled(6).let { setPadding(it, it, it, it) }
backgroundTintList =
context.getColorFromAttr(R.attr.colorSecondaryContainer)
setTextColor(context.getColorFromAttr(R.attr.colorSecondary))
}
} else {
text = item.installedVersion.nullIfEmpty() ?: item.version
if (background != null) {
setPadding(0, 0, 0, 0)
setTextColor(binding.status.context.getColorFromAttr(android.R.attr.colorControlNormal))
background = null
}
}
}
val enabled = item.compatible || item.installedVersion.isNotEmpty()
sequenceOf(binding.name, binding.status, binding.summary).forEach {
it.isEnabled = enabled
}
}
}

View File

@ -2,9 +2,8 @@ package com.looker.droidify.ui.viewmodels
import androidx.lifecycle.ViewModel
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.ui.activities.MainActivityX
class MainActivityViewModelX() : ViewModel() {
class MainActivityViewModelX : ViewModel() {
val activeRequests = mutableMapOf<Int, CursorOwner.ActiveRequest>()
}

View File

@ -1,15 +1,23 @@
package com.looker.droidify.ui.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.database.Product
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.ui.fragments.Request
import com.looker.droidify.ui.fragments.Source
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainNavFragmentViewModelX : ViewModel() {
class MainNavFragmentViewModelX(val db: DatabaseX) : ViewModel() {
private val _order = MutableStateFlow(ProductItem.Order.LAST_UPDATE)
private val _sections = MutableStateFlow<ProductItem.Section>(ProductItem.Section.All)
@ -32,7 +40,7 @@ class MainNavFragmentViewModelX : ViewModel() {
started = SharingStarted.WhileSubscribed(5000)
)
fun request(source: Source): CursorOwner.Request {
fun request(source: Source): Request {
var mSearchQuery = ""
var mSections: ProductItem.Section = ProductItem.Section.All
var mOrder: ProductItem.Order = ProductItem.Order.NAME
@ -42,17 +50,17 @@ class MainNavFragmentViewModelX : ViewModel() {
launch { order.collect { if (source.order) mOrder = it } }
}
return when (source) {
Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
Source.AVAILABLE -> Request.ProductsAvailable(
mSearchQuery,
mSections,
mOrder
)
Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
Source.INSTALLED -> Request.ProductsInstalled(
mSearchQuery,
mSections,
mOrder
)
Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
Source.UPDATES -> Request.ProductsUpdates(
mSearchQuery,
mSections,
mOrder
@ -60,6 +68,46 @@ class MainNavFragmentViewModelX : ViewModel() {
}
}
var productsList = MediatorLiveData<MutableList<Product>>()
fun fillList(source: Source) {
viewModelScope.launch {
productsList.value = query(request(source))?.toMutableList()
}
}
private suspend fun query(request: Request): List<Product>? {
return withContext(Dispatchers.IO) {
when (request) {
is Request.ProductsAvailable -> db.productDao
.queryList(
installed = false,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order
)
is Request.ProductsInstalled -> db.productDao
.queryList(
installed = true,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order
)
is Request.ProductsUpdates -> db.productDao
.queryList(
installed = true,
updates = true,
searchQuery = request.searchQuery,
section = request.section,
order = request.order
)
else -> listOf()
}
}
}
fun setSection(newSection: ProductItem.Section, perform: () -> Unit) {
viewModelScope.launch {
if (newSection != sections.value) {
@ -86,4 +134,14 @@ class MainNavFragmentViewModelX : ViewModel() {
}
}
}
}
class Factory(val db: DatabaseX) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainNavFragmentViewModelX::class.java)) {
return MainNavFragmentViewModelX(db) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}

View File

@ -7,26 +7,32 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.Signature
import android.content.res.Configuration
import android.database.Cursor
import android.graphics.drawable.Drawable
import android.os.Build
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common.PREFS_LANGUAGE_DEFAULT
import com.looker.droidify.R
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.*
import com.looker.droidify.content.Preferences
import com.looker.droidify.entity.InstalledItem
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.service.Connection
import com.looker.droidify.service.DownloadService
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.android.singleSignature
import com.looker.droidify.utility.extension.android.versionCodeCompat
import com.looker.droidify.utility.extension.json.Json
import com.looker.droidify.utility.extension.json.parseDictionary
import com.looker.droidify.utility.extension.json.writeDictionary
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.resources.getDrawableCompat
import com.looker.droidify.utility.extension.text.hex
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.security.cert.Certificate
import java.security.cert.CertificateEncodingException
@ -181,3 +187,51 @@ object Utils {
}
}
fun Cursor.getProduct(): Product = getBlob(getColumnIndex(ROW_DATA))
.jsonParse {
Product.deserialize(it).apply {
this.repositoryId = getLong(getColumnIndex(ROW_REPOSITORY_ID))
this.description = getString(getColumnIndex(ROW_DESCRIPTION))
}
}
fun Cursor.getProductItem(): ProductItem = getBlob(getColumnIndex(ROW_DATA_ITEM))
.jsonParse {
ProductItem.deserialize(it).apply {
this.repositoryId = getLong(getColumnIndex(ROW_REPOSITORY_ID))
this.packageName = getString(getColumnIndex(ROW_PACKAGE_NAME))
this.name = getString(getColumnIndex(ROW_NAME))
this.summary = getString(getColumnIndex(ROW_SUMMARY))
this.installedVersion = getString(getColumnIndex(ROW_VERSION))
.orEmpty()
this.compatible = getInt(getColumnIndex(ROW_COMPATIBLE)) != 0
this.canUpdate = getInt(getColumnIndex(ROW_CAN_UPDATE)) != 0
this.matchRank = getInt(getColumnIndex(ROW_MATCH_RANK))
}
}
fun Cursor.getRepository(): Repository = getBlob(getColumnIndex(ROW_DATA))
.jsonParse {
Repository.deserialize(it).apply {
this.id = getLong(getColumnIndex(ROW_ID))
}
}
fun Cursor.getInstalledItem(): InstalledItem = InstalledItem(
getString(getColumnIndex(ROW_PACKAGE_NAME)),
getString(getColumnIndex(ROW_VERSION)),
getLong(getColumnIndex(ROW_VERSION_CODE)),
getString(getColumnIndex(ROW_SIGNATURE))
)
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()
}