Reformated all the code

This commit is contained in:
Mohit
2021-06-08 21:18:44 +05:30
parent 74e8287cf1
commit 29ef88853d
50 changed files with 6043 additions and 4857 deletions

View File

@@ -3,18 +3,23 @@ package com.looker.droidify
import android.content.Intent import android.content.Intent
import com.looker.droidify.screen.ScreenActivity import com.looker.droidify.screen.ScreenActivity
class MainActivity: ScreenActivity() { class MainActivity : ScreenActivity() {
companion object { companion object {
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES" const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
} }
override fun handleIntent(intent: Intent?) { override fun handleIntent(intent: Intent?) {
when (intent?.action) { when (intent?.action) {
ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
ACTION_INSTALL -> handleSpecialIntent(SpecialIntent.Install(intent.packageName, ACTION_INSTALL -> handleSpecialIntent(
intent.getStringExtra(EXTRA_CACHE_FILE_NAME))) SpecialIntent.Install(
intent.packageName,
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
)
)
else -> super.handleIntent(intent) else -> super.handleIntent(intent)
} }
} }

View File

@@ -4,14 +4,8 @@ import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.app.job.JobInfo import android.app.job.JobInfo
import android.app.job.JobScheduler import android.app.job.JobScheduler
import android.content.BroadcastReceiver import android.content.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import com.looker.droidify.content.Cache import com.looker.droidify.content.Cache
import com.looker.droidify.content.Preferences import com.looker.droidify.content.Preferences
import com.looker.droidify.content.ProductPreferences import com.looker.droidify.content.ProductPreferences
@@ -23,12 +17,16 @@ import com.looker.droidify.network.PicassoDownloader
import com.looker.droidify.service.Connection import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.utility.Utils import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.* 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.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
@Suppress("unused") @Suppress("unused")
class MainApplication: Application() { class MainApplication : Application() {
private fun PackageInfo.toInstalledItem(): InstalledItem { private fun PackageInfo.toInstalledItem(): InstalledItem {
val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty() val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
return InstalledItem(packageName, versionName.orEmpty(), versionCodeCompat, signatureString) return InstalledItem(packageName, versionName.orEmpty(), versionCodeCompat, signatureString)
@@ -48,8 +46,11 @@ class MainApplication: Application() {
listenApplications() listenApplications()
listenPreferences() listenPreferences()
Picasso.setSingletonInstance(Picasso.Builder(this) Picasso.setSingletonInstance(
.downloader(OkHttp3Downloader(PicassoDownloader.Factory(Cache.getImagesDir(this)))).build()) Picasso.Builder(this)
.downloader(OkHttp3Downloader(PicassoDownloader.Factory(Cache.getImagesDir(this))))
.build()
)
if (databaseUpdated) { if (databaseUpdated) {
forceSyncAll() forceSyncAll()
@@ -60,15 +61,19 @@ class MainApplication: Application() {
} }
private fun listenApplications() { private fun listenApplications() {
registerReceiver(object: BroadcastReceiver() { registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null } val packageName =
intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
if (packageName != null) { if (packageName != null) {
when (intent.action.orEmpty()) { when (intent.action.orEmpty()) {
Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_ADDED,
Intent.ACTION_PACKAGE_REMOVED -> { Intent.ACTION_PACKAGE_REMOVED -> {
val packageInfo = try { val packageInfo = try {
packageManager.getPackageInfo(packageName, Android.PackageManager.signaturesFlag) packageManager.getPackageInfo(
packageName,
Android.PackageManager.signaturesFlag
)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -86,7 +91,8 @@ class MainApplication: Application() {
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package") addDataScheme("package")
}) })
val installedItems = packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag) val installedItems =
packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag)
.map { it.toInstalledItem() } .map { it.toInstalledItem() }
Database.InstalledAdapter.putAll(installedItems) Database.InstalledAdapter.putAll(installedItems)
} }
@@ -127,7 +133,10 @@ class MainApplication: Application() {
val period = 12 * 60 * 60 * 1000L // 12 hours val period = 12 * 60 * 60 * 1000L // 12 hours
val wifiOnly = autoSync == Preferences.AutoSync.Wifi val wifiOnly = autoSync == Preferences.AutoSync.Wifi
jobScheduler.schedule(JobInfo jobScheduler.schedule(JobInfo
.Builder(Common.JOB_ID_SYNC, ComponentName(this, SyncService.Job::class.java)) .Builder(
Common.JOB_ID_SYNC,
ComponentName(this, SyncService.Job::class.java)
)
.setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY) .setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY)
.apply { .apply {
if (Android.sdk(26)) { if (Android.sdk(26)) {
@@ -180,7 +189,7 @@ class MainApplication: Application() {
}).bind(this) }).bind(this)
} }
class BootReceiver: BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@SuppressLint("UnsafeProtectedBroadcastReceiver") @SuppressLint("UnsafeProtectedBroadcastReceiver")
override fun onReceive(context: Context, intent: Intent) = Unit override fun onReceive(context: Context, intent: Intent) = Unit
} }

View File

@@ -10,14 +10,17 @@ import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.system.Os import android.system.Os
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import java.io.File import java.io.File
import java.util.UUID import java.util.*
import kotlin.concurrent.thread import kotlin.concurrent.thread
object Cache { object Cache {
private fun ensureCacheDir(context: Context, name: String): File { private fun ensureCacheDir(context: Context, name: String): File {
return File(context.cacheDir, name).apply { isDirectory || mkdirs() || throw RuntimeException() } return File(
context.cacheDir,
name
).apply { isDirectory || mkdirs() || throw RuntimeException() }
} }
private fun applyOrMode(file: File, mode: Int) { private fun applyOrMode(file: File, mode: Int) {
@@ -60,8 +63,10 @@ object Cache {
fun getReleaseUri(context: Context, cacheFileName: String): Uri { fun getReleaseUri(context: Context, cacheFileName: String): Uri {
val file = getReleaseFile(context, cacheFileName) val file = getReleaseFile(context, cacheFileName)
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PROVIDERS) val packageInfo =
val authority = packageInfo.providers.find { it.name == Provider::class.java.name }!!.authority context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PROVIDERS)
val authority =
packageInfo.providers.find { it.name == Provider::class.java.name }!!.authority
return Uri.Builder().scheme("content").authority(authority) return Uri.Builder().scheme("content").authority(authority)
.encodedPath(subPath(context.cacheDir, file)).build() .encodedPath(subPath(context.cacheDir, file)).build()
} }
@@ -71,7 +76,15 @@ object Cache {
} }
fun cleanup(context: Context) { fun cleanup(context: Context) {
thread { cleanup(context, Pair("images", 0), Pair("partial", 24), Pair("releases", 24), Pair("temporary", 1)) } thread {
cleanup(
context,
Pair("images", 0),
Pair("partial", 24),
Pair("releases", 24),
Pair("temporary", 1)
)
}
} }
private fun cleanup(context: Context, vararg dirHours: Pair<String, Int>) { private fun cleanup(context: Context, vararg dirHours: Pair<String, Int>) {
@@ -123,22 +136,27 @@ object Cache {
} }
} }
class Provider: ContentProvider() { class Provider : ContentProvider() {
companion object { companion object {
private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
} }
private fun getFileAndTypeForUri(uri: Uri): Pair<File, String> { private fun getFileAndTypeForUri(uri: Uri): Pair<File, String> {
return when (uri.pathSegments?.firstOrNull()) { return when (uri.pathSegments?.firstOrNull()) {
"releases" -> Pair(File(context!!.cacheDir, uri.encodedPath!!), "application/vnd.android.package-archive") "releases" -> Pair(
File(context!!.cacheDir, uri.encodedPath!!),
"application/vnd.android.package-archive"
)
else -> throw SecurityException() else -> throw SecurityException()
} }
} }
override fun onCreate(): Boolean = true override fun onCreate(): Boolean = true
override fun query(uri: Uri, projection: Array<String>?, override fun query(
selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? { uri: Uri, projection: Array<String>?,
selection: String?, selectionArgs: Array<out String>?, sortOrder: String?
): Cursor {
val file = getFileAndTypeForUri(uri).first val file = getFileAndTypeForUri(uri).first
val columns = (projection ?: defaultColumns).mapNotNull { val columns = (projection ?: defaultColumns).mapNotNull {
when (it) { when (it) {
@@ -150,15 +168,19 @@ object Cache {
return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) } return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) }
} }
override fun getType(uri: Uri): String? = getFileAndTypeForUri(uri).second override fun getType(uri: Uri): String = getFileAndTypeForUri(uri).second
private val unsupported: Nothing private val unsupported: Nothing
get() = throw UnsupportedOperationException() get() = throw UnsupportedOperationException()
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = unsupported override fun insert(uri: Uri, contentValues: ContentValues?): Uri = unsupported
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = unsupported override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
override fun update(uri: Uri, contentValues: ContentValues?, unsupported
selection: String?, selectionArgs: Array<out String>?): Int = unsupported
override fun update(
uri: Uri, contentValues: ContentValues?,
selection: String?, selectionArgs: Array<out String>?
): Int = unsupported
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val openMode = when (mode) { val openMode = when (mode) {

View File

@@ -3,11 +3,11 @@ package com.looker.droidify.content
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Configuration import android.content.res.Configuration
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.entity.ProductItem import com.looker.droidify.entity.ProductItem
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.net.Proxy import java.net.Proxy
object Preferences { object Preferences {
@@ -15,12 +15,19 @@ object Preferences {
private val subject = PublishSubject.create<Key<*>>() private val subject = PublishSubject.create<Key<*>>()
private val keys = sequenceOf(Key.AutoSync, Key.IncompatibleVersions, Key.ProxyHost, Key.ProxyPort, Key.ProxyType, private val keys = sequenceOf(
Key.SortOrder, Key.Theme, Key.UpdateNotify, Key.UpdateUnstable).map { Pair(it.name, it) }.toMap() Key.AutoSync, Key.IncompatibleVersions, Key.ProxyHost, Key.ProxyPort, Key.ProxyType,
Key.SortOrder, Key.Theme, Key.UpdateNotify, Key.UpdateUnstable
).map { Pair(it.name, it) }.toMap()
fun init(context: Context) { fun init(context: Context) {
preferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) preferences =
preferences.registerOnSharedPreferenceChangeListener { _, keyString -> keys[keyString]?.let(subject::onNext) } context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
preferences.registerOnSharedPreferenceChangeListener { _, keyString ->
keys[keyString]?.let(
subject::onNext
)
}
} }
val observable: Observable<Key<*>> val observable: Observable<Key<*>>
@@ -29,11 +36,20 @@ object Preferences {
sealed class Value<T> { sealed class Value<T> {
abstract val value: T abstract val value: T
internal abstract fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): T internal abstract fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<T>
): T
internal abstract fun set(preferences: SharedPreferences, key: String, value: T) internal abstract fun set(preferences: SharedPreferences, key: String, value: T)
class BooleanValue(override val value: Boolean): Value<Boolean>() { class BooleanValue(override val value: Boolean) : Value<Boolean>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Boolean>): Boolean { override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<Boolean>
): Boolean {
return preferences.getBoolean(key, defaultValue.value) return preferences.getBoolean(key, defaultValue.value)
} }
@@ -42,8 +58,12 @@ object Preferences {
} }
} }
class IntValue(override val value: Int): Value<Int>() { class IntValue(override val value: Int) : Value<Int>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Int>): Int { override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<Int>
): Int {
return preferences.getInt(key, defaultValue.value) return preferences.getInt(key, defaultValue.value)
} }
@@ -52,8 +72,12 @@ object Preferences {
} }
} }
class StringValue(override val value: String): Value<String>() { class StringValue(override val value: String) : Value<String>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<String>): String { override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<String>
): String {
return preferences.getString(key, defaultValue.value) ?: defaultValue.value return preferences.getString(key, defaultValue.value) ?: defaultValue.value
} }
@@ -62,10 +86,15 @@ object Preferences {
} }
} }
class EnumerationValue<T: Enumeration<T>>(override val value: T): Value<T>() { class EnumerationValue<T : Enumeration<T>>(override val value: T) : Value<T>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): T { override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<T>
): T {
val value = preferences.getString(key, defaultValue.value.valueString) val value = preferences.getString(key, defaultValue.value.valueString)
return defaultValue.value.values.find { it.valueString == value } ?: defaultValue.value return defaultValue.value.values.find { it.valueString == value }
?: defaultValue.value
} }
override fun set(preferences: SharedPreferences, key: String, value: T) { override fun set(preferences: SharedPreferences, key: String, value: T) {
@@ -80,63 +109,84 @@ object Preferences {
} }
sealed class Key<T>(val name: String, val default: Value<T>) { sealed class Key<T>(val name: String, val default: Value<T>) {
object AutoSync: Key<Preferences.AutoSync>("auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi)) object AutoSync : Key<Preferences.AutoSync>(
object IncompatibleVersions: Key<Boolean>("incompatible_versions", Value.BooleanValue(false)) "auto_sync",
object ProxyHost: Key<String>("proxy_host", Value.StringValue("localhost")) Value.EnumerationValue(Preferences.AutoSync.Wifi)
object ProxyPort: Key<Int>("proxy_port", Value.IntValue(9050)) )
object ProxyType: Key<Preferences.ProxyType>("proxy_type", Value.EnumerationValue(Preferences.ProxyType.Direct))
object SortOrder: Key<Preferences.SortOrder>("sort_order", Value.EnumerationValue(Preferences.SortOrder.Update)) object IncompatibleVersions :
object Theme: Key<Preferences.Theme>("theme", Value.EnumerationValue(if (Android.sdk(29)) Key<Boolean>("incompatible_versions", Value.BooleanValue(false))
Preferences.Theme.System else Preferences.Theme.Light))
object UpdateNotify: Key<Boolean>("update_notify", Value.BooleanValue(true)) object ProxyHost : Key<String>("proxy_host", Value.StringValue("localhost"))
object UpdateUnstable: Key<Boolean>("update_unstable", Value.BooleanValue(false)) object ProxyPort : Key<Int>("proxy_port", Value.IntValue(9050))
object ProxyType : Key<Preferences.ProxyType>(
"proxy_type",
Value.EnumerationValue(Preferences.ProxyType.Direct)
)
object SortOrder : Key<Preferences.SortOrder>(
"sort_order",
Value.EnumerationValue(Preferences.SortOrder.Update)
)
object Theme : Key<Preferences.Theme>(
"theme", Value.EnumerationValue(
if (Android.sdk(29))
Preferences.Theme.System else Preferences.Theme.Light
)
)
object UpdateNotify : Key<Boolean>("update_notify", Value.BooleanValue(true))
object UpdateUnstable : Key<Boolean>("update_unstable", Value.BooleanValue(false))
} }
sealed class AutoSync(override val valueString: String): Enumeration<AutoSync> { sealed class AutoSync(override val valueString: String) : Enumeration<AutoSync> {
override val values: List<AutoSync> override val values: List<AutoSync>
get() = listOf(Never, Wifi, Always) get() = listOf(Never, Wifi, Always)
object Never: AutoSync("never") object Never : AutoSync("never")
object Wifi: AutoSync("wifi") object Wifi : AutoSync("wifi")
object Always: AutoSync("always") object Always : AutoSync("always")
} }
sealed class ProxyType(override val valueString: String, val proxyType: Proxy.Type): Enumeration<ProxyType> { sealed class ProxyType(override val valueString: String, val proxyType: Proxy.Type) :
Enumeration<ProxyType> {
override val values: List<ProxyType> override val values: List<ProxyType>
get() = listOf(Direct, Http, Socks) get() = listOf(Direct, Http, Socks)
object Direct: ProxyType("direct", Proxy.Type.DIRECT) object Direct : ProxyType("direct", Proxy.Type.DIRECT)
object Http: ProxyType("http", Proxy.Type.HTTP) object Http : ProxyType("http", Proxy.Type.HTTP)
object Socks: ProxyType("socks", Proxy.Type.SOCKS) object Socks : ProxyType("socks", Proxy.Type.SOCKS)
} }
sealed class SortOrder(override val valueString: String, val order: ProductItem.Order): Enumeration<SortOrder> { sealed class SortOrder(override val valueString: String, val order: ProductItem.Order) :
Enumeration<SortOrder> {
override val values: List<SortOrder> override val values: List<SortOrder>
get() = listOf(Name, Added, Update) get() = listOf(Name, Added, Update)
object Name: SortOrder("name", ProductItem.Order.NAME) object Name : SortOrder("name", ProductItem.Order.NAME)
object Added: SortOrder("added", ProductItem.Order.DATE_ADDED) object Added : SortOrder("added", ProductItem.Order.DATE_ADDED)
object Update: SortOrder("update", ProductItem.Order.LAST_UPDATE) object Update : SortOrder("update", ProductItem.Order.LAST_UPDATE)
} }
sealed class Theme(override val valueString: String): Enumeration<Theme> { sealed class Theme(override val valueString: String) : Enumeration<Theme> {
override val values: List<Theme> override val values: List<Theme>
get() = if (Android.sdk(29)) listOf(System, Light, Dark) else listOf(Light, Dark) get() = if (Android.sdk(29)) listOf(System, Light, Dark) else listOf(Light, Dark)
abstract fun getResId(configuration: Configuration): Int abstract fun getResId(configuration: Configuration): Int
object System: Theme("system") { object System : Theme("system") {
override fun getResId(configuration: Configuration): Int { override fun getResId(configuration: Configuration): Int {
return if ((configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) return if ((configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) != 0)
R.style.Theme_Main_Dark else R.style.Theme_Main_Light R.style.Theme_Main_Dark else R.style.Theme_Main_Light
} }
} }
object Light: Theme("light") { object Light : Theme("light") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Light override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Light
} }
object Dark: Theme("dark") { object Dark : Theme("dark") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Dark override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Dark
} }
} }

View File

@@ -2,11 +2,13 @@ package com.looker.droidify.content
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.entity.ProductPreference import com.looker.droidify.entity.ProductPreference
import com.looker.droidify.utility.extension.json.* 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.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.nio.charset.Charset import java.nio.charset.Charset
@@ -18,7 +20,14 @@ object ProductPreferences {
fun init(context: Context) { fun init(context: Context) {
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE) preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
Database.LockAdapter.putAll(preferences.all.keys Database.LockAdapter.putAll(preferences.all.keys
.mapNotNull { packageName -> this[packageName].databaseVersionCode?.let { Pair(packageName, it) } }) .mapNotNull { packageName ->
this[packageName].databaseVersionCode?.let {
Pair(
packageName,
it
)
}
})
subject subject
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.subscribe { (packageName, versionCode) -> .subscribe { (packageName, versionCode) ->
@@ -54,10 +63,14 @@ object ProductPreferences {
operator fun set(packageName: String, productPreference: ProductPreference) { operator fun set(packageName: String, productPreference: ProductPreference) {
val oldProductPreference = this[packageName] val oldProductPreference = this[packageName]
preferences.edit().putString(packageName, ByteArrayOutputStream() preferences.edit().putString(packageName, ByteArrayOutputStream()
.apply { Json.factory.createGenerator(this).use { it.writeDictionary(productPreference::serialize) } } .apply {
Json.factory.createGenerator(this)
.use { it.writeDictionary(productPreference::serialize) }
}
.toByteArray().toString(Charset.defaultCharset())).apply() .toByteArray().toString(Charset.defaultCharset())).apply()
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates || if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) { oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode
) {
subject.onNext(Pair(packageName, productPreference.databaseVersionCode)) subject.onNext(Pair(packageName, productPreference.databaseVersionCode))
} }
} }

View File

@@ -7,29 +7,35 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import com.looker.droidify.entity.ProductItem import com.looker.droidify.entity.ProductItem
class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> { class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request { sealed class Request {
internal abstract val id: Int internal abstract val id: Int
data class ProductsAvailable(val searchQuery: String, val section: ProductItem.Section, data class ProductsAvailable(
val order: ProductItem.Order): Request() { val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int override val id: Int
get() = 1 get() = 1
} }
data class ProductsInstalled(val searchQuery: String, val section: ProductItem.Section, data class ProductsInstalled(
val order: ProductItem.Order): Request() { val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int override val id: Int
get() = 2 get() = 2
} }
data class ProductsUpdates(val searchQuery: String, val section: ProductItem.Section, data class ProductsUpdates(
val order: ProductItem.Order): Request() { val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int override val id: Int
get() = 3 get() = 3
} }
object Repositories: Request() { object Repositories : Request() {
override val id: Int override val id: Int
get() = 4 get() = 4
} }
@@ -39,7 +45,11 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
fun onCursorData(request: Request, cursor: Cursor?) fun onCursorData(request: Request, cursor: Cursor?)
} }
private data class ActiveRequest(val request: Request, val callback: Callback?, val cursor: Cursor?) private data class ActiveRequest(
val request: Request,
val callback: Callback?,
val cursor: Cursor?
)
init { init {
retainInstance = true retainInstance = true
@@ -50,7 +60,8 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
fun attach(callback: Callback, request: Request) { fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id] val oldActiveRequest = activeRequests[request.id]
if (oldActiveRequest?.callback != null && if (oldActiveRequest?.callback != null &&
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null) { oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
) {
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null) oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
} }
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) { val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
@@ -79,11 +90,32 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
return QueryLoader(requireContext()) { return QueryLoader(requireContext()) {
when (request) { when (request) {
is Request.ProductsAvailable -> Database.ProductAdapter is Request.ProductsAvailable -> Database.ProductAdapter
.query(false, false, request.searchQuery, request.section, request.order, it) .query(
installed = false,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsInstalled -> Database.ProductAdapter is Request.ProductsInstalled -> Database.ProductAdapter
.query(true, false, request.searchQuery, request.section, request.order, it) .query(
installed = true,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsUpdates -> Database.ProductAdapter is Request.ProductsUpdates -> Database.ProductAdapter
.query(true, true, request.searchQuery, request.section, request.order, it) .query(
installed = true,
updates = true,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.Repositories -> Database.RepositoryAdapter.query(it) is Request.Repositories -> Database.RepositoryAdapter.query(it)
} }
} }

View File

@@ -8,13 +8,16 @@ import android.database.sqlite.SQLiteOpenHelper
import android.os.CancellationSignal import android.os.CancellationSignal
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import io.reactivex.rxjava3.core.Observable
import com.looker.droidify.entity.InstalledItem import com.looker.droidify.entity.InstalledItem
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductItem import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository import com.looker.droidify.entity.Repository
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.json.* 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 import java.io.ByteArrayOutputStream
object Database { object Database {
@@ -49,12 +52,16 @@ object Database {
} }
val createIndexPairFormatted: Pair<String, String>? val createIndexPairFormatted: Pair<String, String>?
get() = createIndex?.let { Pair("CREATE INDEX ${innerName}_index ON $innerName ($it)", get() = createIndex?.let {
"CREATE INDEX ${name}_index ON $innerName ($it)") } Pair(
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
"CREATE INDEX ${name}_index ON $innerName ($it)"
)
}
} }
private object Schema { private object Schema {
object Repository: Table { object Repository : Table {
const val ROW_ID = "_id" const val ROW_ID = "_id"
const val ROW_ENABLED = "enabled" const val ROW_ENABLED = "enabled"
const val ROW_DELETED = "deleted" const val ROW_DELETED = "deleted"
@@ -70,7 +77,7 @@ object Database {
""" """
} }
object Product: Table { object Product : Table {
const val ROW_REPOSITORY_ID = "repository_id" const val ROW_REPOSITORY_ID = "repository_id"
const val ROW_PACKAGE_NAME = "package_name" const val ROW_PACKAGE_NAME = "package_name"
const val ROW_NAME = "name" const val ROW_NAME = "name"
@@ -104,7 +111,7 @@ object Database {
override val createIndex = ROW_PACKAGE_NAME override val createIndex = ROW_PACKAGE_NAME
} }
object Category: Table { object Category : Table {
const val ROW_REPOSITORY_ID = "repository_id" const val ROW_REPOSITORY_ID = "repository_id"
const val ROW_PACKAGE_NAME = "package_name" const val ROW_PACKAGE_NAME = "package_name"
const val ROW_NAME = "name" const val ROW_NAME = "name"
@@ -120,7 +127,7 @@ object Database {
override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME" override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME"
} }
object Installed: Table { object Installed : Table {
const val ROW_PACKAGE_NAME = "package_name" const val ROW_PACKAGE_NAME = "package_name"
const val ROW_VERSION = "version" const val ROW_VERSION = "version"
const val ROW_VERSION_CODE = "version_code" const val ROW_VERSION_CODE = "version_code"
@@ -136,7 +143,7 @@ object Database {
""" """
} }
object Lock: Table { object Lock : Table {
const val ROW_PACKAGE_NAME = "package_name" const val ROW_PACKAGE_NAME = "package_name"
const val ROW_VERSION_CODE = "version_code" const val ROW_VERSION_CODE = "version_code"
@@ -154,15 +161,18 @@ object Database {
} }
} }
private class Helper(context: Context): SQLiteOpenHelper(context, "droidify", null, 1) { private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 1) {
var created = false var created = false
private set private set
var updated = false var updated = false
private set private set
override fun onCreate(db: SQLiteDatabase) = Unit override fun onCreate(db: SQLiteDatabase) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) onVersionChange(db)
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
onVersionChange(db)
private fun onVersionChange(db: SQLiteDatabase) { private fun onVersionChange(db: SQLiteDatabase) {
handleTables(db, true, Schema.Product, Schema.Category) handleTables(db, true, Schema.Product, Schema.Category)
@@ -174,7 +184,14 @@ object Database {
val updated = handleTables(db, create, Schema.Product, Schema.Category) val updated = handleTables(db, create, Schema.Product, Schema.Category)
db.execSQL("ATTACH DATABASE ':memory:' AS memory") db.execSQL("ATTACH DATABASE ':memory:' AS memory")
handleTables(db, false, Schema.Installed, Schema.Lock) handleTables(db, false, Schema.Installed, Schema.Lock)
handleIndexes(db, Schema.Repository, Schema.Product, Schema.Category, Schema.Installed, Schema.Lock) handleIndexes(
db,
Schema.Repository,
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
this.created = this.created || create this.created = this.created || create
this.updated = this.updated || create || updated this.updated = this.updated || create || updated
@@ -183,8 +200,10 @@ object Database {
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
val shouldRecreate = recreate || tables.any { val shouldRecreate = recreate || tables.any {
val sql = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("sql"), val sql = db.query(
selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))) "${it.databasePrefix}sqlite_master", columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))
)
.use { it.firstOrNull()?.getString(0) }.orEmpty() .use { it.firstOrNull()?.getString(0) }.orEmpty()
it.formatCreateTable(it.innerName) != sql it.formatCreateTable(it.innerName) != sql
} }
@@ -203,9 +222,15 @@ object Database {
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { val shouldVacuum = tables.map {
val sqls = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), val sqls = db.query(
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName))) "${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"),
.use { it.asSequence().mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }.toList() } 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_") } .filter { !it.first.startsWith("sqlite_") }
val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty() val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run { createIndexes.map { it.first } != sqls.map { it.second } && run {
@@ -224,8 +249,10 @@ object Database {
} }
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query("sqlite_master", columns = arrayOf("name"), val tables = db.query(
selection = Pair("type = ?", arrayOf("table"))) "sqlite_master", columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table"))
)
.use { it.asSequence().mapNotNull { it.getString(0) }.toList() } .use { it.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name } .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }
@@ -240,14 +267,15 @@ object Database {
} }
sealed class Subject { sealed class Subject {
object Repositories: Subject() object Repositories : Subject()
data class Repository(val id: Long): Subject() data class Repository(val id: Long) : Subject()
object Products: Subject() object Products : Subject()
} }
private val observers = mutableMapOf<Subject, MutableSet<() -> Unit>>() private val observers = mutableMapOf<Subject, MutableSet<() -> Unit>>()
private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = { register, observer -> private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit =
{ register, observer ->
synchronized(observers) { synchronized(observers) {
val set = observers[subject] ?: run { val set = observers[subject] ?: run {
val set = mutableSetOf<() -> Unit>() val set = mutableSetOf<() -> Unit>()
@@ -277,14 +305,35 @@ object Database {
} }
} }
private fun SQLiteDatabase.insertOrReplace(replace: Boolean, table: String, contentValues: ContentValues): Long { private fun SQLiteDatabase.insertOrReplace(
return if (replace) replace(table, null, contentValues) else insert(table, null, contentValues) 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, private fun SQLiteDatabase.query(
table: String, columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null, orderBy: String? = null, selection: Pair<String, Array<String>>? = null, orderBy: String? = null,
signal: CancellationSignal? = null): Cursor { signal: CancellationSignal? = null
return query(false, table, columns, selection?.first, selection?.second, null, null, orderBy, null, signal) ): Cursor {
return query(
false,
table,
columns,
selection?.first,
selection?.second,
null,
null,
orderBy,
null,
signal
)
} }
private fun Cursor.observable(subject: Subject): ObservableCursor { private fun Cursor.observable(subject: Subject): ObservableCursor {
@@ -322,24 +371,41 @@ object Database {
} }
fun get(id: Long): Repository? { fun get(id: Long): Repository? {
return db.query(Schema.Repository.name, return db.query(
selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", Schema.Repository.name,
arrayOf(id.toString()))) selection = Pair(
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
arrayOf(id.toString())
)
)
.use { it.firstOrNull()?.let(::transform) } .use { it.firstOrNull()?.let(::transform) }
} }
fun getAll(signal: CancellationSignal?): List<Repository> { fun getAll(signal: CancellationSignal?): List<Repository> {
return db.query(Schema.Repository.name, return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal).use { it.asSequence().map(::transform).toList() } signal = signal
).use { it.asSequence().map(::transform).toList() }
} }
fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<Long, Boolean>> { fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<Long, Boolean>> {
return db.query(Schema.Repository.name, return db.query(
Schema.Repository.name,
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
selection = Pair("${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", emptyArray()), selection = Pair(
signal = signal).use { it.asSequence().map { Pair(it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)), "${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0",
it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0) }.toSet() } emptyArray()
),
signal = signal
).use {
it.asSequence().map {
Pair(
it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)),
it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0
)
}.toSet()
}
} }
fun markAsDeleted(id: Long) { fun markAsDeleted(id: Long) {
@@ -352,14 +418,22 @@ object Database {
fun cleanup(pairs: Set<Pair<Long, Boolean>>) { fun cleanup(pairs: Set<Pair<Long, Boolean>>) {
val result = pairs.windowed(10, 10, true).map { val result = pairs.windowed(10, 10, true).map {
val idsString = it.joinToString(separator = ", ") { it.first.toString() } val idsString = it.joinToString(separator = ", ") { it.first.toString() }
val productsCount = db.delete(Schema.Product.name, val productsCount = db.delete(
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null) Schema.Product.name,
val categoriesCount = db.delete(Schema.Category.name, "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null
"${Schema.Category.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 } val deleteIdsString = it.asSequence().filter { it.second }
.joinToString(separator = ", ") { it.first.toString() } .joinToString(separator = ", ") { it.first.toString() }
if (deleteIdsString.isNotEmpty()) { if (deleteIdsString.isNotEmpty()) {
db.delete(Schema.Repository.name, "${Schema.Repository.ROW_ID} IN ($deleteIdsString)", null) db.delete(
Schema.Repository.name,
"${Schema.Repository.ROW_ID} IN ($deleteIdsString)",
null
)
} }
productsCount != 0 || categoriesCount != 0 productsCount != 0 || categoriesCount != 0
} }
@@ -369,33 +443,53 @@ object Database {
} }
fun query(signal: CancellationSignal?): Cursor { fun query(signal: CancellationSignal?): Cursor {
return db.query(Schema.Repository.name, return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal).observable(Subject.Repositories) signal = signal
).observable(Subject.Repositories)
} }
fun transform(cursor: Cursor): Repository { fun transform(cursor: Cursor): Repository {
return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA)) return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA))
.jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), it) } .jsonParse {
Repository.deserialize(
cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)),
it
)
}
} }
} }
object ProductAdapter { object ProductAdapter {
fun get(packageName: String, signal: CancellationSignal?): List<Product> { fun get(packageName: String, signal: CancellationSignal?): List<Product> {
return db.query(Schema.Product.name, return db.query(
columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DESCRIPTION, Schema.Product.ROW_DATA), 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)), selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal).use { it.asSequence().map(::transform).toList() } signal = signal
).use { it.asSequence().map(::transform).toList() }
} }
fun getCount(repositoryId: Long): Int { fun getCount(repositoryId: Long): Int {
return db.query(Schema.Product.name, columns = arrayOf("COUNT (*)"), return db.query(
selection = Pair("${Schema.Product.ROW_REPOSITORY_ID} = ?", arrayOf(repositoryId.toString()))) Schema.Product.name, columns = arrayOf("COUNT (*)"),
selection = Pair(
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
arrayOf(repositoryId.toString())
)
)
.use { it.firstOrNull()?.getInt(0) ?: 0 } .use { it.firstOrNull()?.getInt(0) ?: 0 }
} }
fun query(installed: Boolean, updates: Boolean, searchQuery: String, fun query(
section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal?): Cursor { installed: Boolean, updates: Boolean, searchQuery: String,
section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal?
): Cursor {
val builder = QueryBuilder() val builder = QueryBuilder()
val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
@@ -472,20 +566,29 @@ object Database {
private fun transform(cursor: Cursor): Product { private fun transform(cursor: Cursor): Product {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA)) return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA))
.jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), .jsonParse {
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION)), it) } Product.deserialize(
cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION)), it
)
}
} }
fun transformItem(cursor: Cursor): ProductItem { fun transformItem(cursor: Cursor): ProductItem {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM)) return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM))
.jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), .jsonParse {
ProductItem.deserialize(
cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)), cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)).orEmpty(), cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION))
.orEmpty(),
cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0, cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0,
cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0,
cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK)), it) } cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK)), it
)
}
} }
} }
@@ -500,18 +603,24 @@ object Database {
WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
repository.${Schema.Repository.ROW_DELETED} == 0""" repository.${Schema.Repository.ROW_DELETED} == 0"""
return builder.query(db, signal).use { it.asSequence() return builder.query(db, signal).use {
.map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() } it.asSequence()
.map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet()
}
} }
} }
object InstalledAdapter { object InstalledAdapter {
fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
return db.query(Schema.Installed.name, return db.query(
columns = arrayOf(Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION, Schema.Installed.name,
Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE), 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)), selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal).use { it.firstOrNull()?.let(::transform) } signal = signal
).use { it.firstOrNull()?.let(::transform) }
} }
private fun put(installedItem: InstalledItem, notify: Boolean) { private fun put(installedItem: InstalledItem, notify: Boolean) {
@@ -540,17 +649,23 @@ object Database {
} }
fun delete(packageName: String) { fun delete(packageName: String) {
val count = db.delete(Schema.Installed.name, "${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) val count = db.delete(
Schema.Installed.name,
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
arrayOf(packageName)
)
if (count > 0) { if (count > 0) {
notifyChanged(Subject.Products) notifyChanged(Subject.Products)
} }
} }
private fun transform(cursor: Cursor): InstalledItem { private fun transform(cursor: Cursor): InstalledItem {
return InstalledItem(cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)), return InstalledItem(
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)), cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)),
cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)), cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE))) cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE))
)
} }
} }
@@ -617,7 +732,10 @@ object Database {
put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize)) put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize))
}) })
for (category in product.categories) { for (category in product.categories) {
db.insertOrReplace(true, Schema.Category.temporaryName, ContentValues().apply { db.insertOrReplace(
true,
Schema.Category.temporaryName,
ContentValues().apply {
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Category.ROW_NAME, category) put(Schema.Category.ROW_NAME, category)
@@ -634,10 +752,14 @@ object Database {
if (success) { if (success) {
db.beginTransaction() db.beginTransaction()
try { try {
db.delete(Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?", db.delete(
arrayOf(repository.id.toString())) Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?",
db.delete(Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?", arrayOf(repository.id.toString())
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.Product.name} SELECT * FROM ${Schema.Product.temporaryName}")
db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}") db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}")
RepositoryAdapter.putWithoutNotification(repository, true) RepositoryAdapter.putWithoutNotification(repository, true)
@@ -648,7 +770,11 @@ object Database {
db.endTransaction() db.endTransaction()
} }
if (success) { if (success) {
notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products) notifyChanged(
Subject.Repositories,
Subject.Repository(repository.id),
Subject.Products
)
} }
} else { } else {
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")

View File

@@ -5,8 +5,12 @@ import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.database.CursorWrapper import android.database.CursorWrapper
class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean, class ObservableCursor(
observer: () -> Unit) -> Unit): CursorWrapper(cursor) { cursor: Cursor, private val observable: (
register: Boolean,
observer: () -> Unit
) -> Unit
) : CursorWrapper(cursor) {
private var registered = false private var registered = false
private val contentObservable = ContentObservable() private val contentObservable = ContentObservable()

View File

@@ -4,8 +4,8 @@ import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.os.CancellationSignal import android.os.CancellationSignal
import com.looker.droidify.BuildConfig import com.looker.droidify.BuildConfig
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.debug
class QueryBuilder { class QueryBuilder {
companion object { companion object {

View File

@@ -6,7 +6,7 @@ import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import androidx.loader.content.AsyncTaskLoader import androidx.loader.content.AsyncTaskLoader
class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?): class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) :
AsyncTaskLoader<Cursor>(context) { AsyncTaskLoader<Cursor>(context) {
private val observer = ForceLoadContentObserver() private val observer = ForceLoadContentObserver()
private var cancellationSignal: CancellationSignal? = null private var cancellationSignal: CancellationSignal? = null

View File

@@ -1,3 +1,8 @@
package com.looker.droidify.entity package com.looker.droidify.entity
class InstalledItem(val packageName: String, val version: String, val versionCode: Long, val signature: String) class InstalledItem(
val packageName: String,
val version: String,
val versionCode: Long,
val signature: String
)

View File

@@ -4,23 +4,41 @@ import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.*
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.nullIfEmpty
data class Product(val repositoryId: Long, val packageName: String, val name: String, val summary: String, data class Product(
val description: String, val whatsNew: String, val icon: String, val metadataIcon: String, val author: Author, val repositoryId: Long,
val source: String, val changelog: String, val web: String, val tracker: String, val packageName: String,
val added: Long, val updated: Long, val suggestedVersionCode: Long, val name: String,
val categories: List<String>, val antiFeatures: List<String>, val licenses: List<String>, val summary: String,
val donates: List<Donate>, val screenshots: List<Screenshot>, val releases: List<Release>) { val description: String,
val whatsNew: String,
val icon: String,
val metadataIcon: String,
val author: Author,
val source: String,
val changelog: String,
val web: String,
val tracker: String,
val added: Long,
val updated: Long,
val suggestedVersionCode: Long,
val categories: List<String>,
val antiFeatures: List<String>,
val licenses: List<String>,
val donates: List<Donate>,
val screenshots: List<Screenshot>,
val releases: List<Release>
) {
data class Author(val name: String, val email: String, val web: String) data class Author(val name: String, val email: String, val web: String)
sealed class Donate { sealed class Donate {
data class Regular(val url: String): Donate() data class Regular(val url: String) : Donate()
data class Bitcoin(val address: String): Donate() data class Bitcoin(val address: String) : Donate()
data class Litecoin(val address: String): Donate() data class Litecoin(val address: String) : Donate()
data class Flattr(val id: String): Donate() data class Flattr(val id: String) : Donate()
data class Liberapay(val id: String): Donate() data class Liberapay(val id: String) : Donate()
data class OpenCollective(val id: String): Donate() data class OpenCollective(val id: String) : Donate()
} }
class Screenshot(val locale: String, val type: Type, val path: String) { class Screenshot(val locale: String, val type: Type, val path: String) {
@@ -54,7 +72,19 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
get() = selectedReleases.mapNotNull { it.signature.nullIfEmpty() }.distinct().toList() get() = selectedReleases.mapNotNull { it.signature.nullIfEmpty() }.distinct().toList()
fun item(): ProductItem { fun item(): ProductItem {
return ProductItem(repositoryId, packageName, name, summary, icon, metadataIcon, version, "", compatible, false, 0) return ProductItem(
repositoryId,
packageName,
name,
summary,
icon,
metadataIcon,
version,
"",
compatible,
false,
0
)
} }
fun canUpdate(installedItem: InstalledItem?): Boolean { fun canUpdate(installedItem: InstalledItem?): Boolean {
@@ -128,9 +158,15 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
} }
companion object { companion object {
fun <T> findSuggested(products: List<T>, installedItem: InstalledItem?, extract: (T) -> Product): T? { fun <T> findSuggested(
return products.maxWith(compareBy({ extract(it).compatible && products: List<T>,
(installedItem == null || installedItem.signature in extract(it).signatures) }, { extract(it).versionCode })) installedItem: InstalledItem?,
extract: (T) -> Product
): T? {
return products.maxWithOrNull(compareBy({
extract(it).compatible &&
(installedItem == null || installedItem.signature in extract(it).signatures)
}, { extract(it).versionCode }))
} }
fun deserialize(repositoryId: Long, description: String, parser: JsonParser): Product { fun deserialize(repositoryId: Long, description: String, parser: JsonParser): Product {
@@ -156,7 +192,7 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
var donates = emptyList<Donate>() var donates = emptyList<Donate>()
var screenshots = emptyList<Screenshot>() var screenshots = emptyList<Screenshot>()
var releases = emptyList<Release>() var releases = emptyList<Release>()
parser.forEachKey { parser.forEachKey { it ->
when { when {
it.string("packageName") -> packageName = valueAsString it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString it.string("name") -> name = valueAsString
@@ -201,7 +237,8 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
else -> null else -> null
} }
} }
it.array("screenshots") -> screenshots = collectNotNull(JsonToken.START_OBJECT) { it.array("screenshots") -> screenshots =
collectNotNull(JsonToken.START_OBJECT) {
var locale = "" var locale = ""
var type = "" var type = ""
var path = "" var path = ""
@@ -213,15 +250,38 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
else -> skipChildren() else -> skipChildren()
} }
} }
Screenshot.Type.values().find { it.jsonName == type }?.let { Screenshot(locale, it, path) } Screenshot.Type.values().find { it.jsonName == type }
?.let { Screenshot(locale, it, path) }
} }
it.array("releases") -> releases = collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize) it.array("releases") -> releases =
collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
else -> skipChildren() else -> skipChildren()
} }
} }
return Product(repositoryId, packageName, name, summary, description, whatsNew, icon, metadataIcon, return Product(
Author(authorName, authorEmail, authorWeb), source, changelog, web, tracker, added, updated, repositoryId,
suggestedVersionCode, categories, antiFeatures, licenses, donates, screenshots, releases) packageName,
name,
summary,
description,
whatsNew,
icon,
metadataIcon,
Author(authorName, authorEmail, authorWeb),
source,
changelog,
web,
tracker,
added,
updated,
suggestedVersionCode,
categories,
antiFeatures,
licenses,
donates,
screenshots,
releases
)
} }
} }
} }

View File

@@ -5,37 +5,45 @@ import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.utility.KParcelable import com.looker.droidify.utility.KParcelable
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.forEachKey
data class ProductItem(val repositoryId: Long, val packageName: String, val name: String, val summary: String, data class ProductItem(
val repositoryId: Long, val packageName: String, val name: String, val summary: String,
val icon: String, val metadataIcon: String, val version: String, val installedVersion: String, val icon: String, val metadataIcon: String, val version: String, val installedVersion: String,
val compatible: Boolean, val canUpdate: Boolean, val matchRank: Int) { val compatible: Boolean, val canUpdate: Boolean, val matchRank: Int
sealed class Section: KParcelable { ) {
object All: Section() { sealed class Section : KParcelable {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { All } object All : Section() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { All }
} }
data class Category(val name: String): Section() { data class Category(val name: String) : Section() {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(name) dest.writeString(name)
} }
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val name = it.readString()!! val name = it.readString()!!
Category(name) Category(name)
} }
} }
} }
data class Repository(val id: Long, val name: String): Section() { data class Repository(val id: Long, val name: String) : Section() {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeLong(id) dest.writeLong(id)
dest.writeString(name) dest.writeString(name)
} }
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val id = it.readLong() val id = it.readLong()
val name = it.readString()!! val name = it.readString()!!
Repository(id, name) Repository(id, name)
@@ -58,9 +66,11 @@ data class ProductItem(val repositoryId: Long, val packageName: String, val name
} }
companion object { companion object {
fun deserialize(repositoryId: Long, packageName: String, name: String, summary: String, fun deserialize(
repositoryId: Long, packageName: String, name: String, summary: String,
installedVersion: String, compatible: Boolean, canUpdate: Boolean, matchRank: Int, installedVersion: String, compatible: Boolean, canUpdate: Boolean, matchRank: Int,
parser: JsonParser): ProductItem { parser: JsonParser
): ProductItem {
var icon = "" var icon = ""
var metadataIcon = "" var metadataIcon = ""
var version = "" var version = ""
@@ -72,8 +82,10 @@ data class ProductItem(val repositoryId: Long, val packageName: String, val name
else -> skipChildren() else -> skipChildren()
} }
} }
return ProductItem(repositoryId, packageName, name, summary, icon, metadataIcon, return ProductItem(
version, installedVersion, compatible, canUpdate, matchRank) repositoryId, packageName, name, summary, icon, metadataIcon,
version, installedVersion, compatible, canUpdate, matchRank
)
} }
} }
} }

View File

@@ -2,7 +2,7 @@ package com.looker.droidify.entity
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.forEachKey
data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) { data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) {
fun shouldIgnoreUpdate(versionCode: Long): Boolean { fun shouldIgnoreUpdate(versionCode: Long): Boolean {

View File

@@ -6,18 +6,36 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.*
data class Release(val selected: Boolean, val version: String, val versionCode: Long, data class Release(
val added: Long, val size: Long, val minSdkVersion: Int, val targetSdkVersion: Int, val maxSdkVersion: Int, val selected: Boolean,
val source: String, val release: String, val hash: String, val hashType: String, val signature: String, val version: String,
val obbMain: String, val obbMainHash: String, val obbMainHashType: String, val versionCode: Long,
val obbPatch: String, val obbPatchHash: String, val obbPatchHashType: String, val added: Long,
val permissions: List<String>, val features: List<String>, val platforms: List<String>, val size: Long,
val incompatibilities: List<Incompatibility>) { val minSdkVersion: Int,
val targetSdkVersion: Int,
val maxSdkVersion: Int,
val source: String,
val release: String,
val hash: String,
val hashType: String,
val signature: String,
val obbMain: String,
val obbMainHash: String,
val obbMainHashType: String,
val obbPatch: String,
val obbPatchHash: String,
val obbPatchHashType: String,
val permissions: List<String>,
val features: List<String>,
val platforms: List<String>,
val incompatibilities: List<Incompatibility>
) {
sealed class Incompatibility { sealed class Incompatibility {
object MinSdk: Incompatibility() object MinSdk : Incompatibility()
object MaxSdk: Incompatibility() object MaxSdk : Incompatibility()
object Platform: Incompatibility() object Platform : Incompatibility()
data class Feature(val feature: String): Incompatibility() data class Feature(val feature: String) : Incompatibility()
} }
val identifier: String val identifier: String
@@ -102,7 +120,7 @@ data class Release(val selected: Boolean, val version: String, val versionCode:
var features = emptyList<String>() var features = emptyList<String>()
var platforms = emptyList<String>() var platforms = emptyList<String>()
var incompatibilities = emptyList<Incompatibility>() var incompatibilities = emptyList<Incompatibility>()
parser.forEachKey { parser.forEachKey { it ->
when { when {
it.boolean("selected") -> selected = valueAsBoolean it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString it.string("version") -> version = valueAsString
@@ -126,7 +144,8 @@ data class Release(val selected: Boolean, val version: String, val versionCode:
it.array("permissions") -> permissions = collectNotNullStrings() it.array("permissions") -> permissions = collectNotNullStrings()
it.array("features") -> features = collectNotNullStrings() it.array("features") -> features = collectNotNullStrings()
it.array("platforms") -> platforms = collectNotNullStrings() it.array("platforms") -> platforms = collectNotNullStrings()
it.array("incompatibilities") -> incompatibilities = collectNotNull(JsonToken.START_OBJECT) { it.array("incompatibilities") -> incompatibilities =
collectNotNull(JsonToken.START_OBJECT) {
var type = "" var type = ""
var feature = "" var feature = ""
forEachKey { forEachKey {
@@ -147,10 +166,31 @@ data class Release(val selected: Boolean, val version: String, val versionCode:
else -> skipChildren() else -> skipChildren()
} }
} }
return Release(selected, version, versionCode, added, size, return Release(
minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature, selected,
obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType, version,
permissions, features, platforms, incompatibilities) versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions,
features,
platforms,
incompatibilities
)
} }
} }
} }

View File

@@ -2,26 +2,39 @@ package com.looker.droidify.entity
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.collectNotNullStrings
import com.looker.droidify.utility.extension.json.forEachKey
import com.looker.droidify.utility.extension.json.writeArray
import java.net.URL import java.net.URL
data class Repository(val id: Long, val address: String, val mirrors: List<String>, data class Repository(
val id: Long, val address: String, val mirrors: List<String>,
val name: String, val description: String, val version: Int, val enabled: Boolean, val name: String, val description: String, val version: Int, val enabled: Boolean,
val fingerprint: String, val lastModified: String, val entityTag: String, val fingerprint: String, val lastModified: String, val entityTag: String,
val updated: Long, val timestamp: Long, val authentication: String) { val updated: Long, val timestamp: Long, val authentication: String
) {
fun edit(address: String, fingerprint: String, authentication: String): Repository { fun edit(address: String, fingerprint: String, authentication: String): Repository {
val addressChanged = this.address != address val addressChanged = this.address != address
val fingerprintChanged = this.fingerprint != fingerprint val fingerprintChanged = this.fingerprint != fingerprint
val changed = addressChanged || fingerprintChanged val changed = addressChanged || fingerprintChanged
return copy(address = address, fingerprint = fingerprint, lastModified = if (changed) "" else lastModified, return copy(
entityTag = if (changed) "" else entityTag, authentication = authentication) address = address,
fingerprint = fingerprint,
lastModified = if (changed) "" else lastModified,
entityTag = if (changed) "" else entityTag,
authentication = authentication
)
} }
fun update(mirrors: List<String>, name: String, description: String, version: Int, fun update(
lastModified: String, entityTag: String, timestamp: Long): Repository { mirrors: List<String>, name: String, description: String, version: Int,
return copy(mirrors = mirrors, name = name, description = description, lastModified: String, entityTag: String, timestamp: Long
): Repository {
return copy(
mirrors = mirrors, name = name, description = description,
version = if (version >= 0) version else this.version, lastModified = lastModified, version = if (version >= 0) version else this.version, lastModified = lastModified,
entityTag = entityTag, updated = System.currentTimeMillis(), timestamp = timestamp) entityTag = entityTag, updated = System.currentTimeMillis(), timestamp = timestamp
)
} }
fun enable(enabled: Boolean): Repository { fun enable(enabled: Boolean): Repository {
@@ -75,11 +88,17 @@ data class Repository(val id: Long, val address: String, val mirrors: List<Strin
else -> skipChildren() else -> skipChildren()
} }
} }
return Repository(id, address, mirrors, name, description, version, enabled, fingerprint, return Repository(
lastModified, entityTag, updated, timestamp, authentication) id, address, mirrors, name, description, version, enabled, fingerprint,
lastModified, entityTag, updated, timestamp, authentication
)
} }
fun newRepository(address: String, fingerprint: String, authentication: String): Repository { fun newRepository(
address: String,
fingerprint: String,
authentication: String
): Repository {
val name = try { val name = try {
URL(address).let { "${it.host}${it.path}" } URL(address).let { "${it.host}${it.path}" }
} catch (e: Exception) { } catch (e: Exception) {
@@ -88,37 +107,71 @@ data class Repository(val id: Long, val address: String, val mirrors: List<Strin
return defaultRepository(address, name, "", 0, true, fingerprint, authentication) return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
} }
private fun defaultRepository(address: String, name: String, description: String, private fun defaultRepository(
version: Int, enabled: Boolean, fingerprint: String, authentication: String): Repository { address: String, name: String, description: String,
return Repository(-1, address, emptyList(), name, description, version, enabled, version: Int, enabled: Boolean, fingerprint: String, authentication: String
fingerprint, "", "", 0L, 0L, authentication) ): Repository {
return Repository(
-1, address, emptyList(), name, description, version, enabled,
fingerprint, "", "", 0L, 0L, authentication
)
} }
val defaultRepositories = listOf(run { val defaultRepositories = listOf(run {
defaultRepository("https://f-droid.org/repo", "F-Droid", "The official F-Droid Free Software repository. " + defaultRepository(
"https://f-droid.org/repo",
"F-Droid",
"The official F-Droid Free Software repository. " +
"Everything in this repository is always built from the source code.", "Everything in this repository is always built from the source code.",
21, true, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "") 21,
true,
"43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB",
""
)
}, run { }, run {
defaultRepository("https://f-droid.org/archive", "F-Droid Archive", "The archive of the official F-Droid Free " + defaultRepository(
"https://f-droid.org/archive",
"F-Droid Archive",
"The archive of the official F-Droid Free " +
"Software repository. Apps here are old and can contain known vulnerabilities and security issues!", "Software repository. Apps here are old and can contain known vulnerabilities and security issues!",
21, false, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "") 21,
false,
"43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB",
""
)
}, run { }, run {
defaultRepository("https://guardianproject.info/fdroid/repo", "Guardian Project Official Releases", "The " + defaultRepository(
"https://guardianproject.info/fdroid/repo",
"Guardian Project Official Releases",
"The " +
"official repository of The Guardian Project apps for use with the F-Droid client. Applications in this " + "official repository of The Guardian Project apps for use with the F-Droid client. Applications in this " +
"repository are official binaries built by the original application developers and signed by the same key as " + "repository are official binaries built by the original application developers and signed by the same key as " +
"the APKs that are released in the Google Play Store.", "the APKs that are released in the Google Play Store.",
21, false, "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "") 21,
false,
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135",
""
)
}, run { }, run {
defaultRepository("https://guardianproject.info/fdroid/archive", "Guardian Project Archive", "The official " + defaultRepository(
"https://guardianproject.info/fdroid/archive",
"Guardian Project Archive",
"The official " +
"repository of The Guardian Project apps for use with the F-Droid client. This contains older versions of " + "repository of The Guardian Project apps for use with the F-Droid client. This contains older versions of " +
"applications from the main repository.", 21, false, "applications from the main repository.",
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "") 21,
false,
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135",
""
)
}, run { }, run {
defaultRepository("https://apt.izzysoft.de/fdroid/repo", "IzzyOnDroid F-Droid Repo", "This is a " + defaultRepository(
"https://apt.izzysoft.de/fdroid/repo", "IzzyOnDroid F-Droid Repo", "This is a " +
"repository of apps to be used with F-Droid the original application developers, taken from the resp. " + "repository of apps to be used with F-Droid the original application developers, taken from the resp. " +
"repositories (mostly GitHub). At this moment I cannot give guarantees on regular updates for all of them, " + "repositories (mostly GitHub). At this moment I cannot give guarantees on regular updates for all of them, " +
"though most are checked multiple times a week ", 21, true, "though most are checked multiple times a week ", 21, true,
"3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A", "") "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A", ""
)
}) })
} }
} }

View File

@@ -5,9 +5,9 @@ import android.graphics.ColorFilter
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
open class DrawableWrapper(val drawable: Drawable): Drawable() { open class DrawableWrapper(val drawable: Drawable) : Drawable() {
init { init {
drawable.callback = object: Callback { drawable.callback = object : Callback {
override fun invalidateDrawable(who: Drawable) { override fun invalidateDrawable(who: Drawable) {
callback?.invalidateDrawable(who) callback?.invalidateDrawable(who)
} }

View File

@@ -2,9 +2,9 @@ package com.looker.droidify.graphics
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import kotlin.math.* import kotlin.math.roundToInt
class PaddingDrawable(drawable: Drawable, private val factor: Float): DrawableWrapper(drawable) { class PaddingDrawable(drawable: Drawable, private val factor: Float) : DrawableWrapper(drawable) {
override fun getIntrinsicWidth(): Int = (factor * super.getIntrinsicWidth()).roundToInt() override fun getIntrinsicWidth(): Int = (factor * super.getIntrinsicWidth()).roundToInt()
override fun getIntrinsicHeight(): Int = (factor * super.getIntrinsicHeight()).roundToInt() override fun getIntrinsicHeight(): Int = (factor * super.getIntrinsicHeight()).roundToInt()
@@ -13,7 +13,9 @@ class PaddingDrawable(drawable: Drawable, private val factor: Float): DrawableWr
val height = (bounds.height() / factor).roundToInt() val height = (bounds.height() / factor).roundToInt()
val left = (bounds.width() - width) / 2 val left = (bounds.width() - width) / 2
val top = (bounds.height() - height) / 2 val top = (bounds.height() - height) / 2
drawable.setBounds(bounds.left + left, bounds.top + top, drawable.setBounds(
bounds.left + left + width, bounds.top + top + height) bounds.left + left, bounds.top + top,
bounds.left + left + width, bounds.top + top + height
)
} }
} }

View File

@@ -2,14 +2,14 @@ package com.looker.droidify.index
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Release import com.looker.droidify.entity.Release
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import org.xml.sax.Attributes import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler import org.xml.sax.helpers.DefaultHandler
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.*
import java.util.TimeZone
class IndexHandler(private val repositoryId: Long, private val callback: Callback): DefaultHandler() { class IndexHandler(private val repositoryId: Long, private val callback: Callback) :
DefaultHandler() {
companion object { companion object {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") } .apply { timeZone = TimeZone.getTimeZone("UTC") }
@@ -28,15 +28,23 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
} }
interface Callback { interface Callback {
fun onRepository(mirrors: List<String>, name: String, description: String, fun onRepository(
certificate: String, version: Int, timestamp: Long) mirrors: List<String>, name: String, description: String,
certificate: String, version: Int, timestamp: Long
)
fun onProduct(product: Product) fun onProduct(product: Product)
} }
internal object DonateComparator: Comparator<Product.Donate> { internal object DonateComparator : Comparator<Product.Donate> {
private val classes = listOf(Product.Donate.Regular::class, Product.Donate.Bitcoin::class, private val classes = listOf(
Product.Donate.Litecoin::class, Product.Donate.Flattr::class, Product.Donate.Liberapay::class, Product.Donate.Regular::class,
Product.Donate.OpenCollective::class) Product.Donate.Bitcoin::class,
Product.Donate.Litecoin::class,
Product.Donate.Flattr::class,
Product.Donate.Liberapay::class,
Product.Donate.OpenCollective::class
)
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int { override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
val index1 = classes.indexOf(donate1::class) val index1 = classes.indexOf(donate1::class)
@@ -80,10 +88,30 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
val releases = mutableListOf<Release>() val releases = mutableListOf<Release>()
fun build(): Product { fun build(): Product {
return Product(repositoryId, packageName, name, summary, description, "", icon, "", return Product(
Product.Author(authorName, authorEmail, ""), source, changelog, web, tracker, added, updated, repositoryId,
suggestedVersionCode, categories.toList(), antiFeatures.toList(), packageName,
licenses, donates.sortedWith(DonateComparator), emptyList(), releases) name,
summary,
description,
"",
icon,
"",
Product.Author(authorName, authorEmail, ""),
source,
changelog,
web,
tracker,
added,
updated,
suggestedVersionCode,
categories.toList(),
antiFeatures.toList(),
licenses,
donates.sortedWith(DonateComparator),
emptyList(),
releases
)
} }
} }
@@ -112,10 +140,31 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
val hashType = if (hash.isNotEmpty() && hashType.isEmpty()) "sha256" else hashType val hashType = if (hash.isNotEmpty() && hashType.isEmpty()) "sha256" else hashType
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else "" val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else "" val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(false, version, versionCode, added, size, return Release(
minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature, false,
obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType, version,
permissions.toList(), features.toList(), platforms.toList(), emptyList()) versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions.toList(),
features.toList(),
platforms.toList(),
emptyList()
)
} }
} }
@@ -128,7 +177,12 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
private fun Attributes.get(localName: String): String = getValue("", localName).orEmpty() private fun Attributes.get(localName: String): String = getValue("", localName).orEmpty()
private fun String.cleanWhiteSpace(): String = replace("\\s".toRegex(), " ") private fun String.cleanWhiteSpace(): String = replace("\\s".toRegex(), " ")
override fun startElement(uri: String, localName: String, qName: String, attributes: Attributes) { override fun startElement(
uri: String,
localName: String,
qName: String,
attributes: Attributes
) {
super.startElement(uri, localName, qName, attributes) super.startElement(uri, localName, qName, attributes)
val repositoryBuilder = repositoryBuilder val repositoryBuilder = repositoryBuilder
@@ -144,7 +198,8 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
repositoryBuilder.description = attributes.get("description").cleanWhiteSpace() repositoryBuilder.description = attributes.get("description").cleanWhiteSpace()
repositoryBuilder.certificate = attributes.get("pubkey") repositoryBuilder.certificate = attributes.get("pubkey")
repositoryBuilder.version = attributes.get("version").toIntOrNull() ?: 0 repositoryBuilder.version = attributes.get("version").toIntOrNull() ?: 0
repositoryBuilder.timestamp = (attributes.get("timestamp").toLongOrNull() ?: 0L) * 1000L repositoryBuilder.timestamp =
(attributes.get("timestamp").toLongOrNull() ?: 0L) * 1000L
} }
} }
localName == "application" && productBuilder == null -> { localName == "application" && productBuilder == null -> {
@@ -164,7 +219,7 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
null null
} ?: 0 } ?: 0
val maxSdkVersion = attributes.get("maxSdkVersion").toIntOrNull() ?: Int.MAX_VALUE val maxSdkVersion = attributes.get("maxSdkVersion").toIntOrNull() ?: Int.MAX_VALUE
if (Android.sdk in minSdkVersion .. maxSdkVersion) { if (Android.sdk in minSdkVersion..maxSdkVersion) {
releaseBuilder.permissions.add(attributes.get("name")) releaseBuilder.permissions.add(attributes.get("name"))
} else { } else {
releaseBuilder.permissions.remove(attributes.get("name")) releaseBuilder.permissions.remove(attributes.get("name"))
@@ -186,8 +241,14 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
if (repositoryBuilder != null) { if (repositoryBuilder != null) {
val mirrors = (listOf(repositoryBuilder.address) + repositoryBuilder.mirrors) val mirrors = (listOf(repositoryBuilder.address) + repositoryBuilder.mirrors)
.filter { it.isNotEmpty() }.distinct() .filter { it.isNotEmpty() }.distinct()
callback.onRepository(mirrors, repositoryBuilder.name, repositoryBuilder.description, callback.onRepository(
repositoryBuilder.certificate, repositoryBuilder.version, repositoryBuilder.timestamp) mirrors,
repositoryBuilder.name,
repositoryBuilder.description,
repositoryBuilder.certificate,
repositoryBuilder.version,
repositoryBuilder.timestamp
)
this.repositoryBuilder = null this.repositoryBuilder = null
} }
} }
@@ -213,7 +274,8 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
"added" -> releaseBuilder.added = content.parseDate() "added" -> releaseBuilder.added = content.parseDate()
"size" -> releaseBuilder.size = content.toLongOrNull() ?: 0 "size" -> releaseBuilder.size = content.toLongOrNull() ?: 0
"sdkver" -> releaseBuilder.minSdkVersion = content.toIntOrNull() ?: 0 "sdkver" -> releaseBuilder.minSdkVersion = content.toIntOrNull() ?: 0
"targetSdkVersion" -> releaseBuilder.targetSdkVersion = content.toIntOrNull() ?: 0 "targetSdkVersion" -> releaseBuilder.targetSdkVersion =
content.toIntOrNull() ?: 0
"maxsdkver" -> releaseBuilder.maxSdkVersion = content.toIntOrNull() ?: 0 "maxsdkver" -> releaseBuilder.maxSdkVersion = content.toIntOrNull() ?: 0
"srcname" -> releaseBuilder.source = content "srcname" -> releaseBuilder.source = content
"apkname" -> releaseBuilder.release = content "apkname" -> releaseBuilder.release = content
@@ -223,9 +285,12 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
"obbMainFileSha256" -> releaseBuilder.obbMainHash = content "obbMainFileSha256" -> releaseBuilder.obbMainHash = content
"obbPatchFile" -> releaseBuilder.obbPatch = content "obbPatchFile" -> releaseBuilder.obbPatch = content
"obbPatchFileSha256" -> releaseBuilder.obbPatchHash = content "obbPatchFileSha256" -> releaseBuilder.obbPatchHash = content
"permissions" -> releaseBuilder.permissions += content.split(',').filter { it.isNotEmpty() } "permissions" -> releaseBuilder.permissions += content.split(',')
"features" -> releaseBuilder.features += content.split(',').filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
"nativecode" -> releaseBuilder.platforms += content.split(',').filter { it.isNotEmpty() } "features" -> releaseBuilder.features += content.split(',')
.filter { it.isNotEmpty() }
"nativecode" -> releaseBuilder.platforms += content.split(',')
.filter { it.isNotEmpty() }
} }
} }
productBuilder != null -> { productBuilder != null -> {
@@ -243,16 +308,22 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
"tracker" -> productBuilder.tracker = content "tracker" -> productBuilder.tracker = content
"added" -> productBuilder.added = content.parseDate() "added" -> productBuilder.added = content.parseDate()
"lastupdated" -> productBuilder.updated = content.parseDate() "lastupdated" -> productBuilder.updated = content.parseDate()
"marketvercode" -> productBuilder.suggestedVersionCode = content.toLongOrNull() ?: 0L "marketvercode" -> productBuilder.suggestedVersionCode =
"categories" -> productBuilder.categories += content.split(',').filter { it.isNotEmpty() } content.toLongOrNull() ?: 0L
"antifeatures" -> productBuilder.antiFeatures += content.split(',').filter { it.isNotEmpty() } "categories" -> productBuilder.categories += content.split(',')
"license" -> productBuilder.licenses += content.split(',').filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
"antifeatures" -> productBuilder.antiFeatures += content.split(',')
.filter { it.isNotEmpty() }
"license" -> productBuilder.licenses += content.split(',')
.filter { it.isNotEmpty() }
"donate" -> productBuilder.donates += Product.Donate.Regular(content) "donate" -> productBuilder.donates += Product.Donate.Regular(content)
"bitcoin" -> productBuilder.donates += Product.Donate.Bitcoin(content) "bitcoin" -> productBuilder.donates += Product.Donate.Bitcoin(content)
"litecoin" -> productBuilder.donates += Product.Donate.Litecoin(content) "litecoin" -> productBuilder.donates += Product.Donate.Litecoin(content)
"flattr" -> productBuilder.donates += Product.Donate.Flattr(content) "flattr" -> productBuilder.donates += Product.Donate.Flattr(content)
"liberapay" -> productBuilder.donates += Product.Donate.Liberapay(content) "liberapay" -> productBuilder.donates += Product.Donate.Liberapay(content)
"openCollective" -> productBuilder.donates += Product.Donate.OpenCollective(content) "openCollective" -> productBuilder.donates += Product.Donate.OpenCollective(
content
)
} }
} }
} }

View File

@@ -5,13 +5,16 @@ import android.database.sqlite.SQLiteDatabase
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Release import com.looker.droidify.entity.Release
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.android.execWithResult
import com.looker.droidify.utility.extension.json.Json
import com.looker.droidify.utility.extension.json.collectNotNull
import com.looker.droidify.utility.extension.json.writeDictionary
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
class IndexMerger(file: File): Closeable { class IndexMerger(file: File) : Closeable {
private val db = SQLiteDatabase.openOrCreateDatabase(file, null) private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
init { init {
@@ -25,7 +28,8 @@ class IndexMerger(file: File): Closeable {
fun addProducts(products: List<Product>) { fun addProducts(products: List<Product>) {
for (product in products) { for (product in products) {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
Json.factory.createGenerator(outputStream).use { it.writeDictionary(product::serialize) } Json.factory.createGenerator(outputStream)
.use { it.writeDictionary(product::serialize) }
db.insert("product", null, ContentValues().apply { db.insert("product", null, ContentValues().apply {
put("package_name", product.packageName) put("package_name", product.packageName)
put("description", product.description) put("description", product.description)
@@ -61,20 +65,30 @@ class IndexMerger(file: File): Closeable {
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) { fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
closeTransaction() closeTransaction()
db.rawQuery("""SELECT product.description, product.data AS pd, releases.data AS rd FROM product db.rawQuery(
LEFT JOIN releases ON product.package_name = releases.package_name""", null) """SELECT product.description, product.data AS pd, releases.data AS rd FROM product
?.use { it.asSequence().map { LEFT JOIN releases ON product.package_name = releases.package_name""", null
)
?.use { it ->
it.asSequence().map {
val description = it.getString(0) val description = it.getString(0)
val product = Json.factory.createParser(it.getBlob(1)).use { val product = Json.factory.createParser(it.getBlob(1)).use {
it.nextToken() it.nextToken()
Product.deserialize(repositoryId, description, it) Product.deserialize(repositoryId, description, it)
} }
val releases = it.getBlob(2)?.let { Json.factory.createParser(it).use { val releases = it.getBlob(2)?.let {
Json.factory.createParser(it).use {
it.nextToken() it.nextToken()
it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize) it.collectNotNull(
} }.orEmpty() JsonToken.START_OBJECT,
Release.Companion::deserialize
)
}
}.orEmpty()
product.copy(releases = releases) product.copy(releases = releases)
}.windowed(windowSize, windowSize, true).forEach { products -> callback(products, it.count) } } }.windowed(windowSize, windowSize, true)
.forEach { products -> callback(products, it.count) }
}
} }
override fun close() { override fun close() {

View File

@@ -4,31 +4,54 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Release import com.looker.droidify.entity.Release
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.json.* import com.looker.droidify.utility.extension.json.*
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.nullIfEmpty
import java.io.InputStream import java.io.InputStream
object IndexV1Parser { object IndexV1Parser {
interface Callback { interface Callback {
fun onRepository(mirrors: List<String>, name: String, description: String, version: Int, timestamp: Long) fun onRepository(
mirrors: List<String>,
name: String,
description: String,
version: Int,
timestamp: Long
)
fun onProduct(product: Product) fun onProduct(product: Product)
fun onReleases(packageName: String, releases: List<Release>) fun onReleases(packageName: String, releases: List<Release>)
} }
private class Screenshots(val phone: List<String>, val smallTablet: List<String>, val largeTablet: List<String>) private class Screenshots(
private class Localized(val name: String, val summary: String, val description: String, val phone: List<String>,
val whatsNew: String, val metadataIcon: String, val screenshots: Screenshots?) val smallTablet: List<String>,
val largeTablet: List<String>
)
private fun <T> Map<String, Localized>.getAndCall(key: String, callback: (String, Localized) -> T?): T? { private class Localized(
val name: String, val summary: String, val description: String,
val whatsNew: String, val metadataIcon: String, val screenshots: Screenshots?
)
private fun <T> Map<String, Localized>.getAndCall(
key: String,
callback: (String, Localized) -> T?
): T? {
return this[key]?.let { callback(key, it) } return this[key]?.let { callback(key, it) }
} }
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? { private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall("en", callback) return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
"en",
callback
)
} }
private fun Map<String, Localized>.findString(fallback: String, callback: (Localized) -> String): String { private fun Map<String, Localized>.findString(
fallback: String,
callback: (Localized) -> String
): String {
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim() return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
} }
@@ -37,7 +60,7 @@ object IndexV1Parser {
if (jsonParser.nextToken() != JsonToken.START_OBJECT) { if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal() jsonParser.illegal()
} else { } else {
jsonParser.forEachKey { jsonParser.forEachKey { it ->
when { when {
it.dictionary("repo") -> { it.dictionary("repo") -> {
var address = "" var address = ""
@@ -57,7 +80,8 @@ object IndexV1Parser {
else -> skipChildren() else -> skipChildren()
} }
} }
val realMirrors = ((if (address.isNotEmpty()) listOf(address) else emptyList()) + mirrors).distinct() val realMirrors =
((if (address.isNotEmpty()) listOf(address) else emptyList()) + mirrors).distinct()
callback.onRepository(realMirrors, name, description, version, timestamp) callback.onRepository(realMirrors, name, description, version, timestamp)
} }
it.array("apps") -> forEach(JsonToken.START_OBJECT) { it.array("apps") -> forEach(JsonToken.START_OBJECT) {
@@ -100,7 +124,7 @@ object IndexV1Parser {
val licenses = mutableListOf<String>() val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>() val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>() val localizedMap = mutableMapOf<String, Localized>()
forEachKey { forEachKey { it ->
when { when {
it.string("packageName") -> packageName = valueAsString it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString it.string("name") -> nameFallback = valueAsString
@@ -116,16 +140,20 @@ object IndexV1Parser {
it.string("issueTracker") -> tracker = valueAsString it.string("issueTracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong it.number("added") -> added = valueAsLong
it.number("lastUpdated") -> updated = valueAsLong it.number("lastUpdated") -> updated = valueAsLong
it.string("suggestedVersionCode") -> suggestedVersionCode = valueAsString.toLongOrNull() ?: 0L it.string("suggestedVersionCode") -> suggestedVersionCode =
valueAsString.toLongOrNull() ?: 0L
it.array("categories") -> categories = collectDistinctNotEmptyStrings() it.array("categories") -> categories = collectDistinctNotEmptyStrings()
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings() it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
it.string("license") -> licenses += valueAsString.split(',').filter { it.isNotEmpty() } it.string("license") -> licenses += valueAsString.split(',')
.filter { it.isNotEmpty() }
it.string("donate") -> donates += Product.Donate.Regular(valueAsString) it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString) it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString) it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString) it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
it.string("openCollective") -> donates += Product.Donate.OpenCollective(valueAsString) it.string("openCollective") -> donates += Product.Donate.OpenCollective(
it.dictionary("localized") -> forEachKey { valueAsString
)
it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) { if (it.token == JsonToken.START_OBJECT) {
val locale = it.key val locale = it.key
var name = "" var name = ""
@@ -143,16 +171,22 @@ object IndexV1Parser {
it.string("description") -> description = valueAsString it.string("description") -> description = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> metadataIcon = valueAsString it.string("icon") -> metadataIcon = valueAsString
it.array("phoneScreenshots") -> phone = collectDistinctNotEmptyStrings() it.array("phoneScreenshots") -> phone =
it.array("sevenInchScreenshots") -> smallTablet = collectDistinctNotEmptyStrings() collectDistinctNotEmptyStrings()
it.array("tenInchScreenshots") -> largeTablet = collectDistinctNotEmptyStrings() it.array("sevenInchScreenshots") -> smallTablet =
collectDistinctNotEmptyStrings()
it.array("tenInchScreenshots") -> largeTablet =
collectDistinctNotEmptyStrings()
else -> skipChildren() else -> skipChildren()
} }
} }
val screenshots = if (sequenceOf(phone, smallTablet, largeTablet).any { it.isNotEmpty() }) val screenshots =
if (sequenceOf(phone, smallTablet, largeTablet).any { it.isNotEmpty() })
Screenshots(phone, smallTablet, largeTablet) else null Screenshots(phone, smallTablet, largeTablet) else null
localizedMap[locale] = Localized(name, summary, description, whatsNew, localizedMap[locale] = Localized(
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), screenshots) name, summary, description, whatsNew,
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), screenshots
)
} else { } else {
skipChildren() skipChildren()
} }
@@ -162,22 +196,58 @@ object IndexV1Parser {
} }
val name = localizedMap.findString(nameFallback) { it.name } val name = localizedMap.findString(nameFallback) { it.name }
val summary = localizedMap.findString(summaryFallback) { it.summary } val summary = localizedMap.findString(summaryFallback) { it.summary }
val description = localizedMap.findString(descriptionFallback) { it.description }.replace("\n", "<br/>") val description =
localizedMap.findString(descriptionFallback) { it.description }.replace("\n", "<br/>")
val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "<br/>") val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "<br/>")
val metadataIcon = localizedMap.findString("") { it.metadataIcon } val metadataIcon = localizedMap.findString("") { it.metadataIcon }
val screenshotPairs = localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } } val screenshotPairs =
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
val screenshots = screenshotPairs val screenshots = screenshotPairs
?.let { (key, screenshots) -> screenshots.phone.asSequence() ?.let { (key, screenshots) ->
screenshots.phone.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } + .map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
screenshots.smallTablet.asSequence() screenshots.smallTablet.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.SMALL_TABLET, it) } + .map {
Product.Screenshot(
key,
Product.Screenshot.Type.SMALL_TABLET,
it
)
} +
screenshots.largeTablet.asSequence() screenshots.largeTablet.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.LARGE_TABLET, it) } } .map {
Product.Screenshot(
key,
Product.Screenshot.Type.LARGE_TABLET,
it
)
}
}
.orEmpty().toList() .orEmpty().toList()
return Product(repositoryId, packageName, name, summary, description, whatsNew, icon, metadataIcon, return Product(
Product.Author(authorName, authorEmail, authorWeb), source, changelog, web, tracker, added, updated, repositoryId,
suggestedVersionCode, categories, antiFeatures, licenses, packageName,
donates.sortedWith(IndexHandler.DonateComparator), screenshots, emptyList()) name,
summary,
description,
whatsNew,
icon,
metadataIcon,
Product.Author(authorName, authorEmail, authorWeb),
source,
changelog,
web,
tracker,
added,
updated,
suggestedVersionCode,
categories,
antiFeatures,
licenses,
donates.sortedWith(IndexHandler.DonateComparator),
screenshots,
emptyList()
)
} }
private fun JsonParser.parseRelease(): Release { private fun JsonParser.parseRelease(): Release {
@@ -225,13 +295,35 @@ object IndexV1Parser {
else -> skipChildren() else -> skipChildren()
} }
} }
val hashType = if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate val hashType =
if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else "" val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else "" val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(false, version, versionCode, added, size, return Release(
minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature, false,
obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType, version,
permissions.toList(), features, platforms, emptyList()) versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions.toList(),
features,
platforms,
emptyList()
)
} }
private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) { private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) {

View File

@@ -2,9 +2,6 @@ package com.looker.droidify.index
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import com.looker.droidify.content.Cache import com.looker.droidify.content.Cache
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
@@ -14,12 +11,15 @@ import com.looker.droidify.network.Downloader
import com.looker.droidify.utility.ProgressInputStream import com.looker.droidify.utility.ProgressInputStream
import com.looker.droidify.utility.RxUtils import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.Utils import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.unhex
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.xml.sax.InputSource import org.xml.sax.InputSource
import java.io.File import java.io.File
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.Locale import java.util.*
import java.util.jar.JarEntry import java.util.jar.JarEntry
import java.util.jar.JarFile import java.util.jar.JarFile
import javax.xml.parsers.SAXParserFactory import javax.xml.parsers.SAXParserFactory
@@ -29,7 +29,11 @@ object RepositoryUpdater {
DOWNLOAD, PROCESS, MERGE, COMMIT DOWNLOAD, PROCESS, MERGE, COMMIT
} }
private enum class IndexType(val jarName: String, val contentName: String, val certificateFromIndex: Boolean) { private enum class IndexType(
val jarName: String,
val contentName: String,
val certificateFromIndex: Boolean
) {
INDEX("index.jar", "index.xml", true), INDEX("index.jar", "index.xml", true),
INDEX_V1("index-v1.jar", "index-v1.json", false) INDEX_V1("index-v1.jar", "index-v1.json", false)
} }
@@ -38,14 +42,17 @@ object RepositoryUpdater {
NETWORK, HTTP, VALIDATION, PARSING NETWORK, HTTP, VALIDATION, PARSING
} }
class UpdateException: Exception { class UpdateException : Exception {
val errorType: ErrorType val errorType: ErrorType
constructor(errorType: ErrorType, message: String): super(message) { constructor(errorType: ErrorType, message: String) : super(message) {
this.errorType = errorType this.errorType = errorType
} }
constructor(errorType: ErrorType, message: String, cause: Exception): super(message, cause) { constructor(errorType: ErrorType, message: String, cause: Exception) : super(
message,
cause
) {
this.errorType = errorType this.errorType = errorType
} }
} }
@@ -61,8 +68,14 @@ object RepositoryUpdater {
Observable.just(Unit) Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories)) .concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAllDisabledDeleted(it) } } .flatMapSingle {
.forEach { RxUtils.querySingle {
Database.RepositoryAdapter.getAllDisabledDeleted(
it
)
}
}
.forEach { it ->
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet() val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet()
val disabled = newDisabled - lastDisabled val disabled = newDisabled - lastDisabled
lastDisabled = newDisabled lastDisabled = newDisabled
@@ -79,13 +92,17 @@ object RepositoryUpdater {
synchronized(updaterLock) { } synchronized(updaterLock) { }
} }
fun update(repository: Repository, unstable: Boolean, fun update(
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> { repository: Repository, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit
): Single<Boolean> {
return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback) return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback)
} }
private fun update(repository: Repository, indexTypes: List<IndexType>, unstable: Boolean, private fun update(
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> { repository: Repository, indexTypes: List<IndexType>, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit
): Single<Boolean> {
val indexType = indexTypes[0] val indexType = indexTypes[0]
return downloadIndex(repository, indexType, callback) return downloadIndex(repository, indexType, callback)
.flatMap { (result, file) -> .flatMap { (result, file) ->
@@ -97,41 +114,74 @@ object RepositoryUpdater {
!result.success -> { !result.success -> {
file.delete() file.delete()
if (result.code == 404 && indexTypes.isNotEmpty()) { if (result.code == 404 && indexTypes.isNotEmpty()) {
update(repository, indexTypes.subList(1, indexTypes.size), unstable, callback) update(
repository,
indexTypes.subList(1, indexTypes.size),
unstable,
callback
)
} else { } else {
Single.error(UpdateException(ErrorType.HTTP, "Invalid response: HTTP ${result.code}")) Single.error(
UpdateException(
ErrorType.HTTP,
"Invalid response: HTTP ${result.code}"
)
)
} }
} }
else -> { else -> {
RxUtils.managedSingle { processFile(repository, indexType, unstable, RxUtils.managedSingle {
file, result.lastModified, result.entityTag, callback) } processFile(
repository, indexType, unstable,
file, result.lastModified, result.entityTag, callback
)
}
} }
} }
} }
} }
private fun downloadIndex(repository: Repository, indexType: IndexType, private fun downloadIndex(
callback: (Stage, Long, Long?) -> Unit): Single<Pair<Downloader.Result, File>> { repository: Repository, indexType: IndexType,
callback: (Stage, Long, Long?) -> Unit
): Single<Pair<Downloader.Result, File>> {
return Single.just(Unit) return Single.just(Unit)
.map { Cache.getTemporaryFile(context) } .map { Cache.getTemporaryFile(context) }
.flatMap { file -> Downloader .flatMap { file ->
.download(Uri.parse(repository.address).buildUpon() Downloader
.appendPath(indexType.jarName).build().toString(), file, repository.lastModified, repository.entityTag, .download(
repository.authentication) { read, total -> callback(Stage.DOWNLOAD, read, total) } Uri.parse(repository.address).buildUpon()
.appendPath(indexType.jarName).build().toString(),
file,
repository.lastModified,
repository.entityTag,
repository.authentication
) { read, total -> callback(Stage.DOWNLOAD, read, total) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.map { Pair(it, file) } .map { Pair(it, file) }
.onErrorResumeNext { .onErrorResumeNext {
file.delete() file.delete()
when (it) { when (it) {
is InterruptedException, is RuntimeException, is Error -> Single.error(it) is InterruptedException, is RuntimeException, is Error -> Single.error(
is Exception -> Single.error(UpdateException(ErrorType.NETWORK, "Network error", it)) it
)
is Exception -> Single.error(
UpdateException(
ErrorType.NETWORK,
"Network error",
it
)
)
else -> Single.error(it) else -> Single.error(it)
} }
} } }
}
} }
private fun processFile(repository: Repository, indexType: IndexType, unstable: Boolean, private fun processFile(
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit): Boolean { repository: Repository, indexType: IndexType, unstable: Boolean,
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit
): Boolean {
var rollback = true var rollback = true
return synchronized(updaterLock) { return synchronized(updaterLock) {
try { try {
@@ -152,12 +202,17 @@ object RepositoryUpdater {
var certificateFromIndex: String? = null var certificateFromIndex: String? = null
val products = mutableListOf<Product>() val products = mutableListOf<Product>()
reader.contentHandler = IndexHandler(repository.id, object: IndexHandler.Callback { reader.contentHandler =
override fun onRepository(mirrors: List<String>, name: String, description: String, IndexHandler(repository.id, object : IndexHandler.Callback {
certificate: String, version: Int, timestamp: Long) { override fun onRepository(
changedRepository = repository.update(mirrors, name, description, version, mirrors: List<String>, name: String, description: String,
lastModified, entityTag, timestamp) certificate: String, version: Int, timestamp: Long
certificateFromIndex = certificate.toLowerCase(Locale.US) ) {
changedRepository = repository.update(
mirrors, name, description, version,
lastModified, entityTag, timestamp
)
certificateFromIndex = certificate.lowercase(Locale.US)
} }
override fun onProduct(product: Product) { override fun onProduct(product: Product) {
@@ -172,7 +227,13 @@ object RepositoryUpdater {
} }
}) })
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) } ProgressInputStream(jarFile.getInputStream(indexEntry)) {
callback(
Stage.PROCESS,
it,
total
)
}
.use { reader.parse(InputSource(it)) } .use { reader.parse(InputSource(it)) }
if (Thread.interrupted()) { if (Thread.interrupted()) {
throw InterruptedException() throw InterruptedException()
@@ -191,12 +252,28 @@ object RepositoryUpdater {
val unmergedProducts = mutableListOf<Product>() val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>() val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
IndexMerger(mergerFile).use { indexMerger -> IndexMerger(mergerFile).use { indexMerger ->
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }.use { ProgressInputStream(jarFile.getInputStream(indexEntry)) {
IndexV1Parser.parse(repository.id, it, object: IndexV1Parser.Callback { callback(
override fun onRepository(mirrors: List<String>, name: String, description: String, Stage.PROCESS,
version: Int, timestamp: Long) { it,
changedRepository = repository.update(mirrors, name, description, version, total
lastModified, entityTag, timestamp) )
}.use { it ->
IndexV1Parser.parse(
repository.id,
it,
object : IndexV1Parser.Callback {
override fun onRepository(
mirrors: List<String>,
name: String,
description: String,
version: Int,
timestamp: Long
) {
changedRepository = repository.update(
mirrors, name, description, version,
lastModified, entityTag, timestamp
)
} }
override fun onProduct(product: Product) { override fun onProduct(product: Product) {
@@ -210,7 +287,10 @@ object RepositoryUpdater {
} }
} }
override fun onReleases(packageName: String, releases: List<Release>) { override fun onReleases(
packageName: String,
releases: List<Release>
) {
if (Thread.interrupted()) { if (Thread.interrupted()) {
throw InterruptedException() throw InterruptedException()
} }
@@ -239,7 +319,11 @@ object RepositoryUpdater {
throw InterruptedException() throw InterruptedException()
} }
progress += products.size progress += products.size
callback(Stage.MERGE, progress.toLong(), totalCount.toLong()) callback(
Stage.MERGE,
progress.toLong(),
totalCount.toLong()
)
Database.UpdaterAdapter.putTemporary(products Database.UpdaterAdapter.putTemporary(products
.map { transformProduct(it, features, unstable) }) .map { transformProduct(it, features, unstable) })
} }
@@ -254,18 +338,27 @@ object RepositoryUpdater {
val workRepository = changedRepository ?: repository val workRepository = changedRepository ?: repository
if (workRepository.timestamp < repository.timestamp) { if (workRepository.timestamp < repository.timestamp) {
throw UpdateException(ErrorType.VALIDATION, "New index is older than current index: " + throw UpdateException(
"${workRepository.timestamp} < ${repository.timestamp}") ErrorType.VALIDATION, "New index is older than current index: " +
"${workRepository.timestamp} < ${repository.timestamp}"
)
} else { } else {
val fingerprint = run { val fingerprint = run {
val certificateFromJar = run { val certificateFromJar = run {
val codeSigners = indexEntry.codeSigners val codeSigners = indexEntry.codeSigners
if (codeSigners == null || codeSigners.size != 1) { if (codeSigners == null || codeSigners.size != 1) {
throw UpdateException(ErrorType.VALIDATION, "index.jar must be signed by a single code signer") throw UpdateException(
ErrorType.VALIDATION,
"index.jar must be signed by a single code signer"
)
} else { } else {
val certificates = codeSigners[0].signerCertPath?.certificates.orEmpty() val certificates =
codeSigners[0].signerCertPath?.certificates.orEmpty()
if (certificates.size != 1) { if (certificates.size != 1) {
throw UpdateException(ErrorType.VALIDATION, "index.jar code signer should have only one certificate") throw UpdateException(
ErrorType.VALIDATION,
"index.jar code signer should have only one certificate"
)
} else { } else {
certificates[0] as X509Certificate certificates[0] as X509Certificate
} }
@@ -273,9 +366,13 @@ object RepositoryUpdater {
} }
val fingerprintFromJar = Utils.calculateFingerprint(certificateFromJar) val fingerprintFromJar = Utils.calculateFingerprint(certificateFromJar)
if (indexType.certificateFromIndex) { if (indexType.certificateFromIndex) {
val fingerprintFromIndex = certificateFromIndex?.unhex()?.let(Utils::calculateFingerprint) val fingerprintFromIndex =
certificateFromIndex?.unhex()?.let(Utils::calculateFingerprint)
if (fingerprintFromIndex == null || fingerprintFromJar != fingerprintFromIndex) { if (fingerprintFromIndex == null || fingerprintFromJar != fingerprintFromIndex) {
throw UpdateException(ErrorType.VALIDATION, "index.xml contains invalid public key") throw UpdateException(
ErrorType.VALIDATION,
"index.xml contains invalid public key"
)
} }
fingerprintFromIndex fingerprintFromIndex
} else { } else {
@@ -287,7 +384,10 @@ object RepositoryUpdater {
if (workRepository.fingerprint.isEmpty()) { if (workRepository.fingerprint.isEmpty()) {
workRepository.copy(fingerprint = fingerprint) workRepository.copy(fingerprint = fingerprint)
} else { } else {
throw UpdateException(ErrorType.VALIDATION, "Certificate fingerprints do not match") throw UpdateException(
ErrorType.VALIDATION,
"Certificate fingerprints do not match"
)
} }
} else { } else {
workRepository workRepository
@@ -296,7 +396,12 @@ object RepositoryUpdater {
throw InterruptedException() throw InterruptedException()
} }
callback(Stage.COMMIT, 0, null) callback(Stage.COMMIT, 0, null)
synchronized(cleanupLock) { Database.UpdaterAdapter.finishTemporary(commitRepository, true) } synchronized(cleanupLock) {
Database.UpdaterAdapter.finishTemporary(
commitRepository,
true
)
}
rollback = false rollback = false
true true
} }
@@ -314,8 +419,14 @@ object RepositoryUpdater {
} }
} }
private fun transformProduct(product: Product, features: Set<String>, unstable: Boolean): Product { private fun transformProduct(
val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map { product: Product,
features: Set<String>,
unstable: Boolean
): Product {
val releasePairs =
product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }
.map { it ->
val incompatibilities = mutableListOf<Release.Incompatibility>() val incompatibilities = mutableListOf<Release.Incompatibility>()
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) { if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
incompatibilities += Release.Incompatibility.MinSdk incompatibilities += Release.Incompatibility.MinSdk
@@ -323,23 +434,32 @@ object RepositoryUpdater {
if (it.maxSdkVersion > 0 && Android.sdk > it.maxSdkVersion) { if (it.maxSdkVersion > 0 && Android.sdk > it.maxSdkVersion) {
incompatibilities += Release.Incompatibility.MaxSdk incompatibilities += Release.Incompatibility.MaxSdk
} }
if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms).isEmpty()) { if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms)
.isEmpty()
) {
incompatibilities += Release.Incompatibility.Platform incompatibilities += Release.Incompatibility.Platform
} }
incompatibilities += (it.features - features).sorted().map { Release.Incompatibility.Feature(it) } incompatibilities += (it.features - features).sorted()
.map { Release.Incompatibility.Feature(it) }
Pair(it, incompatibilities as List<Release.Incompatibility>) Pair(it, incompatibilities as List<Release.Incompatibility>)
}.toMutableList() }.toMutableList()
val predicate: (Release) -> Boolean = { unstable || product.suggestedVersionCode <= 0 || val predicate: (Release) -> Boolean = {
it.versionCode <= product.suggestedVersionCode } unstable || product.suggestedVersionCode <= 0 ||
val firstCompatibleReleaseIndex = releasePairs.indexOfFirst { it.second.isEmpty() && predicate(it.first) } it.versionCode <= product.suggestedVersionCode
val firstReleaseIndex = if (firstCompatibleReleaseIndex >= 0) firstCompatibleReleaseIndex else }
val firstCompatibleReleaseIndex =
releasePairs.indexOfFirst { it.second.isEmpty() && predicate(it.first) }
val firstReleaseIndex =
if (firstCompatibleReleaseIndex >= 0) firstCompatibleReleaseIndex else
releasePairs.indexOfFirst { predicate(it.first) } releasePairs.indexOfFirst { predicate(it.first) }
val firstSelected = if (firstReleaseIndex >= 0) releasePairs[firstReleaseIndex] else null val firstSelected = if (firstReleaseIndex >= 0) releasePairs[firstReleaseIndex] else null
val releases = releasePairs.map { (release, incompatibilities) -> release val releases = releasePairs.map { (release, incompatibilities) ->
release
.copy(incompatibilities = incompatibilities, selected = firstSelected .copy(incompatibilities = incompatibilities, selected = firstSelected
?.let { it.first.versionCode == release.versionCode && it.second == incompatibilities } == true) } ?.let { it.first.versionCode == release.versionCode && it.second == incompatibilities } == true)
}
return product.copy(releases = releases) return product.copy(releases = releases)
} }
} }

View File

@@ -1,9 +1,9 @@
package com.looker.droidify.network package com.looker.droidify.network
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import com.looker.droidify.utility.ProgressInputStream import com.looker.droidify.utility.ProgressInputStream
import com.looker.droidify.utility.RxUtils import com.looker.droidify.utility.RxUtils
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -66,8 +66,10 @@ object Downloader {
return client.newCall(newRequest) return client.newCall(newRequest)
} }
fun download(url: String, target: File, lastModified: String, entityTag: String, authentication: String, fun download(
callback: ((read: Long, total: Long?) -> Unit)?): Single<Result> { url: String, target: File, lastModified: String, entityTag: String, authentication: String,
callback: ((read: Long, total: Long?) -> Unit)?
): Single<Result> {
val start = if (target.exists()) target.length().let { if (it > 0L) it else null } else null val start = if (target.exists()) target.length().let { if (it > 0L) it else null } else null
val request = Request.Builder().url(url) val request = Request.Builder().url(url)
.apply { .apply {
@@ -84,15 +86,18 @@ object Downloader {
return RxUtils return RxUtils
.callSingle { createCall(request, authentication, null) } .callSingle { createCall(request, authentication, null) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.flatMap { result -> RxUtils .flatMap { result ->
.managedSingle { result.use { RxUtils
.managedSingle {
result.use { it ->
if (result.code == 304) { if (result.code == 304) {
Result(it.code, lastModified, entityTag) Result(it.code, lastModified, entityTag)
} else { } else {
val body = it.body!! val body = it.body!!
val append = start != null && it.header("Content-Range") != null val append = start != null && it.header("Content-Range") != null
val progressStart = if (append && start != null) start else 0L val progressStart = if (append && start != null) start else 0L
val progressTotal = body.contentLength().let { if (it >= 0L) it else null } val progressTotal =
body.contentLength().let { if (it >= 0L) it else null }
?.let { progressStart + it } ?.let { progressStart + it }
val inputStream = ProgressInputStream(body.byteStream()) { val inputStream = ProgressInputStream(body.byteStream()) {
if (Thread.interrupted()) { if (Thread.interrupted()) {
@@ -101,14 +106,23 @@ object Downloader {
callback?.invoke(progressStart + it, progressTotal) callback?.invoke(progressStart + it, progressTotal)
} }
inputStream.use { input -> inputStream.use { input ->
val outputStream = if (append) FileOutputStream(target, true) else FileOutputStream(target) val outputStream = if (append) FileOutputStream(
target,
true
) else FileOutputStream(target)
outputStream.use { output -> outputStream.use { output ->
input.copyTo(output) input.copyTo(output)
output.fd.sync() output.fd.sync()
} }
} }
Result(it.code, it.header("Last-Modified").orEmpty(), it.header("ETag").orEmpty()) Result(
it.code,
it.header("Last-Modified").orEmpty(),
it.header("ETag").orEmpty()
)
}
}
}
} }
} } }
} }
} }

View File

@@ -5,12 +5,13 @@ import android.net.Uri
import android.view.View import android.view.View
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Repository import com.looker.droidify.entity.Repository
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.nullIfEmpty
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File import java.io.File
import kotlin.math.* import kotlin.math.min
import kotlin.math.roundToInt
object PicassoDownloader { object PicassoDownloader {
private const val HOST_ICON = "icon" private const val HOST_ICON = "icon"
@@ -27,7 +28,7 @@ object PicassoDownloader {
private val supportedDpis = listOf(120, 160, 240, 320, 480, 640) private val supportedDpis = listOf(120, 160, 240, 320, 480, 640)
class Factory(cacheDir: File): Call.Factory { class Factory(cacheDir: File) : Call.Factory {
private val cache = Cache(cacheDir, 50_000_000L) private val cache = Cache(cacheDir, 50_000_000L)
override fun newCall(request: okhttp3.Request): Call { override fun newCall(request: okhttp3.Request): Call {
@@ -36,9 +37,11 @@ object PicassoDownloader {
val address = request.url.queryParameter(QUERY_ADDRESS)?.nullIfEmpty() val address = request.url.queryParameter(QUERY_ADDRESS)?.nullIfEmpty()
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION) val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
val path = run { val path = run {
val packageName = request.url.queryParameter(QUERY_PACKAGE_NAME)?.nullIfEmpty() val packageName =
request.url.queryParameter(QUERY_PACKAGE_NAME)?.nullIfEmpty()
val icon = request.url.queryParameter(QUERY_ICON)?.nullIfEmpty() val icon = request.url.queryParameter(QUERY_ICON)?.nullIfEmpty()
val metadataIcon = request.url.queryParameter(QUERY_METADATA_ICON)?.nullIfEmpty() val metadataIcon =
request.url.queryParameter(QUERY_METADATA_ICON)?.nullIfEmpty()
val dpi = request.url.queryParameter(QUERY_DPI)?.nullIfEmpty() val dpi = request.url.queryParameter(QUERY_DPI)?.nullIfEmpty()
when { when {
icon != null -> "${if (dpi != null) "icons-$dpi" else "icons"}/$icon" icon != null -> "${if (dpi != null) "icons-$dpi" else "icons"}/$icon"
@@ -49,8 +52,12 @@ object PicassoDownloader {
if (address == null || path == null) { if (address == null || path == null) {
Downloader.createCall(request.newBuilder(), "", null) Downloader.createCall(request.newBuilder(), "", null)
} else { } else {
Downloader.createCall(request.newBuilder().url(address.toHttpUrl() Downloader.createCall(
.newBuilder().addPathSegments(path).build()), authentication.orEmpty(), cache) request.newBuilder().url(
address.toHttpUrl()
.newBuilder().addPathSegments(path).build()
), authentication.orEmpty(), cache
)
} }
} }
HOST_SCREENSHOT -> { HOST_SCREENSHOT -> {
@@ -63,10 +70,16 @@ object PicassoDownloader {
if (screenshot.isNullOrEmpty() || address.isNullOrEmpty()) { if (screenshot.isNullOrEmpty() || address.isNullOrEmpty()) {
Downloader.createCall(request.newBuilder(), "", null) Downloader.createCall(request.newBuilder(), "", null)
} else { } else {
Downloader.createCall(request.newBuilder().url(address.toHttpUrl() Downloader.createCall(
.newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty()) request.newBuilder().url(
.addPathSegment(device.orEmpty()).addPathSegment(screenshot.orEmpty()).build()), address.toHttpUrl()
authentication.orEmpty(), cache) .newBuilder().addPathSegment(packageName.orEmpty())
.addPathSegment(locale.orEmpty())
.addPathSegment(device.orEmpty())
.addPathSegment(screenshot.orEmpty()).build()
),
authentication.orEmpty(), cache
)
} }
} }
else -> { else -> {
@@ -76,29 +89,43 @@ object PicassoDownloader {
} }
} }
fun createScreenshotUri(repository: Repository, packageName: String, screenshot: Product.Screenshot): Uri { fun createScreenshotUri(
repository: Repository,
packageName: String,
screenshot: Product.Screenshot
): Uri {
return Uri.Builder().scheme("https").authority(HOST_SCREENSHOT) return Uri.Builder().scheme("https").authority(HOST_SCREENSHOT)
.appendQueryParameter(QUERY_ADDRESS, repository.address) .appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication) .appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName) .appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
.appendQueryParameter(QUERY_LOCALE, screenshot.locale) .appendQueryParameter(QUERY_LOCALE, screenshot.locale)
.appendQueryParameter(QUERY_DEVICE, when (screenshot.type) { .appendQueryParameter(
QUERY_DEVICE, when (screenshot.type) {
Product.Screenshot.Type.PHONE -> "phoneScreenshots" Product.Screenshot.Type.PHONE -> "phoneScreenshots"
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots" Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots" Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
}) }
)
.appendQueryParameter(QUERY_SCREENSHOT, screenshot.path) .appendQueryParameter(QUERY_SCREENSHOT, screenshot.path)
.build() .build()
} }
fun createIconUri(view: View, packageName: String, icon: String, metadataIcon: String, repository: Repository): Uri { fun createIconUri(
view: View,
packageName: String,
icon: String,
metadataIcon: String,
repository: Repository
): Uri {
val size = (view.layoutParams.let { min(it.width, it.height) } / val size = (view.layoutParams.let { min(it.width, it.height) } /
view.resources.displayMetrics.density).roundToInt() view.resources.displayMetrics.density).roundToInt()
return createIconUri(view.context, packageName, icon, metadataIcon, size, repository) return createIconUri(view.context, packageName, icon, metadataIcon, size, repository)
} }
private fun createIconUri(context: Context, packageName: String, icon: String, metadataIcon: String, private fun createIconUri(
targetSizeDp: Int, repository: Repository): Uri { context: Context, packageName: String, icon: String, metadataIcon: String,
targetSizeDp: Int, repository: Repository
): Uri {
return Uri.Builder().scheme("https").authority(HOST_ICON) return Uri.Builder().scheme("https").authority(HOST_ICON)
.appendQueryParameter(QUERY_ADDRESS, repository.address) .appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication) .appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)

View File

@@ -12,44 +12,52 @@ import com.looker.droidify.R
import com.looker.droidify.entity.Release import com.looker.droidify.entity.Release
import com.looker.droidify.utility.KParcelable import com.looker.droidify.utility.KParcelable
import com.looker.droidify.utility.PackageItemResolver import com.looker.droidify.utility.PackageItemResolver
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.nullIfEmpty
class MessageDialog(): DialogFragment() { class MessageDialog() : DialogFragment() {
companion object { companion object {
private const val EXTRA_MESSAGE = "message" private const val EXTRA_MESSAGE = "message"
} }
sealed class Message: KParcelable { sealed class Message : KParcelable {
object DeleteRepositoryConfirm: Message() { object DeleteRepositoryConfirm : Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { DeleteRepositoryConfirm } @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { DeleteRepositoryConfirm }
} }
object CantEditSyncing: Message() { object CantEditSyncing : Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { CantEditSyncing } @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { CantEditSyncing }
} }
class Link(val uri: Uri): Message() { class Link(val uri: Uri) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(uri.toString()) dest.writeString(uri.toString())
} }
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val uri = Uri.parse(it.readString()!!) val uri = Uri.parse(it.readString()!!)
Link(uri) Link(uri)
} }
} }
} }
class Permissions(val group: String?, val permissions: List<String>): Message() { class Permissions(val group: String?, val permissions: List<String>) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(group) dest.writeString(group)
dest.writeStringList(permissions) dest.writeStringList(permissions)
} }
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val group = it.readString() val group = it.readString()
val permissions = it.createStringArrayList()!! val permissions = it.createStringArrayList()!!
Permissions(group, permissions) Permissions(group, permissions)
@@ -57,8 +65,10 @@ class MessageDialog(): DialogFragment() {
} }
} }
class ReleaseIncompatible(val incompatibilities: List<Release.Incompatibility>, class ReleaseIncompatible(
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int): Message() { val incompatibilities: List<Release.Incompatibility>,
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int
) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeInt(incompatibilities.size) dest.writeInt(incompatibilities.size)
for (incompatibility in incompatibilities) { for (incompatibility in incompatibilities) {
@@ -84,7 +94,9 @@ class MessageDialog(): DialogFragment() {
} }
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val count = it.readInt() val count = it.readInt()
val incompatibilities = generateSequence { val incompatibilities = generateSequence {
when (it.readInt()) { when (it.readInt()) {
@@ -103,16 +115,20 @@ class MessageDialog(): DialogFragment() {
} }
} }
object ReleaseOlder: Message() { object ReleaseOlder : Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseOlder } @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { ReleaseOlder }
} }
object ReleaseSignatureMismatch: Message() { object ReleaseSignatureMismatch : Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseSignatureMismatch } @Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { ReleaseSignatureMismatch }
} }
} }
constructor(message: Message): this() { constructor(message: Message) : this() {
arguments = Bundle().apply { arguments = Bundle().apply {
putParcelable(EXTRA_MESSAGE, message) putParcelable(EXTRA_MESSAGE, message)
} }
@@ -154,8 +170,13 @@ class MessageDialog(): DialogFragment() {
val localCache = PackageItemResolver.LocalCache() val localCache = PackageItemResolver.LocalCache()
val title = if (message.group != null) { val title = if (message.group != null) {
val name = try { val name = try {
val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0) val permissionGroupInfo =
PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo) packageManager.getPermissionGroupInfo(message.group, 0)
PackageItemResolver.loadLabel(
requireContext(),
localCache,
permissionGroupInfo
)
?.nullIfEmpty()?.let { if (it == message.group) null else it } ?.nullIfEmpty()?.let { if (it == message.group) null else it }
} catch (e: Exception) { } catch (e: Exception) {
null null
@@ -167,7 +188,11 @@ class MessageDialog(): DialogFragment() {
for (permission in message.permissions) { for (permission in message.permissions) {
val description = try { val description = try {
val permissionInfo = packageManager.getPermissionInfo(permission, 0) val permissionInfo = packageManager.getPermissionInfo(permission, 0)
PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo) PackageItemResolver.loadDescription(
requireContext(),
localCache,
permissionInfo
)
?.nullIfEmpty()?.let { if (it == permission) null else it } ?.nullIfEmpty()?.let { if (it == permission) null else it }
} catch (e: Exception) { } catch (e: Exception) {
null null
@@ -190,17 +215,36 @@ class MessageDialog(): DialogFragment() {
val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities) val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities)
message.maxSdkVersion else null message.maxSdkVersion else null
if (minSdkVersion != null || maxSdkVersion != null) { if (minSdkVersion != null || maxSdkVersion != null) {
val versionMessage = minSdkVersion?.let { getString(R.string.incompatible_api_min_DESC_FORMAT, it) } val versionMessage = minSdkVersion?.let {
?: maxSdkVersion?.let { getString(R.string.incompatible_api_max_DESC_FORMAT, it) } getString(
builder.append(getString(R.string.incompatible_api_DESC_FORMAT, R.string.incompatible_api_min_DESC_FORMAT,
Android.name, Android.sdk, versionMessage.orEmpty())).append("\n\n") it
)
}
?: maxSdkVersion?.let {
getString(
R.string.incompatible_api_max_DESC_FORMAT,
it
)
}
builder.append(
getString(
R.string.incompatible_api_DESC_FORMAT,
Android.name, Android.sdk, versionMessage.orEmpty()
)
).append("\n\n")
} }
if (Release.Incompatibility.Platform in message.incompatibilities) { if (Release.Incompatibility.Platform in message.incompatibilities) {
builder.append(getString(R.string.incompatible_platforms_DESC_FORMAT, builder.append(
getString(
R.string.incompatible_platforms_DESC_FORMAT,
Android.primaryPlatform ?: getString(R.string.unknown), Android.primaryPlatform ?: getString(R.string.unknown),
message.platforms.joinToString(separator = ", "))).append("\n\n") message.platforms.joinToString(separator = ", ")
)
).append("\n\n")
} }
val features = message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature } val features =
message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
if (features.isNotEmpty()) { if (features.isNotEmpty()) {
builder.append(getString(R.string.incompatible_features_DESC)) builder.append(getString(R.string.incompatible_features_DESC))
for (feature in features) { for (feature in features) {

View File

@@ -2,7 +2,7 @@ package com.looker.droidify.screen
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
open class ScreenFragment: Fragment() { open class ScreenFragment : Fragment() {
val screenActivity: ScreenActivity val screenActivity: ScreenActivity
get() = requireActivity() as ScreenActivity get() = requireActivity() as ScreenActivity

View File

@@ -15,10 +15,6 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
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 com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.entity.Product import com.looker.droidify.entity.Product
@@ -26,11 +22,15 @@ import com.looker.droidify.entity.Repository
import com.looker.droidify.graphics.PaddingDrawable import com.looker.droidify.graphics.PaddingDrawable
import com.looker.droidify.network.PicassoDownloader import com.looker.droidify.network.PicassoDownloader
import com.looker.droidify.utility.RxUtils import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.widget.StableRecyclerAdapter import com.looker.droidify.widget.StableRecyclerAdapter
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
class ScreenshotsFragment(): DialogFragment() { class ScreenshotsFragment() : DialogFragment() {
companion object { companion object {
private const val EXTRA_PACKAGE_NAME = "packageName" private const val EXTRA_PACKAGE_NAME = "packageName"
private const val EXTRA_REPOSITORY_ID = "repositoryId" private const val EXTRA_REPOSITORY_ID = "repositoryId"
@@ -39,7 +39,7 @@ class ScreenshotsFragment(): DialogFragment() {
private const val STATE_IDENTIFIER = "identifier" private const val STATE_IDENTIFIER = "identifier"
} }
constructor(packageName: String, repositoryId: Long, identifier: String): this() { constructor(packageName: String, repositoryId: Long, identifier: String) : this() {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(EXTRA_PACKAGE_NAME, packageName) putString(EXTRA_PACKAGE_NAME, packageName)
putLong(EXTRA_REPOSITORY_ID, repositoryId) putLong(EXTRA_REPOSITORY_ID, repositoryId)
@@ -62,8 +62,15 @@ class ScreenshotsFragment(): DialogFragment() {
val window = dialog.window!! val window = dialog.window!!
val decorView = window.decorView val decorView = window.decorView
val background = dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor val background =
decorView.setBackgroundColor(background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.9f) }) dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor
decorView.setBackgroundColor(background.let {
ColorUtils.blendARGB(
0x00ffffff and it,
it,
0.9f
)
})
decorView.setPadding(0, 0, 0, 0) decorView.setPadding(0, 0, 0, 0)
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let { background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
window.statusBarColor = it window.statusBarColor = it
@@ -73,8 +80,10 @@ class ScreenshotsFragment(): DialogFragment() {
title = ScreenshotsFragment::class.java.name title = ScreenshotsFragment::class.java.name
format = PixelFormat.TRANSLUCENT format = PixelFormat.TRANSLUCENT
windowAnimations = run { windowAnimations = run {
val typedArray = dialog.context.obtainStyledAttributes(null, val typedArray = dialog.context.obtainStyledAttributes(
intArrayOf(android.R.attr.windowAnimationStyle), android.R.attr.dialogTheme, 0) null,
intArrayOf(android.R.attr.windowAnimationStyle), android.R.attr.dialogTheme, 0
)
try { try {
typedArray.getResourceId(0, 0) typedArray.getResourceId(0, 0)
} finally { } finally {
@@ -82,15 +91,18 @@ class ScreenshotsFragment(): DialogFragment() {
} }
} }
if (Android.sdk(28)) { if (Android.sdk(28)) {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
} }
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or decorView.systemUiVisibility =
decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
val applyHide = Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags } val applyHide =
Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
val handleClick = { val handleClick = {
decorView.removeCallbacks(applyHide) decorView.removeCallbacks(applyHide)
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) { if ((decorView.systemUiVisibility and hideFlags) == hideFlags) {
@@ -108,8 +120,12 @@ class ScreenshotsFragment(): DialogFragment() {
viewPager.viewTreeObserver.addOnGlobalLayoutListener { viewPager.viewTreeObserver.addOnGlobalLayoutListener {
(viewPager.adapter as Adapter).size = Pair(viewPager.width, viewPager.height) (viewPager.adapter as Adapter).size = Pair(viewPager.width, viewPager.height)
} }
dialog.addContentView(viewPager, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dialog.addContentView(
ViewGroup.LayoutParams.MATCH_PARENT)) viewPager, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
this.viewPager = viewPager this.viewPager = viewPager
var restored = false var restored = false
@@ -117,9 +133,14 @@ class ScreenshotsFragment(): DialogFragment() {
.concatWith(Database.observable(Database.Subject.Products)) .concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } } .flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
.map { Pair(it.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) } .map { it ->
Pair(
it.find { it.repositoryId == repositoryId },
Database.RepositoryAdapter.get(repositoryId)
)
}
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { .subscribe { it ->
val (product, repository) = it val (product, repository) = it
val screenshots = product?.screenshots.orEmpty() val screenshots = product?.screenshots.orEmpty()
(viewPager.adapter as Adapter).update(repository, screenshots) (viewPager.adapter as Adapter).update(repository, screenshots)
@@ -158,21 +179,24 @@ class ScreenshotsFragment(): DialogFragment() {
} }
} }
private class Adapter(private val packageName: String, private val onClick: () -> Unit): private class Adapter(private val packageName: String, private val onClick: () -> Unit) :
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() { StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT } enum class ViewType { SCREENSHOT }
private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) { private class ViewHolder(context: Context) : RecyclerView.ViewHolder(ImageView(context)) {
val image: ImageView val image: ImageView
get() = itemView as ImageView get() = itemView as ImageView
val placeholder: Drawable val placeholder: Drawable
init { init {
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT) RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT
)
val placeholder = itemView.context.getDrawableCompat(R.drawable.ic_photo_camera).mutate() val placeholder =
itemView.context.getDrawableCompat(R.drawable.ic_photo_camera).mutate()
placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) }) .let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) })
this.placeholder = PaddingDrawable(placeholder, 4f) this.placeholder = PaddingDrawable(placeholder, 4f)
@@ -208,7 +232,10 @@ class ScreenshotsFragment(): DialogFragment() {
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier override fun getItemDescriptor(position: Int): String = screenshots[position].identifier
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType
): RecyclerView.ViewHolder {
return ViewHolder(parent.context).apply { return ViewHolder(parent.context).apply {
itemView.setOnClickListener { onClick() } itemView.setOnClickListener { onClick() }
} }
@@ -219,7 +246,13 @@ class ScreenshotsFragment(): DialogFragment() {
val screenshot = screenshots[position] val screenshot = screenshots[position]
val (width, height) = size val (width, height) = size
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
holder.image.load(PicassoDownloader.createScreenshotUri(repository!!, packageName, screenshot)) { holder.image.load(
PicassoDownloader.createScreenshotUri(
repository!!,
packageName,
screenshot
)
) {
placeholder(holder.placeholder) placeholder(holder.placeholder)
error(holder.placeholder) error(holder.placeholder)
resize(width, height) resize(width, height)

View File

@@ -6,9 +6,11 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.IBinder import android.os.IBinder
class Connection<B: IBinder, S: ConnectionService<B>>(private val serviceClass: Class<S>, class Connection<B : IBinder, S : ConnectionService<B>>(
private val serviceClass: Class<S>,
private val onBind: ((Connection<B, S>, B) -> Unit)? = null, private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null): ServiceConnection { private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null
) : ServiceConnection {
var binder: B? = null var binder: B? = null
private set private set

View File

@@ -3,9 +3,9 @@ package com.looker.droidify.service
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
abstract class ConnectionService<T: IBinder>: Service() { abstract class ConnectionService<T : IBinder> : Service() {
abstract override fun onBind(intent: Intent): T abstract override fun onBind(intent: Intent): T
fun startSelf() { fun startSelf() {

View File

@@ -9,10 +9,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.BuildConfig import com.looker.droidify.BuildConfig
import com.looker.droidify.Common import com.looker.droidify.Common
import com.looker.droidify.MainActivity import com.looker.droidify.MainActivity
@@ -25,57 +21,75 @@ import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.text.*
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.* import kotlin.math.*
class DownloadService: ConnectionService<DownloadService.Binder>() { class DownloadService : ConnectionService<DownloadService.Binder>() {
companion object { companion object {
private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN" private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN"
private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" private const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
private val downloadingSubject = PublishSubject.create<State.Downloading>() private val downloadingSubject = PublishSubject.create<State.Downloading>()
} }
class Receiver: BroadcastReceiver() { class Receiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty() val action = intent.action.orEmpty()
when { when {
action.startsWith("$ACTION_OPEN.") -> { action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1) val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(Intent(context, MainActivity::class.java) context.startActivity(
.setAction(Intent.ACTION_VIEW).setData(Uri.parse("package:$packageName")) Intent(context, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) .setAction(Intent.ACTION_VIEW)
.setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} }
action.startsWith("$ACTION_INSTALL.") -> { action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1) val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME) val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(Intent(context, MainActivity::class.java) context.startActivity(
.setAction(MainActivity.ACTION_INSTALL).setData(Uri.parse("package:$packageName")) Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL)
.setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName) .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} }
} }
} }
} }
sealed class State(val packageName: String, val name: String) { sealed class State(val packageName: String, val name: String) {
class Pending(packageName: String, name: String): State(packageName, name) class Pending(packageName: String, name: String) : State(packageName, name)
class Connecting(packageName: String, name: String): State(packageName, name) class Connecting(packageName: String, name: String) : State(packageName, name)
class Downloading(packageName: String, name: String, val read: Long, val total: Long?): State(packageName, name) class Downloading(packageName: String, name: String, val read: Long, val total: Long?) :
class Success(packageName: String, name: String, val release: Release, State(packageName, name)
val consume: () -> Unit): State(packageName, name)
class Error(packageName: String, name: String): State(packageName, name) class Success(
class Cancel(packageName: String, name: String): State(packageName, name) packageName: String, name: String, val release: Release,
val consume: () -> Unit
) : State(packageName, name)
class Error(packageName: String, name: String) : State(packageName, name)
class Cancel(packageName: String, name: String) : State(packageName, name)
} }
private val stateSubject = PublishSubject.create<State>() private val stateSubject = PublishSubject.create<State>()
private class Task(val packageName: String, val name: String, val release: Release, private class Task(
val url: String, val authentication: String) { val packageName: String, val name: String, val release: Release,
val url: String, val authentication: String
) {
val notificationTag: String val notificationTag: String
get() = "download-$packageName" get() = "download-$packageName"
} }
@@ -86,13 +100,19 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
private val tasks = mutableListOf<Task>() private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null private var currentTask: CurrentTask? = null
inner class Binder: android.os.Binder() { inner class Binder : android.os.Binder() {
fun events(packageName: String): Observable<State> { fun events(packageName: String): Observable<State> {
return stateSubject.filter { it.packageName == packageName } return stateSubject.filter { it.packageName == packageName }
} }
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) { fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
val task = Task(packageName, name, release, release.getDownloadUrl(repository), repository.authentication) val task = Task(
packageName,
name,
release,
release.getDownloadUrl(repository),
repository.authentication
)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) { if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
publishSuccess(task) publishSuccess(task)
} else { } else {
@@ -116,7 +136,8 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
fun getState(packageName: String): State? = currentTask fun getState(packageName: String): State? = currentTask
?.let { if (it.task.packageName == packageName) it.lastState else null } ?.let { if (it.task.packageName == packageName) it.lastState else null }
?: tasks.find { it.packageName == packageName }?.let { State.Pending(it.packageName, it.name) } ?: tasks.find { it.packageName == packageName }
?.let { State.Pending(it.packageName, it.name) }
} }
private val binder = Binder() private val binder = Binder()
@@ -128,8 +149,10 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
super.onCreate() super.onCreate()
if (Android.sdk(26)) { if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_DOWNLOADING, NotificationChannel(
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW) Common.NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) } .apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel) .let(notificationManager::createNotificationChannel)
} }
@@ -177,39 +200,69 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS } private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS }
private sealed class ErrorType { private sealed class ErrorType {
object Network: ErrorType() object Network : ErrorType()
object Http: ErrorType() object Http : ErrorType()
class Validation(val validateError: ValidationError): ErrorType() class Validation(val validateError: ValidationError) : ErrorType()
} }
private fun showNotificationError(task: Task, errorType: ErrorType) { private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat notificationManager.notify(task.notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING,
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING) .Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true) .setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning) .setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) ContextThemeWrapper(this, R.style.Theme_Main_Light)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java) .getColorFromAttr(android.R.attr.colorAccent).defaultColor
.setAction("$ACTION_OPEN.${task.packageName}"), PendingIntent.FLAG_UPDATE_CURRENT)) )
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.apply { .apply {
when (errorType) { when (errorType) {
is ErrorType.Network -> { is ErrorType.Network -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name)) setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.network_error_DESC)) setContentText(getString(R.string.network_error_DESC))
} }
is ErrorType.Http -> { is ErrorType.Http -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name)) setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.http_error_DESC)) setContentText(getString(R.string.http_error_DESC))
} }
is ErrorType.Validation -> { is ErrorType.Validation -> {
setContentTitle(getString(R.string.could_not_validate_FORMAT, task.name)) setContentTitle(
setContentText(getString(when (errorType.validateError) { getString(
R.string.could_not_validate_FORMAT,
task.name
)
)
setContentText(
getString(
when (errorType.validateError) {
ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
ValidationError.FORMAT -> R.string.file_format_error_DESC ValidationError.FORMAT -> R.string.file_format_error_DESC
ValidationError.METADATA -> R.string.invalid_metadata_error_DESC ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
})) }
)
)
} }
}::class }::class
} }
@@ -217,23 +270,36 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
} }
private fun showNotificationInstall(task: Task) { private fun showNotificationInstall(task: Task) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat notificationManager.notify(
task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING) .Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true) .setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) ContextThemeWrapper(this, R.style.Theme_Main_Light)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java) .getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}") .setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName), PendingIntent.FLAG_UPDATE_CURRENT)) .putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name)) .setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC)) .setContentText(getString(R.string.tap_to_install_DESC))
.build()) .build()
)
} }
private fun publishSuccess(task: Task) { private fun publishSuccess(task: Task) {
var consumed = false var consumed = false
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) { consumed = true }) stateSubject.onNext(State.Success(task.packageName, task.name, task.release) {
consumed = true
})
if (!consumed) { if (!consumed) {
showNotificationInstall(task) showNotificationInstall(task)
} }
@@ -245,7 +311,8 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
val digest = MessageDigest.getInstance(hashType) val digest = MessageDigest.getInstance(hashType)
file.inputStream().use { file.inputStream().use {
val bytes = ByteArray(8 * 1024) val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) } generateSequence { it.read(bytes) }.takeWhile { it >= 0 }
.forEach { digest.update(bytes, 0, it) }
digest.digest().hex() digest.digest().hex()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -255,7 +322,10 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
ValidationError.INTEGRITY ValidationError.INTEGRITY
} else { } else {
val packageInfo = try { val packageInfo = try {
packageManager.getPackageArchiveInfo(file.path, Android.PackageManager.signaturesFlag) packageManager.getPackageArchiveInfo(
file.path,
Android.PackageManager.signaturesFlag
)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null
@@ -263,14 +333,16 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
if (packageInfo == null) { if (packageInfo == null) {
ValidationError.FORMAT ValidationError.FORMAT
} else if (packageInfo.packageName != task.packageName || } else if (packageInfo.packageName != task.packageName ||
packageInfo.versionCodeCompat != task.release.versionCode) { packageInfo.versionCodeCompat != task.release.versionCode
) {
ValidationError.METADATA ValidationError.METADATA
} else { } else {
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty() val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
if (signature.isEmpty() || signature != task.release.signature) { if (signature.isEmpty() || signature != task.release.signature) {
ValidationError.SIGNATURE ValidationError.SIGNATURE
} else { } else {
val permissions = packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet() val permissions =
packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
if (!task.release.permissions.containsAll(permissions)) { if (!task.release.permissions.containsAll(permissions)) {
ValidationError.PERMISSIONS ValidationError.PERMISSIONS
} else { } else {
@@ -281,13 +353,23 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
} }
} }
private val stateNotificationBuilder by lazy { NotificationCompat private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING) .Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) ContextThemeWrapper(this, R.style.Theme_Main_Light)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0, .getColorFromAttr(android.R.attr.colorAccent).defaultColor
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) } )
.addAction(
0, getString(R.string.cancel), PendingIntent.getService(
this,
0,
Intent(this, this::class.java).setAction(ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
private fun publishForegroundState(force: Boolean, state: State) { private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask != null) { if (force || currentTask != null) {
@@ -329,12 +411,26 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
val initialState = State.Connecting(task.packageName, task.name) val initialState = State.Connecting(task.packageName, task.name)
stateNotificationBuilder.setWhen(System.currentTimeMillis()) stateNotificationBuilder.setWhen(System.currentTimeMillis())
publishForegroundState(true, initialState) publishForegroundState(true, initialState)
val partialReleaseFile = Cache.getPartialReleaseFile(this, task.release.cacheFileName) val partialReleaseFile =
Cache.getPartialReleaseFile(this, task.release.cacheFileName)
lateinit var disposable: Disposable lateinit var disposable: Disposable
disposable = Downloader disposable = Downloader
.download(task.url, partialReleaseFile, "", "", task.authentication) { read, total -> .download(
task.url,
partialReleaseFile,
"",
"",
task.authentication
) { read, total ->
if (!disposable.isDisposed) { if (!disposable.isDisposed) {
downloadingSubject.onNext(State.Downloading(task.packageName, task.name, read, total)) downloadingSubject.onNext(
State.Downloading(
task.packageName,
task.name,
read,
total
)
)
} }
} }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@@ -342,12 +438,16 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
currentTask = null currentTask = null
throwable?.printStackTrace() throwable?.printStackTrace()
if (result == null || !result.success) { if (result == null || !result.success) {
showNotificationError(task, if (result != null) ErrorType.Http else ErrorType.Network) showNotificationError(
task,
if (result != null) ErrorType.Http else ErrorType.Network
)
stateSubject.onNext(State.Error(task.packageName, task.name)) stateSubject.onNext(State.Error(task.packageName, task.name))
} else { } else {
val validationError = validatePackage(task, partialReleaseFile) val validationError = validatePackage(task, partialReleaseFile)
if (validationError == null) { if (validationError == null) {
val releaseFile = Cache.getReleaseFile(this, task.release.cacheFileName) val releaseFile =
Cache.getReleaseFile(this, task.release.cacheFileName)
partialReleaseFile.renameTo(releaseFile) partialReleaseFile.renameTo(releaseFile)
publishSuccess(task) publishSuccess(task)
} else { } else {

View File

@@ -12,11 +12,6 @@ import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
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 io.reactivex.rxjava3.subjects.PublishSubject
import com.looker.droidify.BuildConfig import com.looker.droidify.BuildConfig
import com.looker.droidify.Common import com.looker.droidify.Common
import com.looker.droidify.MainActivity import com.looker.droidify.MainActivity
@@ -27,14 +22,21 @@ import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository import com.looker.droidify.entity.Repository
import com.looker.droidify.index.RepositoryUpdater import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.utility.RxUtils import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.text.* 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 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 io.reactivex.rxjava3.subjects.PublishSubject
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.* import kotlin.math.roundToInt
class SyncService: ConnectionService<SyncService.Binder>() { class SyncService : ConnectionService<SyncService.Binder>() {
companion object { companion object {
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
@@ -43,15 +45,21 @@ class SyncService: ConnectionService<SyncService.Binder>() {
} }
private sealed class State { private sealed class State {
data class Connecting(val name: String): State() data class Connecting(val name: String) : State()
data class Syncing(val name: String, val stage: RepositoryUpdater.Stage, data class Syncing(
val read: Long, val total: Long?): State() val name: String, val stage: RepositoryUpdater.Stage,
object Finishing: State() val read: Long, val total: Long?
) : State()
object Finishing : State()
} }
private class Task(val repositoryId: Long, val manual: Boolean) private class Task(val repositoryId: Long, val manual: Boolean)
private data class CurrentTask(val task: Task?, val disposable: Disposable, private data class CurrentTask(
val hasUpdates: Boolean, val lastState: State) val task: Task?, val disposable: Disposable,
val hasUpdates: Boolean, val lastState: State
)
private enum class Started { NO, AUTO, MANUAL } private enum class Started { NO, AUTO, MANUAL }
private var started = Started.NO private var started = Started.NO
@@ -62,17 +70,20 @@ class SyncService: ConnectionService<SyncService.Binder>() {
enum class SyncRequest { AUTO, MANUAL, FORCE } enum class SyncRequest { AUTO, MANUAL, FORCE }
inner class Binder: android.os.Binder() { inner class Binder : android.os.Binder() {
val finish: Observable<Unit> val finish: Observable<Unit>
get() = finishSubject get() = finishSubject
private fun sync(ids: List<Long>, request: SyncRequest) { private fun sync(ids: List<Long>, request: SyncRequest) {
val cancelledTask = cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids } val cancelledTask =
cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
cancelTasks { !it.manual && it.repositoryId in ids } cancelTasks { !it.manual && it.repositoryId in ids }
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet() val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
val manual = request != SyncRequest.AUTO val manual = request != SyncRequest.AUTO
tasks += ids.asSequence().filter { it !in currentIds && tasks += ids.asSequence().filter {
it != currentTask?.task?.repositoryId }.map { Task(it, manual) } it !in currentIds &&
it != currentTask?.task?.repositoryId
}.map { Task(it, manual) }
handleNextTask(cancelledTask?.hasUpdates == true) handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) { if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL started = Started.MANUAL
@@ -146,12 +157,16 @@ class SyncService: ConnectionService<SyncService.Binder>() {
super.onCreate() super.onCreate()
if (Android.sdk(26)) { if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_SYNCING, NotificationChannel(
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW) Common.NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) } .apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel) .let(notificationManager::createNotificationChannel)
NotificationChannel(Common.NOTIFICATION_CHANNEL_UPDATES, NotificationChannel(
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW) Common.NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW
)
.let(notificationManager::createNotificationChannel) .let(notificationManager::createNotificationChannel)
} }
@@ -196,13 +211,18 @@ class SyncService: ConnectionService<SyncService.Binder>() {
} }
private fun showNotificationError(repository: Repository, exception: Exception) { private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify("repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat notificationManager.notify(
"repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING) .Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning) .setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name)) .setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name))
.setContentText(getString(when (exception) { .setContentText(
getString(
when (exception) {
is RepositoryUpdater.UpdateException -> when (exception.errorType) { is RepositoryUpdater.UpdateException -> when (exception.errorType) {
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC
@@ -210,17 +230,30 @@ class SyncService: ConnectionService<SyncService.Binder>() {
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC
} }
else -> R.string.unknown_error_DESC else -> R.string.unknown_error_DESC
})) }
.build()) )
)
.build()
)
} }
private val stateNotificationBuilder by lazy { NotificationCompat private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING) .Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync) .setSmallIcon(R.drawable.ic_sync)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) ContextThemeWrapper(this, R.style.Theme_Main_Light)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0, .getColorFromAttr(android.R.attr.colorAccent).defaultColor
Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)) } )
.addAction(
0, getString(R.string.cancel), PendingIntent.getService(
this,
0,
Intent(this, this::class.java).setAction(ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
}
private fun publishForegroundState(force: Boolean, state: State) { private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask?.lastState != state) { if (force || currentTask?.lastState != state) {
@@ -239,20 +272,36 @@ class SyncService: ConnectionService<SyncService.Binder>() {
RepositoryUpdater.Stage.DOWNLOAD -> { RepositoryUpdater.Stage.DOWNLOAD -> {
if (state.total != null) { if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}") setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false) setProgress(
100,
(100f * state.read / state.total).roundToInt(),
false
)
} else { } else {
setContentText(state.read.formatSize()) setContentText(state.read.formatSize())
setProgress(0, 0, true) setProgress(0, 0, true)
} }
} }
RepositoryUpdater.Stage.PROCESS -> { RepositoryUpdater.Stage.PROCESS -> {
val progress = state.total?.let { 100f * state.read / it }?.roundToInt() val progress =
setContentText(getString(R.string.processing_FORMAT, "${progress ?: 0}%")) state.total?.let { 100f * state.read / it }?.roundToInt()
setContentText(
getString(
R.string.processing_FORMAT,
"${progress ?: 0}%"
)
)
setProgress(100, progress ?: 0, progress == null) setProgress(100, progress ?: 0, progress == null)
} }
RepositoryUpdater.Stage.MERGE -> { RepositoryUpdater.Stage.MERGE -> {
val progress = (100f * state.read / (state.total ?: state.read)).roundToInt() val progress = (100f * state.read / (state.total
setContentText(getString(R.string.merging_FORMAT, "${state.read} / ${state.total ?: state.read}")) ?: state.read)).roundToInt()
setContentText(
getString(
R.string.merging_FORMAT,
"${state.read} / ${state.total ?: state.read}"
)
)
setProgress(100, progress, false) setProgress(100, progress, false)
} }
RepositoryUpdater.Stage.COMMIT -> { RepositoryUpdater.Stage.COMMIT -> {
@@ -283,7 +332,8 @@ class SyncService: ConnectionService<SyncService.Binder>() {
val repository = Database.RepositoryAdapter.get(task.repositoryId) val repository = Database.RepositoryAdapter.get(task.repositoryId)
if (repository != null && repository.enabled) { if (repository != null && repository.enabled) {
val lastStarted = started val lastStarted = started
val newStarted = if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO val newStarted =
if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
started = newStarted started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) { if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startSelf() startSelf()
@@ -296,7 +346,14 @@ class SyncService: ConnectionService<SyncService.Binder>() {
disposable = RepositoryUpdater disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total -> .update(repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) { if (!disposable.isDisposed) {
stateSubject.onNext(State.Syncing(repository.name, stage, progress, total)) stateSubject.onNext(
State.Syncing(
repository.name,
stage,
progress,
total
)
)
} }
} }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@@ -315,9 +372,21 @@ class SyncService: ConnectionService<SyncService.Binder>() {
} else if (started != Started.NO) { } else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) { if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils val disposable = RxUtils
.querySingle { Database.ProductAdapter .querySingle { it ->
.query(true, true, "", ProductItem.Section.All, ProductItem.Order.NAME, it) Database.ProductAdapter
.use { it.asSequence().map(Database.ProductAdapter::transformItem).toList() } } .query(
installed = true,
updates = true,
searchQuery = "",
section = ProductItem.Section.All,
order = ProductItem.Order.NAME,
signal = it
)
.use {
it.asSequence().map(Database.ProductAdapter::transformItem)
.toList()
}
}
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable -> .subscribe { result, throwable ->
@@ -346,26 +415,43 @@ class SyncService: ConnectionService<SyncService.Binder>() {
private fun displayUpdatesNotification(productItems: List<ProductItem>) { private fun displayUpdatesNotification(productItems: List<ProductItem>) {
val maxUpdates = 5 val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback) fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(Common.NOTIFICATION_ID_UPDATES, NotificationCompat notificationManager.notify(
Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES) .Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases) .setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available)) .setContentTitle(getString(R.string.new_updates_available))
.setContentText(resources.getQuantityString(R.plurals.new_updates_DESC_FORMAT, .setContentText(
productItems.size, productItems.size)) resources.getQuantityString(
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) R.plurals.new_updates_DESC_FORMAT,
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) productItems.size, productItems.size
.setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) )
.setAction(MainActivity.ACTION_UPDATES), PendingIntent.FLAG_UPDATE_CURRENT)) )
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.setAction(MainActivity.ACTION_UPDATES),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setStyle(NotificationCompat.InboxStyle().applyHack { .setStyle(NotificationCompat.InboxStyle().applyHack {
for (productItem in productItems.take(maxUpdates)) { for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name) val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(ForegroundColorSpan(Color.BLACK), 0, builder.length, builder.setSpan(
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE) ForegroundColorSpan(Color.BLACK), 0, builder.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.append(' ').append(productItem.version) builder.append(' ').append(productItem.version)
addLine(builder) addLine(builder)
} }
if (productItems.size > maxUpdates) { if (productItems.size > maxUpdates) {
val summary = getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates) val summary =
getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates)
if (Android.sdk(24)) { if (Android.sdk(24)) {
addLine(summary) addLine(summary)
} else { } else {
@@ -373,13 +459,15 @@ class SyncService: ConnectionService<SyncService.Binder>() {
} }
} }
}) })
.build()) .build()
)
} }
class Job: JobService() { class Job : JobService() {
private var syncParams: JobParameters? = null private var syncParams: JobParameters? = null
private var syncDisposable: Disposable? = null private var syncDisposable: Disposable? = null
private val syncConnection = Connection(SyncService::class.java, onBind = { connection, binder -> private val syncConnection =
Connection(SyncService::class.java, onBind = { connection, binder ->
syncDisposable = binder.finish.subscribe { syncDisposable = binder.finish.subscribe {
val params = syncParams val params = syncParams
if (params != null) { if (params != null) {

View File

@@ -3,13 +3,13 @@ package com.looker.droidify.utility
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
interface KParcelable: Parcelable { interface KParcelable : Parcelable {
override fun describeContents(): Int = 0 override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) = Unit override fun writeToParcel(dest: Parcel, flags: Int) = Unit
companion object { companion object {
inline fun <reified T> creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator<T> { inline fun <reified T> creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator<T> {
return object: Parcelable.Creator<T> { return object : Parcelable.Creator<T> {
override fun createFromParcel(source: Parcel): T = create(source) override fun createFromParcel(source: Parcel): T = create(source)
override fun newArray(size: Int): Array<T?> = arrayOfNulls(size) override fun newArray(size: Int): Array<T?> = arrayOfNulls(size)
} }

View File

@@ -4,8 +4,8 @@ import android.content.Context
import android.content.pm.PackageItemInfo import android.content.pm.PackageItemInfo
import android.content.pm.PermissionInfo import android.content.pm.PermissionInfo
import android.content.res.Resources import android.content.res.Resources
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import java.util.Locale import java.util.*
object PackageItemResolver { object PackageItemResolver {
class LocalCache { class LocalCache {
@@ -16,8 +16,10 @@ object PackageItemResolver {
private val cache = mutableMapOf<CacheKey, String?>() private val cache = mutableMapOf<CacheKey, String?>()
private fun load(context: Context, localCache: LocalCache, packageName: String, private fun load(
nonLocalized: CharSequence?, resId: Int): CharSequence? { context: Context, localCache: LocalCache, packageName: String,
nonLocalized: CharSequence?, resId: Int
): CharSequence? {
return when { return when {
nonLocalized != null -> { nonLocalized != null -> {
nonLocalized nonLocalized
@@ -36,7 +38,8 @@ object PackageItemResolver {
} else { } else {
val resources = localCache.resources[packageName] ?: run { val resources = localCache.resources[packageName] ?: run {
val resources = try { val resources = try {
val resources = context.packageManager.getResourcesForApplication(packageName) val resources =
context.packageManager.getResourcesForApplication(packageName)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
resources.updateConfiguration(context.resources.configuration, null) resources.updateConfiguration(context.resources.configuration, null)
resources resources
@@ -57,14 +60,26 @@ object PackageItemResolver {
} }
} }
fun loadLabel(context: Context, localCache: LocalCache, packageItemInfo: PackageItemInfo): CharSequence? { fun loadLabel(
return load(context, localCache, packageItemInfo.packageName, context: Context,
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes) localCache: LocalCache,
packageItemInfo: PackageItemInfo
): CharSequence? {
return load(
context, localCache, packageItemInfo.packageName,
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes
)
} }
fun loadDescription(context: Context, localCache: LocalCache, permissionInfo: PermissionInfo): CharSequence? { fun loadDescription(
return load(context, localCache, permissionInfo.packageName, context: Context,
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes) localCache: LocalCache,
permissionInfo: PermissionInfo
): CharSequence? {
return load(
context, localCache, permissionInfo.packageName,
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes
)
} }
fun getPermissionGroup(permissionInfo: PermissionInfo): String? { fun getPermissionGroup(permissionInfo: PermissionInfo): String? {

View File

@@ -2,11 +2,13 @@ package com.looker.droidify.utility
import java.io.InputStream import java.io.InputStream
class ProgressInputStream(private val inputStream: InputStream, class ProgressInputStream(
private val callback: (Long) -> Unit): InputStream() { private val inputStream: InputStream,
private val callback: (Long) -> Unit
) : InputStream() {
private var count = 0L private var count = 0L
private inline fun <reified T: Number> notify(one: Boolean, read: () -> T): T { private inline fun <reified T : Number> notify(one: Boolean, read: () -> T): T {
val result = read() val result = read()
count += if (one) 1L else result.toLong() count += if (one) 1L else result.toLong()
callback(count) callback(count)
@@ -15,7 +17,9 @@ class ProgressInputStream(private val inputStream: InputStream,
override fun read(): Int = notify(true) { inputStream.read() } override fun read(): Int = notify(true) { inputStream.read() }
override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) } override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) }
override fun read(b: ByteArray, off: Int, len: Int): Int = notify(false) { inputStream.read(b, off, len) } override fun read(b: ByteArray, off: Int, len: Int): Int =
notify(false) { inputStream.read(b, off, len) }
override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) } override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) }
override fun available(): Int { override fun available(): Int {

View File

@@ -11,8 +11,9 @@ import okhttp3.Call
import okhttp3.Response import okhttp3.Response
object RxUtils { object RxUtils {
private class ManagedDisposable(private val cancel: () -> Unit): Disposable { private class ManagedDisposable(private val cancel: () -> Unit) : Disposable {
@Volatile var disposed = false @Volatile
var disposed = false
override fun isDisposed(): Boolean = disposed override fun isDisposed(): Boolean = disposed
override fun dispose() { override fun dispose() {
@@ -21,7 +22,11 @@ object RxUtils {
} }
} }
private fun <T, R> managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single<R> { private fun <T, R> managedSingle(
create: () -> T,
cancel: (T) -> Unit,
execute: (T) -> R
): Single<R> {
return Single.create { return Single.create {
val task = create() val task = create()
val thread = Thread.currentThread() val thread = Thread.currentThread()
@@ -53,7 +58,7 @@ object RxUtils {
} }
fun <R> managedSingle(execute: () -> R): Single<R> { fun <R> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ Unit }, { }, { execute() }) return managedSingle({ }, { }, { execute() })
} }
fun callSingle(create: () -> Call): Single<Response> { fun callSingle(create: () -> Call): Single<Response> {

View File

@@ -9,13 +9,14 @@ import android.os.LocaleList
import android.provider.Settings import android.provider.Settings
import com.looker.droidify.BuildConfig import com.looker.droidify.BuildConfig
import com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.utility.extension.android.* import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.text.* import com.looker.droidify.utility.extension.resources.getDrawableCompat
import com.looker.droidify.utility.extension.text.hex
import java.security.MessageDigest import java.security.MessageDigest
import java.security.cert.Certificate import java.security.cert.Certificate
import java.security.cert.CertificateEncodingException import java.security.cert.CertificateEncodingException
import java.util.Locale import java.util.*
object Utils { object Utils {
private fun createDefaultApplicationIcon(context: Context, tintAttrResId: Int): Drawable { private fun createDefaultApplicationIcon(context: Context, tintAttrResId: Int): Drawable {
@@ -24,19 +25,22 @@ object Utils {
} }
fun getDefaultApplicationIcons(context: Context): Pair<Drawable, Drawable> { fun getDefaultApplicationIcons(context: Context): Pair<Drawable, Drawable> {
val progressIcon: Drawable = createDefaultApplicationIcon(context, android.R.attr.textColorSecondary) val progressIcon: Drawable =
val defaultIcon: Drawable = createDefaultApplicationIcon(context, android.R.attr.colorAccent) createDefaultApplicationIcon(context, android.R.attr.textColorSecondary)
val defaultIcon: Drawable =
createDefaultApplicationIcon(context, android.R.attr.colorAccent)
return Pair(progressIcon, defaultIcon) return Pair(progressIcon, defaultIcon)
} }
fun getToolbarIcon(context: Context, resId: Int): Drawable { fun getToolbarIcon(context: Context, resId: Int): Drawable {
val drawable = context.getDrawableCompat(resId).mutate() val drawable = context.getDrawableCompat(resId).mutate()
drawable.setTintList(context.getColorFromAttr(android.R.attr.textColorPrimary)) drawable.setTintList(context.getColorFromAttr(android.R.attr.titleTextColor))
return drawable return drawable
} }
fun calculateHash(signature: Signature): String? { fun calculateHash(signature: Signature): String {
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray()).hex() return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray())
.hex()
} }
fun calculateFingerprint(certificate: Certificate): String { fun calculateFingerprint(certificate: Certificate): String {
@@ -94,7 +98,11 @@ object Utils {
return if (Android.sdk(26)) { return if (Android.sdk(26)) {
ValueAnimator.areAnimatorsEnabled() ValueAnimator.areAnimatorsEnabled()
} else { } else {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) != 0f
} }
} }
} }

View File

@@ -1,4 +1,5 @@
@file:Suppress("PackageDirectoryMismatch") @file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.android package com.looker.droidify.utility.extension.android
import android.app.NotificationManager import android.app.NotificationManager
@@ -49,7 +50,8 @@ object Android {
val platforms = Build.SUPPORTED_ABIS.toSet() val platforms = Build.SUPPORTED_ABIS.toSet()
val primaryPlatform: String? val primaryPlatform: String?
get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull() get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull()
?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
fun sdk(sdk: Int): Boolean { fun sdk(sdk: Int): Boolean {
return Build.VERSION.SDK_INT >= sdk return Build.VERSION.SDK_INT >= sdk

View File

@@ -1,11 +1,8 @@
@file:Suppress("PackageDirectoryMismatch") @file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.json package com.looker.droidify.utility.extension.json
import com.fasterxml.jackson.core.JsonFactory import com.fasterxml.jackson.core.*
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
object Json { object Json {
val factory = JsonFactory() val factory = JsonFactory()
@@ -29,7 +26,7 @@ interface KeyToken {
inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) { inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) {
var passKey = "" var passKey = ""
var passToken = JsonToken.NOT_AVAILABLE var passToken = JsonToken.NOT_AVAILABLE
val keyToken = object: KeyToken { val keyToken = object : KeyToken {
override val key: String override val key: String
get() = passKey get() = passKey
override val token: JsonToken override val token: JsonToken
@@ -62,7 +59,10 @@ fun JsonParser.forEach(requiredToken: JsonToken, callback: JsonParser.() -> Unit
} }
} }
fun <T> JsonParser.collectNotNull(requiredToken: JsonToken, callback: JsonParser.() -> T?): List<T> { fun <T> JsonParser.collectNotNull(
requiredToken: JsonToken,
callback: JsonParser.() -> T?
): List<T> {
val list = mutableListOf<T>() val list = mutableListOf<T>()
forEach(requiredToken) { forEach(requiredToken) {
val result = callback() val result = callback()

View File

@@ -1,4 +1,5 @@
@file:Suppress("PackageDirectoryMismatch") @file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.resources package com.looker.droidify.utility.extension.resources
import android.content.Context import android.content.Context
@@ -16,11 +17,11 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.looker.droidify.utility.extension.android.Android
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator import com.squareup.picasso.RequestCreator
import com.looker.droidify.utility.extension.android.*
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import kotlin.math.* import kotlin.math.roundToInt
object TypefaceExtra { object TypefaceExtra {
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!! val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
@@ -31,12 +32,17 @@ fun Context.getDrawableCompat(resId: Int): Drawable {
val drawable = if (!Android.sdk(24)) { val drawable = if (!Android.sdk(24)) {
val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string
if (fileName.endsWith(".xml")) { if (fileName.endsWith(".xml")) {
resources.getXml(resId).use { resources.getXml(resId).use { it ->
val eventType = generateSequence { it.next() } val eventType = generateSequence { it.next() }
.find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT } .find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT }
if (eventType == XmlPullParser.START_TAG) { if (eventType == XmlPullParser.START_TAG) {
when (it.name) { when (it.name) {
"vector" -> VectorDrawableCompat.createFromXmlInner(resources, it, Xml.asAttributeSet(it), theme) "vector" -> VectorDrawableCompat.createFromXmlInner(
resources,
it,
Xml.asAttributeSet(it),
theme
)
else -> null else -> null
} }
} else { } else {

View File

@@ -1,26 +1,29 @@
@file:Suppress("PackageDirectoryMismatch") @file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.text package com.looker.droidify.utility.extension.text
import android.util.Log import android.util.Log
import java.util.Locale import java.util.*
fun <T: CharSequence> T.nullIfEmpty(): T? { fun <T : CharSequence> T.nullIfEmpty(): T? {
return if (isNullOrEmpty()) null else this return if (isNullOrEmpty()) null else this
} }
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB") private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
fun Long.formatSize(): String { fun Long.formatSize(): String {
val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) -> if (size >= 1000f) val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) ->
Pair(size / 1000f, index + 1) else null }.take(sizeFormats.size).last() if (size >= 1000f)
Pair(size / 1000f, index + 1) else null
}.take(sizeFormats.size).last()
return sizeFormats[index].format(Locale.US, size) return sizeFormats[index].format(Locale.US, size)
} }
fun Char.halfByte(): Int { fun Char.halfByte(): Int {
return when (this) { return when (this) {
in '0' .. '9' -> this - '0' in '0'..'9' -> this - '0'
in 'a' .. 'f' -> this - 'a' + 10 in 'a'..'f' -> this - 'a' + 10
in 'A' .. 'F' -> this - 'A' + 10 in 'A'..'F' -> this - 'A' + 10
else -> -1 else -> -1
} }
} }

View File

@@ -8,7 +8,7 @@ import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.TextView import android.widget.TextView
object ClickableMovementMethod: MovementMethod { object ClickableMovementMethod : MovementMethod {
override fun initialize(widget: TextView, text: Spannable) { override fun initialize(widget: TextView, text: Spannable) {
Selection.removeSelection(text) Selection.removeSelection(text)
} }
@@ -38,11 +38,30 @@ object ClickableMovementMethod: MovementMethod {
} }
} }
override fun onKeyDown(widget: TextView, text: Spannable, keyCode: Int, event: KeyEvent): Boolean = false override fun onKeyDown(
override fun onKeyUp(widget: TextView, text: Spannable, keyCode: Int, event: KeyEvent): Boolean = false widget: TextView,
text: Spannable,
keyCode: Int,
event: KeyEvent
): Boolean = false
override fun onKeyUp(
widget: TextView,
text: Spannable,
keyCode: Int,
event: KeyEvent
): Boolean = false
override fun onKeyOther(view: TextView, text: Spannable, event: KeyEvent): Boolean = false override fun onKeyOther(view: TextView, text: Spannable, event: KeyEvent): Boolean = false
override fun onTakeFocus(widget: TextView, text: Spannable, direction: Int) = Unit override fun onTakeFocus(widget: TextView, text: Spannable, direction: Int) = Unit
override fun onTrackballEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false override fun onTrackballEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean =
override fun onGenericMotionEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false false
override fun onGenericMotionEvent(
widget: TextView,
text: Spannable,
event: MotionEvent
): Boolean = false
override fun canSelectArbitrarily(): Boolean = false override fun canSelectArbitrarily(): Boolean = false
} }

View File

@@ -3,7 +3,8 @@ package com.looker.droidify.widget
import android.database.Cursor import android.database.Cursor
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class CursorRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() { abstract class CursorRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
EnumRecyclerAdapter<VT, VH>() {
init { init {
super.setHasStableIds(true) super.setHasStableIds(true)
} }

View File

@@ -6,16 +6,20 @@ import android.graphics.Rect
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R import com.looker.droidify.R
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import kotlin.math.* import kotlin.math.roundToInt
class DividerItemDecoration(context: Context, private val configure: (context: Context, class DividerItemDecoration(
position: Int, configuration: Configuration) -> Unit): RecyclerView.ItemDecoration() { context: Context, private val configure: (
context: Context,
position: Int, configuration: Configuration
) -> Unit
) : RecyclerView.ItemDecoration() {
interface Configuration { interface Configuration {
fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int)
} }
private class ConfigurationHolder: Configuration { private class ConfigurationHolder : Configuration {
var needDivider = false var needDivider = false
var toTop = false var toTop = false
var paddingStart = 0 var paddingStart = 0
@@ -39,7 +43,14 @@ class DividerItemDecoration(context: Context, private val configure: (context: C
private val divider = context.getDrawableFromAttr(android.R.attr.listDivider) private val divider = context.getDrawableFromAttr(android.R.attr.listDivider)
private val bounds = Rect() private val bounds = Rect()
private fun draw(c: Canvas, configuration: ConfigurationHolder, view: View, top: Int, width: Int, rtl: Boolean) { private fun draw(
c: Canvas,
configuration: ConfigurationHolder,
view: View,
top: Int,
width: Int,
rtl: Boolean
) {
val divider = divider val divider = divider
val left = if (rtl) configuration.paddingEnd else configuration.paddingStart val left = if (rtl) configuration.paddingEnd else configuration.paddingStart
val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd) val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd)
@@ -66,17 +77,36 @@ class DividerItemDecoration(context: Context, private val configure: (context: C
parent.findViewHolderForAdapterPosition(position + 1)?.itemView else null parent.findViewHolderForAdapterPosition(position + 1)?.itemView else null
if (toTopView != null) { if (toTopView != null) {
parent.getDecoratedBoundsWithMargins(toTopView, bounds) parent.getDecoratedBoundsWithMargins(toTopView, bounds)
draw(c, configuration, toTopView, bounds.top - divider.intrinsicHeight, parent.width, rtl) draw(
c,
configuration,
toTopView,
bounds.top - divider.intrinsicHeight,
parent.width,
rtl
)
} else { } else {
parent.getDecoratedBoundsWithMargins(view, bounds) parent.getDecoratedBoundsWithMargins(view, bounds)
draw(c, configuration, view, bounds.bottom - divider.intrinsicHeight, parent.width, rtl) draw(
c,
configuration,
view,
bounds.bottom - divider.intrinsicHeight,
parent.width,
rtl
)
} }
} }
} }
} }
} }
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val configuration = view.configuration val configuration = view.configuration
val position = parent.getChildAdapterPosition(view) val position = parent.getChildAdapterPosition(view)
if (position >= 0) { if (position >= 0) {

View File

@@ -4,7 +4,8 @@ import android.util.SparseArray
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class EnumRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: RecyclerView.Adapter<VH>() { abstract class EnumRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
abstract val viewTypeClass: Class<VT> abstract val viewTypeClass: Class<VT>
private val names = SparseArray<String>() private val names = SparseArray<String>()

View File

@@ -5,10 +5,14 @@ import android.util.AttributeSet
import android.view.KeyEvent import android.view.KeyEvent
import android.widget.SearchView import android.widget.SearchView
class FocusSearchView: SearchView { class FocusSearchView : SearchView {
constructor(context: Context): super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
var allowFocus = true var allowFocus = true

View File

@@ -4,10 +4,14 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.LinearLayout import android.widget.LinearLayout
class FragmentLinearLayout: LinearLayout { class FragmentLinearLayout : LinearLayout {
constructor(context: Context): super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init { init {
fitsSystemWindows = true fitsSystemWindows = true

View File

@@ -8,8 +8,10 @@ import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.utility.extension.resources.* import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import kotlin.math.* import com.looker.droidify.utility.extension.resources.sizeScaled
import kotlin.math.max
import kotlin.math.roundToInt
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
class RecyclerFastScroller(private val recyclerView: RecyclerView) { class RecyclerFastScroller(private val recyclerView: RecyclerView) {
@@ -22,11 +24,17 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
private val statePressed = intArrayOf(android.R.attr.state_pressed) private val statePressed = intArrayOf(android.R.attr.state_pressed)
} }
private val thumbDrawable = recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollThumbDrawable) private val thumbDrawable =
private val trackDrawable = recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollTrackDrawable) recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollThumbDrawable)
private val trackDrawable =
recyclerView.context.getDrawableFromAttr(android.R.attr.fastScrollTrackDrawable)
private val minTrackSize = recyclerView.resources.sizeScaled(16) private val minTrackSize = recyclerView.resources.sizeScaled(16)
private data class FastScrolling(val startAtThumbOffset: Float?, val startY: Float, val currentY: Float) private data class FastScrolling(
val startAtThumbOffset: Float?,
val startY: Float,
val currentY: Float
)
private var scrolling = false private var scrolling = false
private var fastScrolling: FastScrolling? = null private var fastScrolling: FastScrolling? = null
@@ -64,7 +72,7 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
} }
} }
private val scrollListener = object: RecyclerView.OnScrollListener() { private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
updateState(newState != RecyclerView.SCROLL_STATE_IDLE, fastScrolling) updateState(newState != RecyclerView.SCROLL_STATE_IDLE, fastScrolling)
} }
@@ -79,12 +87,17 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
private inline fun withScroll(callback: (itemHeight: Int, thumbHeight: Int, range: Int) -> Unit): Boolean { private inline fun withScroll(callback: (itemHeight: Int, thumbHeight: Int, range: Int) -> Unit): Boolean {
val count = recyclerView.adapter?.itemCount ?: 0 val count = recyclerView.adapter?.itemCount ?: 0
return count > 0 && run { return count > 0 && run {
val itemHeight = Rect().apply { recyclerView val itemHeight = Rect().apply {
.getDecoratedBoundsWithMargins(recyclerView.getChildAt(0), this) }.height() recyclerView
.getDecoratedBoundsWithMargins(recyclerView.getChildAt(0), this)
}.height()
val scrollCount = count - recyclerView.height / itemHeight val scrollCount = count - recyclerView.height / itemHeight
scrollCount > 0 && run { scrollCount > 0 && run {
val range = count * itemHeight val range = count * itemHeight
val thumbHeight = max(recyclerView.height * recyclerView.height / range, thumbDrawable.intrinsicHeight) val thumbHeight = max(
recyclerView.height * recyclerView.height / range,
thumbDrawable.intrinsicHeight
)
range >= recyclerView.height * 2 && run { range >= recyclerView.height * 2 && run {
callback(itemHeight, thumbHeight, range) callback(itemHeight, thumbHeight, range)
true true
@@ -98,7 +111,10 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
(fastScrolling.startAtThumbOffset + (fastScrolling.currentY - fastScrolling.startY) / (fastScrolling.startAtThumbOffset + (fastScrolling.currentY - fastScrolling.startY) /
(recyclerView.height - thumbHeight)).coerceIn(0f, 1f) (recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
} else { } else {
((fastScrolling.currentY - thumbHeight / 2f) / (recyclerView.height - thumbHeight)).coerceIn(0f, 1f) ((fastScrolling.currentY - thumbHeight / 2f) / (recyclerView.height - thumbHeight)).coerceIn(
0f,
1f
)
} }
} }
@@ -110,7 +126,12 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
return scrollPosition.toFloat() / (range - recyclerView.height) return scrollPosition.toFloat() / (range - recyclerView.height)
} }
private fun scroll(itemHeight: Int, thumbHeight: Int, range: Int, fastScrolling: FastScrolling) { private fun scroll(
itemHeight: Int,
thumbHeight: Int,
range: Int,
fastScrolling: FastScrolling
) {
val offset = calculateOffset(thumbHeight, fastScrolling) val offset = calculateOffset(thumbHeight, fastScrolling)
val scrollPosition = ((range - recyclerView.height) * offset).roundToInt() val scrollPosition = ((range - recyclerView.height) * offset).roundToInt()
val position = scrollPosition / itemHeight val position = scrollPosition / itemHeight
@@ -119,7 +140,7 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
layoutManager.scrollToPositionWithOffset(position, -positionOffset) layoutManager.scrollToPositionWithOffset(position, -positionOffset)
} }
private val touchListener = object: RecyclerView.OnItemTouchListener { private val touchListener = object : RecyclerView.OnItemTouchListener {
private var disallowIntercept = false private var disallowIntercept = false
private fun handleTouchEvent(event: MotionEvent, intercept: Boolean): Boolean { private fun handleTouchEvent(event: MotionEvent, intercept: Boolean): Boolean {
@@ -131,15 +152,22 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
} }
event.action == MotionEvent.ACTION_DOWN -> { event.action == MotionEvent.ACTION_DOWN -> {
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
val trackWidth = max(minTrackSize, max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicWidth)) val trackWidth = max(
val atThumbVertical = if (rtl) event.x <= trackWidth else event.x >= recyclerView.width - trackWidth minTrackSize,
max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicWidth)
)
val atThumbVertical =
if (rtl) event.x <= trackWidth else event.x >= recyclerView.width - trackWidth
atThumbVertical && run { atThumbVertical && run {
withScroll { itemHeight, thumbHeight, range -> withScroll { itemHeight, thumbHeight, range ->
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(true) (recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(
true
)
val offset = currentOffset(itemHeight, range) val offset = currentOffset(itemHeight, range)
val thumbY = ((recyclerView.height - thumbHeight) * offset).roundToInt() val thumbY = ((recyclerView.height - thumbHeight) * offset).roundToInt()
val atThumb = event.y >= thumbY && event.y <= thumbY + thumbHeight val atThumb = event.y >= thumbY && event.y <= thumbY + thumbHeight
val fastScrolling = FastScrolling(if (atThumb) offset else null, event.y, event.y) val fastScrolling =
FastScrolling(if (atThumb) offset else null, event.y, event.y)
scroll(itemHeight, thumbHeight, range, fastScrolling) scroll(itemHeight, thumbHeight, range, fastScrolling)
updateState(scrolling, fastScrolling) updateState(scrolling, fastScrolling)
recyclerView.invalidate() recyclerView.invalidate()
@@ -153,9 +181,12 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
updateState(scrolling, fastScrolling) updateState(scrolling, fastScrolling)
recyclerView.invalidate() recyclerView.invalidate()
} }
val cancel = event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL val cancel =
event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL
if (!success || cancel) { if (!success || cancel) {
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(false) (recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(
false
)
updateState(scrolling, null) updateState(scrolling, null)
recyclerView.invalidate() recyclerView.invalidate()
} }
@@ -212,21 +243,29 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
trackDrawable.state = if (fastScrolling != null) statePressed else stateNormal trackDrawable.state = if (fastScrolling != null) statePressed else stateNormal
val trackExtra = (maxWidth - trackDrawable.intrinsicWidth) / 2 val trackExtra = (maxWidth - trackDrawable.intrinsicWidth) / 2
if (rtl) { if (rtl) {
trackDrawable.setBounds(trackExtra - translateX, 0, trackDrawable.setBounds(
trackExtra + trackDrawable.intrinsicWidth - translateX, recyclerView.height) trackExtra - translateX, 0,
trackExtra + trackDrawable.intrinsicWidth - translateX, recyclerView.height
)
} else { } else {
trackDrawable.setBounds(recyclerView.width - trackExtra - trackDrawable.intrinsicWidth + translateX, trackDrawable.setBounds(
0, recyclerView.width - trackExtra + translateX, recyclerView.height) recyclerView.width - trackExtra - trackDrawable.intrinsicWidth + translateX,
0, recyclerView.width - trackExtra + translateX, recyclerView.height
)
} }
trackDrawable.draw(canvas) trackDrawable.draw(canvas)
val thumbExtra = (maxWidth - thumbDrawable.intrinsicWidth) / 2 val thumbExtra = (maxWidth - thumbDrawable.intrinsicWidth) / 2
thumbDrawable.state = if (fastScrolling != null) statePressed else stateNormal thumbDrawable.state = if (fastScrolling != null) statePressed else stateNormal
if (rtl) { if (rtl) {
thumbDrawable.setBounds(thumbExtra - translateX, thumbY, thumbDrawable.setBounds(
thumbExtra + thumbDrawable.intrinsicWidth - translateX, thumbY + thumbHeight) thumbExtra - translateX, thumbY,
thumbExtra + thumbDrawable.intrinsicWidth - translateX, thumbY + thumbHeight
)
} else { } else {
thumbDrawable.setBounds(recyclerView.width - thumbExtra - thumbDrawable.intrinsicWidth + translateX, thumbDrawable.setBounds(
thumbY, recyclerView.width - thumbExtra + translateX, thumbY + thumbHeight) recyclerView.width - thumbExtra - thumbDrawable.intrinsicWidth + translateX,
thumbY, recyclerView.width - thumbExtra + translateX, thumbY + thumbHeight
)
} }
thumbDrawable.draw(canvas) thumbDrawable.draw(canvas)
} }
@@ -240,8 +279,9 @@ class RecyclerFastScroller(private val recyclerView: RecyclerView) {
init { init {
recyclerView.addOnScrollListener(scrollListener) recyclerView.addOnScrollListener(scrollListener)
recyclerView.addOnItemTouchListener(touchListener) recyclerView.addOnItemTouchListener(touchListener)
recyclerView.addItemDecoration(object: RecyclerView.ItemDecoration() { recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) = handleDraw(c) override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) =
handleDraw(c)
}) })
} }
} }

View File

@@ -2,7 +2,8 @@ package com.looker.droidify.widget
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class StableRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() { abstract class StableRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
EnumRecyclerAdapter<VT, VH>() {
private var nextId = 1L private var nextId = 1L
private val descriptorToId = mutableMapOf<String, Long>() private val descriptorToId = mutableMapOf<String, Long>()

View File

@@ -4,12 +4,19 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.Toolbar import android.widget.Toolbar
class Toolbar: Toolbar { class Toolbar : Toolbar {
constructor(context: Context): super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, context,
defStyleRes: Int): super(context, attrs, defStyleAttr, defStyleRes) attrs,
defStyleAttr
)
constructor(
context: Context, attrs: AttributeSet?, defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
private var initalized = false private var initalized = false
private var layoutDirectionChanged: Int? = null private var layoutDirectionChanged: Int? = null
@@ -18,9 +25,7 @@ class Toolbar: Toolbar {
initalized = true initalized = true
val layoutDirection = layoutDirectionChanged val layoutDirection = layoutDirectionChanged
layoutDirectionChanged = null layoutDirectionChanged = null
if (layoutDirection != null) { if (layoutDirection != null) onRtlPropertiesChanged(layoutDirection)
onRtlPropertiesChanged(layoutDirection)
}
} }
override fun onRtlPropertiesChanged(layoutDirection: Int) { override fun onRtlPropertiesChanged(layoutDirection: Int) {