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

@ -1,13 +1,13 @@
package com.looker.droidify
object Common {
const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
const val NOTIFICATION_CHANNEL_UPDATES = "updates"
const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
const val NOTIFICATION_CHANNEL_UPDATES = "updates"
const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
const val NOTIFICATION_ID_SYNCING = 1
const val NOTIFICATION_ID_UPDATES = 2
const val NOTIFICATION_ID_DOWNLOADING = 3
const val NOTIFICATION_ID_SYNCING = 1
const val NOTIFICATION_ID_UPDATES = 2
const val NOTIFICATION_ID_DOWNLOADING = 3
const val JOB_ID_SYNC = 1
const val JOB_ID_SYNC = 1
}

View File

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

View File

@ -4,14 +4,8 @@ import android.annotation.SuppressLint
import android.app.Application
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.*
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.Preferences
import com.looker.droidify.content.ProductPreferences
@ -23,165 +17,180 @@ import com.looker.droidify.network.PicassoDownloader
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
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.Proxy
@Suppress("unused")
class MainApplication: Application() {
private fun PackageInfo.toInstalledItem(): InstalledItem {
val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
return InstalledItem(packageName, versionName.orEmpty(), versionCodeCompat, signatureString)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(Utils.configureLocale(base))
}
override fun onCreate() {
super.onCreate()
val databaseUpdated = Database.init(this)
Preferences.init(this)
ProductPreferences.init(this)
RepositoryUpdater.init(this)
listenApplications()
listenPreferences()
Picasso.setSingletonInstance(Picasso.Builder(this)
.downloader(OkHttp3Downloader(PicassoDownloader.Factory(Cache.getImagesDir(this)))).build())
if (databaseUpdated) {
forceSyncAll()
class MainApplication : Application() {
private fun PackageInfo.toInstalledItem(): InstalledItem {
val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
return InstalledItem(packageName, versionName.orEmpty(), versionCodeCompat, signatureString)
}
Cache.cleanup(this)
updateSyncJob(false)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(Utils.configureLocale(base))
}
private fun listenApplications() {
registerReceiver(object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val packageName = intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
if (packageName != null) {
when (intent.action.orEmpty()) {
Intent.ACTION_PACKAGE_ADDED,
Intent.ACTION_PACKAGE_REMOVED -> {
val packageInfo = try {
packageManager.getPackageInfo(packageName, Android.PackageManager.signaturesFlag)
} catch (e: Exception) {
null
}
if (packageInfo != null) {
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
} else {
Database.InstalledAdapter.delete(packageName)
}
}
}
override fun onCreate() {
super.onCreate()
val databaseUpdated = Database.init(this)
Preferences.init(this)
ProductPreferences.init(this)
RepositoryUpdater.init(this)
listenApplications()
listenPreferences()
Picasso.setSingletonInstance(
Picasso.Builder(this)
.downloader(OkHttp3Downloader(PicassoDownloader.Factory(Cache.getImagesDir(this))))
.build()
)
if (databaseUpdated) {
forceSyncAll()
}
}
}, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
val installedItems = packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag)
.map { it.toInstalledItem() }
Database.InstalledAdapter.putAll(installedItems)
}
private fun listenPreferences() {
updateProxy()
var lastAutoSync = Preferences[Preferences.Key.AutoSync]
var lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable]
Preferences.observable.subscribe {
if (it == Preferences.Key.ProxyType || it == Preferences.Key.ProxyHost || it == Preferences.Key.ProxyPort) {
Cache.cleanup(this)
updateSyncJob(false)
}
private fun listenApplications() {
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val packageName =
intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
if (packageName != null) {
when (intent.action.orEmpty()) {
Intent.ACTION_PACKAGE_ADDED,
Intent.ACTION_PACKAGE_REMOVED -> {
val packageInfo = try {
packageManager.getPackageInfo(
packageName,
Android.PackageManager.signaturesFlag
)
} catch (e: Exception) {
null
}
if (packageInfo != null) {
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
} else {
Database.InstalledAdapter.delete(packageName)
}
}
}
}
}
}, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
val installedItems =
packageManager.getInstalledPackages(Android.PackageManager.signaturesFlag)
.map { it.toInstalledItem() }
Database.InstalledAdapter.putAll(installedItems)
}
private fun listenPreferences() {
updateProxy()
} else if (it == Preferences.Key.AutoSync) {
val autoSync = Preferences[Preferences.Key.AutoSync]
if (lastAutoSync != autoSync) {
lastAutoSync = autoSync
updateSyncJob(true)
}
} else if (it == Preferences.Key.UpdateUnstable) {
val updateUnstable = Preferences[Preferences.Key.UpdateUnstable]
if (lastUpdateUnstable != updateUnstable) {
lastUpdateUnstable = updateUnstable
forceSyncAll()
}
}
}
}
private fun updateSyncJob(force: Boolean) {
val jobScheduler = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
val reschedule = force || !jobScheduler.allPendingJobs.any { it.id == Common.JOB_ID_SYNC }
if (reschedule) {
val autoSync = Preferences[Preferences.Key.AutoSync]
when (autoSync) {
Preferences.AutoSync.Never -> {
jobScheduler.cancel(Common.JOB_ID_SYNC)
}
Preferences.AutoSync.Wifi, Preferences.AutoSync.Always -> {
val period = 12 * 60 * 60 * 1000L // 12 hours
val wifiOnly = autoSync == Preferences.AutoSync.Wifi
jobScheduler.schedule(JobInfo
.Builder(Common.JOB_ID_SYNC, ComponentName(this, SyncService.Job::class.java))
.setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY)
.apply {
if (Android.sdk(26)) {
setRequiresBatteryNotLow(true)
setRequiresStorageNotLow(true)
}
if (Android.sdk(24)) {
setPeriodic(period, JobInfo.getMinFlexMillis())
} else {
setPeriodic(period)
}
var lastAutoSync = Preferences[Preferences.Key.AutoSync]
var lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable]
Preferences.observable.subscribe {
if (it == Preferences.Key.ProxyType || it == Preferences.Key.ProxyHost || it == Preferences.Key.ProxyPort) {
updateProxy()
} else if (it == Preferences.Key.AutoSync) {
val autoSync = Preferences[Preferences.Key.AutoSync]
if (lastAutoSync != autoSync) {
lastAutoSync = autoSync
updateSyncJob(true)
}
} else if (it == Preferences.Key.UpdateUnstable) {
val updateUnstable = Preferences[Preferences.Key.UpdateUnstable]
if (lastUpdateUnstable != updateUnstable) {
lastUpdateUnstable = updateUnstable
forceSyncAll()
}
}
.build())
Unit
}
}::class.java
}
}
private fun updateProxy() {
val type = Preferences[Preferences.Key.ProxyType].proxyType
val host = Preferences[Preferences.Key.ProxyHost]
val port = Preferences[Preferences.Key.ProxyPort]
val socketAddress = when (type) {
Proxy.Type.DIRECT -> {
null
}
Proxy.Type.HTTP, Proxy.Type.SOCKS -> {
try {
InetSocketAddress.createUnresolved(host, port)
} catch (e: Exception) {
e.printStackTrace()
null
private fun updateSyncJob(force: Boolean) {
val jobScheduler = getSystemService(JOB_SCHEDULER_SERVICE) as JobScheduler
val reschedule = force || !jobScheduler.allPendingJobs.any { it.id == Common.JOB_ID_SYNC }
if (reschedule) {
val autoSync = Preferences[Preferences.Key.AutoSync]
when (autoSync) {
Preferences.AutoSync.Never -> {
jobScheduler.cancel(Common.JOB_ID_SYNC)
}
Preferences.AutoSync.Wifi, Preferences.AutoSync.Always -> {
val period = 12 * 60 * 60 * 1000L // 12 hours
val wifiOnly = autoSync == Preferences.AutoSync.Wifi
jobScheduler.schedule(JobInfo
.Builder(
Common.JOB_ID_SYNC,
ComponentName(this, SyncService.Job::class.java)
)
.setRequiredNetworkType(if (wifiOnly) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY)
.apply {
if (Android.sdk(26)) {
setRequiresBatteryNotLow(true)
setRequiresStorageNotLow(true)
}
if (Android.sdk(24)) {
setPeriodic(period, JobInfo.getMinFlexMillis())
} else {
setPeriodic(period)
}
}
.build())
Unit
}
}::class.java
}
}
}
val proxy = socketAddress?.let { Proxy(type, socketAddress) }
Downloader.proxy = proxy
}
private fun forceSyncAll() {
Database.RepositoryAdapter.getAll(null).forEach {
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
}
private fun updateProxy() {
val type = Preferences[Preferences.Key.ProxyType].proxyType
val host = Preferences[Preferences.Key.ProxyHost]
val port = Preferences[Preferences.Key.ProxyPort]
val socketAddress = when (type) {
Proxy.Type.DIRECT -> {
null
}
Proxy.Type.HTTP, Proxy.Type.SOCKS -> {
try {
InetSocketAddress.createUnresolved(host, port)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
val proxy = socketAddress?.let { Proxy(type, socketAddress) }
Downloader.proxy = proxy
}
Connection(SyncService::class.java, onBind = { connection, binder ->
binder.sync(SyncService.SyncRequest.FORCE)
connection.unbind(this)
}).bind(this)
}
class BootReceiver: BroadcastReceiver() {
@SuppressLint("UnsafeProtectedBroadcastReceiver")
override fun onReceive(context: Context, intent: Intent) = Unit
}
private fun forceSyncAll() {
Database.RepositoryAdapter.getAll(null).forEach {
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
}
}
Connection(SyncService::class.java, onBind = { connection, binder ->
binder.sync(SyncService.SyncRequest.FORCE)
connection.unbind(this)
}).bind(this)
}
class BootReceiver : BroadcastReceiver() {
@SuppressLint("UnsafeProtectedBroadcastReceiver")
override fun onReceive(context: Context, intent: Intent) = Unit
}
}

View File

@ -10,170 +10,192 @@ import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
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.util.UUID
import java.util.*
import kotlin.concurrent.thread
object Cache {
private fun ensureCacheDir(context: Context, name: String): File {
return File(context.cacheDir, name).apply { isDirectory || mkdirs() || throw RuntimeException() }
}
private fun applyOrMode(file: File, mode: Int) {
val oldMode = Os.stat(file.path).st_mode and 0b111111111111
val newMode = oldMode or mode
if (newMode != oldMode) {
Os.chmod(file.path, newMode)
private fun ensureCacheDir(context: Context, name: String): File {
return File(
context.cacheDir,
name
).apply { isDirectory || mkdirs() || throw RuntimeException() }
}
}
private fun subPath(dir: File, file: File): String {
val dirPath = "${dir.path}/"
val filePath = file.path
filePath.startsWith(dirPath) || throw RuntimeException()
return filePath.substring(dirPath.length)
}
fun getImagesDir(context: Context): File {
return ensureCacheDir(context, "images")
}
fun getPartialReleaseFile(context: Context, cacheFileName: String): File {
return File(ensureCacheDir(context, "partial"), cacheFileName)
}
fun getReleaseFile(context: Context, cacheFileName: String): File {
return File(ensureCacheDir(context, "releases"), cacheFileName).apply {
if (!Android.sdk(24)) {
// Make readable for package installer
val cacheDir = context.cacheDir.parentFile!!.parentFile!!
generateSequence(this) { it.parentFile!! }.takeWhile { it != cacheDir }.forEach {
when {
it.isDirectory -> applyOrMode(it, 0b001001001)
it.isFile -> applyOrMode(it, 0b100100100)
}
private fun applyOrMode(file: File, mode: Int) {
val oldMode = Os.stat(file.path).st_mode and 0b111111111111
val newMode = oldMode or mode
if (newMode != oldMode) {
Os.chmod(file.path, newMode)
}
}
}
}
fun getReleaseUri(context: Context, cacheFileName: String): Uri {
val file = getReleaseFile(context, cacheFileName)
val packageInfo = 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)
.encodedPath(subPath(context.cacheDir, file)).build()
}
fun getTemporaryFile(context: Context): File {
return File(ensureCacheDir(context, "temporary"), UUID.randomUUID().toString())
}
fun cleanup(context: Context) {
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>) {
val knownNames = dirHours.asSequence().map { it.first }.toSet()
val files = context.cacheDir.listFiles().orEmpty()
files.asSequence().filter { it.name !in knownNames }.forEach {
if (it.isDirectory) {
cleanupDir(it, 0)
it.delete()
} else {
it.delete()
}
private fun subPath(dir: File, file: File): String {
val dirPath = "${dir.path}/"
val filePath = file.path
filePath.startsWith(dirPath) || throw RuntimeException()
return filePath.substring(dirPath.length)
}
dirHours.forEach { (name, hours) ->
if (hours > 0) {
val file = File(context.cacheDir, name)
if (file.exists()) {
if (file.isDirectory) {
cleanupDir(file, hours)
} else {
file.delete()
}
fun getImagesDir(context: Context): File {
return ensureCacheDir(context, "images")
}
fun getPartialReleaseFile(context: Context, cacheFileName: String): File {
return File(ensureCacheDir(context, "partial"), cacheFileName)
}
fun getReleaseFile(context: Context, cacheFileName: String): File {
return File(ensureCacheDir(context, "releases"), cacheFileName).apply {
if (!Android.sdk(24)) {
// Make readable for package installer
val cacheDir = context.cacheDir.parentFile!!.parentFile!!
generateSequence(this) { it.parentFile!! }.takeWhile { it != cacheDir }.forEach {
when {
it.isDirectory -> applyOrMode(it, 0b001001001)
it.isFile -> applyOrMode(it, 0b100100100)
}
}
}
}
}
}
}
private fun cleanupDir(dir: File, hours: Int) {
dir.listFiles()?.forEach {
val older = hours <= 0 || run {
val olderThan = System.currentTimeMillis() / 1000L - hours * 60 * 60
try {
val stat = Os.lstat(it.path)
stat.st_atime < olderThan
} catch (e: Exception) {
false
fun getReleaseUri(context: Context, cacheFileName: String): Uri {
val file = getReleaseFile(context, cacheFileName)
val packageInfo =
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)
.encodedPath(subPath(context.cacheDir, file)).build()
}
fun getTemporaryFile(context: Context): File {
return File(ensureCacheDir(context, "temporary"), UUID.randomUUID().toString())
}
fun cleanup(context: Context) {
thread {
cleanup(
context,
Pair("images", 0),
Pair("partial", 24),
Pair("releases", 24),
Pair("temporary", 1)
)
}
}
if (older) {
if (it.isDirectory) {
cleanupDir(it, hours)
if (it.isDirectory) {
it.delete()
}
} else {
it.delete()
}
private fun cleanup(context: Context, vararg dirHours: Pair<String, Int>) {
val knownNames = dirHours.asSequence().map { it.first }.toSet()
val files = context.cacheDir.listFiles().orEmpty()
files.asSequence().filter { it.name !in knownNames }.forEach {
if (it.isDirectory) {
cleanupDir(it, 0)
it.delete()
} else {
it.delete()
}
}
}
}
}
class Provider: ContentProvider() {
companion object {
private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
}
private fun getFileAndTypeForUri(uri: Uri): Pair<File, String> {
return when (uri.pathSegments?.firstOrNull()) {
"releases" -> Pair(File(context!!.cacheDir, uri.encodedPath!!), "application/vnd.android.package-archive")
else -> throw SecurityException()
}
}
override fun onCreate(): Boolean = true
override fun query(uri: Uri, projection: Array<String>?,
selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
val file = getFileAndTypeForUri(uri).first
val columns = (projection ?: defaultColumns).mapNotNull {
when (it) {
OpenableColumns.DISPLAY_NAME -> Pair(it, file.name)
OpenableColumns.SIZE -> Pair(it, file.length())
else -> null
dirHours.forEach { (name, hours) ->
if (hours > 0) {
val file = File(context.cacheDir, name)
if (file.exists()) {
if (file.isDirectory) {
cleanupDir(file, hours)
} else {
file.delete()
}
}
}
}
}.unzip()
return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) }
}
override fun getType(uri: Uri): String? = getFileAndTypeForUri(uri).second
private val unsupported: Nothing
get() = throw UnsupportedOperationException()
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = unsupported
override fun delete(uri: Uri, 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? {
val openMode = when (mode) {
"r" -> ParcelFileDescriptor.MODE_READ_ONLY
"w", "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_TRUNCATE
"wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_APPEND
"rw" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
"rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_TRUNCATE
else -> throw IllegalArgumentException()
}
val file = getFileAndTypeForUri(uri).first
return ParcelFileDescriptor.open(file, openMode)
private fun cleanupDir(dir: File, hours: Int) {
dir.listFiles()?.forEach {
val older = hours <= 0 || run {
val olderThan = System.currentTimeMillis() / 1000L - hours * 60 * 60
try {
val stat = Os.lstat(it.path)
stat.st_atime < olderThan
} catch (e: Exception) {
false
}
}
if (older) {
if (it.isDirectory) {
cleanupDir(it, hours)
if (it.isDirectory) {
it.delete()
}
} else {
it.delete()
}
}
}
}
class Provider : ContentProvider() {
companion object {
private val defaultColumns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
}
private fun getFileAndTypeForUri(uri: Uri): Pair<File, String> {
return when (uri.pathSegments?.firstOrNull()) {
"releases" -> Pair(
File(context!!.cacheDir, uri.encodedPath!!),
"application/vnd.android.package-archive"
)
else -> throw SecurityException()
}
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri, projection: Array<String>?,
selection: String?, selectionArgs: Array<out String>?, sortOrder: String?
): Cursor {
val file = getFileAndTypeForUri(uri).first
val columns = (projection ?: defaultColumns).mapNotNull {
when (it) {
OpenableColumns.DISPLAY_NAME -> Pair(it, file.name)
OpenableColumns.SIZE -> Pair(it, file.length())
else -> null
}
}.unzip()
return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) }
}
override fun getType(uri: Uri): String = getFileAndTypeForUri(uri).second
private val unsupported: Nothing
get() = throw UnsupportedOperationException()
override fun insert(uri: Uri, contentValues: ContentValues?): Uri = unsupported
override fun delete(uri: Uri, 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? {
val openMode = when (mode) {
"r" -> ParcelFileDescriptor.MODE_READ_ONLY
"w", "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_TRUNCATE
"wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_APPEND
"rw" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
"rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or
ParcelFileDescriptor.MODE_TRUNCATE
else -> throw IllegalArgumentException()
}
val file = getFileAndTypeForUri(uri).first
return ParcelFileDescriptor.open(file, openMode)
}
}
}
}

View File

@ -3,149 +3,199 @@ package com.looker.droidify.content
import android.content.Context
import android.content.SharedPreferences
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.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
object Preferences {
private lateinit var preferences: SharedPreferences
private lateinit var preferences: SharedPreferences
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,
Key.SortOrder, Key.Theme, Key.UpdateNotify, Key.UpdateUnstable).map { Pair(it.name, it) }.toMap()
private val keys = sequenceOf(
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) {
preferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
preferences.registerOnSharedPreferenceChangeListener { _, keyString -> keys[keyString]?.let(subject::onNext) }
}
val observable: Observable<Key<*>>
get() = subject
sealed class Value<T> {
abstract val value: T
internal abstract fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): T
internal abstract fun set(preferences: SharedPreferences, key: String, value: T)
class BooleanValue(override val value: Boolean): Value<Boolean>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Boolean>): Boolean {
return preferences.getBoolean(key, defaultValue.value)
}
override fun set(preferences: SharedPreferences, key: String, value: Boolean) {
preferences.edit().putBoolean(key, value).apply()
}
fun init(context: Context) {
preferences =
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
preferences.registerOnSharedPreferenceChangeListener { _, keyString ->
keys[keyString]?.let(
subject::onNext
)
}
}
class IntValue(override val value: Int): Value<Int>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<Int>): Int {
return preferences.getInt(key, defaultValue.value)
}
val observable: Observable<Key<*>>
get() = subject
override fun set(preferences: SharedPreferences, key: String, value: Int) {
preferences.edit().putInt(key, value).apply()
}
sealed class Value<T> {
abstract val value: T
internal abstract fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<T>
): T
internal abstract fun set(preferences: SharedPreferences, key: String, value: T)
class BooleanValue(override val value: Boolean) : Value<Boolean>() {
override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<Boolean>
): Boolean {
return preferences.getBoolean(key, defaultValue.value)
}
override fun set(preferences: SharedPreferences, key: String, value: Boolean) {
preferences.edit().putBoolean(key, value).apply()
}
}
class IntValue(override val value: Int) : Value<Int>() {
override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<Int>
): Int {
return preferences.getInt(key, defaultValue.value)
}
override fun set(preferences: SharedPreferences, key: String, value: Int) {
preferences.edit().putInt(key, value).apply()
}
}
class StringValue(override val value: String) : Value<String>() {
override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<String>
): String {
return preferences.getString(key, defaultValue.value) ?: defaultValue.value
}
override fun set(preferences: SharedPreferences, key: String, value: String) {
preferences.edit().putString(key, value).apply()
}
}
class EnumerationValue<T : Enumeration<T>>(override val value: T) : Value<T>() {
override fun get(
preferences: SharedPreferences,
key: String,
defaultValue: Value<T>
): T {
val value = preferences.getString(key, defaultValue.value.valueString)
return defaultValue.value.values.find { it.valueString == value }
?: defaultValue.value
}
override fun set(preferences: SharedPreferences, key: String, value: T) {
preferences.edit().putString(key, value.valueString).apply()
}
}
}
class StringValue(override val value: String): Value<String>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<String>): String {
return preferences.getString(key, defaultValue.value) ?: defaultValue.value
}
override fun set(preferences: SharedPreferences, key: String, value: String) {
preferences.edit().putString(key, value).apply()
}
interface Enumeration<T> {
val values: List<T>
val valueString: String
}
class EnumerationValue<T: Enumeration<T>>(override val value: T): Value<T>() {
override fun get(preferences: SharedPreferences, key: String, defaultValue: Value<T>): T {
val value = preferences.getString(key, defaultValue.value.valueString)
return defaultValue.value.values.find { it.valueString == value } ?: defaultValue.value
}
sealed class Key<T>(val name: String, val default: Value<T>) {
object AutoSync : Key<Preferences.AutoSync>(
"auto_sync",
Value.EnumerationValue(Preferences.AutoSync.Wifi)
)
override fun set(preferences: SharedPreferences, key: String, value: T) {
preferences.edit().putString(key, value.valueString).apply()
}
}
}
object IncompatibleVersions :
Key<Boolean>("incompatible_versions", Value.BooleanValue(false))
interface Enumeration<T> {
val values: List<T>
val valueString: String
}
object ProxyHost : Key<String>("proxy_host", Value.StringValue("localhost"))
object ProxyPort : Key<Int>("proxy_port", Value.IntValue(9050))
object ProxyType : Key<Preferences.ProxyType>(
"proxy_type",
Value.EnumerationValue(Preferences.ProxyType.Direct)
)
sealed class Key<T>(val name: String, val default: Value<T>) {
object AutoSync: Key<Preferences.AutoSync>("auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi))
object IncompatibleVersions: Key<Boolean>("incompatible_versions", Value.BooleanValue(false))
object ProxyHost: Key<String>("proxy_host", Value.StringValue("localhost"))
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))
}
object SortOrder : Key<Preferences.SortOrder>(
"sort_order",
Value.EnumerationValue(Preferences.SortOrder.Update)
)
sealed class AutoSync(override val valueString: String): Enumeration<AutoSync> {
override val values: List<AutoSync>
get() = listOf(Never, Wifi, Always)
object Theme : Key<Preferences.Theme>(
"theme", Value.EnumerationValue(
if (Android.sdk(29))
Preferences.Theme.System else Preferences.Theme.Light
)
)
object Never: AutoSync("never")
object Wifi: AutoSync("wifi")
object Always: AutoSync("always")
}
sealed class ProxyType(override val valueString: String, val proxyType: Proxy.Type): Enumeration<ProxyType> {
override val values: List<ProxyType>
get() = listOf(Direct, Http, Socks)
object Direct: ProxyType("direct", Proxy.Type.DIRECT)
object Http: ProxyType("http", Proxy.Type.HTTP)
object Socks: ProxyType("socks", Proxy.Type.SOCKS)
}
sealed class SortOrder(override val valueString: String, val order: ProductItem.Order): Enumeration<SortOrder> {
override val values: List<SortOrder>
get() = listOf(Name, Added, Update)
object Name: SortOrder("name", ProductItem.Order.NAME)
object Added: SortOrder("added", ProductItem.Order.DATE_ADDED)
object Update: SortOrder("update", ProductItem.Order.LAST_UPDATE)
}
sealed class Theme(override val valueString: String): Enumeration<Theme> {
override val values: List<Theme>
get() = if (Android.sdk(29)) listOf(System, Light, Dark) else listOf(Light, Dark)
abstract fun getResId(configuration: Configuration): Int
object System: Theme("system") {
override fun getResId(configuration: Configuration): Int {
return if ((configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) != 0)
R.style.Theme_Main_Dark else R.style.Theme_Main_Light
}
object UpdateNotify : Key<Boolean>("update_notify", Value.BooleanValue(true))
object UpdateUnstable : Key<Boolean>("update_unstable", Value.BooleanValue(false))
}
object Light: Theme("light") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Light
sealed class AutoSync(override val valueString: String) : Enumeration<AutoSync> {
override val values: List<AutoSync>
get() = listOf(Never, Wifi, Always)
object Never : AutoSync("never")
object Wifi : AutoSync("wifi")
object Always : AutoSync("always")
}
object Dark: Theme("dark") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Dark
sealed class ProxyType(override val valueString: String, val proxyType: Proxy.Type) :
Enumeration<ProxyType> {
override val values: List<ProxyType>
get() = listOf(Direct, Http, Socks)
object Direct : ProxyType("direct", Proxy.Type.DIRECT)
object Http : ProxyType("http", Proxy.Type.HTTP)
object Socks : ProxyType("socks", Proxy.Type.SOCKS)
}
}
operator fun <T> get(key: Key<T>): T {
return key.default.get(preferences, key.name, key.default)
}
sealed class SortOrder(override val valueString: String, val order: ProductItem.Order) :
Enumeration<SortOrder> {
override val values: List<SortOrder>
get() = listOf(Name, Added, Update)
operator fun <T> set(key: Key<T>, value: T) {
key.default.set(preferences, key.name, value)
}
object Name : SortOrder("name", ProductItem.Order.NAME)
object Added : SortOrder("added", ProductItem.Order.DATE_ADDED)
object Update : SortOrder("update", ProductItem.Order.LAST_UPDATE)
}
sealed class Theme(override val valueString: String) : Enumeration<Theme> {
override val values: List<Theme>
get() = if (Android.sdk(29)) listOf(System, Light, Dark) else listOf(Light, Dark)
abstract fun getResId(configuration: Configuration): Int
object System : Theme("system") {
override fun getResId(configuration: Configuration): Int {
return if ((configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) != 0)
R.style.Theme_Main_Dark else R.style.Theme_Main_Light
}
}
object Light : Theme("light") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Light
}
object Dark : Theme("dark") {
override fun getResId(configuration: Configuration): Int = R.style.Theme_Main_Dark
}
}
operator fun <T> get(key: Key<T>): T {
return key.default.get(preferences, key.name, key.default)
}
operator fun <T> set(key: Key<T>, value: T) {
key.default.set(preferences, key.name, value)
}
}

View File

@ -2,63 +2,76 @@ package com.looker.droidify.content
import android.content.Context
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.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.nio.charset.Charset
object ProductPreferences {
private val defaultProductPreference = ProductPreference(false, 0L)
private lateinit var preferences: SharedPreferences
private val subject = PublishSubject.create<Pair<String, Long?>>()
private val defaultProductPreference = ProductPreference(false, 0L)
private lateinit var preferences: SharedPreferences
private val subject = PublishSubject.create<Pair<String, Long?>>()
fun init(context: Context) {
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
Database.LockAdapter.putAll(preferences.all.keys
.mapNotNull { packageName -> this[packageName].databaseVersionCode?.let { Pair(packageName, it) } })
subject
.observeOn(Schedulers.io())
.subscribe { (packageName, versionCode) ->
if (versionCode != null) {
Database.LockAdapter.put(Pair(packageName, versionCode))
} else {
Database.LockAdapter.delete(packageName)
fun init(context: Context) {
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
Database.LockAdapter.putAll(preferences.all.keys
.mapNotNull { packageName ->
this[packageName].databaseVersionCode?.let {
Pair(
packageName,
it
)
}
})
subject
.observeOn(Schedulers.io())
.subscribe { (packageName, versionCode) ->
if (versionCode != null) {
Database.LockAdapter.put(Pair(packageName, versionCode))
} else {
Database.LockAdapter.delete(packageName)
}
}
}
private val ProductPreference.databaseVersionCode: Long?
get() = when {
ignoreUpdates -> 0L
ignoreVersionCode > 0L -> ignoreVersionCode
else -> null
}
}
}
private val ProductPreference.databaseVersionCode: Long?
get() = when {
ignoreUpdates -> 0L
ignoreVersionCode > 0L -> ignoreVersionCode
else -> null
operator fun get(packageName: String): ProductPreference {
return if (preferences.contains(packageName)) {
try {
Json.factory.createParser(preferences.getString(packageName, "{}"))
.use { it.parseDictionary(ProductPreference.Companion::deserialize) }
} catch (e: Exception) {
e.printStackTrace()
defaultProductPreference
}
} else {
defaultProductPreference
}
}
operator fun get(packageName: String): ProductPreference {
return if (preferences.contains(packageName)) {
try {
Json.factory.createParser(preferences.getString(packageName, "{}"))
.use { it.parseDictionary(ProductPreference.Companion::deserialize) }
} catch (e: Exception) {
e.printStackTrace()
defaultProductPreference
}
} else {
defaultProductPreference
operator fun set(packageName: String, productPreference: ProductPreference) {
val oldProductPreference = this[packageName]
preferences.edit().putString(packageName, ByteArrayOutputStream()
.apply {
Json.factory.createGenerator(this)
.use { it.writeDictionary(productPreference::serialize) }
}
.toByteArray().toString(Charset.defaultCharset())).apply()
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode
) {
subject.onNext(Pair(packageName, productPreference.databaseVersionCode))
}
}
}
operator fun set(packageName: String, productPreference: ProductPreference) {
val oldProductPreference = this[packageName]
preferences.edit().putString(packageName, ByteArrayOutputStream()
.apply { Json.factory.createGenerator(this).use { it.writeDictionary(productPreference::serialize) } }
.toByteArray().toString(Charset.defaultCharset())).apply()
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) {
subject.onNext(Pair(packageName, productPreference.databaseVersionCode))
}
}
}

View File

@ -7,95 +7,127 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import com.looker.droidify.entity.ProductItem
class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request {
internal abstract val id: Int
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request {
internal abstract val id: Int
data class ProductsAvailable(val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order): Request() {
override val id: Int
get() = 1
data class ProductsAvailable(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int
get() = 1
}
data class ProductsInstalled(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int
get() = 2
}
data class ProductsUpdates(
val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order
) : Request() {
override val id: Int
get() = 3
}
object Repositories : Request() {
override val id: Int
get() = 4
}
}
data class ProductsInstalled(val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order): Request() {
override val id: Int
get() = 2
interface Callback {
fun onCursorData(request: Request, cursor: Cursor?)
}
data class ProductsUpdates(val searchQuery: String, val section: ProductItem.Section,
val order: ProductItem.Order): Request() {
override val id: Int
get() = 3
private data class ActiveRequest(
val request: Request,
val callback: Callback?,
val cursor: Cursor?
)
init {
retainInstance = true
}
object Repositories: Request() {
override val id: Int
get() = 4
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id]
if (oldActiveRequest?.callback != null &&
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
) {
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
}
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
callback.onCursorData(request, oldActiveRequest.cursor)
oldActiveRequest.cursor
} else {
null
}
activeRequests[request.id] = ActiveRequest(request, callback, cursor)
if (cursor == null) {
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
}
}
}
interface Callback {
fun onCursorData(request: Request, cursor: Cursor?)
}
private data class ActiveRequest(val request: Request, val callback: Callback?, val cursor: Cursor?)
init {
retainInstance = true
}
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id]
if (oldActiveRequest?.callback != null &&
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null) {
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
fun detach(callback: Callback) {
for (id in activeRequests.keys) {
val activeRequest = activeRequests[id]!!
if (activeRequest.callback == callback) {
activeRequests[id] = activeRequest.copy(callback = null)
}
}
}
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
callback.onCursorData(request, oldActiveRequest.cursor)
oldActiveRequest.cursor
} else {
null
}
activeRequests[request.id] = ActiveRequest(request, callback, cursor)
if (cursor == null) {
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
}
}
fun detach(callback: Callback) {
for (id in activeRequests.keys) {
val activeRequest = activeRequests[id]!!
if (activeRequest.callback == callback) {
activeRequests[id] = activeRequest.copy(callback = null)
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) {
when (request) {
is Request.ProductsAvailable -> Database.ProductAdapter
.query(
installed = false,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsInstalled -> Database.ProductAdapter
.query(
installed = true,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsUpdates -> Database.ProductAdapter
.query(
installed = true,
updates = true,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.Repositories -> Database.RepositoryAdapter.query(it)
}
}
}
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) {
when (request) {
is Request.ProductsAvailable -> Database.ProductAdapter
.query(false, false, request.searchQuery, request.section, request.order, it)
is Request.ProductsInstalled -> Database.ProductAdapter
.query(true, false, request.searchQuery, request.section, request.order, it)
is Request.ProductsUpdates -> Database.ProductAdapter
.query(true, true, request.searchQuery, request.section, request.order, it)
is Request.Repositories -> Database.RepositoryAdapter.query(it)
}
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
val activeRequest = activeRequests[loader.id]
if (activeRequest != null) {
activeRequests[loader.id] = activeRequest.copy(cursor = data)
activeRequest.callback?.onCursorData(activeRequest.request, data)
}
}
}
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
val activeRequest = activeRequests[loader.id]
if (activeRequest != null) {
activeRequests[loader.id] = activeRequest.copy(cursor = data)
activeRequest.callback?.onCursorData(activeRequest.request, data)
}
}
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
}

File diff suppressed because it is too large Load Diff

View File

@ -5,53 +5,57 @@ import android.database.ContentObserver
import android.database.Cursor
import android.database.CursorWrapper
class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean,
observer: () -> Unit) -> Unit): CursorWrapper(cursor) {
private var registered = false
private val contentObservable = ContentObservable()
class ObservableCursor(
cursor: Cursor, private val observable: (
register: Boolean,
observer: () -> Unit
) -> Unit
) : CursorWrapper(cursor) {
private var registered = false
private val contentObservable = ContentObservable()
private val onChange: () -> Unit = {
contentObservable.dispatchChange(false, null)
}
init {
observable(true, onChange)
registered = true
}
override fun registerContentObserver(observer: ContentObserver) {
super.registerContentObserver(observer)
contentObservable.registerObserver(observer)
}
override fun unregisterContentObserver(observer: ContentObserver) {
super.unregisterContentObserver(observer)
contentObservable.unregisterObserver(observer)
}
@Suppress("DEPRECATION")
override fun requery(): Boolean {
if (!registered) {
observable(true, onChange)
registered = true
private val onChange: () -> Unit = {
contentObservable.dispatchChange(false, null)
}
return super.requery()
}
@Suppress("DEPRECATION")
override fun deactivate() {
super.deactivate()
deactivateOrClose()
}
init {
observable(true, onChange)
registered = true
}
override fun close() {
super.close()
contentObservable.unregisterAll()
deactivateOrClose()
}
override fun registerContentObserver(observer: ContentObserver) {
super.registerContentObserver(observer)
contentObservable.registerObserver(observer)
}
private fun deactivateOrClose() {
observable(false, onChange)
registered = false
}
override fun unregisterContentObserver(observer: ContentObserver) {
super.unregisterContentObserver(observer)
contentObservable.unregisterObserver(observer)
}
@Suppress("DEPRECATION")
override fun requery(): Boolean {
if (!registered) {
observable(true, onChange)
registered = true
}
return super.requery()
}
@Suppress("DEPRECATION")
override fun deactivate() {
super.deactivate()
deactivateOrClose()
}
override fun close() {
super.close()
contentObservable.unregisterAll()
deactivateOrClose()
}
private fun deactivateOrClose() {
observable(false, onChange)
registered = false
}
}

View File

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

View File

@ -6,89 +6,89 @@ import android.os.CancellationSignal
import android.os.OperationCanceledException
import androidx.loader.content.AsyncTaskLoader
class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?):
AsyncTaskLoader<Cursor>(context) {
private val observer = ForceLoadContentObserver()
private var cancellationSignal: CancellationSignal? = null
private var cursor: Cursor? = null
class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) :
AsyncTaskLoader<Cursor>(context) {
private val observer = ForceLoadContentObserver()
private var cancellationSignal: CancellationSignal? = null
private var cursor: Cursor? = null
override fun loadInBackground(): Cursor? {
val cancellationSignal = synchronized(this) {
if (isLoadInBackgroundCanceled) {
throw OperationCanceledException()
}
val cancellationSignal = CancellationSignal()
this.cancellationSignal = cancellationSignal
cancellationSignal
}
try {
val cursor = query(cancellationSignal)
if (cursor != null) {
try {
cursor.count // Ensure the cursor window is filled
cursor.registerContentObserver(observer)
} catch (e: Exception) {
cursor.close()
throw e
override fun loadInBackground(): Cursor? {
val cancellationSignal = synchronized(this) {
if (isLoadInBackgroundCanceled) {
throw OperationCanceledException()
}
val cancellationSignal = CancellationSignal()
this.cancellationSignal = cancellationSignal
cancellationSignal
}
try {
val cursor = query(cancellationSignal)
if (cursor != null) {
try {
cursor.count // Ensure the cursor window is filled
cursor.registerContentObserver(observer)
} catch (e: Exception) {
cursor.close()
throw e
}
}
return cursor
} finally {
synchronized(this) {
this.cancellationSignal = null
}
}
}
return cursor
} finally {
synchronized(this) {
this.cancellationSignal = null
}
}
}
override fun cancelLoadInBackground() {
super.cancelLoadInBackground()
override fun cancelLoadInBackground() {
super.cancelLoadInBackground()
synchronized(this) {
cancellationSignal?.cancel()
synchronized(this) {
cancellationSignal?.cancel()
}
}
}
override fun deliverResult(data: Cursor?) {
if (isReset) {
data?.close()
} else {
val oldCursor = cursor
cursor = data
if (isStarted) {
super.deliverResult(data)
}
if (oldCursor != data) {
oldCursor.closeIfNeeded()
}
override fun deliverResult(data: Cursor?) {
if (isReset) {
data?.close()
} else {
val oldCursor = cursor
cursor = data
if (isStarted) {
super.deliverResult(data)
}
if (oldCursor != data) {
oldCursor.closeIfNeeded()
}
}
}
}
override fun onStartLoading() {
cursor?.let(this::deliverResult)
if (takeContentChanged() || cursor == null) {
forceLoad()
override fun onStartLoading() {
cursor?.let(this::deliverResult)
if (takeContentChanged() || cursor == null) {
forceLoad()
}
}
}
override fun onStopLoading() {
cancelLoad()
}
override fun onCanceled(data: Cursor?) {
data.closeIfNeeded()
}
override fun onReset() {
super.onReset()
stopLoading()
cursor.closeIfNeeded()
cursor = null
}
private fun Cursor?.closeIfNeeded() {
if (this != null && !isClosed) {
close()
override fun onStopLoading() {
cancelLoad()
}
override fun onCanceled(data: Cursor?) {
data.closeIfNeeded()
}
override fun onReset() {
super.onReset()
stopLoading()
cursor.closeIfNeeded()
cursor = null
}
private fun Cursor?.closeIfNeeded() {
if (this != null && !isClosed) {
close()
}
}
}
}

View File

@ -1,3 +1,8 @@
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,224 +4,284 @@ import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
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,
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 Product(
val repositoryId: Long,
val packageName: String,
val name: String,
val summary: String,
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)
sealed class Donate {
data class Regular(val url: String): Donate()
data class Bitcoin(val address: String): Donate()
data class Litecoin(val address: String): Donate()
data class Flattr(val id: String): Donate()
data class Liberapay(val id: String): Donate()
data class OpenCollective(val id: String): Donate()
}
class Screenshot(val locale: String, val type: Type, val path: String) {
enum class Type(val jsonName: String) {
PHONE("phone"),
SMALL_TABLET("smallTablet"),
LARGE_TABLET("largeTablet")
sealed class Donate {
data class Regular(val url: String) : Donate()
data class Bitcoin(val address: String) : Donate()
data class Litecoin(val address: String) : Donate()
data class Flattr(val id: String) : Donate()
data class Liberapay(val id: String) : Donate()
data class OpenCollective(val id: String) : Donate()
}
val identifier: String
get() = "$locale.${type.name}.$path"
}
// Same releases with different signatures
val selectedReleases: List<Release>
get() = releases.filter { it.selected }
val displayRelease: Release?
get() = selectedReleases.firstOrNull() ?: releases.firstOrNull()
val version: String
get() = displayRelease?.version.orEmpty()
val versionCode: Long
get() = selectedReleases.firstOrNull()?.versionCode ?: 0L
val compatible: Boolean
get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true
val signatures: List<String>
get() = selectedReleases.mapNotNull { it.signature.nullIfEmpty() }.distinct().toList()
fun item(): ProductItem {
return ProductItem(repositoryId, packageName, name, summary, icon, metadataIcon, version, "", compatible, false, 0)
}
fun canUpdate(installedItem: InstalledItem?): Boolean {
return installedItem != null && compatible && versionCode > installedItem.versionCode &&
installedItem.signature in signatures
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("packageName", packageName)
generator.writeStringField("name", name)
generator.writeStringField("summary", summary)
generator.writeStringField("whatsNew", whatsNew)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("authorName", author.name)
generator.writeStringField("authorEmail", author.email)
generator.writeStringField("authorWeb", author.web)
generator.writeStringField("source", source)
generator.writeStringField("changelog", changelog)
generator.writeStringField("web", web)
generator.writeStringField("tracker", tracker)
generator.writeNumberField("added", added)
generator.writeNumberField("updated", updated)
generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
generator.writeArray("categories") { categories.forEach(::writeString) }
generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
generator.writeArray("licenses") { licenses.forEach(::writeString) }
generator.writeArray("donates") {
donates.forEach {
writeDictionary {
when (it) {
is Donate.Regular -> {
writeStringField("type", "")
writeStringField("url", it.url)
}
is Donate.Bitcoin -> {
writeStringField("type", "bitcoin")
writeStringField("address", it.address)
}
is Donate.Litecoin -> {
writeStringField("type", "litecoin")
writeStringField("address", it.address)
}
is Donate.Flattr -> {
writeStringField("type", "flattr")
writeStringField("id", it.id)
}
is Donate.Liberapay -> {
writeStringField("type", "liberapay")
writeStringField("id", it.id)
}
is Donate.OpenCollective -> {
writeStringField("type", "openCollective")
writeStringField("id", it.id)
}
}::class
class Screenshot(val locale: String, val type: Type, val path: String) {
enum class Type(val jsonName: String) {
PHONE("phone"),
SMALL_TABLET("smallTablet"),
LARGE_TABLET("largeTablet")
}
}
}
generator.writeArray("screenshots") {
screenshots.forEach {
writeDictionary {
writeStringField("locale", it.locale)
writeStringField("type", it.type.jsonName)
writeStringField("path", it.path)
}
}
}
generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } }
}
companion object {
fun <T> findSuggested(products: List<T>, installedItem: InstalledItem?, extract: (T) -> Product): T? {
return products.maxWith(compareBy({ extract(it).compatible &&
(installedItem == null || installedItem.signature in extract(it).signatures) }, { extract(it).versionCode }))
val identifier: String
get() = "$locale.${type.name}.$path"
}
fun deserialize(repositoryId: Long, description: String, parser: JsonParser): Product {
var packageName = ""
var name = ""
var summary = ""
var whatsNew = ""
var icon = ""
var metadataIcon = ""
var authorName = ""
var authorEmail = ""
var authorWeb = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
var categories = emptyList<String>()
var antiFeatures = emptyList<String>()
var licenses = emptyList<String>()
var donates = emptyList<Donate>()
var screenshots = emptyList<Screenshot>()
var releases = emptyList<Release>()
parser.forEachKey {
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWeb") -> authorWeb = valueAsString
it.string("source") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("web") -> web = valueAsString
it.string("tracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("updated") -> updated = valueAsLong
it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
it.array("categories") -> categories = collectNotNullStrings()
it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
it.array("licenses") -> licenses = collectNotNullStrings()
it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
var type = ""
var url = ""
var address = ""
var id = ""
forEachKey {
when {
it.string("type") -> type = valueAsString
it.string("url") -> url = valueAsString
it.string("address") -> address = valueAsString
it.string("id") -> id = valueAsString
else -> skipChildren()
}
}
when (type) {
"" -> Donate.Regular(url)
"bitcoin" -> Donate.Bitcoin(address)
"litecoin" -> Donate.Litecoin(address)
"flattr" -> Donate.Flattr(id)
"liberapay" -> Donate.Liberapay(id)
"openCollective" -> Donate.OpenCollective(id)
else -> null
}
}
it.array("screenshots") -> screenshots = collectNotNull(JsonToken.START_OBJECT) {
var locale = ""
var type = ""
var path = ""
forEachKey {
when {
it.string("locale") -> locale = valueAsString
it.string("type") -> type = valueAsString
it.string("path") -> path = valueAsString
else -> skipChildren()
}
}
Screenshot.Type.values().find { it.jsonName == type }?.let { Screenshot(locale, it, path) }
}
it.array("releases") -> releases = collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
else -> skipChildren()
}
}
return Product(repositoryId, packageName, name, summary, description, whatsNew, icon, metadataIcon,
Author(authorName, authorEmail, authorWeb), source, changelog, web, tracker, added, updated,
suggestedVersionCode, categories, antiFeatures, licenses, donates, screenshots, releases)
// Same releases with different signatures
val selectedReleases: List<Release>
get() = releases.filter { it.selected }
val displayRelease: Release?
get() = selectedReleases.firstOrNull() ?: releases.firstOrNull()
val version: String
get() = displayRelease?.version.orEmpty()
val versionCode: Long
get() = selectedReleases.firstOrNull()?.versionCode ?: 0L
val compatible: Boolean
get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true
val signatures: List<String>
get() = selectedReleases.mapNotNull { it.signature.nullIfEmpty() }.distinct().toList()
fun item(): ProductItem {
return ProductItem(
repositoryId,
packageName,
name,
summary,
icon,
metadataIcon,
version,
"",
compatible,
false,
0
)
}
fun canUpdate(installedItem: InstalledItem?): Boolean {
return installedItem != null && compatible && versionCode > installedItem.versionCode &&
installedItem.signature in signatures
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("packageName", packageName)
generator.writeStringField("name", name)
generator.writeStringField("summary", summary)
generator.writeStringField("whatsNew", whatsNew)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("authorName", author.name)
generator.writeStringField("authorEmail", author.email)
generator.writeStringField("authorWeb", author.web)
generator.writeStringField("source", source)
generator.writeStringField("changelog", changelog)
generator.writeStringField("web", web)
generator.writeStringField("tracker", tracker)
generator.writeNumberField("added", added)
generator.writeNumberField("updated", updated)
generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
generator.writeArray("categories") { categories.forEach(::writeString) }
generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
generator.writeArray("licenses") { licenses.forEach(::writeString) }
generator.writeArray("donates") {
donates.forEach {
writeDictionary {
when (it) {
is Donate.Regular -> {
writeStringField("type", "")
writeStringField("url", it.url)
}
is Donate.Bitcoin -> {
writeStringField("type", "bitcoin")
writeStringField("address", it.address)
}
is Donate.Litecoin -> {
writeStringField("type", "litecoin")
writeStringField("address", it.address)
}
is Donate.Flattr -> {
writeStringField("type", "flattr")
writeStringField("id", it.id)
}
is Donate.Liberapay -> {
writeStringField("type", "liberapay")
writeStringField("id", it.id)
}
is Donate.OpenCollective -> {
writeStringField("type", "openCollective")
writeStringField("id", it.id)
}
}::class
}
}
}
generator.writeArray("screenshots") {
screenshots.forEach {
writeDictionary {
writeStringField("locale", it.locale)
writeStringField("type", it.type.jsonName)
writeStringField("path", it.path)
}
}
}
generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } }
}
companion object {
fun <T> findSuggested(
products: List<T>,
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 {
var packageName = ""
var name = ""
var summary = ""
var whatsNew = ""
var icon = ""
var metadataIcon = ""
var authorName = ""
var authorEmail = ""
var authorWeb = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
var categories = emptyList<String>()
var antiFeatures = emptyList<String>()
var licenses = emptyList<String>()
var donates = emptyList<Donate>()
var screenshots = emptyList<Screenshot>()
var releases = emptyList<Release>()
parser.forEachKey { it ->
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWeb") -> authorWeb = valueAsString
it.string("source") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("web") -> web = valueAsString
it.string("tracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("updated") -> updated = valueAsLong
it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
it.array("categories") -> categories = collectNotNullStrings()
it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
it.array("licenses") -> licenses = collectNotNullStrings()
it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
var type = ""
var url = ""
var address = ""
var id = ""
forEachKey {
when {
it.string("type") -> type = valueAsString
it.string("url") -> url = valueAsString
it.string("address") -> address = valueAsString
it.string("id") -> id = valueAsString
else -> skipChildren()
}
}
when (type) {
"" -> Donate.Regular(url)
"bitcoin" -> Donate.Bitcoin(address)
"litecoin" -> Donate.Litecoin(address)
"flattr" -> Donate.Flattr(id)
"liberapay" -> Donate.Liberapay(id)
"openCollective" -> Donate.OpenCollective(id)
else -> null
}
}
it.array("screenshots") -> screenshots =
collectNotNull(JsonToken.START_OBJECT) {
var locale = ""
var type = ""
var path = ""
forEachKey {
when {
it.string("locale") -> locale = valueAsString
it.string("type") -> type = valueAsString
it.string("path") -> path = valueAsString
else -> skipChildren()
}
}
Screenshot.Type.values().find { it.jsonName == type }
?.let { Screenshot(locale, it, path) }
}
it.array("releases") -> releases =
collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
else -> skipChildren()
}
}
return Product(
repositoryId,
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,75 +5,87 @@ import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.R
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,
val icon: String, val metadataIcon: String, val version: String, val installedVersion: String,
val compatible: Boolean, val canUpdate: Boolean, val matchRank: Int) {
sealed class Section: KParcelable {
object All: Section() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { All }
}
data class Category(val name: String): Section() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(name)
}
companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val name = it.readString()!!
Category(name)
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 compatible: Boolean, val canUpdate: Boolean, val matchRank: Int
) {
sealed class Section : KParcelable {
object All : Section() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { All }
}
}
}
data class Repository(val id: Long, val name: String): Section() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeLong(id)
dest.writeString(name)
}
data class Category(val name: String) : Section() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(name)
}
companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val id = it.readLong()
val name = it.readString()!!
Repository(id, name)
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val name = it.readString()!!
Category(name)
}
}
}
}
}
}
enum class Order(val titleResId: Int) {
NAME(R.string.name),
DATE_ADDED(R.string.date_added),
LAST_UPDATE(R.string.last_update)
}
data class Repository(val id: Long, val name: String) : Section() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeLong(id)
dest.writeString(name)
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("version", version)
}
companion object {
fun deserialize(repositoryId: Long, packageName: String, name: String, summary: String,
installedVersion: String, compatible: Boolean, canUpdate: Boolean, matchRank: Int,
parser: JsonParser): ProductItem {
var icon = ""
var metadataIcon = ""
var version = ""
parser.forEachKey {
when {
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("version") -> version = valueAsString
else -> skipChildren()
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val id = it.readLong()
val name = it.readString()!!
Repository(id, name)
}
}
}
}
enum class Order(val titleResId: Int) {
NAME(R.string.name),
DATE_ADDED(R.string.date_added),
LAST_UPDATE(R.string.last_update)
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("version", version)
}
companion object {
fun deserialize(
repositoryId: Long, packageName: String, name: String, summary: String,
installedVersion: String, compatible: Boolean, canUpdate: Boolean, matchRank: Int,
parser: JsonParser
): ProductItem {
var icon = ""
var metadataIcon = ""
var version = ""
parser.forEachKey {
when {
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("version") -> version = valueAsString
else -> skipChildren()
}
}
return ProductItem(
repositoryId, packageName, name, summary, icon, metadataIcon,
version, installedVersion, compatible, canUpdate, matchRank
)
}
}
return ProductItem(repositoryId, packageName, name, summary, icon, metadataIcon,
version, installedVersion, compatible, canUpdate, matchRank)
}
}
}

View File

@ -2,30 +2,30 @@ package com.looker.droidify.entity
import com.fasterxml.jackson.core.JsonGenerator
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) {
fun shouldIgnoreUpdate(versionCode: Long): Boolean {
return ignoreUpdates || ignoreVersionCode == versionCode
}
fun serialize(generator: JsonGenerator) {
generator.writeBooleanField("ignoreUpdates", ignoreUpdates)
generator.writeNumberField("ignoreVersionCode", ignoreVersionCode)
}
companion object {
fun deserialize(parser: JsonParser): ProductPreference {
var ignoreUpdates = false
var ignoreVersionCode = 0L
parser.forEachKey {
when {
it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean
it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong
else -> skipChildren()
}
}
return ProductPreference(ignoreUpdates, ignoreVersionCode)
fun shouldIgnoreUpdate(versionCode: Long): Boolean {
return ignoreUpdates || ignoreVersionCode == versionCode
}
fun serialize(generator: JsonGenerator) {
generator.writeBooleanField("ignoreUpdates", ignoreUpdates)
generator.writeNumberField("ignoreVersionCode", ignoreVersionCode)
}
companion object {
fun deserialize(parser: JsonParser): ProductPreference {
var ignoreUpdates = false
var ignoreVersionCode = 0L
parser.forEachKey {
when {
it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean
it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong
else -> skipChildren()
}
}
return ProductPreference(ignoreUpdates, ignoreVersionCode)
}
}
}
}

View File

@ -6,151 +6,191 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.utility.extension.json.*
data class Release(val selected: Boolean, val version: String, val versionCode: Long,
val added: Long, val size: Long, 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 {
object MinSdk: Incompatibility()
object MaxSdk: Incompatibility()
object Platform: Incompatibility()
data class Feature(val feature: String): Incompatibility()
}
val identifier: String
get() = "$versionCode.$hash"
fun getDownloadUrl(repository: Repository): String {
return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString()
}
val cacheFileName: String
get() = "${hash.replace('/', '-')}.apk"
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeBooleanField("selected", selected)
generator.writeStringField("version", version)
generator.writeNumberField("versionCode", versionCode)
generator.writeNumberField("added", added)
generator.writeNumberField("size", size)
generator.writeNumberField("minSdkVersion", minSdkVersion)
generator.writeNumberField("targetSdkVersion", targetSdkVersion)
generator.writeNumberField("maxSdkVersion", maxSdkVersion)
generator.writeStringField("source", source)
generator.writeStringField("release", release)
generator.writeStringField("hash", hash)
generator.writeStringField("hashType", hashType)
generator.writeStringField("signature", signature)
generator.writeStringField("obbMain", obbMain)
generator.writeStringField("obbMainHash", obbMainHash)
generator.writeStringField("obbMainHashType", obbMainHashType)
generator.writeStringField("obbPatch", obbPatch)
generator.writeStringField("obbPatchHash", obbPatchHash)
generator.writeStringField("obbPatchHashType", obbPatchHashType)
generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
generator.writeArray("features") { features.forEach { writeString(it) } }
generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
generator.writeArray("incompatibilities") {
incompatibilities.forEach {
writeDictionary {
when (it) {
is Incompatibility.MinSdk -> {
writeStringField("type", "minSdk")
}
is Incompatibility.MaxSdk -> {
writeStringField("type", "maxSdk")
}
is Incompatibility.Platform -> {
writeStringField("type", "platform")
}
is Incompatibility.Feature -> {
writeStringField("type", "feature")
writeStringField("feature", it.feature)
}
}::class
}
}
data class Release(
val selected: Boolean,
val version: String,
val versionCode: Long,
val added: Long,
val size: Long,
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 {
object MinSdk : Incompatibility()
object MaxSdk : Incompatibility()
object Platform : Incompatibility()
data class Feature(val feature: String) : Incompatibility()
}
}
companion object {
fun deserialize(parser: JsonParser): Release {
var selected = false
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashType = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbMainHashType = ""
var obbPatch = ""
var obbPatchHash = ""
var obbPatchHashType = ""
var permissions = emptyList<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
var incompatibilities = emptyList<Incompatibility>()
parser.forEachKey {
when {
it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("source") -> source = valueAsString
it.string("release") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashType = valueAsString
it.string("signature") -> signature = valueAsString
it.string("obbMain") -> obbMain = valueAsString
it.string("obbMainHash") -> obbMainHash = valueAsString
it.string("obbMainHashType") -> obbMainHashType = valueAsString
it.string("obbPatch") -> obbPatch = valueAsString
it.string("obbPatchHash") -> obbPatchHash = valueAsString
it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
it.array("permissions") -> permissions = collectNotNullStrings()
it.array("features") -> features = collectNotNullStrings()
it.array("platforms") -> platforms = collectNotNullStrings()
it.array("incompatibilities") -> incompatibilities = collectNotNull(JsonToken.START_OBJECT) {
var type = ""
var feature = ""
forEachKey {
when {
it.string("type") -> type = valueAsString
it.string("feature") -> feature = valueAsString
else -> skipChildren()
}
}
when (type) {
"minSdk" -> Incompatibility.MinSdk
"maxSdk" -> Incompatibility.MaxSdk
"platform" -> Incompatibility.Platform
"feature" -> Incompatibility.Feature(feature)
else -> null
}
}
else -> skipChildren()
}
}
return Release(selected, version, versionCode, added, size,
minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature,
obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType,
permissions, features, platforms, incompatibilities)
val identifier: String
get() = "$versionCode.$hash"
fun getDownloadUrl(repository: Repository): String {
return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString()
}
val cacheFileName: String
get() = "${hash.replace('/', '-')}.apk"
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeBooleanField("selected", selected)
generator.writeStringField("version", version)
generator.writeNumberField("versionCode", versionCode)
generator.writeNumberField("added", added)
generator.writeNumberField("size", size)
generator.writeNumberField("minSdkVersion", minSdkVersion)
generator.writeNumberField("targetSdkVersion", targetSdkVersion)
generator.writeNumberField("maxSdkVersion", maxSdkVersion)
generator.writeStringField("source", source)
generator.writeStringField("release", release)
generator.writeStringField("hash", hash)
generator.writeStringField("hashType", hashType)
generator.writeStringField("signature", signature)
generator.writeStringField("obbMain", obbMain)
generator.writeStringField("obbMainHash", obbMainHash)
generator.writeStringField("obbMainHashType", obbMainHashType)
generator.writeStringField("obbPatch", obbPatch)
generator.writeStringField("obbPatchHash", obbPatchHash)
generator.writeStringField("obbPatchHashType", obbPatchHashType)
generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
generator.writeArray("features") { features.forEach { writeString(it) } }
generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
generator.writeArray("incompatibilities") {
incompatibilities.forEach {
writeDictionary {
when (it) {
is Incompatibility.MinSdk -> {
writeStringField("type", "minSdk")
}
is Incompatibility.MaxSdk -> {
writeStringField("type", "maxSdk")
}
is Incompatibility.Platform -> {
writeStringField("type", "platform")
}
is Incompatibility.Feature -> {
writeStringField("type", "feature")
writeStringField("feature", it.feature)
}
}::class
}
}
}
}
companion object {
fun deserialize(parser: JsonParser): Release {
var selected = false
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashType = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbMainHashType = ""
var obbPatch = ""
var obbPatchHash = ""
var obbPatchHashType = ""
var permissions = emptyList<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
var incompatibilities = emptyList<Incompatibility>()
parser.forEachKey { it ->
when {
it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("source") -> source = valueAsString
it.string("release") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashType = valueAsString
it.string("signature") -> signature = valueAsString
it.string("obbMain") -> obbMain = valueAsString
it.string("obbMainHash") -> obbMainHash = valueAsString
it.string("obbMainHashType") -> obbMainHashType = valueAsString
it.string("obbPatch") -> obbPatch = valueAsString
it.string("obbPatchHash") -> obbPatchHash = valueAsString
it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
it.array("permissions") -> permissions = collectNotNullStrings()
it.array("features") -> features = collectNotNullStrings()
it.array("platforms") -> platforms = collectNotNullStrings()
it.array("incompatibilities") -> incompatibilities =
collectNotNull(JsonToken.START_OBJECT) {
var type = ""
var feature = ""
forEachKey {
when {
it.string("type") -> type = valueAsString
it.string("feature") -> feature = valueAsString
else -> skipChildren()
}
}
when (type) {
"minSdk" -> Incompatibility.MinSdk
"maxSdk" -> Incompatibility.MaxSdk
"platform" -> Incompatibility.Platform
"feature" -> Incompatibility.Feature(feature)
else -> null
}
}
else -> skipChildren()
}
}
return Release(
selected,
version,
versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions,
features,
platforms,
incompatibilities
)
}
}
}
}

View File

@ -2,123 +2,176 @@ package com.looker.droidify.entity
import com.fasterxml.jackson.core.JsonGenerator
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
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 fingerprint: String, val lastModified: String, val entityTag: String,
val updated: Long, val timestamp: Long, val authentication: String) {
fun edit(address: String, fingerprint: String, authentication: String): Repository {
val addressChanged = this.address != address
val fingerprintChanged = this.fingerprint != fingerprint
val changed = addressChanged || fingerprintChanged
return copy(address = address, fingerprint = fingerprint, lastModified = if (changed) "" else lastModified,
entityTag = if (changed) "" else entityTag, authentication = authentication)
}
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 fingerprint: String, val lastModified: String, val entityTag: String,
val updated: Long, val timestamp: Long, val authentication: String
) {
fun edit(address: String, fingerprint: String, authentication: String): Repository {
val addressChanged = this.address != address
val fingerprintChanged = this.fingerprint != fingerprint
val changed = addressChanged || fingerprintChanged
return copy(
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,
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,
entityTag = entityTag, updated = System.currentTimeMillis(), timestamp = timestamp)
}
fun update(
mirrors: List<String>, name: String, description: String, version: Int,
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,
entityTag = entityTag, updated = System.currentTimeMillis(), timestamp = timestamp
)
}
fun enable(enabled: Boolean): Repository {
return copy(enabled = enabled, lastModified = "", entityTag = "")
}
fun enable(enabled: Boolean): Repository {
return copy(enabled = enabled, lastModified = "", entityTag = "")
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("address", address)
generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } }
generator.writeStringField("name", name)
generator.writeStringField("description", description)
generator.writeNumberField("version", version)
generator.writeBooleanField("enabled", enabled)
generator.writeStringField("fingerprint", fingerprint)
generator.writeStringField("lastModified", lastModified)
generator.writeStringField("entityTag", entityTag)
generator.writeNumberField("updated", updated)
generator.writeNumberField("timestamp", timestamp)
generator.writeStringField("authentication", authentication)
}
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeStringField("address", address)
generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } }
generator.writeStringField("name", name)
generator.writeStringField("description", description)
generator.writeNumberField("version", version)
generator.writeBooleanField("enabled", enabled)
generator.writeStringField("fingerprint", fingerprint)
generator.writeStringField("lastModified", lastModified)
generator.writeStringField("entityTag", entityTag)
generator.writeNumberField("updated", updated)
generator.writeNumberField("timestamp", timestamp)
generator.writeStringField("authentication", authentication)
}
companion object {
fun deserialize(id: Long, parser: JsonParser): Repository {
var address = ""
var mirrors = emptyList<String>()
var name = ""
var description = ""
var version = 0
var enabled = false
var fingerprint = ""
var lastModified = ""
var entityTag = ""
var updated = 0L
var timestamp = 0L
var authentication = ""
parser.forEachKey {
when {
it.string("address") -> address = valueAsString
it.array("mirrors") -> mirrors = collectNotNullStrings()
it.string("name") -> name = valueAsString
it.string("description") -> description = valueAsString
it.number("version") -> version = valueAsInt
it.boolean("enabled") -> enabled = valueAsBoolean
it.string("fingerprint") -> fingerprint = valueAsString
it.string("lastModified") -> lastModified = valueAsString
it.string("entityTag") -> entityTag = valueAsString
it.number("updated") -> updated = valueAsLong
it.number("timestamp") -> timestamp = valueAsLong
it.string("authentication") -> authentication = valueAsString
else -> skipChildren()
companion object {
fun deserialize(id: Long, parser: JsonParser): Repository {
var address = ""
var mirrors = emptyList<String>()
var name = ""
var description = ""
var version = 0
var enabled = false
var fingerprint = ""
var lastModified = ""
var entityTag = ""
var updated = 0L
var timestamp = 0L
var authentication = ""
parser.forEachKey {
when {
it.string("address") -> address = valueAsString
it.array("mirrors") -> mirrors = collectNotNullStrings()
it.string("name") -> name = valueAsString
it.string("description") -> description = valueAsString
it.number("version") -> version = valueAsInt
it.boolean("enabled") -> enabled = valueAsBoolean
it.string("fingerprint") -> fingerprint = valueAsString
it.string("lastModified") -> lastModified = valueAsString
it.string("entityTag") -> entityTag = valueAsString
it.number("updated") -> updated = valueAsLong
it.number("timestamp") -> timestamp = valueAsLong
it.string("authentication") -> authentication = valueAsString
else -> skipChildren()
}
}
return Repository(
id, address, mirrors, name, description, version, enabled, fingerprint,
lastModified, entityTag, updated, timestamp, authentication
)
}
}
return Repository(id, address, mirrors, name, description, version, enabled, fingerprint,
lastModified, entityTag, updated, timestamp, authentication)
}
fun newRepository(address: String, fingerprint: String, authentication: String): Repository {
val name = try {
URL(address).let { "${it.host}${it.path}" }
} catch (e: Exception) {
address
}
return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
}
fun newRepository(
address: String,
fingerprint: String,
authentication: String
): Repository {
val name = try {
URL(address).let { "${it.host}${it.path}" }
} catch (e: Exception) {
address
}
return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
}
private fun defaultRepository(address: String, name: String, description: String,
version: Int, enabled: Boolean, fingerprint: String, authentication: String): Repository {
return Repository(-1, address, emptyList(), name, description, version, enabled,
fingerprint, "", "", 0L, 0L, authentication)
}
private fun defaultRepository(
address: String, name: String, description: String,
version: Int, enabled: Boolean, fingerprint: String, authentication: String
): Repository {
return Repository(
-1, address, emptyList(), name, description, version, enabled,
fingerprint, "", "", 0L, 0L, authentication
)
}
val defaultRepositories = listOf(run {
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.",
21, true, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
}, run {
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!",
21, false, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
}, run {
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 " +
"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.",
21, false, "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "")
}, run {
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 " +
"applications from the main repository.", 21, false,
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135", "")
}, run {
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. " +
"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,
"3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A", "")
})
}
val defaultRepositories = listOf(run {
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.",
21,
true,
"43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB",
""
)
}, run {
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!",
21,
false,
"43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB",
""
)
}, run {
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 " +
"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.",
21,
false,
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135",
""
)
}, run {
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 " +
"applications from the main repository.",
21,
false,
"B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135",
""
)
}, run {
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. " +
"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,
"3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A", ""
)
})
}
}

View File

@ -5,52 +5,52 @@ import android.graphics.ColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
open class DrawableWrapper(val drawable: Drawable): Drawable() {
init {
drawable.callback = object: Callback {
override fun invalidateDrawable(who: Drawable) {
callback?.invalidateDrawable(who)
}
open class DrawableWrapper(val drawable: Drawable) : Drawable() {
init {
drawable.callback = object : Callback {
override fun invalidateDrawable(who: Drawable) {
callback?.invalidateDrawable(who)
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
callback?.scheduleDrawable(who, what, `when`)
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
callback?.scheduleDrawable(who, what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
callback?.unscheduleDrawable(who, what)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
callback?.unscheduleDrawable(who, what)
}
}
}
}
override fun onBoundsChange(bounds: Rect) {
drawable.bounds = bounds
}
override fun onBoundsChange(bounds: Rect) {
drawable.bounds = bounds
}
override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth
override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight
override fun getMinimumWidth(): Int = drawable.minimumWidth
override fun getMinimumHeight(): Int = drawable.minimumHeight
override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth
override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight
override fun getMinimumWidth(): Int = drawable.minimumWidth
override fun getMinimumHeight(): Int = drawable.minimumHeight
override fun draw(canvas: Canvas) {
drawable.draw(canvas)
}
override fun draw(canvas: Canvas) {
drawable.draw(canvas)
}
override fun getAlpha(): Int {
return drawable.alpha
}
override fun getAlpha(): Int {
return drawable.alpha
}
override fun setAlpha(alpha: Int) {
drawable.alpha = alpha
}
override fun setAlpha(alpha: Int) {
drawable.alpha = alpha
}
override fun getColorFilter(): ColorFilter? {
return drawable.colorFilter
}
override fun getColorFilter(): ColorFilter? {
return drawable.colorFilter
}
override fun setColorFilter(colorFilter: ColorFilter?) {
drawable.colorFilter = colorFilter
}
override fun setColorFilter(colorFilter: ColorFilter?) {
drawable.colorFilter = colorFilter
}
@Suppress("DEPRECATION")
override fun getOpacity(): Int = drawable.opacity
@Suppress("DEPRECATION")
override fun getOpacity(): Int = drawable.opacity
}

View File

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

View File

@ -2,264 +2,335 @@ package com.looker.droidify.index
import com.looker.droidify.entity.Product
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.helpers.DefaultHandler
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import java.util.*
class IndexHandler(private val repositoryId: Long, private val callback: Callback): DefaultHandler() {
companion object {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
class IndexHandler(private val repositoryId: Long, private val callback: Callback) :
DefaultHandler() {
companion object {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
private fun String.parseDate(): Long {
return try {
dateFormat.parse(this)?.time ?: 0L
} catch (e: Exception) {
0L
}
}
internal fun validateIcon(icon: String): String {
return if (icon.endsWith(".xml")) "" else icon
}
}
interface Callback {
fun onRepository(mirrors: List<String>, name: String, description: String,
certificate: String, version: Int, timestamp: Long)
fun onProduct(product: Product)
}
internal object DonateComparator: Comparator<Product.Donate> {
private val classes = listOf(Product.Donate.Regular::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 {
val index1 = classes.indexOf(donate1::class)
val index2 = classes.indexOf(donate2::class)
return when {
index1 >= 0 && index2 == -1 -> -1
index2 >= 0 && index1 == -1 -> 1
else -> index1.compareTo(index2)
}
}
}
private class RepositoryBuilder {
var address = ""
val mirrors = mutableListOf<String>()
var name = ""
var description = ""
var certificate = ""
var version = -1
var timestamp = 0L
}
private class ProductBuilder(val repositoryId: Long, val packageName: String) {
var name = ""
var summary = ""
var description = ""
var icon = ""
var authorName = ""
var authorEmail = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
val categories = linkedSetOf<String>()
val antiFeatures = linkedSetOf<String>()
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val releases = mutableListOf<Release>()
fun build(): Product {
return Product(repositoryId, packageName, 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)
}
}
private class ReleaseBuilder {
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashType = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbPatch = ""
var obbPatchHash = ""
val permissions = linkedSetOf<String>()
val features = linkedSetOf<String>()
val platforms = linkedSetOf<String>()
fun build(): Release {
val hashType = if (hash.isNotEmpty() && hashType.isEmpty()) "sha256" else hashType
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(false, version, versionCode, added, size,
minSdkVersion, targetSdkVersion, maxSdkVersion, source, release, hash, hashType, signature,
obbMain, obbMainHash, obbMainHashType, obbPatch, obbPatchHash, obbPatchHashType,
permissions.toList(), features.toList(), platforms.toList(), emptyList())
}
}
private val contentBuilder = StringBuilder()
private var repositoryBuilder: RepositoryBuilder? = RepositoryBuilder()
private var productBuilder: ProductBuilder? = null
private var releaseBuilder: ReleaseBuilder? = null
private fun Attributes.get(localName: String): String = getValue("", localName).orEmpty()
private fun String.cleanWhiteSpace(): String = replace("\\s".toRegex(), " ")
override fun startElement(uri: String, localName: String, qName: String, attributes: Attributes) {
super.startElement(uri, localName, qName, attributes)
val repositoryBuilder = repositoryBuilder
val productBuilder = productBuilder
val releaseBuilder = releaseBuilder
contentBuilder.setLength(0)
when {
localName == "repo" -> {
if (repositoryBuilder != null) {
repositoryBuilder.address = attributes.get("url").cleanWhiteSpace()
repositoryBuilder.name = attributes.get("name").cleanWhiteSpace()
repositoryBuilder.description = attributes.get("description").cleanWhiteSpace()
repositoryBuilder.certificate = attributes.get("pubkey")
repositoryBuilder.version = attributes.get("version").toIntOrNull() ?: 0
repositoryBuilder.timestamp = (attributes.get("timestamp").toLongOrNull() ?: 0L) * 1000L
private fun String.parseDate(): Long {
return try {
dateFormat.parse(this)?.time ?: 0L
} catch (e: Exception) {
0L
}
}
}
localName == "application" && productBuilder == null -> {
this.productBuilder = ProductBuilder(repositoryId, attributes.get("id"))
}
localName == "package" && productBuilder != null && releaseBuilder == null -> {
this.releaseBuilder = ReleaseBuilder()
}
localName == "hash" && releaseBuilder != null -> {
releaseBuilder.hashType = attributes.get("type")
}
(localName == "uses-permission" || localName.startsWith("uses-permission-")) && releaseBuilder != null -> {
val minSdkVersion = if (localName != "uses-permission") {
"uses-permission-sdk-(\\d+)".toRegex().matchEntire(localName)
?.destructured?.let { (version) -> version.toIntOrNull() }
} else {
null
} ?: 0
val maxSdkVersion = attributes.get("maxSdkVersion").toIntOrNull() ?: Int.MAX_VALUE
if (Android.sdk in minSdkVersion .. maxSdkVersion) {
releaseBuilder.permissions.add(attributes.get("name"))
} else {
releaseBuilder.permissions.remove(attributes.get("name"))
internal fun validateIcon(icon: String): String {
return if (icon.endsWith(".xml")) "" else icon
}
}
}
}
override fun endElement(uri: String, localName: String, qName: String) {
super.endElement(uri, localName, qName)
interface Callback {
fun onRepository(
mirrors: List<String>, name: String, description: String,
certificate: String, version: Int, timestamp: Long
)
val repositoryBuilder = repositoryBuilder
val productBuilder = productBuilder
val releaseBuilder = releaseBuilder
val content = contentBuilder.toString()
when {
localName == "repo" -> {
if (repositoryBuilder != null) {
val mirrors = (listOf(repositoryBuilder.address) + repositoryBuilder.mirrors)
.filter { it.isNotEmpty() }.distinct()
callback.onRepository(mirrors, repositoryBuilder.name, repositoryBuilder.description,
repositoryBuilder.certificate, repositoryBuilder.version, repositoryBuilder.timestamp)
this.repositoryBuilder = null
}
}
localName == "application" && productBuilder != null -> {
val product = productBuilder.build()
this.productBuilder = null
callback.onProduct(product)
}
localName == "package" && productBuilder != null && releaseBuilder != null -> {
productBuilder.releases.add(releaseBuilder.build())
this.releaseBuilder = null
}
repositoryBuilder != null -> {
when (localName) {
"description" -> repositoryBuilder.description = content.cleanWhiteSpace()
"mirror" -> repositoryBuilder.mirrors += content
}
}
productBuilder != null && releaseBuilder != null -> {
when (localName) {
"version" -> releaseBuilder.version = content
"versioncode" -> releaseBuilder.versionCode = content.toLongOrNull() ?: 0L
"added" -> releaseBuilder.added = content.parseDate()
"size" -> releaseBuilder.size = content.toLongOrNull() ?: 0
"sdkver" -> releaseBuilder.minSdkVersion = content.toIntOrNull() ?: 0
"targetSdkVersion" -> releaseBuilder.targetSdkVersion = content.toIntOrNull() ?: 0
"maxsdkver" -> releaseBuilder.maxSdkVersion = content.toIntOrNull() ?: 0
"srcname" -> releaseBuilder.source = content
"apkname" -> releaseBuilder.release = content
"hash" -> releaseBuilder.hash = content
"sig" -> releaseBuilder.signature = content
"obbMainFile" -> releaseBuilder.obbMain = content
"obbMainFileSha256" -> releaseBuilder.obbMainHash = content
"obbPatchFile" -> releaseBuilder.obbPatch = content
"obbPatchFileSha256" -> releaseBuilder.obbPatchHash = content
"permissions" -> releaseBuilder.permissions += content.split(',').filter { it.isNotEmpty() }
"features" -> releaseBuilder.features += content.split(',').filter { it.isNotEmpty() }
"nativecode" -> releaseBuilder.platforms += content.split(',').filter { it.isNotEmpty() }
}
}
productBuilder != null -> {
when (localName) {
"name" -> productBuilder.name = content
"summary" -> productBuilder.summary = content
"description" -> productBuilder.description = "<p>$content</p>"
"desc" -> productBuilder.description = content.replace("\n", "<br/>")
"icon" -> productBuilder.icon = validateIcon(content)
"author" -> productBuilder.authorName = content
"email" -> productBuilder.authorEmail = content
"source" -> productBuilder.source = content
"changelog" -> productBuilder.changelog = content
"web" -> productBuilder.web = content
"tracker" -> productBuilder.tracker = content
"added" -> productBuilder.added = content.parseDate()
"lastupdated" -> productBuilder.updated = content.parseDate()
"marketvercode" -> productBuilder.suggestedVersionCode = content.toLongOrNull() ?: 0L
"categories" -> productBuilder.categories += content.split(',').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)
"bitcoin" -> productBuilder.donates += Product.Donate.Bitcoin(content)
"litecoin" -> productBuilder.donates += Product.Donate.Litecoin(content)
"flattr" -> productBuilder.donates += Product.Donate.Flattr(content)
"liberapay" -> productBuilder.donates += Product.Donate.Liberapay(content)
"openCollective" -> productBuilder.donates += Product.Donate.OpenCollective(content)
}
}
fun onProduct(product: Product)
}
}
override fun characters(ch: CharArray, start: Int, length: Int) {
super.characters(ch, start, length)
contentBuilder.append(ch, start, length)
}
internal object DonateComparator : Comparator<Product.Donate> {
private val classes = listOf(
Product.Donate.Regular::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 {
val index1 = classes.indexOf(donate1::class)
val index2 = classes.indexOf(donate2::class)
return when {
index1 >= 0 && index2 == -1 -> -1
index2 >= 0 && index1 == -1 -> 1
else -> index1.compareTo(index2)
}
}
}
private class RepositoryBuilder {
var address = ""
val mirrors = mutableListOf<String>()
var name = ""
var description = ""
var certificate = ""
var version = -1
var timestamp = 0L
}
private class ProductBuilder(val repositoryId: Long, val packageName: String) {
var name = ""
var summary = ""
var description = ""
var icon = ""
var authorName = ""
var authorEmail = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
val categories = linkedSetOf<String>()
val antiFeatures = linkedSetOf<String>()
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val releases = mutableListOf<Release>()
fun build(): Product {
return Product(
repositoryId,
packageName,
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
)
}
}
private class ReleaseBuilder {
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashType = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbPatch = ""
var obbPatchHash = ""
val permissions = linkedSetOf<String>()
val features = linkedSetOf<String>()
val platforms = linkedSetOf<String>()
fun build(): Release {
val hashType = if (hash.isNotEmpty() && hashType.isEmpty()) "sha256" else hashType
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(
false,
version,
versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions.toList(),
features.toList(),
platforms.toList(),
emptyList()
)
}
}
private val contentBuilder = StringBuilder()
private var repositoryBuilder: RepositoryBuilder? = RepositoryBuilder()
private var productBuilder: ProductBuilder? = null
private var releaseBuilder: ReleaseBuilder? = null
private fun Attributes.get(localName: String): String = getValue("", localName).orEmpty()
private fun String.cleanWhiteSpace(): String = replace("\\s".toRegex(), " ")
override fun startElement(
uri: String,
localName: String,
qName: String,
attributes: Attributes
) {
super.startElement(uri, localName, qName, attributes)
val repositoryBuilder = repositoryBuilder
val productBuilder = productBuilder
val releaseBuilder = releaseBuilder
contentBuilder.setLength(0)
when {
localName == "repo" -> {
if (repositoryBuilder != null) {
repositoryBuilder.address = attributes.get("url").cleanWhiteSpace()
repositoryBuilder.name = attributes.get("name").cleanWhiteSpace()
repositoryBuilder.description = attributes.get("description").cleanWhiteSpace()
repositoryBuilder.certificate = attributes.get("pubkey")
repositoryBuilder.version = attributes.get("version").toIntOrNull() ?: 0
repositoryBuilder.timestamp =
(attributes.get("timestamp").toLongOrNull() ?: 0L) * 1000L
}
}
localName == "application" && productBuilder == null -> {
this.productBuilder = ProductBuilder(repositoryId, attributes.get("id"))
}
localName == "package" && productBuilder != null && releaseBuilder == null -> {
this.releaseBuilder = ReleaseBuilder()
}
localName == "hash" && releaseBuilder != null -> {
releaseBuilder.hashType = attributes.get("type")
}
(localName == "uses-permission" || localName.startsWith("uses-permission-")) && releaseBuilder != null -> {
val minSdkVersion = if (localName != "uses-permission") {
"uses-permission-sdk-(\\d+)".toRegex().matchEntire(localName)
?.destructured?.let { (version) -> version.toIntOrNull() }
} else {
null
} ?: 0
val maxSdkVersion = attributes.get("maxSdkVersion").toIntOrNull() ?: Int.MAX_VALUE
if (Android.sdk in minSdkVersion..maxSdkVersion) {
releaseBuilder.permissions.add(attributes.get("name"))
} else {
releaseBuilder.permissions.remove(attributes.get("name"))
}
}
}
}
override fun endElement(uri: String, localName: String, qName: String) {
super.endElement(uri, localName, qName)
val repositoryBuilder = repositoryBuilder
val productBuilder = productBuilder
val releaseBuilder = releaseBuilder
val content = contentBuilder.toString()
when {
localName == "repo" -> {
if (repositoryBuilder != null) {
val mirrors = (listOf(repositoryBuilder.address) + repositoryBuilder.mirrors)
.filter { it.isNotEmpty() }.distinct()
callback.onRepository(
mirrors,
repositoryBuilder.name,
repositoryBuilder.description,
repositoryBuilder.certificate,
repositoryBuilder.version,
repositoryBuilder.timestamp
)
this.repositoryBuilder = null
}
}
localName == "application" && productBuilder != null -> {
val product = productBuilder.build()
this.productBuilder = null
callback.onProduct(product)
}
localName == "package" && productBuilder != null && releaseBuilder != null -> {
productBuilder.releases.add(releaseBuilder.build())
this.releaseBuilder = null
}
repositoryBuilder != null -> {
when (localName) {
"description" -> repositoryBuilder.description = content.cleanWhiteSpace()
"mirror" -> repositoryBuilder.mirrors += content
}
}
productBuilder != null && releaseBuilder != null -> {
when (localName) {
"version" -> releaseBuilder.version = content
"versioncode" -> releaseBuilder.versionCode = content.toLongOrNull() ?: 0L
"added" -> releaseBuilder.added = content.parseDate()
"size" -> releaseBuilder.size = content.toLongOrNull() ?: 0
"sdkver" -> releaseBuilder.minSdkVersion = content.toIntOrNull() ?: 0
"targetSdkVersion" -> releaseBuilder.targetSdkVersion =
content.toIntOrNull() ?: 0
"maxsdkver" -> releaseBuilder.maxSdkVersion = content.toIntOrNull() ?: 0
"srcname" -> releaseBuilder.source = content
"apkname" -> releaseBuilder.release = content
"hash" -> releaseBuilder.hash = content
"sig" -> releaseBuilder.signature = content
"obbMainFile" -> releaseBuilder.obbMain = content
"obbMainFileSha256" -> releaseBuilder.obbMainHash = content
"obbPatchFile" -> releaseBuilder.obbPatch = content
"obbPatchFileSha256" -> releaseBuilder.obbPatchHash = content
"permissions" -> releaseBuilder.permissions += content.split(',')
.filter { it.isNotEmpty() }
"features" -> releaseBuilder.features += content.split(',')
.filter { it.isNotEmpty() }
"nativecode" -> releaseBuilder.platforms += content.split(',')
.filter { it.isNotEmpty() }
}
}
productBuilder != null -> {
when (localName) {
"name" -> productBuilder.name = content
"summary" -> productBuilder.summary = content
"description" -> productBuilder.description = "<p>$content</p>"
"desc" -> productBuilder.description = content.replace("\n", "<br/>")
"icon" -> productBuilder.icon = validateIcon(content)
"author" -> productBuilder.authorName = content
"email" -> productBuilder.authorEmail = content
"source" -> productBuilder.source = content
"changelog" -> productBuilder.changelog = content
"web" -> productBuilder.web = content
"tracker" -> productBuilder.tracker = content
"added" -> productBuilder.added = content.parseDate()
"lastupdated" -> productBuilder.updated = content.parseDate()
"marketvercode" -> productBuilder.suggestedVersionCode =
content.toLongOrNull() ?: 0L
"categories" -> productBuilder.categories += content.split(',')
.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)
"bitcoin" -> productBuilder.donates += Product.Donate.Bitcoin(content)
"litecoin" -> productBuilder.donates += Product.Donate.Litecoin(content)
"flattr" -> productBuilder.donates += Product.Donate.Flattr(content)
"liberapay" -> productBuilder.donates += Product.Donate.Liberapay(content)
"openCollective" -> productBuilder.donates += Product.Donate.OpenCollective(
content
)
}
}
}
}
override fun characters(ch: CharArray, start: Int, length: Int) {
super.characters(ch, start, length)
contentBuilder.append(ch, start, length)
}
}

View File

@ -5,79 +5,93 @@ import android.database.sqlite.SQLiteDatabase
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.Release
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.json.*
import com.looker.droidify.utility.extension.android.asSequence
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.Closeable
import java.io.File
class IndexMerger(file: File): Closeable {
private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
class IndexMerger(file: File) : Closeable {
private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
init {
db.execWithResult("PRAGMA synchronous = OFF")
db.execWithResult("PRAGMA journal_mode = OFF")
db.execSQL("CREATE TABLE product (package_name TEXT PRIMARY KEY, description TEXT NOT NULL, data BLOB NOT NULL)")
db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
db.beginTransaction()
}
fun addProducts(products: List<Product>) {
for (product in products) {
val outputStream = ByteArrayOutputStream()
Json.factory.createGenerator(outputStream).use { it.writeDictionary(product::serialize) }
db.insert("product", null, ContentValues().apply {
put("package_name", product.packageName)
put("description", product.description)
put("data", outputStream.toByteArray())
})
init {
db.execWithResult("PRAGMA synchronous = OFF")
db.execWithResult("PRAGMA journal_mode = OFF")
db.execSQL("CREATE TABLE product (package_name TEXT PRIMARY KEY, description TEXT NOT NULL, data BLOB NOT NULL)")
db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
db.beginTransaction()
}
}
fun addReleases(pairs: List<Pair<String, List<Release>>>) {
for (pair in pairs) {
val (packageName, releases) = pair
val outputStream = ByteArrayOutputStream()
Json.factory.createGenerator(outputStream).use {
it.writeStartArray()
for (release in releases) {
it.writeDictionary(release::serialize)
fun addProducts(products: List<Product>) {
for (product in products) {
val outputStream = ByteArrayOutputStream()
Json.factory.createGenerator(outputStream)
.use { it.writeDictionary(product::serialize) }
db.insert("product", null, ContentValues().apply {
put("package_name", product.packageName)
put("description", product.description)
put("data", outputStream.toByteArray())
})
}
it.writeEndArray()
}
db.insert("releases", null, ContentValues().apply {
put("package_name", packageName)
put("data", outputStream.toByteArray())
})
}
}
private fun closeTransaction() {
if (db.inTransaction()) {
db.setTransactionSuccessful()
db.endTransaction()
}
}
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
closeTransaction()
db.rawQuery("""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
LEFT JOIN releases ON product.package_name = releases.package_name""", null)
?.use { it.asSequence().map {
val description = it.getString(0)
val product = Json.factory.createParser(it.getBlob(1)).use {
it.nextToken()
Product.deserialize(repositoryId, description, it)
fun addReleases(pairs: List<Pair<String, List<Release>>>) {
for (pair in pairs) {
val (packageName, releases) = pair
val outputStream = ByteArrayOutputStream()
Json.factory.createGenerator(outputStream).use {
it.writeStartArray()
for (release in releases) {
it.writeDictionary(release::serialize)
}
it.writeEndArray()
}
db.insert("releases", null, ContentValues().apply {
put("package_name", packageName)
put("data", outputStream.toByteArray())
})
}
val releases = it.getBlob(2)?.let { Json.factory.createParser(it).use {
it.nextToken()
it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
} }.orEmpty()
product.copy(releases = releases)
}.windowed(windowSize, windowSize, true).forEach { products -> callback(products, it.count) } }
}
}
override fun close() {
db.use { closeTransaction() }
}
private fun closeTransaction() {
if (db.inTransaction()) {
db.setTransactionSuccessful()
db.endTransaction()
}
}
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
closeTransaction()
db.rawQuery(
"""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
LEFT JOIN releases ON product.package_name = releases.package_name""", null
)
?.use { it ->
it.asSequence().map {
val description = it.getString(0)
val product = Json.factory.createParser(it.getBlob(1)).use {
it.nextToken()
Product.deserialize(repositoryId, description, it)
}
val releases = it.getBlob(2)?.let {
Json.factory.createParser(it).use {
it.nextToken()
it.collectNotNull(
JsonToken.START_OBJECT,
Release.Companion::deserialize
)
}
}.orEmpty()
product.copy(releases = releases)
}.windowed(windowSize, windowSize, true)
.forEach { products -> callback(products, it.count) }
}
}
override fun close() {
db.use { closeTransaction() }
}
}

View File

@ -4,257 +4,349 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.looker.droidify.entity.Product
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.text.*
import com.looker.droidify.utility.extension.text.nullIfEmpty
import java.io.InputStream
object IndexV1Parser {
interface Callback {
fun onRepository(mirrors: List<String>, name: String, description: String, version: Int, timestamp: Long)
fun onProduct(product: Product)
fun onReleases(packageName: String, releases: List<Release>)
}
interface Callback {
fun onRepository(
mirrors: List<String>,
name: String,
description: String,
version: Int,
timestamp: Long
)
private class Screenshots(val phone: List<String>, val smallTablet: List<String>, val largeTablet: List<String>)
private class Localized(val name: String, val summary: String, val description: String,
val whatsNew: String, val metadataIcon: String, val screenshots: Screenshots?)
fun onProduct(product: Product)
fun onReleases(packageName: String, releases: List<Release>)
}
private fun <T> Map<String, Localized>.getAndCall(key: String, callback: (String, Localized) -> T?): T? {
return this[key]?.let { callback(key, it) }
}
private class Screenshots(
val phone: List<String>,
val smallTablet: List<String>,
val largeTablet: List<String>
)
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall("en", callback)
}
private class Localized(
val name: String, val summary: String, val description: String,
val whatsNew: String, val metadataIcon: String, val screenshots: Screenshots?
)
private fun Map<String, Localized>.findString(fallback: String, callback: (Localized) -> String): String {
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
}
private fun <T> Map<String, Localized>.getAndCall(
key: String,
callback: (String, Localized) -> T?
): T? {
return this[key]?.let { callback(key, it) }
}
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
val jsonParser = Json.factory.createParser(inputStream)
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal()
} else {
jsonParser.forEachKey {
when {
it.dictionary("repo") -> {
var address = ""
var mirrors = emptyList<String>()
var name = ""
var description = ""
var version = 0
var timestamp = 0L
forEachKey {
when {
it.string("address") -> address = valueAsString
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
it.string("name") -> name = valueAsString
it.string("description") -> description = valueAsString
it.number("version") -> version = valueAsInt
it.number("timestamp") -> timestamp = valueAsLong
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
"en",
callback
)
}
private fun Map<String, Localized>.findString(
fallback: String,
callback: (Localized) -> String
): String {
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
}
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
val jsonParser = Json.factory.createParser(inputStream)
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal()
} else {
jsonParser.forEachKey { it ->
when {
it.dictionary("repo") -> {
var address = ""
var mirrors = emptyList<String>()
var name = ""
var description = ""
var version = 0
var timestamp = 0L
forEachKey {
when {
it.string("address") -> address = valueAsString
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
it.string("name") -> name = valueAsString
it.string("description") -> description = valueAsString
it.number("version") -> version = valueAsInt
it.number("timestamp") -> timestamp = valueAsLong
else -> skipChildren()
}
}
val realMirrors =
((if (address.isNotEmpty()) listOf(address) else emptyList()) + mirrors).distinct()
callback.onRepository(realMirrors, name, description, version, timestamp)
}
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
val product = parseProduct(repositoryId)
callback.onProduct(product)
}
it.dictionary("packages") -> forEachKey {
if (it.token == JsonToken.START_ARRAY) {
val packageName = it.key
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
callback.onReleases(packageName, releases)
} else {
skipChildren()
}
}
else -> skipChildren()
}
}
}
}
private fun JsonParser.parseProduct(repositoryId: Long): Product {
var packageName = ""
var nameFallback = ""
var summaryFallback = ""
var descriptionFallback = ""
var icon = ""
var authorName = ""
var authorEmail = ""
var authorWeb = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
var categories = emptyList<String>()
var antiFeatures = emptyList<String>()
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>()
forEachKey { it ->
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString
it.string("summary") -> summaryFallback = valueAsString
it.string("description") -> descriptionFallback = valueAsString
it.string("icon") -> icon = IndexHandler.validateIcon(valueAsString)
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWebSite") -> authorWeb = valueAsString
it.string("sourceCode") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("webSite") -> web = valueAsString
it.string("issueTracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("lastUpdated") -> updated = valueAsLong
it.string("suggestedVersionCode") -> suggestedVersionCode =
valueAsString.toLongOrNull() ?: 0L
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
it.string("license") -> licenses += valueAsString.split(',')
.filter { it.isNotEmpty() }
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
valueAsString
)
it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) {
val locale = it.key
var name = ""
var summary = ""
var description = ""
var whatsNew = ""
var metadataIcon = ""
var phone = emptyList<String>()
var smallTablet = emptyList<String>()
var largeTablet = emptyList<String>()
forEachKey {
when {
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("description") -> description = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> metadataIcon = valueAsString
it.array("phoneScreenshots") -> phone =
collectDistinctNotEmptyStrings()
it.array("sevenInchScreenshots") -> smallTablet =
collectDistinctNotEmptyStrings()
it.array("tenInchScreenshots") -> largeTablet =
collectDistinctNotEmptyStrings()
else -> skipChildren()
}
}
val screenshots =
if (sequenceOf(phone, smallTablet, largeTablet).any { it.isNotEmpty() })
Screenshots(phone, smallTablet, largeTablet) else null
localizedMap[locale] = Localized(
name, summary, description, whatsNew,
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), screenshots
)
} else {
skipChildren()
}
}
else -> skipChildren()
}
}
val realMirrors = ((if (address.isNotEmpty()) listOf(address) else emptyList()) + mirrors).distinct()
callback.onRepository(realMirrors, name, description, version, timestamp)
}
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
val product = parseProduct(repositoryId)
callback.onProduct(product)
}
it.dictionary("packages") -> forEachKey {
if (it.token == JsonToken.START_ARRAY) {
val packageName = it.key
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
callback.onReleases(packageName, releases)
} else {
skipChildren()
}
}
else -> skipChildren()
}
}
val name = localizedMap.findString(nameFallback) { it.name }
val summary = localizedMap.findString(summaryFallback) { it.summary }
val description =
localizedMap.findString(descriptionFallback) { it.description }.replace("\n", "<br/>")
val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "<br/>")
val metadataIcon = localizedMap.findString("") { it.metadataIcon }
val screenshotPairs =
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
val screenshots = screenshotPairs
?.let { (key, screenshots) ->
screenshots.phone.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
screenshots.smallTablet.asSequence()
.map {
Product.Screenshot(
key,
Product.Screenshot.Type.SMALL_TABLET,
it
)
} +
screenshots.largeTablet.asSequence()
.map {
Product.Screenshot(
key,
Product.Screenshot.Type.LARGE_TABLET,
it
)
}
}
.orEmpty().toList()
return Product(
repositoryId,
packageName,
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.parseProduct(repositoryId: Long): Product {
var packageName = ""
var nameFallback = ""
var summaryFallback = ""
var descriptionFallback = ""
var icon = ""
var authorName = ""
var authorEmail = ""
var authorWeb = ""
var source = ""
var changelog = ""
var web = ""
var tracker = ""
var added = 0L
var updated = 0L
var suggestedVersionCode = 0L
var categories = emptyList<String>()
var antiFeatures = emptyList<String>()
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>()
forEachKey {
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString
it.string("summary") -> summaryFallback = valueAsString
it.string("description") -> descriptionFallback = valueAsString
it.string("icon") -> icon = IndexHandler.validateIcon(valueAsString)
it.string("authorName") -> authorName = valueAsString
it.string("authorEmail") -> authorEmail = valueAsString
it.string("authorWebSite") -> authorWeb = valueAsString
it.string("sourceCode") -> source = valueAsString
it.string("changelog") -> changelog = valueAsString
it.string("webSite") -> web = valueAsString
it.string("issueTracker") -> tracker = valueAsString
it.number("added") -> added = valueAsLong
it.number("lastUpdated") -> updated = valueAsLong
it.string("suggestedVersionCode") -> suggestedVersionCode = valueAsString.toLongOrNull() ?: 0L
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
it.string("license") -> licenses += valueAsString.split(',').filter { it.isNotEmpty() }
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
it.string("openCollective") -> donates += Product.Donate.OpenCollective(valueAsString)
it.dictionary("localized") -> forEachKey {
if (it.token == JsonToken.START_OBJECT) {
val locale = it.key
var name = ""
var summary = ""
var description = ""
var whatsNew = ""
var metadataIcon = ""
var phone = emptyList<String>()
var smallTablet = emptyList<String>()
var largeTablet = emptyList<String>()
forEachKey {
when {
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("description") -> description = valueAsString
it.string("whatsNew") -> whatsNew = valueAsString
it.string("icon") -> metadataIcon = valueAsString
it.array("phoneScreenshots") -> phone = collectDistinctNotEmptyStrings()
it.array("sevenInchScreenshots") -> smallTablet = collectDistinctNotEmptyStrings()
it.array("tenInchScreenshots") -> largeTablet = collectDistinctNotEmptyStrings()
private fun JsonParser.parseRelease(): Release {
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashTypeCandidate = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbPatch = ""
var obbPatchHash = ""
val permissions = linkedSetOf<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
forEachKey {
when {
it.string("versionName") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("srcname") -> source = valueAsString
it.string("apkName") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashTypeCandidate = valueAsString
it.string("sig") -> signature = valueAsString
it.string("obbMainFile") -> obbMain = valueAsString
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
it.string("obbPatchFile") -> obbPatch = valueAsString
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
it.array("uses-permission") -> collectPermissions(permissions, 0)
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
it.array("features") -> features = collectDistinctNotEmptyStrings()
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
else -> skipChildren()
}
}
val screenshots = if (sequenceOf(phone, smallTablet, largeTablet).any { it.isNotEmpty() })
Screenshots(phone, smallTablet, largeTablet) else null
localizedMap[locale] = Localized(name, summary, description, whatsNew,
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(), screenshots)
} else {
skipChildren()
}
}
else -> skipChildren()
}
val hashType =
if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(
false,
version,
versionCode,
added,
size,
minSdkVersion,
targetSdkVersion,
maxSdkVersion,
source,
release,
hash,
hashType,
signature,
obbMain,
obbMainHash,
obbMainHashType,
obbPatch,
obbPatchHash,
obbPatchHashType,
permissions.toList(),
features,
platforms,
emptyList()
)
}
val name = localizedMap.findString(nameFallback) { it.name }
val summary = localizedMap.findString(summaryFallback) { it.summary }
val description = localizedMap.findString(descriptionFallback) { it.description }.replace("\n", "<br/>")
val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "<br/>")
val metadataIcon = localizedMap.findString("") { it.metadataIcon }
val screenshotPairs = localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
val screenshots = screenshotPairs
?.let { (key, screenshots) -> screenshots.phone.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
screenshots.smallTablet.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.SMALL_TABLET, it) } +
screenshots.largeTablet.asSequence()
.map { Product.Screenshot(key, Product.Screenshot.Type.LARGE_TABLET, it) } }
.orEmpty().toList()
return Product(repositoryId, packageName, 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 {
var version = ""
var versionCode = 0L
var added = 0L
var size = 0L
var minSdkVersion = 0
var targetSdkVersion = 0
var maxSdkVersion = 0
var source = ""
var release = ""
var hash = ""
var hashTypeCandidate = ""
var signature = ""
var obbMain = ""
var obbMainHash = ""
var obbPatch = ""
var obbPatchHash = ""
val permissions = linkedSetOf<String>()
var features = emptyList<String>()
var platforms = emptyList<String>()
forEachKey {
when {
it.string("versionName") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong
it.number("added") -> added = valueAsLong
it.number("size") -> size = valueAsLong
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
it.string("srcname") -> source = valueAsString
it.string("apkName") -> release = valueAsString
it.string("hash") -> hash = valueAsString
it.string("hashType") -> hashTypeCandidate = valueAsString
it.string("sig") -> signature = valueAsString
it.string("obbMainFile") -> obbMain = valueAsString
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
it.string("obbPatchFile") -> obbPatch = valueAsString
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
it.array("uses-permission") -> collectPermissions(permissions, 0)
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
it.array("features") -> features = collectDistinctNotEmptyStrings()
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
else -> skipChildren()
}
}
val hashType = if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
return Release(false, version, 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) {
forEach(JsonToken.START_ARRAY) {
val firstToken = nextToken()
val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else ""
if (firstToken != JsonToken.END_ARRAY) {
val secondToken = nextToken()
val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0
if (permission.isNotEmpty() && Android.sdk >= minSdk && (maxSdk <= 0 || Android.sdk <= maxSdk)) {
permissions.add(permission)
}
if (secondToken != JsonToken.END_ARRAY) {
while (true) {
val token = nextToken()
if (token == JsonToken.END_ARRAY) {
break
} else if (token.isStructStart) {
skipChildren()
private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) {
forEach(JsonToken.START_ARRAY) {
val firstToken = nextToken()
val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else ""
if (firstToken != JsonToken.END_ARRAY) {
val secondToken = nextToken()
val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0
if (permission.isNotEmpty() && Android.sdk >= minSdk && (maxSdk <= 0 || Android.sdk <= maxSdk)) {
permissions.add(permission)
}
if (secondToken != JsonToken.END_ARRAY) {
while (true) {
val token = nextToken()
if (token == JsonToken.END_ARRAY) {
break
} else if (token.isStructStart) {
skipChildren()
}
}
}
}
}
}
}
}
}
}

View File

@ -2,9 +2,6 @@ package com.looker.droidify.index
import android.content.Context
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.database.Database
import com.looker.droidify.entity.Product
@ -14,332 +11,455 @@ import com.looker.droidify.network.Downloader
import com.looker.droidify.utility.ProgressInputStream
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.text.*
import com.looker.droidify.utility.extension.android.Android
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 java.io.File
import java.security.cert.X509Certificate
import java.util.Locale
import java.util.*
import java.util.jar.JarEntry
import java.util.jar.JarFile
import javax.xml.parsers.SAXParserFactory
object RepositoryUpdater {
enum class Stage {
DOWNLOAD, PROCESS, MERGE, COMMIT
}
private enum class IndexType(val jarName: String, val contentName: String, val certificateFromIndex: Boolean) {
INDEX("index.jar", "index.xml", true),
INDEX_V1("index-v1.jar", "index-v1.json", false)
}
enum class ErrorType {
NETWORK, HTTP, VALIDATION, PARSING
}
class UpdateException: Exception {
val errorType: ErrorType
constructor(errorType: ErrorType, message: String): super(message) {
this.errorType = errorType
enum class Stage {
DOWNLOAD, PROCESS, MERGE, COMMIT
}
constructor(errorType: ErrorType, message: String, cause: Exception): super(message, cause) {
this.errorType = errorType
private enum class IndexType(
val jarName: String,
val contentName: String,
val certificateFromIndex: Boolean
) {
INDEX("index.jar", "index.xml", true),
INDEX_V1("index-v1.jar", "index-v1.json", false)
}
}
private lateinit var context: Context
private val updaterLock = Any()
private val cleanupLock = Any()
enum class ErrorType {
NETWORK, HTTP, VALIDATION, PARSING
}
fun init(context: Context) {
this.context = context
class UpdateException : Exception {
val errorType: ErrorType
var lastDisabled = setOf<Long>()
Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAllDisabledDeleted(it) } }
.forEach {
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet()
val disabled = newDisabled - lastDisabled
lastDisabled = newDisabled
val deleted = it.asSequence().filter { it.second }.map { it.first }.toSet()
if (disabled.isNotEmpty() || deleted.isNotEmpty()) {
val pairs = (disabled.asSequence().map { Pair(it, false) } +
deleted.asSequence().map { Pair(it, true) }).toSet()
synchronized(cleanupLock) { Database.RepositoryAdapter.cleanup(pairs) }
constructor(errorType: ErrorType, message: String) : super(message) {
this.errorType = errorType
}
}
}
fun await() {
synchronized(updaterLock) { }
}
fun update(repository: Repository, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback)
}
private fun update(repository: Repository, indexTypes: List<IndexType>, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
val indexType = indexTypes[0]
return downloadIndex(repository, indexType, callback)
.flatMap { (result, file) ->
when {
result.isNotChanged -> {
file.delete()
Single.just(false)
}
!result.success -> {
file.delete()
if (result.code == 404 && indexTypes.isNotEmpty()) {
update(repository, indexTypes.subList(1, indexTypes.size), unstable, callback)
} else {
Single.error(UpdateException(ErrorType.HTTP, "Invalid response: HTTP ${result.code}"))
}
}
else -> {
RxUtils.managedSingle { processFile(repository, indexType, unstable,
file, result.lastModified, result.entityTag, callback) }
}
constructor(errorType: ErrorType, message: String, cause: Exception) : super(
message,
cause
) {
this.errorType = errorType
}
}
}
}
private fun downloadIndex(repository: Repository, indexType: IndexType,
callback: (Stage, Long, Long?) -> Unit): Single<Pair<Downloader.Result, File>> {
return Single.just(Unit)
.map { Cache.getTemporaryFile(context) }
.flatMap { file -> Downloader
.download(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())
.map { Pair(it, file) }
.onErrorResumeNext {
file.delete()
when (it) {
is InterruptedException, is RuntimeException, is Error -> Single.error(it)
is Exception -> Single.error(UpdateException(ErrorType.NETWORK, "Network error", it))
else -> Single.error(it)
}
} }
}
private lateinit var context: Context
private val updaterLock = Any()
private val cleanupLock = Any()
private fun processFile(repository: Repository, indexType: IndexType, unstable: Boolean,
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit): Boolean {
var rollback = true
return synchronized(updaterLock) {
try {
val jarFile = JarFile(file, true)
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
val total = indexEntry.size
Database.UpdaterAdapter.createTemporaryTable()
val features = context.packageManager.systemAvailableFeatures
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
fun init(context: Context) {
this.context = context
val (changedRepository, certificateFromIndex) = when (indexType) {
IndexType.INDEX -> {
val factory = SAXParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newSAXParser()
val reader = parser.xmlReader
var changedRepository: Repository? = null
var certificateFromIndex: String? = null
val products = mutableListOf<Product>()
reader.contentHandler = IndexHandler(repository.id, object: IndexHandler.Callback {
override fun onRepository(mirrors: List<String>, name: String, description: String,
certificate: String, version: Int, timestamp: Long) {
changedRepository = repository.update(mirrors, name, description, version,
lastModified, entityTag, timestamp)
certificateFromIndex = certificate.toLowerCase(Locale.US)
}
override fun onProduct(product: Product) {
if (Thread.interrupted()) {
throw InterruptedException()
var lastDisabled = setOf<Long>()
Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io())
.flatMapSingle {
RxUtils.querySingle {
Database.RepositoryAdapter.getAllDisabledDeleted(
it
)
}
products += transformProduct(product, features, unstable)
if (products.size >= 50) {
Database.UpdaterAdapter.putTemporary(products)
products.clear()
}
.forEach { it ->
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet()
val disabled = newDisabled - lastDisabled
lastDisabled = newDisabled
val deleted = it.asSequence().filter { it.second }.map { it.first }.toSet()
if (disabled.isNotEmpty() || deleted.isNotEmpty()) {
val pairs = (disabled.asSequence().map { Pair(it, false) } +
deleted.asSequence().map { Pair(it, true) }).toSet()
synchronized(cleanupLock) { Database.RepositoryAdapter.cleanup(pairs) }
}
}
})
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }
.use { reader.parse(InputSource(it)) }
if (Thread.interrupted()) {
throw InterruptedException()
}
if (products.isNotEmpty()) {
Database.UpdaterAdapter.putTemporary(products)
products.clear()
}
Pair(changedRepository, certificateFromIndex)
}
IndexType.INDEX_V1 -> {
var changedRepository: Repository? = null
}
val mergerFile = Cache.getTemporaryFile(context)
fun await() {
synchronized(updaterLock) { }
}
fun update(
repository: Repository, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit
): Single<Boolean> {
return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback)
}
private fun update(
repository: Repository, indexTypes: List<IndexType>, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit
): Single<Boolean> {
val indexType = indexTypes[0]
return downloadIndex(repository, indexType, callback)
.flatMap { (result, file) ->
when {
result.isNotChanged -> {
file.delete()
Single.just(false)
}
!result.success -> {
file.delete()
if (result.code == 404 && indexTypes.isNotEmpty()) {
update(
repository,
indexTypes.subList(1, indexTypes.size),
unstable,
callback
)
} else {
Single.error(
UpdateException(
ErrorType.HTTP,
"Invalid response: HTTP ${result.code}"
)
)
}
}
else -> {
RxUtils.managedSingle {
processFile(
repository, indexType, unstable,
file, result.lastModified, result.entityTag, callback
)
}
}
}
}
}
private fun downloadIndex(
repository: Repository, indexType: IndexType,
callback: (Stage, Long, Long?) -> Unit
): Single<Pair<Downloader.Result, File>> {
return Single.just(Unit)
.map { Cache.getTemporaryFile(context) }
.flatMap { file ->
Downloader
.download(
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())
.map { Pair(it, file) }
.onErrorResumeNext {
file.delete()
when (it) {
is InterruptedException, is RuntimeException, is Error -> Single.error(
it
)
is Exception -> Single.error(
UpdateException(
ErrorType.NETWORK,
"Network error",
it
)
)
else -> Single.error(it)
}
}
}
}
private fun processFile(
repository: Repository, indexType: IndexType, unstable: Boolean,
file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit
): Boolean {
var rollback = true
return synchronized(updaterLock) {
try {
val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
IndexMerger(mergerFile).use { indexMerger ->
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }.use {
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)
}
val jarFile = JarFile(file, true)
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
val total = indexEntry.size
Database.UpdaterAdapter.createTemporaryTable()
val features = context.packageManager.systemAvailableFeatures
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
override fun onProduct(product: Product) {
if (Thread.interrupted()) {
throw InterruptedException()
}
unmergedProducts += product
if (unmergedProducts.size >= 50) {
indexMerger.addProducts(unmergedProducts)
unmergedProducts.clear()
}
}
val (changedRepository, certificateFromIndex) = when (indexType) {
IndexType.INDEX -> {
val factory = SAXParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newSAXParser()
val reader = parser.xmlReader
var changedRepository: Repository? = null
var certificateFromIndex: String? = null
val products = mutableListOf<Product>()
override fun onReleases(packageName: String, releases: List<Release>) {
if (Thread.interrupted()) {
throw InterruptedException()
}
unmergedReleases += Pair(packageName, releases)
if (unmergedReleases.size >= 50) {
indexMerger.addReleases(unmergedReleases)
unmergedReleases.clear()
}
}
})
reader.contentHandler =
IndexHandler(repository.id, object : IndexHandler.Callback {
override fun onRepository(
mirrors: List<String>, name: String, description: String,
certificate: String, version: Int, timestamp: Long
) {
changedRepository = repository.update(
mirrors, name, description, version,
lastModified, entityTag, timestamp
)
certificateFromIndex = certificate.lowercase(Locale.US)
}
if (Thread.interrupted()) {
throw InterruptedException()
}
if (unmergedProducts.isNotEmpty()) {
indexMerger.addProducts(unmergedProducts)
unmergedProducts.clear()
}
if (unmergedReleases.isNotEmpty()) {
indexMerger.addReleases(unmergedReleases)
unmergedReleases.clear()
}
var progress = 0
indexMerger.forEach(repository.id, 50) { products, totalCount ->
if (Thread.interrupted()) {
throw InterruptedException()
override fun onProduct(product: Product) {
if (Thread.interrupted()) {
throw InterruptedException()
}
products += transformProduct(product, features, unstable)
if (products.size >= 50) {
Database.UpdaterAdapter.putTemporary(products)
products.clear()
}
}
})
ProgressInputStream(jarFile.getInputStream(indexEntry)) {
callback(
Stage.PROCESS,
it,
total
)
}
.use { reader.parse(InputSource(it)) }
if (Thread.interrupted()) {
throw InterruptedException()
}
if (products.isNotEmpty()) {
Database.UpdaterAdapter.putTemporary(products)
products.clear()
}
Pair(changedRepository, certificateFromIndex)
}
IndexType.INDEX_V1 -> {
var changedRepository: Repository? = null
val mergerFile = Cache.getTemporaryFile(context)
try {
val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
IndexMerger(mergerFile).use { indexMerger ->
ProgressInputStream(jarFile.getInputStream(indexEntry)) {
callback(
Stage.PROCESS,
it,
total
)
}.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) {
if (Thread.interrupted()) {
throw InterruptedException()
}
unmergedProducts += product
if (unmergedProducts.size >= 50) {
indexMerger.addProducts(unmergedProducts)
unmergedProducts.clear()
}
}
override fun onReleases(
packageName: String,
releases: List<Release>
) {
if (Thread.interrupted()) {
throw InterruptedException()
}
unmergedReleases += Pair(packageName, releases)
if (unmergedReleases.size >= 50) {
indexMerger.addReleases(unmergedReleases)
unmergedReleases.clear()
}
}
})
if (Thread.interrupted()) {
throw InterruptedException()
}
if (unmergedProducts.isNotEmpty()) {
indexMerger.addProducts(unmergedProducts)
unmergedProducts.clear()
}
if (unmergedReleases.isNotEmpty()) {
indexMerger.addReleases(unmergedReleases)
unmergedReleases.clear()
}
var progress = 0
indexMerger.forEach(repository.id, 50) { products, totalCount ->
if (Thread.interrupted()) {
throw InterruptedException()
}
progress += products.size
callback(
Stage.MERGE,
progress.toLong(),
totalCount.toLong()
)
Database.UpdaterAdapter.putTemporary(products
.map { transformProduct(it, features, unstable) })
}
}
}
} finally {
mergerFile.delete()
}
Pair(changedRepository, null)
}
progress += products.size
callback(Stage.MERGE, progress.toLong(), totalCount.toLong())
Database.UpdaterAdapter.putTemporary(products
.map { transformProduct(it, features, unstable) })
}
}
}
} finally {
mergerFile.delete()
}
Pair(changedRepository, null)
}
}
val workRepository = changedRepository ?: repository
if (workRepository.timestamp < repository.timestamp) {
throw UpdateException(ErrorType.VALIDATION, "New index is older than current index: " +
"${workRepository.timestamp} < ${repository.timestamp}")
} else {
val fingerprint = run {
val certificateFromJar = run {
val codeSigners = indexEntry.codeSigners
if (codeSigners == null || codeSigners.size != 1) {
throw UpdateException(ErrorType.VALIDATION, "index.jar must be signed by a single code signer")
} else {
val certificates = codeSigners[0].signerCertPath?.certificates.orEmpty()
if (certificates.size != 1) {
throw UpdateException(ErrorType.VALIDATION, "index.jar code signer should have only one certificate")
val workRepository = changedRepository ?: repository
if (workRepository.timestamp < repository.timestamp) {
throw UpdateException(
ErrorType.VALIDATION, "New index is older than current index: " +
"${workRepository.timestamp} < ${repository.timestamp}"
)
} else {
certificates[0] as X509Certificate
val fingerprint = run {
val certificateFromJar = run {
val codeSigners = indexEntry.codeSigners
if (codeSigners == null || codeSigners.size != 1) {
throw UpdateException(
ErrorType.VALIDATION,
"index.jar must be signed by a single code signer"
)
} else {
val certificates =
codeSigners[0].signerCertPath?.certificates.orEmpty()
if (certificates.size != 1) {
throw UpdateException(
ErrorType.VALIDATION,
"index.jar code signer should have only one certificate"
)
} else {
certificates[0] as X509Certificate
}
}
}
val fingerprintFromJar = Utils.calculateFingerprint(certificateFromJar)
if (indexType.certificateFromIndex) {
val fingerprintFromIndex =
certificateFromIndex?.unhex()?.let(Utils::calculateFingerprint)
if (fingerprintFromIndex == null || fingerprintFromJar != fingerprintFromIndex) {
throw UpdateException(
ErrorType.VALIDATION,
"index.xml contains invalid public key"
)
}
fingerprintFromIndex
} else {
fingerprintFromJar
}
}
val commitRepository = if (workRepository.fingerprint != fingerprint) {
if (workRepository.fingerprint.isEmpty()) {
workRepository.copy(fingerprint = fingerprint)
} else {
throw UpdateException(
ErrorType.VALIDATION,
"Certificate fingerprints do not match"
)
}
} else {
workRepository
}
if (Thread.interrupted()) {
throw InterruptedException()
}
callback(Stage.COMMIT, 0, null)
synchronized(cleanupLock) {
Database.UpdaterAdapter.finishTemporary(
commitRepository,
true
)
}
rollback = false
true
}
} catch (e: Exception) {
throw when (e) {
is UpdateException, is InterruptedException -> e
else -> UpdateException(ErrorType.PARSING, "Error parsing index", e)
}
} finally {
file.delete()
if (rollback) {
Database.UpdaterAdapter.finishTemporary(repository, false)
}
}
}
val fingerprintFromJar = Utils.calculateFingerprint(certificateFromJar)
if (indexType.certificateFromIndex) {
val fingerprintFromIndex = certificateFromIndex?.unhex()?.let(Utils::calculateFingerprint)
if (fingerprintFromIndex == null || fingerprintFromJar != fingerprintFromIndex) {
throw UpdateException(ErrorType.VALIDATION, "index.xml contains invalid public key")
}
fingerprintFromIndex
} else {
fingerprintFromJar
}
}
val commitRepository = if (workRepository.fingerprint != fingerprint) {
if (workRepository.fingerprint.isEmpty()) {
workRepository.copy(fingerprint = fingerprint)
} else {
throw UpdateException(ErrorType.VALIDATION, "Certificate fingerprints do not match")
}
} else {
workRepository
}
if (Thread.interrupted()) {
throw InterruptedException()
}
callback(Stage.COMMIT, 0, null)
synchronized(cleanupLock) { Database.UpdaterAdapter.finishTemporary(commitRepository, true) }
rollback = false
true
}
} catch (e: Exception) {
throw when (e) {
is UpdateException, is InterruptedException -> e
else -> UpdateException(ErrorType.PARSING, "Error parsing index", e)
}
} finally {
file.delete()
if (rollback) {
Database.UpdaterAdapter.finishTemporary(repository, false)
}
}
}
}
private fun transformProduct(product: Product, features: Set<String>, unstable: Boolean): Product {
val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map {
val incompatibilities = mutableListOf<Release.Incompatibility>()
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
incompatibilities += Release.Incompatibility.MinSdk
}
if (it.maxSdkVersion > 0 && Android.sdk > it.maxSdkVersion) {
incompatibilities += Release.Incompatibility.MaxSdk
}
if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms).isEmpty()) {
incompatibilities += Release.Incompatibility.Platform
}
incompatibilities += (it.features - features).sorted().map { Release.Incompatibility.Feature(it) }
Pair(it, incompatibilities as List<Release.Incompatibility>)
}.toMutableList()
private fun transformProduct(
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>()
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
incompatibilities += Release.Incompatibility.MinSdk
}
if (it.maxSdkVersion > 0 && Android.sdk > it.maxSdkVersion) {
incompatibilities += Release.Incompatibility.MaxSdk
}
if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms)
.isEmpty()
) {
incompatibilities += Release.Incompatibility.Platform
}
incompatibilities += (it.features - features).sorted()
.map { Release.Incompatibility.Feature(it) }
Pair(it, incompatibilities as List<Release.Incompatibility>)
}.toMutableList()
val predicate: (Release) -> Boolean = { unstable || product.suggestedVersionCode <= 0 ||
it.versionCode <= product.suggestedVersionCode }
val firstCompatibleReleaseIndex = releasePairs.indexOfFirst { it.second.isEmpty() && predicate(it.first) }
val firstReleaseIndex = if (firstCompatibleReleaseIndex >= 0) firstCompatibleReleaseIndex else
releasePairs.indexOfFirst { predicate(it.first) }
val firstSelected = if (firstReleaseIndex >= 0) releasePairs[firstReleaseIndex] else null
val predicate: (Release) -> Boolean = {
unstable || product.suggestedVersionCode <= 0 ||
it.versionCode <= product.suggestedVersionCode
}
val firstCompatibleReleaseIndex =
releasePairs.indexOfFirst { it.second.isEmpty() && predicate(it.first) }
val firstReleaseIndex =
if (firstCompatibleReleaseIndex >= 0) firstCompatibleReleaseIndex else
releasePairs.indexOfFirst { predicate(it.first) }
val firstSelected = if (firstReleaseIndex >= 0) releasePairs[firstReleaseIndex] else null
val releases = releasePairs.map { (release, incompatibilities) -> release
.copy(incompatibilities = incompatibilities, selected = firstSelected
?.let { it.first.versionCode == release.versionCode && it.second == incompatibilities } == true) }
return product.copy(releases = releases)
}
val releases = releasePairs.map { (release, incompatibilities) ->
release
.copy(incompatibilities = incompatibilities, selected = firstSelected
?.let { it.first.versionCode == release.versionCode && it.second == incompatibilities } == true)
}
return product.copy(releases = releases)
}
}

View File

@ -1,9 +1,9 @@
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.RxUtils
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.Cache
import okhttp3.Call
import okhttp3.OkHttpClient
@ -15,100 +15,114 @@ import java.net.Proxy
import java.util.concurrent.TimeUnit
object Downloader {
private data class ClientConfiguration(val cache: Cache?, val onion: Boolean)
private data class ClientConfiguration(val cache: Cache?, val onion: Boolean)
private val clients = mutableMapOf<ClientConfiguration, OkHttpClient>()
private val onionProxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050))
private val clients = mutableMapOf<ClientConfiguration, OkHttpClient>()
private val onionProxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050))
var proxy: Proxy? = null
set(value) {
if (field != value) {
synchronized(clients) {
field = value
clients.keys.removeAll { !it.onion }
}
}
}
private fun createClient(proxy: Proxy?, cache: Cache?): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30L, TimeUnit.SECONDS)
.readTimeout(15L, TimeUnit.SECONDS)
.writeTimeout(15L, TimeUnit.SECONDS)
.proxy(proxy).cache(cache).build()
}
class Result(val code: Int, val lastModified: String, val entityTag: String) {
val success: Boolean
get() = code == 200 || code == 206
val isNotChanged: Boolean
get() = code == 304
}
fun createCall(request: Request.Builder, authentication: String, cache: Cache?): Call {
val oldRequest = request.build()
val newRequest = if (authentication.isNotEmpty()) {
request.addHeader("Authorization", authentication).build()
} else {
request.build()
}
val onion = oldRequest.url.host.endsWith(".onion")
val client = synchronized(clients) {
val proxy = if (onion) onionProxy else proxy
val clientConfiguration = ClientConfiguration(cache, onion)
clients[clientConfiguration] ?: run {
val client = createClient(proxy, cache)
clients[clientConfiguration] = client
client
}
}
return client.newCall(newRequest)
}
fun download(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 request = Request.Builder().url(url)
.apply {
if (entityTag.isNotEmpty()) {
addHeader("If-None-Match", entityTag)
} else if (lastModified.isNotEmpty()) {
addHeader("If-Modified-Since", lastModified)
}
if (start != null) {
addHeader("Range", "bytes=$start-")
}
}
return RxUtils
.callSingle { createCall(request, authentication, null) }
.subscribeOn(Schedulers.io())
.flatMap { result -> RxUtils
.managedSingle { result.use {
if (result.code == 304) {
Result(it.code, lastModified, entityTag)
} else {
val body = it.body!!
val append = start != null && it.header("Content-Range") != null
val progressStart = if (append && start != null) start else 0L
val progressTotal = body.contentLength().let { if (it >= 0L) it else null }
?.let { progressStart + it }
val inputStream = ProgressInputStream(body.byteStream()) {
if (Thread.interrupted()) {
throw InterruptedException()
}
callback?.invoke(progressStart + it, progressTotal)
var proxy: Proxy? = null
set(value) {
if (field != value) {
synchronized(clients) {
field = value
clients.keys.removeAll { !it.onion }
}
}
inputStream.use { input ->
val outputStream = if (append) FileOutputStream(target, true) else FileOutputStream(target)
outputStream.use { output ->
input.copyTo(output)
output.fd.sync()
}
}
private fun createClient(proxy: Proxy?, cache: Cache?): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30L, TimeUnit.SECONDS)
.readTimeout(15L, TimeUnit.SECONDS)
.writeTimeout(15L, TimeUnit.SECONDS)
.proxy(proxy).cache(cache).build()
}
class Result(val code: Int, val lastModified: String, val entityTag: String) {
val success: Boolean
get() = code == 200 || code == 206
val isNotChanged: Boolean
get() = code == 304
}
fun createCall(request: Request.Builder, authentication: String, cache: Cache?): Call {
val oldRequest = request.build()
val newRequest = if (authentication.isNotEmpty()) {
request.addHeader("Authorization", authentication).build()
} else {
request.build()
}
val onion = oldRequest.url.host.endsWith(".onion")
val client = synchronized(clients) {
val proxy = if (onion) onionProxy else proxy
val clientConfiguration = ClientConfiguration(cache, onion)
clients[clientConfiguration] ?: run {
val client = createClient(proxy, cache)
clients[clientConfiguration] = client
client
}
Result(it.code, it.header("Last-Modified").orEmpty(), it.header("ETag").orEmpty())
}
} } }
}
}
return client.newCall(newRequest)
}
fun download(
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 request = Request.Builder().url(url)
.apply {
if (entityTag.isNotEmpty()) {
addHeader("If-None-Match", entityTag)
} else if (lastModified.isNotEmpty()) {
addHeader("If-Modified-Since", lastModified)
}
if (start != null) {
addHeader("Range", "bytes=$start-")
}
}
return RxUtils
.callSingle { createCall(request, authentication, null) }
.subscribeOn(Schedulers.io())
.flatMap { result ->
RxUtils
.managedSingle {
result.use { it ->
if (result.code == 304) {
Result(it.code, lastModified, entityTag)
} else {
val body = it.body!!
val append = start != null && it.header("Content-Range") != null
val progressStart = if (append && start != null) start else 0L
val progressTotal =
body.contentLength().let { if (it >= 0L) it else null }
?.let { progressStart + it }
val inputStream = ProgressInputStream(body.byteStream()) {
if (Thread.interrupted()) {
throw InterruptedException()
}
callback?.invoke(progressStart + it, progressTotal)
}
inputStream.use { input ->
val outputStream = if (append) FileOutputStream(
target,
true
) else FileOutputStream(target)
outputStream.use { output ->
input.copyTo(output)
output.fd.sync()
}
}
Result(
it.code,
it.header("Last-Modified").orEmpty(),
it.header("ETag").orEmpty()
)
}
}
}
}
}
}

View File

@ -5,114 +5,141 @@ import android.net.Uri
import android.view.View
import com.looker.droidify.entity.Product
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.Call
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File
import kotlin.math.*
import kotlin.math.min
import kotlin.math.roundToInt
object PicassoDownloader {
private const val HOST_ICON = "icon"
private const val HOST_SCREENSHOT = "screenshot"
private const val QUERY_ADDRESS = "address"
private const val QUERY_AUTHENTICATION = "authentication"
private const val QUERY_PACKAGE_NAME = "packageName"
private const val QUERY_ICON = "icon"
private const val QUERY_METADATA_ICON = "metadataIcon"
private const val QUERY_LOCALE = "locale"
private const val QUERY_DEVICE = "device"
private const val QUERY_SCREENSHOT = "screenshot"
private const val QUERY_DPI = "dpi"
private const val HOST_ICON = "icon"
private const val HOST_SCREENSHOT = "screenshot"
private const val QUERY_ADDRESS = "address"
private const val QUERY_AUTHENTICATION = "authentication"
private const val QUERY_PACKAGE_NAME = "packageName"
private const val QUERY_ICON = "icon"
private const val QUERY_METADATA_ICON = "metadataIcon"
private const val QUERY_LOCALE = "locale"
private const val QUERY_DEVICE = "device"
private const val QUERY_SCREENSHOT = "screenshot"
private const val QUERY_DPI = "dpi"
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 {
private val cache = Cache(cacheDir, 50_000_000L)
class Factory(cacheDir: File) : Call.Factory {
private val cache = Cache(cacheDir, 50_000_000L)
override fun newCall(request: okhttp3.Request): Call {
return when (request.url.host) {
HOST_ICON -> {
val address = request.url.queryParameter(QUERY_ADDRESS)?.nullIfEmpty()
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
val path = run {
val packageName = request.url.queryParameter(QUERY_PACKAGE_NAME)?.nullIfEmpty()
val icon = request.url.queryParameter(QUERY_ICON)?.nullIfEmpty()
val metadataIcon = request.url.queryParameter(QUERY_METADATA_ICON)?.nullIfEmpty()
val dpi = request.url.queryParameter(QUERY_DPI)?.nullIfEmpty()
when {
icon != null -> "${if (dpi != null) "icons-$dpi" else "icons"}/$icon"
packageName != null && metadataIcon != null -> "$packageName/$metadataIcon"
else -> null
override fun newCall(request: okhttp3.Request): Call {
return when (request.url.host) {
HOST_ICON -> {
val address = request.url.queryParameter(QUERY_ADDRESS)?.nullIfEmpty()
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
val path = run {
val packageName =
request.url.queryParameter(QUERY_PACKAGE_NAME)?.nullIfEmpty()
val icon = request.url.queryParameter(QUERY_ICON)?.nullIfEmpty()
val metadataIcon =
request.url.queryParameter(QUERY_METADATA_ICON)?.nullIfEmpty()
val dpi = request.url.queryParameter(QUERY_DPI)?.nullIfEmpty()
when {
icon != null -> "${if (dpi != null) "icons-$dpi" else "icons"}/$icon"
packageName != null && metadataIcon != null -> "$packageName/$metadataIcon"
else -> null
}
}
if (address == null || path == null) {
Downloader.createCall(request.newBuilder(), "", null)
} else {
Downloader.createCall(
request.newBuilder().url(
address.toHttpUrl()
.newBuilder().addPathSegments(path).build()
), authentication.orEmpty(), cache
)
}
}
HOST_SCREENSHOT -> {
val address = request.url.queryParameter(QUERY_ADDRESS)
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
val packageName = request.url.queryParameter(QUERY_PACKAGE_NAME)
val locale = request.url.queryParameter(QUERY_LOCALE)
val device = request.url.queryParameter(QUERY_DEVICE)
val screenshot = request.url.queryParameter(QUERY_SCREENSHOT)
if (screenshot.isNullOrEmpty() || address.isNullOrEmpty()) {
Downloader.createCall(request.newBuilder(), "", null)
} else {
Downloader.createCall(
request.newBuilder().url(
address.toHttpUrl()
.newBuilder().addPathSegment(packageName.orEmpty())
.addPathSegment(locale.orEmpty())
.addPathSegment(device.orEmpty())
.addPathSegment(screenshot.orEmpty()).build()
),
authentication.orEmpty(), cache
)
}
}
else -> {
Downloader.createCall(request.newBuilder(), "", null)
}
}
}
if (address == null || path == null) {
Downloader.createCall(request.newBuilder(), "", null)
} else {
Downloader.createCall(request.newBuilder().url(address.toHttpUrl()
.newBuilder().addPathSegments(path).build()), authentication.orEmpty(), cache)
}
}
HOST_SCREENSHOT -> {
val address = request.url.queryParameter(QUERY_ADDRESS)
val authentication = request.url.queryParameter(QUERY_AUTHENTICATION)
val packageName = request.url.queryParameter(QUERY_PACKAGE_NAME)
val locale = request.url.queryParameter(QUERY_LOCALE)
val device = request.url.queryParameter(QUERY_DEVICE)
val screenshot = request.url.queryParameter(QUERY_SCREENSHOT)
if (screenshot.isNullOrEmpty() || address.isNullOrEmpty()) {
Downloader.createCall(request.newBuilder(), "", null)
} else {
Downloader.createCall(request.newBuilder().url(address.toHttpUrl()
.newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty())
.addPathSegment(device.orEmpty()).addPathSegment(screenshot.orEmpty()).build()),
authentication.orEmpty(), cache)
}
}
else -> {
Downloader.createCall(request.newBuilder(), "", null)
}
}
}
}
fun createScreenshotUri(repository: Repository, packageName: String, screenshot: Product.Screenshot): Uri {
return Uri.Builder().scheme("https").authority(HOST_SCREENSHOT)
.appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
.appendQueryParameter(QUERY_LOCALE, screenshot.locale)
.appendQueryParameter(QUERY_DEVICE, when (screenshot.type) {
Product.Screenshot.Type.PHONE -> "phoneScreenshots"
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
})
.appendQueryParameter(QUERY_SCREENSHOT, screenshot.path)
.build()
}
fun createScreenshotUri(
repository: Repository,
packageName: String,
screenshot: Product.Screenshot
): Uri {
return Uri.Builder().scheme("https").authority(HOST_SCREENSHOT)
.appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
.appendQueryParameter(QUERY_LOCALE, screenshot.locale)
.appendQueryParameter(
QUERY_DEVICE, when (screenshot.type) {
Product.Screenshot.Type.PHONE -> "phoneScreenshots"
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
}
)
.appendQueryParameter(QUERY_SCREENSHOT, screenshot.path)
.build()
}
fun createIconUri(view: View, packageName: String, icon: String, metadataIcon: String, repository: Repository): Uri {
val size = (view.layoutParams.let { min(it.width, it.height) } /
view.resources.displayMetrics.density).roundToInt()
return createIconUri(view.context, packageName, icon, metadataIcon, size, repository)
}
fun createIconUri(
view: View,
packageName: String,
icon: String,
metadataIcon: String,
repository: Repository
): Uri {
val size = (view.layoutParams.let { min(it.width, it.height) } /
view.resources.displayMetrics.density).roundToInt()
return createIconUri(view.context, packageName, icon, metadataIcon, size, repository)
}
private fun createIconUri(context: Context, packageName: String, icon: String, metadataIcon: String,
targetSizeDp: Int, repository: Repository): Uri {
return Uri.Builder().scheme("https").authority(HOST_ICON)
.appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
.appendQueryParameter(QUERY_ICON, icon)
.appendQueryParameter(QUERY_METADATA_ICON, metadataIcon)
.apply {
if (repository.version >= 11) {
val displayDpi = context.resources.displayMetrics.densityDpi
val requiredDpi = displayDpi * targetSizeDp / 48
val iconDpi = supportedDpis.find { it >= requiredDpi } ?: supportedDpis.last()
appendQueryParameter(QUERY_DPI, iconDpi.toString())
}
}
.build()
}
private fun createIconUri(
context: Context, packageName: String, icon: String, metadataIcon: String,
targetSizeDp: Int, repository: Repository
): Uri {
return Uri.Builder().scheme("https").authority(HOST_ICON)
.appendQueryParameter(QUERY_ADDRESS, repository.address)
.appendQueryParameter(QUERY_AUTHENTICATION, repository.authentication)
.appendQueryParameter(QUERY_PACKAGE_NAME, packageName)
.appendQueryParameter(QUERY_ICON, icon)
.appendQueryParameter(QUERY_METADATA_ICON, metadataIcon)
.apply {
if (repository.version >= 11) {
val displayDpi = context.resources.displayMetrics.densityDpi
val requiredDpi = displayDpi * targetSizeDp / 48
val iconDpi = supportedDpis.find { it >= requiredDpi } ?: supportedDpis.last()
appendQueryParameter(QUERY_DPI, iconDpi.toString())
}
}
.build()
}
}

View File

@ -12,220 +12,264 @@ import com.looker.droidify.R
import com.looker.droidify.entity.Release
import com.looker.droidify.utility.KParcelable
import com.looker.droidify.utility.PackageItemResolver
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.text.*
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.text.nullIfEmpty
class MessageDialog(): DialogFragment() {
companion object {
private const val EXTRA_MESSAGE = "message"
}
sealed class Message: KParcelable {
object DeleteRepositoryConfirm: Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { DeleteRepositoryConfirm }
class MessageDialog() : DialogFragment() {
companion object {
private const val EXTRA_MESSAGE = "message"
}
object CantEditSyncing: Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { CantEditSyncing }
}
class Link(val uri: Uri): Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(uri.toString())
}
companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val uri = Uri.parse(it.readString()!!)
Link(uri)
sealed class Message : KParcelable {
object DeleteRepositoryConfirm : Message() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { DeleteRepositoryConfirm }
}
}
}
class Permissions(val group: String?, val permissions: List<String>): Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(group)
dest.writeStringList(permissions)
}
companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val group = it.readString()
val permissions = it.createStringArrayList()!!
Permissions(group, permissions)
object CantEditSyncing : Message() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { CantEditSyncing }
}
}
}
class ReleaseIncompatible(val incompatibilities: List<Release.Incompatibility>,
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int): Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeInt(incompatibilities.size)
for (incompatibility in incompatibilities) {
when (incompatibility) {
is Release.Incompatibility.MinSdk -> {
dest.writeInt(0)
class Link(val uri: Uri) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(uri.toString())
}
is Release.Incompatibility.MaxSdk -> {
dest.writeInt(1)
}
is Release.Incompatibility.Platform -> {
dest.writeInt(2)
}
is Release.Incompatibility.Feature -> {
dest.writeInt(3)
dest.writeString(incompatibility.feature)
}
}::class
}
dest.writeStringList(platforms)
dest.writeInt(minSdkVersion)
dest.writeInt(maxSdkVersion)
}
companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val count = it.readInt()
val incompatibilities = generateSequence {
when (it.readInt()) {
0 -> Release.Incompatibility.MinSdk
1 -> Release.Incompatibility.MaxSdk
2 -> Release.Incompatibility.Platform
3 -> Release.Incompatibility.Feature(it.readString()!!)
else -> throw RuntimeException()
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val uri = Uri.parse(it.readString()!!)
Link(uri)
}
}
}.take(count).toList()
val platforms = it.createStringArrayList()!!
val minSdkVersion = it.readInt()
val maxSdkVersion = it.readInt()
ReleaseIncompatible(incompatibilities, platforms, minSdkVersion, maxSdkVersion)
}
}
class Permissions(val group: String?, val permissions: List<String>) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(group)
dest.writeStringList(permissions)
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val group = it.readString()
val permissions = it.createStringArrayList()!!
Permissions(group, permissions)
}
}
}
class ReleaseIncompatible(
val incompatibilities: List<Release.Incompatibility>,
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int
) : Message() {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeInt(incompatibilities.size)
for (incompatibility in incompatibilities) {
when (incompatibility) {
is Release.Incompatibility.MinSdk -> {
dest.writeInt(0)
}
is Release.Incompatibility.MaxSdk -> {
dest.writeInt(1)
}
is Release.Incompatibility.Platform -> {
dest.writeInt(2)
}
is Release.Incompatibility.Feature -> {
dest.writeInt(3)
dest.writeString(incompatibility.feature)
}
}::class
}
dest.writeStringList(platforms)
dest.writeInt(minSdkVersion)
dest.writeInt(maxSdkVersion)
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val count = it.readInt()
val incompatibilities = generateSequence {
when (it.readInt()) {
0 -> Release.Incompatibility.MinSdk
1 -> Release.Incompatibility.MaxSdk
2 -> Release.Incompatibility.Platform
3 -> Release.Incompatibility.Feature(it.readString()!!)
else -> throw RuntimeException()
}
}.take(count).toList()
val platforms = it.createStringArrayList()!!
val minSdkVersion = it.readInt()
val maxSdkVersion = it.readInt()
ReleaseIncompatible(incompatibilities, platforms, minSdkVersion, maxSdkVersion)
}
}
}
object ReleaseOlder : Message() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { ReleaseOlder }
}
object ReleaseSignatureMismatch : Message() {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator { ReleaseSignatureMismatch }
}
}
object ReleaseOlder: Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseOlder }
constructor(message: Message) : this() {
arguments = Bundle().apply {
putParcelable(EXTRA_MESSAGE, message)
}
}
object ReleaseSignatureMismatch: Message() {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseSignatureMismatch }
fun show(fragmentManager: FragmentManager) {
show(fragmentManager, this::class.java.name)
}
}
constructor(message: Message): this() {
arguments = Bundle().apply {
putParcelable(EXTRA_MESSAGE, message)
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val dialog = AlertDialog.Builder(requireContext())
when (val message = requireArguments().getParcelable<Message>(EXTRA_MESSAGE)!!) {
is Message.DeleteRepositoryConfirm -> {
dialog.setTitle(R.string.confirmation)
dialog.setMessage(R.string.delete_repository_DESC)
dialog.setPositiveButton(R.string.delete) { _, _ -> (parentFragment as RepositoryFragment).onDeleteConfirm() }
dialog.setNegativeButton(R.string.cancel, null)
}
is Message.CantEditSyncing -> {
dialog.setTitle(R.string.action_failed)
dialog.setMessage(R.string.cant_edit_sync_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.Link -> {
dialog.setTitle(R.string.confirmation)
dialog.setMessage(getString(R.string.open_DESC_FORMAT, message.uri.toString()))
dialog.setPositiveButton(R.string.ok) { _, _ ->
try {
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
}
}
dialog.setNegativeButton(R.string.cancel, null)
}
is Message.Permissions -> {
val packageManager = requireContext().packageManager
val builder = StringBuilder()
val localCache = PackageItemResolver.LocalCache()
val title = if (message.group != null) {
val name = try {
val permissionGroupInfo =
packageManager.getPermissionGroupInfo(message.group, 0)
PackageItemResolver.loadLabel(
requireContext(),
localCache,
permissionGroupInfo
)
?.nullIfEmpty()?.let { if (it == message.group) null else it }
} catch (e: Exception) {
null
}
name ?: getString(R.string.unknown)
} else {
getString(R.string.other)
}
for (permission in message.permissions) {
val description = try {
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
PackageItemResolver.loadDescription(
requireContext(),
localCache,
permissionInfo
)
?.nullIfEmpty()?.let { if (it == permission) null else it }
} catch (e: Exception) {
null
}
description?.let { builder.append(it).append("\n\n") }
}
if (builder.isNotEmpty()) {
builder.delete(builder.length - 2, builder.length)
} else {
builder.append(getString(R.string.no_description_available_DESC))
}
dialog.setTitle(title)
dialog.setMessage(builder)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseIncompatible -> {
val builder = StringBuilder()
val minSdkVersion = if (Release.Incompatibility.MinSdk in message.incompatibilities)
message.minSdkVersion else null
val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities)
message.maxSdkVersion else null
if (minSdkVersion != null || maxSdkVersion != null) {
val versionMessage = minSdkVersion?.let {
getString(
R.string.incompatible_api_min_DESC_FORMAT,
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) {
builder.append(
getString(
R.string.incompatible_platforms_DESC_FORMAT,
Android.primaryPlatform ?: getString(R.string.unknown),
message.platforms.joinToString(separator = ", ")
)
).append("\n\n")
}
val features =
message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
if (features.isNotEmpty()) {
builder.append(getString(R.string.incompatible_features_DESC))
for (feature in features) {
builder.append("\n\u2022 ").append(feature.feature)
}
builder.append("\n\n")
}
if (builder.isNotEmpty()) {
builder.delete(builder.length - 2, builder.length)
}
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(builder)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseOlder -> {
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(R.string.incompatible_older_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseSignatureMismatch -> {
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(R.string.incompatible_signature_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
}::class
return dialog.create()
}
}
fun show(fragmentManager: FragmentManager) {
show(fragmentManager, this::class.java.name)
}
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val dialog = AlertDialog.Builder(requireContext())
when (val message = requireArguments().getParcelable<Message>(EXTRA_MESSAGE)!!) {
is Message.DeleteRepositoryConfirm -> {
dialog.setTitle(R.string.confirmation)
dialog.setMessage(R.string.delete_repository_DESC)
dialog.setPositiveButton(R.string.delete) { _, _ -> (parentFragment as RepositoryFragment).onDeleteConfirm() }
dialog.setNegativeButton(R.string.cancel, null)
}
is Message.CantEditSyncing -> {
dialog.setTitle(R.string.action_failed)
dialog.setMessage(R.string.cant_edit_sync_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.Link -> {
dialog.setTitle(R.string.confirmation)
dialog.setMessage(getString(R.string.open_DESC_FORMAT, message.uri.toString()))
dialog.setPositiveButton(R.string.ok) { _, _ ->
try {
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
}
}
dialog.setNegativeButton(R.string.cancel, null)
}
is Message.Permissions -> {
val packageManager = requireContext().packageManager
val builder = StringBuilder()
val localCache = PackageItemResolver.LocalCache()
val title = if (message.group != null) {
val name = try {
val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0)
PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo)
?.nullIfEmpty()?.let { if (it == message.group) null else it }
} catch (e: Exception) {
null
}
name ?: getString(R.string.unknown)
} else {
getString(R.string.other)
}
for (permission in message.permissions) {
val description = try {
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo)
?.nullIfEmpty()?.let { if (it == permission) null else it }
} catch (e: Exception) {
null
}
description?.let { builder.append(it).append("\n\n") }
}
if (builder.isNotEmpty()) {
builder.delete(builder.length - 2, builder.length)
} else {
builder.append(getString(R.string.no_description_available_DESC))
}
dialog.setTitle(title)
dialog.setMessage(builder)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseIncompatible -> {
val builder = StringBuilder()
val minSdkVersion = if (Release.Incompatibility.MinSdk in message.incompatibilities)
message.minSdkVersion else null
val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities)
message.maxSdkVersion else null
if (minSdkVersion != null || maxSdkVersion != null) {
val versionMessage = minSdkVersion?.let { getString(R.string.incompatible_api_min_DESC_FORMAT, 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) {
builder.append(getString(R.string.incompatible_platforms_DESC_FORMAT,
Android.primaryPlatform ?: getString(R.string.unknown),
message.platforms.joinToString(separator = ", "))).append("\n\n")
}
val features = message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
if (features.isNotEmpty()) {
builder.append(getString(R.string.incompatible_features_DESC))
for (feature in features) {
builder.append("\n\u2022 ").append(feature.feature)
}
builder.append("\n\n")
}
if (builder.isNotEmpty()) {
builder.delete(builder.length - 2, builder.length)
}
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(builder)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseOlder -> {
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(R.string.incompatible_older_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
is Message.ReleaseSignatureMismatch -> {
dialog.setTitle(R.string.incompatible_version)
dialog.setMessage(R.string.incompatible_signature_DESC)
dialog.setPositiveButton(R.string.ok, null)
}
}::class
return dialog.create()
}
}

View File

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

View File

@ -15,10 +15,6 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.MarginPageTransformer
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.database.Database
import com.looker.droidify.entity.Product
@ -26,208 +22,245 @@ import com.looker.droidify.entity.Repository
import com.looker.droidify.graphics.PaddingDrawable
import com.looker.droidify.network.PicassoDownloader
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.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() {
companion object {
private const val EXTRA_PACKAGE_NAME = "packageName"
private const val EXTRA_REPOSITORY_ID = "repositoryId"
private const val EXTRA_IDENTIFIER = "identifier"
class ScreenshotsFragment() : DialogFragment() {
companion object {
private const val EXTRA_PACKAGE_NAME = "packageName"
private const val EXTRA_REPOSITORY_ID = "repositoryId"
private const val EXTRA_IDENTIFIER = "identifier"
private const val STATE_IDENTIFIER = "identifier"
}
constructor(packageName: String, repositoryId: Long, identifier: String): this() {
arguments = Bundle().apply {
putString(EXTRA_PACKAGE_NAME, packageName)
putLong(EXTRA_REPOSITORY_ID, repositoryId)
putString(EXTRA_IDENTIFIER, identifier)
private const val STATE_IDENTIFIER = "identifier"
}
}
fun show(fragmentManager: FragmentManager) {
show(fragmentManager, this::class.java.name)
}
private var viewPager: ViewPager2? = null
private var productDisposable: Disposable? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
val repositoryId = requireArguments().getLong(EXTRA_REPOSITORY_ID)
val dialog = Dialog(requireContext(), R.style.Theme_Main_Dark)
val window = dialog.window!!
val decorView = window.decorView
val background = 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)
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
window.statusBarColor = it
window.navigationBarColor = it
}
window.attributes = window.attributes.apply {
title = ScreenshotsFragment::class.java.name
format = PixelFormat.TRANSLUCENT
windowAnimations = run {
val typedArray = dialog.context.obtainStyledAttributes(null,
intArrayOf(android.R.attr.windowAnimationStyle), android.R.attr.dialogTheme, 0)
try {
typedArray.getResourceId(0, 0)
} finally {
typedArray.recycle()
constructor(packageName: String, repositoryId: Long, identifier: String) : this() {
arguments = Bundle().apply {
putString(EXTRA_PACKAGE_NAME, packageName)
putLong(EXTRA_REPOSITORY_ID, repositoryId)
putString(EXTRA_IDENTIFIER, identifier)
}
}
if (Android.sdk(28)) {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
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
val applyHide = Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
val handleClick = {
decorView.removeCallbacks(applyHide)
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) {
decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv()
} else {
decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags
}
fun show(fragmentManager: FragmentManager) {
show(fragmentManager, this::class.java.name)
}
decorView.postDelayed(applyHide, 2000L)
decorView.setOnClickListener { handleClick() }
val viewPager = ViewPager2(dialog.context)
viewPager.adapter = Adapter(packageName) { handleClick() }
viewPager.setPageTransformer(MarginPageTransformer(resources.sizeScaled(16)))
viewPager.viewTreeObserver.addOnGlobalLayoutListener {
(viewPager.adapter as Adapter).size = Pair(viewPager.width, viewPager.height)
}
dialog.addContentView(viewPager, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT))
this.viewPager = viewPager
private var viewPager: ViewPager2? = null
var restored = false
productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
.map { Pair(it.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
val (product, repository) = it
val screenshots = product?.screenshots.orEmpty()
(viewPager.adapter as Adapter).update(repository, screenshots)
if (!restored) {
restored = true
val identifier = savedInstanceState?.getString(STATE_IDENTIFIER)
?: requireArguments().getString(STATE_IDENTIFIER)
if (identifier != null) {
val index = screenshots.indexOfFirst { it.identifier == identifier }
if (index >= 0) {
viewPager.setCurrentItem(index, false)
private var productDisposable: Disposable? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
val repositoryId = requireArguments().getLong(EXTRA_REPOSITORY_ID)
val dialog = Dialog(requireContext(), R.style.Theme_Main_Dark)
val window = dialog.window!!
val decorView = window.decorView
val background =
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)
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
window.statusBarColor = it
window.navigationBarColor = it
}
window.attributes = window.attributes.apply {
title = ScreenshotsFragment::class.java.name
format = PixelFormat.TRANSLUCENT
windowAnimations = run {
val typedArray = dialog.context.obtainStyledAttributes(
null,
intArrayOf(android.R.attr.windowAnimationStyle), android.R.attr.dialogTheme, 0
)
try {
typedArray.getResourceId(0, 0)
} finally {
typedArray.recycle()
}
}
if (Android.sdk(28)) {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
}
return dialog
}
override fun onDestroyView() {
super.onDestroyView()
viewPager = null
productDisposable?.dispose()
productDisposable = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val viewPager = viewPager
if (viewPager != null) {
val identifier = (viewPager.adapter as Adapter).getCurrentIdentifier(viewPager)
identifier?.let { outState.putString(STATE_IDENTIFIER, it) }
}
}
private class Adapter(private val packageName: String, private val onClick: () -> Unit):
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT }
private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) {
val image: ImageView
get() = itemView as ImageView
val placeholder: Drawable
init {
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT)
val placeholder = itemView.context.getDrawableCompat(R.drawable.ic_photo_camera).mutate()
placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) })
this.placeholder = PaddingDrawable(placeholder, 4f)
}
}
private var repository: Repository? = null
private var screenshots = emptyList<Product.Screenshot>()
fun update(repository: Repository?, screenshots: List<Product.Screenshot>) {
this.repository = repository
this.screenshots = screenshots
notifyDataSetChanged()
}
var size = Pair(0, 0)
set(value) {
if (field != value) {
field = value
notifyDataSetChanged()
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
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
val applyHide =
Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
val handleClick = {
decorView.removeCallbacks(applyHide)
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) {
decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv()
} else {
decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags
}
}
}
decorView.postDelayed(applyHide, 2000L)
decorView.setOnClickListener { handleClick() }
fun getCurrentIdentifier(viewPager: ViewPager2): String? {
val position = viewPager.currentItem
return screenshots.getOrNull(position)?.identifier
}
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
override fun getItemCount(): Int = screenshots.size
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return ViewHolder(parent.context).apply {
itemView.setOnClickListener { onClick() }
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as ViewHolder
val screenshot = screenshots[position]
val (width, height) = size
if (width > 0 && height > 0) {
holder.image.load(PicassoDownloader.createScreenshotUri(repository!!, packageName, screenshot)) {
placeholder(holder.placeholder)
error(holder.placeholder)
resize(width, height)
centerInside()
val viewPager = ViewPager2(dialog.context)
viewPager.adapter = Adapter(packageName) { handleClick() }
viewPager.setPageTransformer(MarginPageTransformer(resources.sizeScaled(16)))
viewPager.viewTreeObserver.addOnGlobalLayoutListener {
(viewPager.adapter as Adapter).size = Pair(viewPager.width, viewPager.height)
}
dialog.addContentView(
viewPager, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
this.viewPager = viewPager
var restored = false
productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
.map { it ->
Pair(
it.find { it.repositoryId == repositoryId },
Database.RepositoryAdapter.get(repositoryId)
)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { it ->
val (product, repository) = it
val screenshots = product?.screenshots.orEmpty()
(viewPager.adapter as Adapter).update(repository, screenshots)
if (!restored) {
restored = true
val identifier = savedInstanceState?.getString(STATE_IDENTIFIER)
?: requireArguments().getString(STATE_IDENTIFIER)
if (identifier != null) {
val index = screenshots.indexOfFirst { it.identifier == identifier }
if (index >= 0) {
viewPager.setCurrentItem(index, false)
}
}
}
}
return dialog
}
override fun onDestroyView() {
super.onDestroyView()
viewPager = null
productDisposable?.dispose()
productDisposable = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val viewPager = viewPager
if (viewPager != null) {
val identifier = (viewPager.adapter as Adapter).getCurrentIdentifier(viewPager)
identifier?.let { outState.putString(STATE_IDENTIFIER, it) }
}
}
private class Adapter(private val packageName: String, private val onClick: () -> Unit) :
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT }
private class ViewHolder(context: Context) : RecyclerView.ViewHolder(ImageView(context)) {
val image: ImageView
get() = itemView as ImageView
val placeholder: Drawable
init {
itemView.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT
)
val placeholder =
itemView.context.getDrawableCompat(R.drawable.ic_photo_camera).mutate()
placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) })
this.placeholder = PaddingDrawable(placeholder, 4f)
}
}
private var repository: Repository? = null
private var screenshots = emptyList<Product.Screenshot>()
fun update(repository: Repository?, screenshots: List<Product.Screenshot>) {
this.repository = repository
this.screenshots = screenshots
notifyDataSetChanged()
}
var size = Pair(0, 0)
set(value) {
if (field != value) {
field = value
notifyDataSetChanged()
}
}
fun getCurrentIdentifier(viewPager: ViewPager2): String? {
val position = viewPager.currentItem
return screenshots.getOrNull(position)?.identifier
}
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
override fun getItemCount(): Int = screenshots.size
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType
): RecyclerView.ViewHolder {
return ViewHolder(parent.context).apply {
itemView.setOnClickListener { onClick() }
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as ViewHolder
val screenshot = screenshots[position]
val (width, height) = size
if (width > 0 && height > 0) {
holder.image.load(
PicassoDownloader.createScreenshotUri(
repository!!,
packageName,
screenshot
)
) {
placeholder(holder.placeholder)
error(holder.placeholder)
resize(width, height)
centerInside()
}
} else {
holder.image.clear()
}
}
} else {
holder.image.clear()
}
}
}
}

View File

@ -6,36 +6,38 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
class Connection<B: IBinder, S: ConnectionService<B>>(private val serviceClass: Class<S>,
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null): ServiceConnection {
var binder: B? = null
private set
class Connection<B : IBinder, S : ConnectionService<B>>(
private val serviceClass: Class<S>,
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null
) : ServiceConnection {
var binder: B? = null
private set
private fun handleUnbind() {
binder?.let {
binder = null
onUnbind?.invoke(this, it)
private fun handleUnbind() {
binder?.let {
binder = null
onUnbind?.invoke(this, it)
}
}
}
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
@Suppress("UNCHECKED_CAST")
binder as B
this.binder = binder
onBind?.invoke(this, binder)
}
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
@Suppress("UNCHECKED_CAST")
binder as B
this.binder = binder
onBind?.invoke(this, binder)
}
override fun onServiceDisconnected(componentName: ComponentName) {
handleUnbind()
}
override fun onServiceDisconnected(componentName: ComponentName) {
handleUnbind()
}
fun bind(context: Context) {
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
}
fun bind(context: Context) {
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
}
fun unbind(context: Context) {
context.unbindService(this)
handleUnbind()
}
fun unbind(context: Context) {
context.unbindService(this)
handleUnbind()
}
}

View File

@ -3,17 +3,17 @@ package com.looker.droidify.service
import android.app.Service
import android.content.Intent
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 override fun onBind(intent: Intent): T
abstract class ConnectionService<T : IBinder> : Service() {
abstract override fun onBind(intent: Intent): T
fun startSelf() {
val intent = Intent(this, this::class.java)
if (Android.sdk(26)) {
startForegroundService(intent)
} else {
startService(intent)
fun startSelf() {
val intent = Intent(this, this::class.java)
if (Android.sdk(26)) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
}

View File

@ -9,10 +9,6 @@ import android.content.Intent
import android.net.Uri
import android.view.ContextThemeWrapper
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.Common
import com.looker.droidify.MainActivity
@ -25,345 +21,449 @@ import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
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.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.math.*
class DownloadService: ConnectionService<DownloadService.Binder>() {
companion object {
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_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME = "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
class DownloadService : ConnectionService<DownloadService.Binder>() {
companion object {
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_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
private val downloadingSubject = PublishSubject.create<State.Downloading>()
}
class Receiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty()
when {
action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW).setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL).setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
}
sealed class State(val packageName: String, val name: String) {
class Pending(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 Success(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 class Task(val packageName: String, val name: String, val release: Release,
val url: String, val authentication: String) {
val notificationTag: String
get() = "download-$packageName"
}
private data class CurrentTask(val task: Task, val disposable: Disposable, val lastState: State)
private var started = false
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
inner class Binder: android.os.Binder() {
fun events(packageName: String): Observable<State> {
return stateSubject.filter { it.packageName == packageName }
private val downloadingSubject = PublishSubject.create<State.Downloading>()
}
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
val task = Task(packageName, name, release, release.getDownloadUrl(repository), repository.authentication)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
publishSuccess(task)
} else {
cancelTasks(packageName)
cancelCurrentTask(packageName)
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
tasks += task
if (currentTask == null) {
handleDownload()
} else {
stateSubject.onNext(State.Pending(packageName, name))
}
}
}
fun cancel(packageName: String) {
cancelTasks(packageName)
cancelCurrentTask(packageName)
handleDownload()
}
fun getState(packageName: String): State? = currentTask
?.let { if (it.task.packageName == packageName) it.lastState else null }
?: tasks.find { it.packageName == packageName }?.let { State.Pending(it.packageName, it.name) }
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var downloadingDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
}
downloadingDisposable = downloadingSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
downloadingDisposable?.dispose()
downloadingDisposable = null
cancelTasks(null)
cancelCurrentTask(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
currentTask?.let { binder.cancel(it.task.packageName) }
}
return START_NOT_STICKY
}
private fun cancelTasks(packageName: String?) {
tasks.removeAll {
(packageName == null || it.packageName == packageName) && run {
stateSubject.onNext(State.Cancel(it.packageName, it.name))
true
}
}
}
private fun cancelCurrentTask(packageName: String?) {
currentTask?.let {
if (packageName == null || it.task.packageName == packageName) {
currentTask = null
stateSubject.onNext(State.Cancel(it.task.packageName, it.task.name))
it.disposable.dispose()
}
}
}
private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS }
private sealed class ErrorType {
object Network: ErrorType()
object Http: ErrorType()
class Validation(val validateError: ValidationError): ErrorType()
}
private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"), PendingIntent.FLAG_UPDATE_CURRENT))
.apply {
when (errorType) {
is ErrorType.Network -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name))
setContentText(getString(R.string.network_error_DESC))
}
is ErrorType.Http -> {
setContentTitle(getString(R.string.could_not_download_FORMAT, task.name))
setContentText(getString(R.string.http_error_DESC))
}
is ErrorType.Validation -> {
setContentTitle(getString(R.string.could_not_validate_FORMAT, task.name))
setContentText(getString(when (errorType.validateError) {
ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
ValidationError.FORMAT -> R.string.file_format_error_DESC
ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
}))
}
}::class
}
.build())
}
private fun showNotificationInstall(task: Task) {
notificationManager.notify(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName), PendingIntent.FLAG_UPDATE_CURRENT))
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC))
.build())
}
private fun publishSuccess(task: Task) {
var consumed = false
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) { consumed = true })
if (!consumed) {
showNotificationInstall(task)
}
}
private fun validatePackage(task: Task, file: File): ValidationError? {
val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType)
file.inputStream().use {
val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) }
digest.digest().hex()
}
} catch (e: Exception) {
""
}
return if (hash.isEmpty() || hash != task.release.hash) {
ValidationError.INTEGRITY
} else {
val packageInfo = try {
packageManager.getPackageArchiveInfo(file.path, Android.PackageManager.signaturesFlag)
} catch (e: Exception) {
e.printStackTrace()
null
}
if (packageInfo == null) {
ValidationError.FORMAT
} else if (packageInfo.packageName != task.packageName ||
packageInfo.versionCodeCompat != task.release.versionCode) {
ValidationError.METADATA
} else {
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
if (signature.isEmpty() || signature != task.release.signature) {
ValidationError.SIGNATURE
} else {
val permissions = packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
if (!task.release.permissions.containsAll(permissions)) {
ValidationError.PERMISSIONS
} else {
null
}
}
}
}
}
private val stateNotificationBuilder by lazy { NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.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) {
if (force || currentTask != null) {
currentTask = currentTask?.copy(lastState = state)
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(1, 0, true)
}
is State.Downloading -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
class Receiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty()
when {
action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL)
.setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
is State.Pending, is State.Success, is State.Error, is State.Cancel -> {
throw IllegalStateException()
}
}::class
}.build())
stateSubject.onNext(state)
}
}
private fun handleDownload() {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
if (!started) {
started = true
startSelf()
}
val initialState = State.Connecting(task.packageName, task.name)
stateNotificationBuilder.setWhen(System.currentTimeMillis())
publishForegroundState(true, initialState)
val partialReleaseFile = Cache.getPartialReleaseFile(this, task.release.cacheFileName)
lateinit var disposable: Disposable
disposable = Downloader
.download(task.url, partialReleaseFile, "", "", task.authentication) { read, total ->
if (!disposable.isDisposed) {
downloadingSubject.onNext(State.Downloading(task.packageName, task.name, read, total))
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (result == null || !result.success) {
showNotificationError(task, if (result != null) ErrorType.Http else ErrorType.Network)
stateSubject.onNext(State.Error(task.packageName, task.name))
} else {
val validationError = validatePackage(task, partialReleaseFile)
if (validationError == null) {
val releaseFile = Cache.getReleaseFile(this, task.release.cacheFileName)
partialReleaseFile.renameTo(releaseFile)
}
sealed class State(val packageName: String, val name: String) {
class Pending(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 Success(
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 class Task(
val packageName: String, val name: String, val release: Release,
val url: String, val authentication: String
) {
val notificationTag: String
get() = "download-$packageName"
}
private data class CurrentTask(val task: Task, val disposable: Disposable, val lastState: State)
private var started = false
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
inner class Binder : android.os.Binder() {
fun events(packageName: String): Observable<State> {
return stateSubject.filter { it.packageName == packageName }
}
fun enqueue(packageName: String, name: String, repository: Repository, release: Release) {
val task = Task(
packageName,
name,
release,
release.getDownloadUrl(repository),
repository.authentication
)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
publishSuccess(task)
} else {
partialReleaseFile.delete()
showNotificationError(task, ErrorType.Validation(validationError))
stateSubject.onNext(State.Error(task.packageName, task.name))
}
} else {
cancelTasks(packageName)
cancelCurrentTask(packageName)
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
tasks += task
if (currentTask == null) {
handleDownload()
} else {
stateSubject.onNext(State.Pending(packageName, name))
}
}
}
fun cancel(packageName: String) {
cancelTasks(packageName)
cancelCurrentTask(packageName)
handleDownload()
}
currentTask = CurrentTask(task, disposable, initialState)
} else if (started) {
started = false
stopForeground(true)
stopSelf()
}
}
fun getState(packageName: String): State? = currentTask
?.let { if (it.task.packageName == packageName) it.lastState else null }
?: tasks.find { it.packageName == packageName }
?.let { State.Pending(it.packageName, it.name) }
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var downloadingDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
}
downloadingDisposable = downloadingSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
downloadingDisposable?.dispose()
downloadingDisposable = null
cancelTasks(null)
cancelCurrentTask(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
currentTask?.let { binder.cancel(it.task.packageName) }
}
return START_NOT_STICKY
}
private fun cancelTasks(packageName: String?) {
tasks.removeAll {
(packageName == null || it.packageName == packageName) && run {
stateSubject.onNext(State.Cancel(it.packageName, it.name))
true
}
}
}
private fun cancelCurrentTask(packageName: String?) {
currentTask?.let {
if (packageName == null || it.task.packageName == packageName) {
currentTask = null
stateSubject.onNext(State.Cancel(it.task.packageName, it.task.name))
it.disposable.dispose()
}
}
}
private enum class ValidationError { INTEGRITY, FORMAT, METADATA, SIGNATURE, PERMISSIONS }
private sealed class ErrorType {
object Network : ErrorType()
object Http : ErrorType()
class Validation(val validateError: ValidationError) : ErrorType()
}
private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING,
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.apply {
when (errorType) {
is ErrorType.Network -> {
setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.network_error_DESC))
}
is ErrorType.Http -> {
setContentTitle(
getString(
R.string.could_not_download_FORMAT,
task.name
)
)
setContentText(getString(R.string.http_error_DESC))
}
is ErrorType.Validation -> {
setContentTitle(
getString(
R.string.could_not_validate_FORMAT,
task.name
)
)
setContentText(
getString(
when (errorType.validateError) {
ValidationError.INTEGRITY -> R.string.integrity_check_error_DESC
ValidationError.FORMAT -> R.string.file_format_error_DESC
ValidationError.METADATA -> R.string.invalid_metadata_error_DESC
ValidationError.SIGNATURE -> R.string.invalid_signature_error_DESC
ValidationError.PERMISSIONS -> R.string.invalid_permissions_error_DESC
}
)
)
}
}::class
}
.build())
}
private fun showNotificationInstall(task: Task) {
notificationManager.notify(
task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentIntent(
PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC))
.build()
)
}
private fun publishSuccess(task: Task) {
var consumed = false
stateSubject.onNext(State.Success(task.packageName, task.name, task.release) {
consumed = true
})
if (!consumed) {
showNotificationInstall(task)
}
}
private fun validatePackage(task: Task, file: File): ValidationError? {
val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType)
file.inputStream().use {
val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }
.forEach { digest.update(bytes, 0, it) }
digest.digest().hex()
}
} catch (e: Exception) {
""
}
return if (hash.isEmpty() || hash != task.release.hash) {
ValidationError.INTEGRITY
} else {
val packageInfo = try {
packageManager.getPackageArchiveInfo(
file.path,
Android.PackageManager.signaturesFlag
)
} catch (e: Exception) {
e.printStackTrace()
null
}
if (packageInfo == null) {
ValidationError.FORMAT
} else if (packageInfo.packageName != task.packageName ||
packageInfo.versionCodeCompat != task.release.versionCode
) {
ValidationError.METADATA
} else {
val signature = packageInfo.singleSignature?.let(Utils::calculateHash).orEmpty()
if (signature.isEmpty() || signature != task.release.signature) {
ValidationError.SIGNATURE
} else {
val permissions =
packageInfo.permissions?.asSequence().orEmpty().map { it.name }.toSet()
if (!task.release.permissions.containsAll(permissions)) {
ValidationError.PERMISSIONS
} else {
null
}
}
}
}
}
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.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) {
if (force || currentTask != null) {
currentTask = currentTask?.copy(lastState = state)
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(1, 0, true)
}
is State.Downloading -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
is State.Pending, is State.Success, is State.Error, is State.Cancel -> {
throw IllegalStateException()
}
}::class
}.build())
stateSubject.onNext(state)
}
}
private fun handleDownload() {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
if (!started) {
started = true
startSelf()
}
val initialState = State.Connecting(task.packageName, task.name)
stateNotificationBuilder.setWhen(System.currentTimeMillis())
publishForegroundState(true, initialState)
val partialReleaseFile =
Cache.getPartialReleaseFile(this, task.release.cacheFileName)
lateinit var disposable: Disposable
disposable = Downloader
.download(
task.url,
partialReleaseFile,
"",
"",
task.authentication
) { read, total ->
if (!disposable.isDisposed) {
downloadingSubject.onNext(
State.Downloading(
task.packageName,
task.name,
read,
total
)
)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (result == null || !result.success) {
showNotificationError(
task,
if (result != null) ErrorType.Http else ErrorType.Network
)
stateSubject.onNext(State.Error(task.packageName, task.name))
} else {
val validationError = validatePackage(task, partialReleaseFile)
if (validationError == null) {
val releaseFile =
Cache.getReleaseFile(this, task.release.cacheFileName)
partialReleaseFile.renameTo(releaseFile)
publishSuccess(task)
} else {
partialReleaseFile.delete()
showNotificationError(task, ErrorType.Validation(validationError))
stateSubject.onNext(State.Error(task.packageName, task.name))
}
}
handleDownload()
}
currentTask = CurrentTask(task, disposable, initialState)
} else if (started) {
started = false
stopForeground(true)
stopSelf()
}
}
}
}
}

View File

@ -12,11 +12,6 @@ import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
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.Common
import com.looker.droidify.MainActivity
@ -27,394 +22,487 @@ import com.looker.droidify.entity.ProductItem
import com.looker.droidify.entity.Repository
import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.*
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.android.asSequence
import com.looker.droidify.utility.extension.android.notificationManager
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.text.formatSize
import 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.util.concurrent.TimeUnit
import kotlin.math.*
import kotlin.math.roundToInt
class SyncService: ConnectionService<SyncService.Binder>() {
companion object {
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
class SyncService : ConnectionService<SyncService.Binder>() {
companion object {
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private val stateSubject = PublishSubject.create<State>()
private val finishSubject = PublishSubject.create<Unit>()
}
private sealed class State {
data class Connecting(val name: String): State()
data class Syncing(val name: String, val stage: RepositoryUpdater.Stage,
val read: Long, val total: Long?): State()
object Finishing: State()
}
private class Task(val repositoryId: Long, val manual: Boolean)
private data class CurrentTask(val task: Task?, val disposable: Disposable,
val hasUpdates: Boolean, val lastState: State)
private enum class Started { NO, AUTO, MANUAL }
private var started = Started.NO
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
enum class SyncRequest { AUTO, MANUAL, FORCE }
inner class Binder: android.os.Binder() {
val finish: Observable<Unit>
get() = finishSubject
private fun sync(ids: List<Long>, request: SyncRequest) {
val cancelledTask = cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
cancelTasks { !it.manual && it.repositoryId in ids }
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
val manual = request != SyncRequest.AUTO
tasks += ids.asSequence().filter { it !in currentIds &&
it != currentTask?.task?.repositoryId }.map { Task(it, manual) }
handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL
startSelf()
handleSetStarted()
currentTask?.lastState?.let { publishForegroundState(true, it) }
}
private val stateSubject = PublishSubject.create<State>()
private val finishSubject = PublishSubject.create<Unit>()
}
fun sync(request: SyncRequest) {
val ids = Database.RepositoryAdapter.getAll(null)
.asSequence().filter { it.enabled }.map { it.id }.toList()
sync(ids, request)
private sealed class State {
data class Connecting(val name: String) : State()
data class Syncing(
val name: String, val stage: RepositoryUpdater.Stage,
val read: Long, val total: Long?
) : State()
object Finishing : State()
}
fun sync(repository: Repository) {
if (repository.enabled) {
sync(listOf(repository.id), SyncRequest.FORCE)
}
}
private class Task(val repositoryId: Long, val manual: Boolean)
private data class CurrentTask(
val task: Task?, val disposable: Disposable,
val hasUpdates: Boolean, val lastState: State
)
fun cancelAuto(): Boolean {
val removed = cancelTasks { !it.manual }
val currentTask = cancelCurrentTask { it.task?.manual == false }
handleNextTask(currentTask?.hasUpdates == true)
return removed || currentTask != null
}
private enum class Started { NO, AUTO, MANUAL }
fun setUpdateNotificationBlocker(fragment: Fragment?) {
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
if (fragment != null) {
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
}
}
private var started = Started.NO
private val tasks = mutableListOf<Task>()
private var currentTask: CurrentTask? = null
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
Database.RepositoryAdapter.put(repository.enable(enabled))
if (enabled) {
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
tasks += Task(repository.id, true)
handleNextTask(false)
}
} else {
cancelTasks { it.repositoryId == repository.id }
val cancelledTask = cancelCurrentTask { it.task?.repositoryId == repository.id }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return true
}
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
fun isCurrentlySyncing(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
enum class SyncRequest { AUTO, MANUAL, FORCE }
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run {
setEnabled(repository, false)
Database.RepositoryAdapter.markAsDeleted(repository.id)
true
}
}
}
inner class Binder : android.os.Binder() {
val finish: Observable<Unit>
get() = finishSubject
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var stateDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(Common.NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
NotificationChannel(Common.NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW)
.let(notificationManager::createNotificationChannel)
}
stateDisposable = stateSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
stateDisposable?.dispose()
stateDisposable = null
cancelTasks { true }
cancelCurrentTask { true }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
tasks.clear()
val cancelledTask = cancelCurrentTask { it.task != null }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return START_NOT_STICKY
}
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
return tasks.removeAll(condition)
}
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
return currentTask?.let {
if (condition(it)) {
currentTask = null
it.disposable.dispose()
RepositoryUpdater.await()
it
} else {
null
}
}
}
private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify("repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name))
.setContentText(getString(when (exception) {
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC
RepositoryUpdater.ErrorType.VALIDATION -> R.string.validation_index_error_DESC
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC
}
else -> R.string.unknown_error_DESC
}))
.build())
}
private val stateNotificationBuilder by lazy { NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.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) {
if (force || currentTask?.lastState != state) {
currentTask = currentTask?.copy(lastState = state)
if (started == Started.MANUAL) {
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(0, 0, true)
private fun sync(ids: List<Long>, request: SyncRequest) {
val cancelledTask =
cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
cancelTasks { !it.manual && it.repositoryId in ids }
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
val manual = request != SyncRequest.AUTO
tasks += ids.asSequence().filter {
it !in currentIds &&
it != currentTask?.task?.repositoryId
}.map { Task(it, manual) }
handleNextTask(cancelledTask?.hasUpdates == true)
if (request != SyncRequest.AUTO && started == Started.AUTO) {
started = Started.MANUAL
startSelf()
handleSetStarted()
currentTask?.lastState?.let { publishForegroundState(true, it) }
}
is State.Syncing -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
when (state.stage) {
RepositoryUpdater.Stage.DOWNLOAD -> {
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(100, (100f * state.read / state.total).roundToInt(), false)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
fun sync(request: SyncRequest) {
val ids = Database.RepositoryAdapter.getAll(null)
.asSequence().filter { it.enabled }.map { it.id }.toList()
sync(ids, request)
}
fun sync(repository: Repository) {
if (repository.enabled) {
sync(listOf(repository.id), SyncRequest.FORCE)
}
}
fun cancelAuto(): Boolean {
val removed = cancelTasks { !it.manual }
val currentTask = cancelCurrentTask { it.task?.manual == false }
handleNextTask(currentTask?.hasUpdates == true)
return removed || currentTask != null
}
fun setUpdateNotificationBlocker(fragment: Fragment?) {
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
if (fragment != null) {
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
}
}
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
Database.RepositoryAdapter.put(repository.enable(enabled))
if (enabled) {
if (repository.id != currentTask?.task?.repositoryId && !tasks.any { it.repositoryId == repository.id }) {
tasks += Task(repository.id, true)
handleNextTask(false)
}
RepositoryUpdater.Stage.PROCESS -> {
val progress = state.total?.let { 100f * state.read / it }?.roundToInt()
setContentText(getString(R.string.processing_FORMAT, "${progress ?: 0}%"))
setProgress(100, progress ?: 0, progress == null)
} else {
cancelTasks { it.repositoryId == repository.id }
val cancelledTask = cancelCurrentTask { it.task?.repositoryId == repository.id }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return true
}
fun isCurrentlySyncing(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run {
setEnabled(repository, false)
Database.RepositoryAdapter.markAsDeleted(repository.id)
true
}
}
}
private val binder = Binder()
override fun onBind(intent: Intent): Binder = binder
private var stateDisposable: Disposable? = null
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
NotificationChannel(
Common.NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW
)
.let(notificationManager::createNotificationChannel)
}
stateDisposable = stateSubject
.sample(500L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { publishForegroundState(false, it) }
}
override fun onDestroy() {
super.onDestroy()
stateDisposable?.dispose()
stateDisposable = null
cancelTasks { true }
cancelCurrentTask { true }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
tasks.clear()
val cancelledTask = cancelCurrentTask { it.task != null }
handleNextTask(cancelledTask?.hasUpdates == true)
}
return START_NOT_STICKY
}
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
return tasks.removeAll(condition)
}
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
return currentTask?.let {
if (condition(it)) {
currentTask = null
it.disposable.dispose()
RepositoryUpdater.await()
it
} else {
null
}
}
}
private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify(
"repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.setContentTitle(getString(R.string.could_not_sync_FORMAT, repository.name))
.setContentText(
getString(
when (exception) {
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
RepositoryUpdater.ErrorType.NETWORK -> R.string.network_error_DESC
RepositoryUpdater.ErrorType.HTTP -> R.string.http_error_DESC
RepositoryUpdater.ErrorType.VALIDATION -> R.string.validation_index_error_DESC
RepositoryUpdater.ErrorType.PARSING -> R.string.parsing_index_error_DESC
}
else -> R.string.unknown_error_DESC
}
)
)
.build()
)
}
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor
)
.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) {
if (force || currentTask?.lastState != state) {
currentTask = currentTask?.copy(lastState = state)
if (started == Started.MANUAL) {
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
setContentText(getString(R.string.connecting))
setProgress(0, 0, true)
}
is State.Syncing -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
when (state.stage) {
RepositoryUpdater.Stage.DOWNLOAD -> {
if (state.total != null) {
setContentText("${state.read.formatSize()} / ${state.total.formatSize()}")
setProgress(
100,
(100f * state.read / state.total).roundToInt(),
false
)
} else {
setContentText(state.read.formatSize())
setProgress(0, 0, true)
}
}
RepositoryUpdater.Stage.PROCESS -> {
val progress =
state.total?.let { 100f * state.read / it }?.roundToInt()
setContentText(
getString(
R.string.processing_FORMAT,
"${progress ?: 0}%"
)
)
setProgress(100, progress ?: 0, progress == null)
}
RepositoryUpdater.Stage.MERGE -> {
val progress = (100f * state.read / (state.total
?: state.read)).roundToInt()
setContentText(
getString(
R.string.merging_FORMAT,
"${state.read} / ${state.total ?: state.read}"
)
)
setProgress(100, progress, false)
}
RepositoryUpdater.Stage.COMMIT -> {
setContentText(getString(R.string.saving_details))
setProgress(0, 0, true)
}
}
}
is State.Finishing -> {
setContentTitle(getString(R.string.syncing))
setContentText(null)
setProgress(0, 0, true)
}
}::class
}.build())
}
}
}
private fun handleSetStarted() {
stateNotificationBuilder.setWhen(System.currentTimeMillis())
}
private fun handleNextTask(hasUpdates: Boolean) {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
val repository = Database.RepositoryAdapter.get(task.repositoryId)
if (repository != null && repository.enabled) {
val lastStarted = started
val newStarted =
if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startSelf()
handleSetStarted()
}
val initialState = State.Connecting(repository.name)
publishForegroundState(true, initialState)
val unstable = Preferences[Preferences.Key.UpdateUnstable]
lateinit var disposable: Disposable
disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) {
stateSubject.onNext(
State.Syncing(
repository.name,
stage,
progress,
total
)
)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (throwable != null && task.manual) {
showNotificationError(repository, throwable as Exception)
}
handleNextTask(result == true || hasUpdates)
}
currentTask = CurrentTask(task, disposable, hasUpdates, initialState)
} else {
handleNextTask(hasUpdates)
}
RepositoryUpdater.Stage.MERGE -> {
val progress = (100f * state.read / (state.total ?: state.read)).roundToInt()
setContentText(getString(R.string.merging_FORMAT, "${state.read} / ${state.total ?: state.read}"))
setProgress(100, progress, false)
} else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils
.querySingle { it ->
Database.ProductAdapter
.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())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
throwable?.printStackTrace()
currentTask = null
handleNextTask(false)
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
if (!blocked && result != null && result.isNotEmpty()) {
displayUpdatesNotification(result)
}
}
currentTask = CurrentTask(null, disposable, true, State.Finishing)
} else {
finishSubject.onNext(Unit)
val needStop = started == Started.MANUAL
started = Started.NO
if (needStop) {
stopForeground(true)
stopSelf()
}
}
RepositoryUpdater.Stage.COMMIT -> {
setContentText(getString(R.string.saving_details))
setProgress(0, 0, true)
}
}
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(
Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available))
.setContentText(
resources.getQuantityString(
R.plurals.new_updates_DESC_FORMAT,
productItems.size, productItems.size
)
)
.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 {
for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(
ForegroundColorSpan(Color.BLACK), 0, builder.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.append(' ').append(productItem.version)
addLine(builder)
}
if (productItems.size > maxUpdates) {
val summary =
getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates)
if (Android.sdk(24)) {
addLine(summary)
} else {
setSummaryText(summary)
}
}
})
.build()
)
}
class Job : JobService() {
private var syncParams: JobParameters? = null
private var syncDisposable: Disposable? = null
private val syncConnection =
Connection(SyncService::class.java, onBind = { connection, binder ->
syncDisposable = binder.finish.subscribe {
val params = syncParams
if (params != null) {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
connection.unbind(this)
jobFinished(params, false)
}
}
}
}
is State.Finishing -> {
setContentTitle(getString(R.string.syncing))
setContentText(null)
setProgress(0, 0, true)
}
}::class
}.build())
}
binder.sync(SyncRequest.AUTO)
}, onUnbind = { _, binder ->
syncDisposable?.dispose()
syncDisposable = null
binder.cancelAuto()
val params = syncParams
if (params != null) {
syncParams = null
jobFinished(params, true)
}
})
override fun onStartJob(params: JobParameters): Boolean {
syncParams = params
syncConnection.bind(this)
return true
}
override fun onStopJob(params: JobParameters): Boolean {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
val reschedule = syncConnection.binder?.cancelAuto() == true
syncConnection.unbind(this)
return reschedule
}
}
}
private fun handleSetStarted() {
stateNotificationBuilder.setWhen(System.currentTimeMillis())
}
private fun handleNextTask(hasUpdates: Boolean) {
if (currentTask == null) {
if (tasks.isNotEmpty()) {
val task = tasks.removeAt(0)
val repository = Database.RepositoryAdapter.get(task.repositoryId)
if (repository != null && repository.enabled) {
val lastStarted = started
val newStarted = if (task.manual || lastStarted == Started.MANUAL) Started.MANUAL else Started.AUTO
started = newStarted
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
startSelf()
handleSetStarted()
}
val initialState = State.Connecting(repository.name)
publishForegroundState(true, initialState)
val unstable = Preferences[Preferences.Key.UpdateUnstable]
lateinit var disposable: Disposable
disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) {
stateSubject.onNext(State.Syncing(repository.name, stage, progress, total))
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
currentTask = null
throwable?.printStackTrace()
if (throwable != null && task.manual) {
showNotificationError(repository, throwable as Exception)
}
handleNextTask(result == true || hasUpdates)
}
currentTask = CurrentTask(task, disposable, hasUpdates, initialState)
} else {
handleNextTask(hasUpdates)
}
} else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils
.querySingle { Database.ProductAdapter
.query(true, true, "", ProductItem.Section.All, ProductItem.Order.NAME, it)
.use { it.asSequence().map(Database.ProductAdapter::transformItem).toList() } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result, throwable ->
throwable?.printStackTrace()
currentTask = null
handleNextTask(false)
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
if (!blocked && result != null && result.isNotEmpty()) {
displayUpdatesNotification(result)
}
}
currentTask = CurrentTask(null, disposable, true, State.Finishing)
} else {
finishSubject.onNext(Unit)
val needStop = started == Started.MANUAL
started = Started.NO
if (needStop) {
stopForeground(true)
stopSelf()
}
}
}
}
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available))
.setContentText(resources.getQuantityString(R.plurals.new_updates_DESC_FORMAT,
productItems.size, productItems.size))
.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 {
for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name)
builder.setSpan(ForegroundColorSpan(Color.BLACK), 0, builder.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.append(' ').append(productItem.version)
addLine(builder)
}
if (productItems.size > maxUpdates) {
val summary = getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates)
if (Android.sdk(24)) {
addLine(summary)
} else {
setSummaryText(summary)
}
}
})
.build())
}
class Job: JobService() {
private var syncParams: JobParameters? = null
private var syncDisposable: Disposable? = null
private val syncConnection = Connection(SyncService::class.java, onBind = { connection, binder ->
syncDisposable = binder.finish.subscribe {
val params = syncParams
if (params != null) {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
connection.unbind(this)
jobFinished(params, false)
}
}
binder.sync(SyncRequest.AUTO)
}, onUnbind = { _, binder ->
syncDisposable?.dispose()
syncDisposable = null
binder.cancelAuto()
val params = syncParams
if (params != null) {
syncParams = null
jobFinished(params, true)
}
})
override fun onStartJob(params: JobParameters): Boolean {
syncParams = params
syncConnection.bind(this)
return true
}
override fun onStopJob(params: JobParameters): Boolean {
syncParams = null
syncDisposable?.dispose()
syncDisposable = null
val reschedule = syncConnection.binder?.cancelAuto() == true
syncConnection.unbind(this)
return reschedule
}
}
}

View File

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

View File

@ -4,119 +4,134 @@ import android.content.Context
import android.content.pm.PackageItemInfo
import android.content.pm.PermissionInfo
import android.content.res.Resources
import com.looker.droidify.utility.extension.android.*
import java.util.Locale
import com.looker.droidify.utility.extension.android.Android
import java.util.*
object PackageItemResolver {
class LocalCache {
internal val resources = mutableMapOf<String, Resources>()
}
class LocalCache {
internal val resources = mutableMapOf<String, Resources>()
}
private data class CacheKey(val locales: List<Locale>, val packageName: String, val resId: Int)
private data class CacheKey(val locales: List<Locale>, val packageName: String, val resId: Int)
private val cache = mutableMapOf<CacheKey, String?>()
private val cache = mutableMapOf<CacheKey, String?>()
private fun load(context: Context, localCache: LocalCache, packageName: String,
nonLocalized: CharSequence?, resId: Int): CharSequence? {
return when {
nonLocalized != null -> {
nonLocalized
}
resId != 0 -> {
val locales = if (Android.sdk(24)) {
val localesList = context.resources.configuration.locales
(0 until localesList.size()).map(localesList::get)
} else {
@Suppress("DEPRECATION")
listOf(context.resources.configuration.locale)
}
val cacheKey = CacheKey(locales, packageName, resId)
if (cache.containsKey(cacheKey)) {
cache[cacheKey]
} else {
val resources = localCache.resources[packageName] ?: run {
val resources = try {
val resources = context.packageManager.getResourcesForApplication(packageName)
@Suppress("DEPRECATION")
resources.updateConfiguration(context.resources.configuration, null)
resources
} catch (e: Exception) {
null
private fun load(
context: Context, localCache: LocalCache, packageName: String,
nonLocalized: CharSequence?, resId: Int
): CharSequence? {
return when {
nonLocalized != null -> {
nonLocalized
}
resId != 0 -> {
val locales = if (Android.sdk(24)) {
val localesList = context.resources.configuration.locales
(0 until localesList.size()).map(localesList::get)
} else {
@Suppress("DEPRECATION")
listOf(context.resources.configuration.locale)
}
val cacheKey = CacheKey(locales, packageName, resId)
if (cache.containsKey(cacheKey)) {
cache[cacheKey]
} else {
val resources = localCache.resources[packageName] ?: run {
val resources = try {
val resources =
context.packageManager.getResourcesForApplication(packageName)
@Suppress("DEPRECATION")
resources.updateConfiguration(context.resources.configuration, null)
resources
} catch (e: Exception) {
null
}
resources?.let { localCache.resources[packageName] = it }
resources
}
val label = resources?.getString(resId)
cache[cacheKey] = label
label
}
}
else -> {
null
}
resources?.let { localCache.resources[packageName] = it }
resources
}
val label = resources?.getString(resId)
cache[cacheKey] = label
label
}
}
else -> {
null
}
}
}
fun loadLabel(context: Context, localCache: LocalCache, packageItemInfo: PackageItemInfo): CharSequence? {
return load(context, localCache, packageItemInfo.packageName,
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes)
}
fun loadDescription(context: Context, localCache: LocalCache, permissionInfo: PermissionInfo): CharSequence? {
return load(context, localCache, permissionInfo.packageName,
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes)
}
fun getPermissionGroup(permissionInfo: PermissionInfo): String? {
return if (Android.sdk(29)) {
// Copied from package installer (Utils.java)
when (permissionInfo.name) {
android.Manifest.permission.READ_CONTACTS,
android.Manifest.permission.WRITE_CONTACTS,
android.Manifest.permission.GET_ACCOUNTS ->
android.Manifest.permission_group.CONTACTS
android.Manifest.permission.READ_CALENDAR,
android.Manifest.permission.WRITE_CALENDAR ->
android.Manifest.permission_group.CALENDAR
android.Manifest.permission.SEND_SMS,
android.Manifest.permission.RECEIVE_SMS,
android.Manifest.permission.READ_SMS,
android.Manifest.permission.RECEIVE_MMS,
android.Manifest.permission.RECEIVE_WAP_PUSH,
"android.permission.READ_CELL_BROADCASTS" ->
android.Manifest.permission_group.SMS
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.ACCESS_MEDIA_LOCATION ->
android.Manifest.permission_group.STORAGE
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION ->
android.Manifest.permission_group.LOCATION
android.Manifest.permission.READ_CALL_LOG,
android.Manifest.permission.WRITE_CALL_LOG,
@Suppress("DEPRECATION") android.Manifest.permission.PROCESS_OUTGOING_CALLS ->
android.Manifest.permission_group.CALL_LOG
android.Manifest.permission.READ_PHONE_STATE,
android.Manifest.permission.READ_PHONE_NUMBERS,
android.Manifest.permission.CALL_PHONE,
android.Manifest.permission.ADD_VOICEMAIL,
android.Manifest.permission.USE_SIP,
android.Manifest.permission.ANSWER_PHONE_CALLS,
android.Manifest.permission.ACCEPT_HANDOVER ->
android.Manifest.permission_group.PHONE
android.Manifest.permission.RECORD_AUDIO ->
android.Manifest.permission_group.MICROPHONE
android.Manifest.permission.ACTIVITY_RECOGNITION ->
android.Manifest.permission_group.ACTIVITY_RECOGNITION
android.Manifest.permission.CAMERA ->
android.Manifest.permission_group.CAMERA
android.Manifest.permission.BODY_SENSORS ->
android.Manifest.permission_group.SENSORS
else -> null
}
} else {
permissionInfo.group
fun loadLabel(
context: Context,
localCache: LocalCache,
packageItemInfo: PackageItemInfo
): CharSequence? {
return load(
context, localCache, packageItemInfo.packageName,
packageItemInfo.nonLocalizedLabel, packageItemInfo.labelRes
)
}
fun loadDescription(
context: Context,
localCache: LocalCache,
permissionInfo: PermissionInfo
): CharSequence? {
return load(
context, localCache, permissionInfo.packageName,
permissionInfo.nonLocalizedDescription, permissionInfo.descriptionRes
)
}
fun getPermissionGroup(permissionInfo: PermissionInfo): String? {
return if (Android.sdk(29)) {
// Copied from package installer (Utils.java)
when (permissionInfo.name) {
android.Manifest.permission.READ_CONTACTS,
android.Manifest.permission.WRITE_CONTACTS,
android.Manifest.permission.GET_ACCOUNTS ->
android.Manifest.permission_group.CONTACTS
android.Manifest.permission.READ_CALENDAR,
android.Manifest.permission.WRITE_CALENDAR ->
android.Manifest.permission_group.CALENDAR
android.Manifest.permission.SEND_SMS,
android.Manifest.permission.RECEIVE_SMS,
android.Manifest.permission.READ_SMS,
android.Manifest.permission.RECEIVE_MMS,
android.Manifest.permission.RECEIVE_WAP_PUSH,
"android.permission.READ_CELL_BROADCASTS" ->
android.Manifest.permission_group.SMS
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.ACCESS_MEDIA_LOCATION ->
android.Manifest.permission_group.STORAGE
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION ->
android.Manifest.permission_group.LOCATION
android.Manifest.permission.READ_CALL_LOG,
android.Manifest.permission.WRITE_CALL_LOG,
@Suppress("DEPRECATION") android.Manifest.permission.PROCESS_OUTGOING_CALLS ->
android.Manifest.permission_group.CALL_LOG
android.Manifest.permission.READ_PHONE_STATE,
android.Manifest.permission.READ_PHONE_NUMBERS,
android.Manifest.permission.CALL_PHONE,
android.Manifest.permission.ADD_VOICEMAIL,
android.Manifest.permission.USE_SIP,
android.Manifest.permission.ANSWER_PHONE_CALLS,
android.Manifest.permission.ACCEPT_HANDOVER ->
android.Manifest.permission_group.PHONE
android.Manifest.permission.RECORD_AUDIO ->
android.Manifest.permission_group.MICROPHONE
android.Manifest.permission.ACTIVITY_RECOGNITION ->
android.Manifest.permission_group.ACTIVITY_RECOGNITION
android.Manifest.permission.CAMERA ->
android.Manifest.permission_group.CAMERA
android.Manifest.permission.BODY_SENSORS ->
android.Manifest.permission_group.SENSORS
else -> null
}
} else {
permissionInfo.group
}
}
}
}

View File

@ -2,28 +2,32 @@ package com.looker.droidify.utility
import java.io.InputStream
class ProgressInputStream(private val inputStream: InputStream,
private val callback: (Long) -> Unit): InputStream() {
private var count = 0L
class ProgressInputStream(
private val inputStream: InputStream,
private val callback: (Long) -> Unit
) : InputStream() {
private var count = 0L
private inline fun <reified T: Number> notify(one: Boolean, read: () -> T): T {
val result = read()
count += if (one) 1L else result.toLong()
callback(count)
return result
}
private inline fun <reified T : Number> notify(one: Boolean, read: () -> T): T {
val result = read()
count += if (one) 1L else result.toLong()
callback(count)
return result
}
override fun read(): Int = notify(true) { inputStream.read() }
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 skip(n: Long): Long = notify(false) { inputStream.skip(n) }
override fun read(): Int = notify(true) { inputStream.read() }
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 available(): Int {
return inputStream.available()
}
override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) }
override fun close() {
inputStream.close()
super.close()
}
override fun available(): Int {
return inputStream.available()
}
override fun close() {
inputStream.close()
super.close()
}
}

View File

@ -11,73 +11,78 @@ import okhttp3.Call
import okhttp3.Response
object RxUtils {
private class ManagedDisposable(private val cancel: () -> Unit): Disposable {
@Volatile var disposed = false
override fun isDisposed(): Boolean = disposed
private class ManagedDisposable(private val cancel: () -> Unit) : Disposable {
@Volatile
var disposed = false
override fun isDisposed(): Boolean = disposed
override fun dispose() {
disposed = true
cancel()
override fun dispose() {
disposed = true
cancel()
}
}
}
private fun <T, R> managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single<R> {
return Single.create {
val task = create()
val thread = Thread.currentThread()
val disposable = ManagedDisposable {
thread.interrupt()
cancel(task)
}
it.setDisposable(disposable)
if (!disposable.isDisposed) {
val result = try {
execute(task)
} catch (e: Throwable) {
Exceptions.throwIfFatal(e)
if (!disposable.isDisposed) {
try {
it.onError(e)
} catch (inner: Throwable) {
Exceptions.throwIfFatal(inner)
RxJavaPlugins.onError(CompositeException(e, inner))
private fun <T, R> managedSingle(
create: () -> T,
cancel: (T) -> Unit,
execute: (T) -> R
): Single<R> {
return Single.create {
val task = create()
val thread = Thread.currentThread()
val disposable = ManagedDisposable {
thread.interrupt()
cancel(task)
}
it.setDisposable(disposable)
if (!disposable.isDisposed) {
val result = try {
execute(task)
} catch (e: Throwable) {
Exceptions.throwIfFatal(e)
if (!disposable.isDisposed) {
try {
it.onError(e)
} catch (inner: Throwable) {
Exceptions.throwIfFatal(inner)
RxJavaPlugins.onError(CompositeException(e, inner))
}
}
null
}
if (result != null && !disposable.isDisposed) {
it.onSuccess(result)
}
}
}
null
}
if (result != null && !disposable.isDisposed) {
it.onSuccess(result)
}
}
}
}
fun <R> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ Unit }, { }, { execute() })
}
fun callSingle(create: () -> Call): Single<Response> {
return managedSingle(create, Call::cancel, Call::execute)
}
fun <T> querySingle(query: (CancellationSignal) -> T): Single<T> {
return Single.create {
val cancellationSignal = CancellationSignal()
it.setCancellable {
try {
cancellationSignal.cancel()
} catch (e: OperationCanceledException) {
// Do nothing
}
}
val result = try {
query(cancellationSignal)
} catch (e: OperationCanceledException) {
null
}
if (result != null) {
it.onSuccess(result)
}
fun <R> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ }, { }, { execute() })
}
fun callSingle(create: () -> Call): Single<Response> {
return managedSingle(create, Call::cancel, Call::execute)
}
fun <T> querySingle(query: (CancellationSignal) -> T): Single<T> {
return Single.create {
val cancellationSignal = CancellationSignal()
it.setCancellable {
try {
cancellationSignal.cancel()
} catch (e: OperationCanceledException) {
// Do nothing
}
}
val result = try {
query(cancellationSignal)
} catch (e: OperationCanceledException) {
null
}
if (result != null) {
it.onSuccess(result)
}
}
}
}
}

View File

@ -9,92 +9,100 @@ import android.os.LocaleList
import android.provider.Settings
import com.looker.droidify.BuildConfig
import com.looker.droidify.R
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.*
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.resources.getDrawableCompat
import com.looker.droidify.utility.extension.text.hex
import java.security.MessageDigest
import java.security.cert.Certificate
import java.security.cert.CertificateEncodingException
import java.util.Locale
import java.util.*
object Utils {
private fun createDefaultApplicationIcon(context: Context, tintAttrResId: Int): Drawable {
return context.getDrawableCompat(R.drawable.ic_application_default).mutate()
.apply { setTintList(context.getColorFromAttr(tintAttrResId)) }
}
fun getDefaultApplicationIcons(context: Context): Pair<Drawable, Drawable> {
val progressIcon: Drawable = createDefaultApplicationIcon(context, android.R.attr.textColorSecondary)
val defaultIcon: Drawable = createDefaultApplicationIcon(context, android.R.attr.colorAccent)
return Pair(progressIcon, defaultIcon)
}
fun getToolbarIcon(context: Context, resId: Int): Drawable {
val drawable = context.getDrawableCompat(resId).mutate()
drawable.setTintList(context.getColorFromAttr(android.R.attr.textColorPrimary))
return drawable
}
fun calculateHash(signature: Signature): String? {
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray()).hex()
}
fun calculateFingerprint(certificate: Certificate): String {
val encoded = try {
certificate.encoded
} catch (e: CertificateEncodingException) {
null
private fun createDefaultApplicationIcon(context: Context, tintAttrResId: Int): Drawable {
return context.getDrawableCompat(R.drawable.ic_application_default).mutate()
.apply { setTintList(context.getColorFromAttr(tintAttrResId)) }
}
return encoded?.let(::calculateFingerprint).orEmpty()
}
fun calculateFingerprint(key: ByteArray): String {
return if (key.size >= 256) {
try {
val fingerprint = MessageDigest.getInstance("SHA-256").digest(key)
val builder = StringBuilder()
for (byte in fingerprint) {
builder.append("%02X".format(Locale.US, byte.toInt() and 0xff))
fun getDefaultApplicationIcons(context: Context): Pair<Drawable, Drawable> {
val progressIcon: Drawable =
createDefaultApplicationIcon(context, android.R.attr.textColorSecondary)
val defaultIcon: Drawable =
createDefaultApplicationIcon(context, android.R.attr.colorAccent)
return Pair(progressIcon, defaultIcon)
}
fun getToolbarIcon(context: Context, resId: Int): Drawable {
val drawable = context.getDrawableCompat(resId).mutate()
drawable.setTintList(context.getColorFromAttr(android.R.attr.titleTextColor))
return drawable
}
fun calculateHash(signature: Signature): String {
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray())
.hex()
}
fun calculateFingerprint(certificate: Certificate): String {
val encoded = try {
certificate.encoded
} catch (e: CertificateEncodingException) {
null
}
builder.toString()
} catch (e: Exception) {
e.printStackTrace()
""
}
} else {
""
return encoded?.let(::calculateFingerprint).orEmpty()
}
}
fun configureLocale(context: Context): Context {
val supportedLanguages = BuildConfig.LANGUAGES.toSet()
val configuration = context.resources.configuration
val currentLocales = if (Android.sdk(24)) {
val localesList = configuration.locales
(0 until localesList.size()).map(localesList::get)
} else {
@Suppress("DEPRECATION")
listOf(configuration.locale)
fun calculateFingerprint(key: ByteArray): String {
return if (key.size >= 256) {
try {
val fingerprint = MessageDigest.getInstance("SHA-256").digest(key)
val builder = StringBuilder()
for (byte in fingerprint) {
builder.append("%02X".format(Locale.US, byte.toInt() and 0xff))
}
builder.toString()
} catch (e: Exception) {
e.printStackTrace()
""
}
} else {
""
}
}
val compatibleLocales = currentLocales
.filter { it.language in supportedLanguages }
.let { if (it.isEmpty()) listOf(Locale.US) else it }
Locale.setDefault(compatibleLocales.first())
val newConfiguration = Configuration(configuration)
if (Android.sdk(24)) {
newConfiguration.setLocales(LocaleList(*compatibleLocales.toTypedArray()))
} else {
@Suppress("DEPRECATION")
newConfiguration.locale = compatibleLocales.first()
}
return context.createConfigurationContext(newConfiguration)
}
fun areAnimationsEnabled(context: Context): Boolean {
return if (Android.sdk(26)) {
ValueAnimator.areAnimatorsEnabled()
} else {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f
fun configureLocale(context: Context): Context {
val supportedLanguages = BuildConfig.LANGUAGES.toSet()
val configuration = context.resources.configuration
val currentLocales = if (Android.sdk(24)) {
val localesList = configuration.locales
(0 until localesList.size()).map(localesList::get)
} else {
@Suppress("DEPRECATION")
listOf(configuration.locale)
}
val compatibleLocales = currentLocales
.filter { it.language in supportedLanguages }
.let { if (it.isEmpty()) listOf(Locale.US) else it }
Locale.setDefault(compatibleLocales.first())
val newConfiguration = Configuration(configuration)
if (Android.sdk(24)) {
newConfiguration.setLocales(LocaleList(*compatibleLocales.toTypedArray()))
} else {
@Suppress("DEPRECATION")
newConfiguration.locale = compatibleLocales.first()
}
return context.createConfigurationContext(newConfiguration)
}
fun areAnimationsEnabled(context: Context): Boolean {
return if (Android.sdk(26)) {
ValueAnimator.areAnimatorsEnabled()
} else {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) != 0f
}
}
}
}

View File

@ -1,4 +1,5 @@
@file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.android
import android.app.NotificationManager
@ -10,67 +11,68 @@ import android.database.sqlite.SQLiteDatabase
import android.os.Build
fun Cursor.asSequence(): Sequence<Cursor> {
return generateSequence { if (moveToNext()) this else null }
return generateSequence { if (moveToNext()) this else null }
}
fun Cursor.firstOrNull(): Cursor? {
return if (moveToFirst()) this else null
return if (moveToFirst()) this else null
}
fun SQLiteDatabase.execWithResult(sql: String) {
rawQuery(sql, null).use { it.count }
rawQuery(sql, null).use { it.count }
}
val Context.notificationManager: NotificationManager
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val PackageInfo.versionCodeCompat: Long
get() = if (Android.sdk(28)) longVersionCode else @Suppress("DEPRECATION") versionCode.toLong()
get() = if (Android.sdk(28)) longVersionCode else @Suppress("DEPRECATION") versionCode.toLong()
val PackageInfo.singleSignature: Signature?
get() {
return if (Android.sdk(28)) {
val signingInfo = signingInfo
if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners
?.let { if (it.size == 1) it[0] else null } else null
} else {
@Suppress("DEPRECATION")
signatures?.let { if (it.size == 1) it[0] else null }
get() {
return if (Android.sdk(28)) {
val signingInfo = signingInfo
if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners
?.let { if (it.size == 1) it[0] else null } else null
} else {
@Suppress("DEPRECATION")
signatures?.let { if (it.size == 1) it[0] else null }
}
}
}
object Android {
val sdk: Int
get() = Build.VERSION.SDK_INT
val sdk: Int
get() = Build.VERSION.SDK_INT
val name: String
get() = "Android ${Build.VERSION.RELEASE}"
val name: String
get() = "Android ${Build.VERSION.RELEASE}"
val platforms = Build.SUPPORTED_ABIS.toSet()
val platforms = Build.SUPPORTED_ABIS.toSet()
val primaryPlatform: String?
get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull() ?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
val primaryPlatform: String?
get() = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull()
?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
fun sdk(sdk: Int): Boolean {
return Build.VERSION.SDK_INT >= sdk
}
fun sdk(sdk: Int): Boolean {
return Build.VERSION.SDK_INT >= sdk
}
object PackageManager {
// GET_SIGNATURES should always present for getPackageArchiveInfo
val signaturesFlag: Int
get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) or
@Suppress("DEPRECATION") android.content.pm.PackageManager.GET_SIGNATURES
}
object PackageManager {
// GET_SIGNATURES should always present for getPackageArchiveInfo
val signaturesFlag: Int
get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) or
@Suppress("DEPRECATION") android.content.pm.PackageManager.GET_SIGNATURES
}
object Device {
val isHuaweiEmui: Boolean
get() {
return try {
Class.forName("com.huawei.android.os.BuildEx")
true
} catch (e: Exception) {
false
}
}
}
object Device {
val isHuaweiEmui: Boolean
get() {
return try {
Class.forName("com.huawei.android.os.BuildEx")
true
} catch (e: Exception) {
false
}
}
}
}

View File

@ -1,106 +1,106 @@
@file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.json
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.core.*
object Json {
val factory = JsonFactory()
val factory = JsonFactory()
}
fun JsonParser.illegal(): Nothing {
throw JsonParseException(this, "Illegal state")
throw JsonParseException(this, "Illegal state")
}
interface KeyToken {
val key: String
val token: JsonToken
val key: String
val token: JsonToken
fun number(key: String): Boolean = this.key == key && this.token.isNumeric
fun string(key: String): Boolean = this.key == key && this.token == JsonToken.VALUE_STRING
fun boolean(key: String): Boolean = this.key == key && this.token.isBoolean
fun dictionary(key: String): Boolean = this.key == key && this.token == JsonToken.START_OBJECT
fun array(key: String): Boolean = this.key == key && this.token == JsonToken.START_ARRAY
fun number(key: String): Boolean = this.key == key && this.token.isNumeric
fun string(key: String): Boolean = this.key == key && this.token == JsonToken.VALUE_STRING
fun boolean(key: String): Boolean = this.key == key && this.token.isBoolean
fun dictionary(key: String): Boolean = this.key == key && this.token == JsonToken.START_OBJECT
fun array(key: String): Boolean = this.key == key && this.token == JsonToken.START_ARRAY
}
inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) {
var passKey = ""
var passToken = JsonToken.NOT_AVAILABLE
val keyToken = object: KeyToken {
override val key: String
get() = passKey
override val token: JsonToken
get() = passToken
}
while (true) {
val token = nextToken()
if (token == JsonToken.FIELD_NAME) {
passKey = currentName
passToken = nextToken()
callback(keyToken)
} else if (token == JsonToken.END_OBJECT) {
break
} else {
illegal()
var passKey = ""
var passToken = JsonToken.NOT_AVAILABLE
val keyToken = object : KeyToken {
override val key: String
get() = passKey
override val token: JsonToken
get() = passToken
}
while (true) {
val token = nextToken()
if (token == JsonToken.FIELD_NAME) {
passKey = currentName
passToken = nextToken()
callback(keyToken)
} else if (token == JsonToken.END_OBJECT) {
break
} else {
illegal()
}
}
}
}
fun JsonParser.forEach(requiredToken: JsonToken, callback: JsonParser.() -> Unit) {
while (true) {
val token = nextToken()
if (token == JsonToken.END_ARRAY) {
break
} else if (token == requiredToken) {
callback()
} else if (token.isStructStart) {
skipChildren()
while (true) {
val token = nextToken()
if (token == JsonToken.END_ARRAY) {
break
} else if (token == requiredToken) {
callback()
} else if (token.isStructStart) {
skipChildren()
}
}
}
}
fun <T> JsonParser.collectNotNull(requiredToken: JsonToken, callback: JsonParser.() -> T?): List<T> {
val list = mutableListOf<T>()
forEach(requiredToken) {
val result = callback()
if (result != null) {
list += result
fun <T> JsonParser.collectNotNull(
requiredToken: JsonToken,
callback: JsonParser.() -> T?
): List<T> {
val list = mutableListOf<T>()
forEach(requiredToken) {
val result = callback()
if (result != null) {
list += result
}
}
}
return list
return list
}
fun JsonParser.collectNotNullStrings(): List<String> {
return collectNotNull(JsonToken.VALUE_STRING) { valueAsString }
return collectNotNull(JsonToken.VALUE_STRING) { valueAsString }
}
fun JsonParser.collectDistinctNotEmptyStrings(): List<String> {
return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList()
return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList()
}
inline fun <T> JsonParser.parseDictionary(callback: JsonParser.() -> T): T {
if (nextToken() == JsonToken.START_OBJECT) {
val result = callback()
if (nextToken() != null) {
illegal()
if (nextToken() == JsonToken.START_OBJECT) {
val result = callback()
if (nextToken() != null) {
illegal()
}
return result
} else {
illegal()
}
return result
} else {
illegal()
}
}
inline fun JsonGenerator.writeDictionary(callback: JsonGenerator.() -> Unit) {
writeStartObject()
callback()
writeEndObject()
writeStartObject()
callback()
writeEndObject()
}
inline fun JsonGenerator.writeArray(fieldName: String, callback: JsonGenerator.() -> Unit) {
writeArrayFieldStart(fieldName)
callback()
writeEndArray()
writeArrayFieldStart(fieldName)
callback()
writeEndArray()
}

View File

@ -1,4 +1,5 @@
@file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.resources
import android.content.Context
@ -16,79 +17,84 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.looker.droidify.utility.extension.android.Android
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import com.looker.droidify.utility.extension.android.*
import org.xmlpull.v1.XmlPullParser
import kotlin.math.*
import kotlin.math.roundToInt
object TypefaceExtra {
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!!
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!!
}
fun Context.getDrawableCompat(resId: Int): Drawable {
val drawable = if (!Android.sdk(24)) {
val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string
if (fileName.endsWith(".xml")) {
resources.getXml(resId).use {
val eventType = generateSequence { it.next() }
.find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT }
if (eventType == XmlPullParser.START_TAG) {
when (it.name) {
"vector" -> VectorDrawableCompat.createFromXmlInner(resources, it, Xml.asAttributeSet(it), theme)
else -> null
}
val drawable = if (!Android.sdk(24)) {
val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string
if (fileName.endsWith(".xml")) {
resources.getXml(resId).use { it ->
val eventType = generateSequence { it.next() }
.find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT }
if (eventType == XmlPullParser.START_TAG) {
when (it.name) {
"vector" -> VectorDrawableCompat.createFromXmlInner(
resources,
it,
Xml.asAttributeSet(it),
theme
)
else -> null
}
} else {
null
}
}
} else {
null
null
}
}
} else {
null
null
}
} else {
null
}
return drawable ?: ContextCompat.getDrawable(this, resId)!!
return drawable ?: ContextCompat.getDrawable(this, resId)!!
}
fun Context.getColorFromAttr(attrResId: Int): ColorStateList {
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
val (colorStateList, resId) = try {
Pair(typedArray.getColorStateList(0), typedArray.getResourceId(0, 0))
} finally {
typedArray.recycle()
}
return colorStateList ?: ContextCompat.getColorStateList(this, resId)!!
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
val (colorStateList, resId) = try {
Pair(typedArray.getColorStateList(0), typedArray.getResourceId(0, 0))
} finally {
typedArray.recycle()
}
return colorStateList ?: ContextCompat.getColorStateList(this, resId)!!
}
fun Context.getDrawableFromAttr(attrResId: Int): Drawable {
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
val resId = try {
typedArray.getResourceId(0, 0)
} finally {
typedArray.recycle()
}
return getDrawableCompat(resId)
val typedArray = obtainStyledAttributes(intArrayOf(attrResId))
val resId = try {
typedArray.getResourceId(0, 0)
} finally {
typedArray.recycle()
}
return getDrawableCompat(resId)
}
fun Resources.sizeScaled(size: Int): Int {
return (size * displayMetrics.density).roundToInt()
return (size * displayMetrics.density).roundToInt()
}
fun TextView.setTextSizeScaled(size: Int) {
val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt()
setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat())
val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt()
setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat())
}
fun ViewGroup.inflate(layoutResId: Int): View {
return LayoutInflater.from(context).inflate(layoutResId, this, false)
return LayoutInflater.from(context).inflate(layoutResId, this, false)
}
fun ImageView.load(uri: Uri, builder: RequestCreator.() -> Unit) {
Picasso.get().load(uri).noFade().apply(builder).into(this)
Picasso.get().load(uri).noFade().apply(builder).into(this)
}
fun ImageView.clear() {
Picasso.get().cancelRequest(this)
Picasso.get().cancelRequest(this)
}

View File

@ -1,59 +1,62 @@
@file:Suppress("PackageDirectoryMismatch")
package com.looker.droidify.utility.extension.text
import android.util.Log
import java.util.Locale
import java.util.*
fun <T: CharSequence> T.nullIfEmpty(): T? {
return if (isNullOrEmpty()) null else this
fun <T : CharSequence> T.nullIfEmpty(): T? {
return if (isNullOrEmpty()) null else this
}
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
fun Long.formatSize(): String {
val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) -> if (size >= 1000f)
Pair(size / 1000f, index + 1) else null }.take(sizeFormats.size).last()
return sizeFormats[index].format(Locale.US, size)
val (size, index) = generateSequence(Pair(this.toFloat(), 0)) { (size, index) ->
if (size >= 1000f)
Pair(size / 1000f, index + 1) else null
}.take(sizeFormats.size).last()
return sizeFormats[index].format(Locale.US, size)
}
fun Char.halfByte(): Int {
return when (this) {
in '0' .. '9' -> this - '0'
in 'a' .. 'f' -> this - 'a' + 10
in 'A' .. 'F' -> this - 'A' + 10
else -> -1
}
return when (this) {
in '0'..'9' -> this - '0'
in 'a'..'f' -> this - 'a' + 10
in 'A'..'F' -> this - 'A' + 10
else -> -1
}
}
fun CharSequence.unhex(): ByteArray? {
return if (length % 2 == 0) {
val ints = windowed(2, 2, false).map {
val high = it[0].halfByte()
val low = it[1].halfByte()
if (high >= 0 && low >= 0) {
(high shl 4) or low
} else {
-1
}
return if (length % 2 == 0) {
val ints = windowed(2, 2, false).map {
val high = it[0].halfByte()
val low = it[1].halfByte()
if (high >= 0 && low >= 0) {
(high shl 4) or low
} else {
-1
}
}
if (ints.any { it < 0 }) null else ints.map { it.toByte() }.toByteArray()
} else {
null
}
if (ints.any { it < 0 }) null else ints.map { it.toByte() }.toByteArray()
} else {
null
}
}
fun ByteArray.hex(): String {
val builder = StringBuilder()
for (byte in this) {
builder.append("%02x".format(Locale.US, byte.toInt() and 0xff))
}
return builder.toString()
val builder = StringBuilder()
for (byte in this) {
builder.append("%02x".format(Locale.US, byte.toInt() and 0xff))
}
return builder.toString()
}
fun Any.debug(message: String) {
val tag = this::class.java.name.let {
val index = it.lastIndexOf('.')
if (index >= 0) it.substring(index + 1) else it
}.replace('$', '.')
Log.d(tag, message)
val tag = this::class.java.name.let {
val index = it.lastIndexOf('.')
if (index >= 0) it.substring(index + 1) else it
}.replace('$', '.')
Log.d(tag, message)
}

View File

@ -8,41 +8,60 @@ import android.view.KeyEvent
import android.view.MotionEvent
import android.widget.TextView
object ClickableMovementMethod: MovementMethod {
override fun initialize(widget: TextView, text: Spannable) {
Selection.removeSelection(text)
}
override fun onTouchEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean {
val action = event.action
val down = action == MotionEvent.ACTION_DOWN
val up = action == MotionEvent.ACTION_UP
return (down || up) && run {
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
val span = text.getSpans(offset, offset, ClickableSpan::class.java)?.firstOrNull()
if (span != null) {
if (down) {
Selection.setSelection(text, text.getSpanStart(span), text.getSpanEnd(span))
} else {
span.onClick(widget)
}
true
} else {
object ClickableMovementMethod : MovementMethod {
override fun initialize(widget: TextView, text: Spannable) {
Selection.removeSelection(text)
false
}
}
}
override fun onKeyDown(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 onTakeFocus(widget: TextView, text: Spannable, direction: Int) = Unit
override fun onTrackballEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false
override fun onGenericMotionEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean = false
override fun canSelectArbitrarily(): Boolean = false
override fun onTouchEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean {
val action = event.action
val down = action == MotionEvent.ACTION_DOWN
val up = action == MotionEvent.ACTION_UP
return (down || up) && run {
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
val span = text.getSpans(offset, offset, ClickableSpan::class.java)?.firstOrNull()
if (span != null) {
if (down) {
Selection.setSelection(text, text.getSpanStart(span), text.getSpanEnd(span))
} else {
span.onClick(widget)
}
true
} else {
Selection.removeSelection(text)
false
}
}
}
override fun onKeyDown(
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 onTakeFocus(widget: TextView, text: Spannable, direction: Int) = Unit
override fun onTrackballEvent(widget: TextView, text: Spannable, event: MotionEvent): Boolean =
false
override fun onGenericMotionEvent(
widget: TextView,
text: Spannable,
event: MotionEvent
): Boolean = false
override fun canSelectArbitrarily(): Boolean = false
}

View File

@ -3,33 +3,34 @@ package com.looker.droidify.widget
import android.database.Cursor
import androidx.recyclerview.widget.RecyclerView
abstract class CursorRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() {
init {
super.setHasStableIds(true)
}
private var rowIdIndex = 0
var cursor: Cursor? = null
set(value) {
if (field != value) {
field?.close()
field = value
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
notifyDataSetChanged()
}
abstract class CursorRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
EnumRecyclerAdapter<VT, VH>() {
init {
super.setHasStableIds(true)
}
final override fun setHasStableIds(hasStableIds: Boolean) {
throw UnsupportedOperationException()
}
private var rowIdIndex = 0
override fun getItemCount(): Int = cursor?.count ?: 0
override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex)
var cursor: Cursor? = null
set(value) {
if (field != value) {
field?.close()
field = value
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
notifyDataSetChanged()
}
}
fun moveTo(position: Int): Cursor {
val cursor = cursor!!
cursor.moveToPosition(position)
return cursor
}
final override fun setHasStableIds(hasStableIds: Boolean) {
throw UnsupportedOperationException()
}
override fun getItemCount(): Int = cursor?.count ?: 0
override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex)
fun moveTo(position: Int): Cursor {
val cursor = cursor!!
cursor.moveToPosition(position)
return cursor
}
}

View File

@ -6,83 +6,113 @@ import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R
import com.looker.droidify.utility.extension.resources.*
import kotlin.math.*
import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import kotlin.math.roundToInt
class DividerItemDecoration(context: Context, private val configure: (context: Context,
position: Int, configuration: Configuration) -> Unit): RecyclerView.ItemDecoration() {
interface Configuration {
fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int)
}
private class ConfigurationHolder: Configuration {
var needDivider = false
var toTop = false
var paddingStart = 0
var paddingEnd = 0
override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) {
this.needDivider = needDivider
this.toTop = toTop
this.paddingStart = paddingStart
this.paddingEnd = paddingEnd
}
}
private val View.configuration: ConfigurationHolder
get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run {
val configuration = ConfigurationHolder()
setTag(R.id.divider_configuration, configuration)
configuration
class DividerItemDecoration(
context: Context, private val configure: (
context: Context,
position: Int, configuration: Configuration
) -> Unit
) : RecyclerView.ItemDecoration() {
interface Configuration {
fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int)
}
private val divider = context.getDrawableFromAttr(android.R.attr.listDivider)
private val bounds = Rect()
private class ConfigurationHolder : Configuration {
var needDivider = false
var toTop = false
var paddingStart = 0
var paddingEnd = 0
private fun draw(c: Canvas, configuration: ConfigurationHolder, view: View, top: Int, width: Int, rtl: Boolean) {
val divider = divider
val left = if (rtl) configuration.paddingEnd else configuration.paddingStart
val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd)
val translatedTop = top + view.translationY.roundToInt()
divider.alpha = (view.alpha * 0xff).toInt()
divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight)
divider.draw(c)
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val divider = divider
val bounds = bounds
val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
for (i in 0 until parent.childCount) {
val view = parent.getChildAt(i)
val configuration = view.configuration
if (configuration.needDivider) {
val position = parent.getChildAdapterPosition(view)
if (position == parent.adapter!!.itemCount - 1) {
parent.getDecoratedBoundsWithMargins(view, bounds)
draw(c, configuration, view, bounds.bottom, parent.width, rtl)
} else {
val toTopView = if (configuration.toTop && position >= 0)
parent.findViewHolderForAdapterPosition(position + 1)?.itemView else null
if (toTopView != null) {
parent.getDecoratedBoundsWithMargins(toTopView, bounds)
draw(c, configuration, toTopView, bounds.top - divider.intrinsicHeight, parent.width, rtl)
} else {
parent.getDecoratedBoundsWithMargins(view, bounds)
draw(c, configuration, view, bounds.bottom - divider.intrinsicHeight, parent.width, rtl)
}
override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) {
this.needDivider = needDivider
this.toTop = toTop
this.paddingStart = paddingStart
this.paddingEnd = paddingEnd
}
}
}
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val configuration = view.configuration
val position = parent.getChildAdapterPosition(view)
if (position >= 0) {
configure(view.context, position, configuration)
private val View.configuration: ConfigurationHolder
get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run {
val configuration = ConfigurationHolder()
setTag(R.id.divider_configuration, configuration)
configuration
}
private val divider = context.getDrawableFromAttr(android.R.attr.listDivider)
private val bounds = Rect()
private fun draw(
c: Canvas,
configuration: ConfigurationHolder,
view: View,
top: Int,
width: Int,
rtl: Boolean
) {
val divider = divider
val left = if (rtl) configuration.paddingEnd else configuration.paddingStart
val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd)
val translatedTop = top + view.translationY.roundToInt()
divider.alpha = (view.alpha * 0xff).toInt()
divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight)
divider.draw(c)
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val divider = divider
val bounds = bounds
val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
for (i in 0 until parent.childCount) {
val view = parent.getChildAt(i)
val configuration = view.configuration
if (configuration.needDivider) {
val position = parent.getChildAdapterPosition(view)
if (position == parent.adapter!!.itemCount - 1) {
parent.getDecoratedBoundsWithMargins(view, bounds)
draw(c, configuration, view, bounds.bottom, parent.width, rtl)
} else {
val toTopView = if (configuration.toTop && position >= 0)
parent.findViewHolderForAdapterPosition(position + 1)?.itemView else null
if (toTopView != null) {
parent.getDecoratedBoundsWithMargins(toTopView, bounds)
draw(
c,
configuration,
toTopView,
bounds.top - divider.intrinsicHeight,
parent.width,
rtl
)
} else {
parent.getDecoratedBoundsWithMargins(view, bounds)
draw(
c,
configuration,
view,
bounds.bottom - divider.intrinsicHeight,
parent.width,
rtl
)
}
}
}
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val configuration = view.configuration
val position = parent.getChildAdapterPosition(view)
if (position >= 0) {
configure(view.context, position, configuration)
}
val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider
outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0)
}
val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider
outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0)
}
}

View File

@ -4,25 +4,26 @@ import android.util.SparseArray
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
abstract class EnumRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: RecyclerView.Adapter<VH>() {
abstract val viewTypeClass: Class<VT>
abstract class EnumRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
abstract val viewTypeClass: Class<VT>
private val names = SparseArray<String>()
private val names = SparseArray<String>()
private fun getViewType(viewType: Int): VT {
return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType))
}
private fun getViewType(viewType: Int): VT {
return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType))
}
final override fun getItemViewType(position: Int): Int {
val enum = getItemEnumViewType(position)
names.put(enum.ordinal, enum.name)
return enum.ordinal
}
final override fun getItemViewType(position: Int): Int {
val enum = getItemEnumViewType(position)
names.put(enum.ordinal, enum.name)
return enum.ordinal
}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return onCreateViewHolder(parent, getViewType(viewType))
}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return onCreateViewHolder(parent, getViewType(viewType))
}
abstract fun getItemEnumViewType(position: Int): VT
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH
abstract fun getItemEnumViewType(position: Int): VT
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH
}

View File

@ -5,31 +5,35 @@ import android.util.AttributeSet
import android.view.KeyEvent
import android.widget.SearchView
class FocusSearchView: SearchView {
constructor(context: Context): super(context)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr)
class FocusSearchView : SearchView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
var allowFocus = true
var allowFocus = true
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
// Always clear focus on back press
return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_UP) {
clearFocus()
}
true
} else {
super.dispatchKeyEventPreIme(event)
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
// Always clear focus on back press
return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_UP) {
clearFocus()
}
true
} else {
super.dispatchKeyEventPreIme(event)
}
}
}
override fun setIconified(iconify: Boolean) {
super.setIconified(iconify)
override fun setIconified(iconify: Boolean) {
super.setIconified(iconify)
// Don't focus view and raise keyboard unless allowed
if (!iconify && !allowFocus) {
clearFocus()
// Don't focus view and raise keyboard unless allowed
if (!iconify && !allowFocus) {
clearFocus()
}
}
}
}

View File

@ -4,19 +4,23 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
class FragmentLinearLayout: LinearLayout {
constructor(context: Context): super(context)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr)
class FragmentLinearLayout : LinearLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
fitsSystemWindows = true
}
@Suppress("unused")
var percentTranslationY: Float
get() = height.let { if (it > 0) translationY / it else 0f }
set(value) {
translationY = value * height
init {
fitsSystemWindows = true
}
@Suppress("unused")
var percentTranslationY: Float
get() = height.let { if (it > 0) translationY / it else 0f }
set(value) {
translationY = value * height
}
}

View File

@ -8,240 +8,280 @@ import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.utility.extension.resources.*
import kotlin.math.*
import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import com.looker.droidify.utility.extension.resources.sizeScaled
import kotlin.math.max
import kotlin.math.roundToInt
@SuppressLint("ClickableViewAccessibility")
class RecyclerFastScroller(private val recyclerView: RecyclerView) {
companion object {
private const val TRANSITION_IN = 100L
private const val TRANSITION_OUT = 200L
private const val TRANSITION_OUT_DELAY = 1000L
companion object {
private const val TRANSITION_IN = 100L
private const val TRANSITION_OUT = 200L
private const val TRANSITION_OUT_DELAY = 1000L
private val stateNormal = intArrayOf()
private val statePressed = intArrayOf(android.R.attr.state_pressed)
}
private val thumbDrawable = 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 data class FastScrolling(val startAtThumbOffset: Float?, val startY: Float, val currentY: Float)
private var scrolling = false
private var fastScrolling: FastScrolling? = null
private var display = Pair(0L, false)
private val invalidateTransition = Runnable(recyclerView::invalidate)
private fun updateState(scrolling: Boolean, fastScrolling: FastScrolling?) {
val oldDisplay = this.scrolling || this.fastScrolling != null
val newDisplay = scrolling || fastScrolling != null
this.scrolling = scrolling
this.fastScrolling = fastScrolling
if (oldDisplay != newDisplay) {
recyclerView.removeCallbacks(invalidateTransition)
val time = SystemClock.elapsedRealtime()
val passed = time - display.first
val start = if (newDisplay && passed < (TRANSITION_OUT + TRANSITION_OUT_DELAY)) {
if (passed <= TRANSITION_OUT_DELAY) {
0L
} else {
time - ((TRANSITION_OUT_DELAY + TRANSITION_OUT - passed).toFloat() /
TRANSITION_OUT * TRANSITION_IN).toLong()
}
} else if (!newDisplay && passed < TRANSITION_IN) {
time - ((TRANSITION_IN - passed).toFloat() / TRANSITION_IN *
TRANSITION_OUT).toLong() - TRANSITION_OUT_DELAY
} else {
if (!newDisplay) {
recyclerView.postDelayed(invalidateTransition, TRANSITION_OUT_DELAY)
}
time
}
display = Pair(start, newDisplay)
recyclerView.invalidate()
}
}
private val scrollListener = object: RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
updateState(newState != RecyclerView.SCROLL_STATE_IDLE, fastScrolling)
private val stateNormal = intArrayOf()
private val statePressed = intArrayOf(android.R.attr.state_pressed)
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (fastScrolling == null) {
recyclerView.invalidate()
}
}
}
private val thumbDrawable =
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 inline fun withScroll(callback: (itemHeight: Int, thumbHeight: Int, range: Int) -> Unit): Boolean {
val count = recyclerView.adapter?.itemCount ?: 0
return count > 0 && run {
val itemHeight = Rect().apply { recyclerView
.getDecoratedBoundsWithMargins(recyclerView.getChildAt(0), this) }.height()
val scrollCount = count - recyclerView.height / itemHeight
scrollCount > 0 && run {
val range = count * itemHeight
val thumbHeight = max(recyclerView.height * recyclerView.height / range, thumbDrawable.intrinsicHeight)
range >= recyclerView.height * 2 && run {
callback(itemHeight, thumbHeight, range)
true
}
}
}
}
private data class FastScrolling(
val startAtThumbOffset: Float?,
val startY: Float,
val currentY: Float
)
private fun calculateOffset(thumbHeight: Int, fastScrolling: FastScrolling): Float {
return if (fastScrolling.startAtThumbOffset != null) {
(fastScrolling.startAtThumbOffset + (fastScrolling.currentY - fastScrolling.startY) /
(recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
} else {
((fastScrolling.currentY - thumbHeight / 2f) / (recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
}
}
private var scrolling = false
private var fastScrolling: FastScrolling? = null
private var display = Pair(0L, false)
private fun currentOffset(itemHeight: Int, range: Int): Float {
val view = recyclerView.getChildAt(0)
val position = recyclerView.getChildAdapterPosition(view)
val positionOffset = -view.top
val scrollPosition = position * itemHeight + positionOffset
return scrollPosition.toFloat() / (range - recyclerView.height)
}
private val invalidateTransition = Runnable(recyclerView::invalidate)
private fun scroll(itemHeight: Int, thumbHeight: Int, range: Int, fastScrolling: FastScrolling) {
val offset = calculateOffset(thumbHeight, fastScrolling)
val scrollPosition = ((range - recyclerView.height) * offset).roundToInt()
val position = scrollPosition / itemHeight
val positionOffset = scrollPosition - position * itemHeight
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(position, -positionOffset)
}
private val touchListener = object: RecyclerView.OnItemTouchListener {
private var disallowIntercept = false
private fun handleTouchEvent(event: MotionEvent, intercept: Boolean): Boolean {
val recyclerView = recyclerView
val lastFastScrolling = fastScrolling
return when {
intercept && disallowIntercept -> {
false
}
event.action == MotionEvent.ACTION_DOWN -> {
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
val trackWidth = max(minTrackSize, max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicWidth))
val atThumbVertical = if (rtl) event.x <= trackWidth else event.x >= recyclerView.width - trackWidth
atThumbVertical && run {
withScroll { itemHeight, thumbHeight, range ->
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(true)
val offset = currentOffset(itemHeight, range)
val thumbY = ((recyclerView.height - thumbHeight) * offset).roundToInt()
val atThumb = event.y >= thumbY && event.y <= thumbY + thumbHeight
val fastScrolling = FastScrolling(if (atThumb) offset else null, event.y, event.y)
scroll(itemHeight, thumbHeight, range, fastScrolling)
updateState(scrolling, fastScrolling)
recyclerView.invalidate()
private fun updateState(scrolling: Boolean, fastScrolling: FastScrolling?) {
val oldDisplay = this.scrolling || this.fastScrolling != null
val newDisplay = scrolling || fastScrolling != null
this.scrolling = scrolling
this.fastScrolling = fastScrolling
if (oldDisplay != newDisplay) {
recyclerView.removeCallbacks(invalidateTransition)
val time = SystemClock.elapsedRealtime()
val passed = time - display.first
val start = if (newDisplay && passed < (TRANSITION_OUT + TRANSITION_OUT_DELAY)) {
if (passed <= TRANSITION_OUT_DELAY) {
0L
} else {
time - ((TRANSITION_OUT_DELAY + TRANSITION_OUT - passed).toFloat() /
TRANSITION_OUT * TRANSITION_IN).toLong()
}
} else if (!newDisplay && passed < TRANSITION_IN) {
time - ((TRANSITION_IN - passed).toFloat() / TRANSITION_IN *
TRANSITION_OUT).toLong() - TRANSITION_OUT_DELAY
} else {
if (!newDisplay) {
recyclerView.postDelayed(invalidateTransition, TRANSITION_OUT_DELAY)
}
time
}
}
}
else -> lastFastScrolling != null && run {
val success = withScroll { itemHeight, thumbHeight, range ->
val fastScrolling = lastFastScrolling.copy(currentY = event.y)
scroll(itemHeight, thumbHeight, range, fastScrolling)
updateState(scrolling, fastScrolling)
display = Pair(start, newDisplay)
recyclerView.invalidate()
}
val cancel = event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL
if (!success || cancel) {
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(false)
updateState(scrolling, null)
recyclerView.invalidate()
}
true
}
}
}
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
return handleTouchEvent(e, true)
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
handleTouchEvent(e, false)
}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
this.disallowIntercept = disallowIntercept
if (disallowIntercept && fastScrolling != null) {
updateState(scrolling, null)
recyclerView.invalidate()
}
}
}
private fun handleDraw(canvas: Canvas) {
withScroll { itemHeight, thumbHeight, range ->
val display = display
val time = SystemClock.elapsedRealtime()
val passed = time - display.first
val shouldInvalidate = display.second && passed < TRANSITION_IN ||
!display.second && passed >= TRANSITION_OUT_DELAY && passed < TRANSITION_OUT_DELAY + TRANSITION_OUT
val stateValue = (if (display.second) {
passed.toFloat() / TRANSITION_IN
} else {
1f - (passed - TRANSITION_OUT_DELAY).toFloat() / TRANSITION_OUT
}).coerceIn(0f, 1f)
if (stateValue > 0f) {
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
val thumbDrawable = thumbDrawable
val trackDrawable = trackDrawable
val maxWidth = max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicHeight)
val translateX = (maxWidth * (1f - stateValue)).roundToInt()
val fastScrolling = fastScrolling
val scrollValue = (if (fastScrolling != null) {
calculateOffset(thumbHeight, fastScrolling)
} else {
currentOffset(itemHeight, range)
}).coerceIn(0f, 1f)
val thumbY = ((recyclerView.height - thumbHeight) * scrollValue).roundToInt()
trackDrawable.state = if (fastScrolling != null) statePressed else stateNormal
val trackExtra = (maxWidth - trackDrawable.intrinsicWidth) / 2
if (rtl) {
trackDrawable.setBounds(trackExtra - translateX, 0,
trackExtra + trackDrawable.intrinsicWidth - translateX, recyclerView.height)
} else {
trackDrawable.setBounds(recyclerView.width - trackExtra - trackDrawable.intrinsicWidth + translateX,
0, recyclerView.width - trackExtra + translateX, recyclerView.height)
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
updateState(newState != RecyclerView.SCROLL_STATE_IDLE, fastScrolling)
}
trackDrawable.draw(canvas)
val thumbExtra = (maxWidth - thumbDrawable.intrinsicWidth) / 2
thumbDrawable.state = if (fastScrolling != null) statePressed else stateNormal
if (rtl) {
thumbDrawable.setBounds(thumbExtra - translateX, thumbY,
thumbExtra + thumbDrawable.intrinsicWidth - translateX, thumbY + thumbHeight)
} else {
thumbDrawable.setBounds(recyclerView.width - thumbExtra - thumbDrawable.intrinsicWidth + translateX,
thumbY, recyclerView.width - thumbExtra + translateX, thumbY + thumbHeight)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (fastScrolling == null) {
recyclerView.invalidate()
}
}
thumbDrawable.draw(canvas)
}
if (shouldInvalidate) {
recyclerView.invalidate()
}
}
}
init {
recyclerView.addOnScrollListener(scrollListener)
recyclerView.addOnItemTouchListener(touchListener)
recyclerView.addItemDecoration(object: RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) = handleDraw(c)
})
}
private inline fun withScroll(callback: (itemHeight: Int, thumbHeight: Int, range: Int) -> Unit): Boolean {
val count = recyclerView.adapter?.itemCount ?: 0
return count > 0 && run {
val itemHeight = Rect().apply {
recyclerView
.getDecoratedBoundsWithMargins(recyclerView.getChildAt(0), this)
}.height()
val scrollCount = count - recyclerView.height / itemHeight
scrollCount > 0 && run {
val range = count * itemHeight
val thumbHeight = max(
recyclerView.height * recyclerView.height / range,
thumbDrawable.intrinsicHeight
)
range >= recyclerView.height * 2 && run {
callback(itemHeight, thumbHeight, range)
true
}
}
}
}
private fun calculateOffset(thumbHeight: Int, fastScrolling: FastScrolling): Float {
return if (fastScrolling.startAtThumbOffset != null) {
(fastScrolling.startAtThumbOffset + (fastScrolling.currentY - fastScrolling.startY) /
(recyclerView.height - thumbHeight)).coerceIn(0f, 1f)
} else {
((fastScrolling.currentY - thumbHeight / 2f) / (recyclerView.height - thumbHeight)).coerceIn(
0f,
1f
)
}
}
private fun currentOffset(itemHeight: Int, range: Int): Float {
val view = recyclerView.getChildAt(0)
val position = recyclerView.getChildAdapterPosition(view)
val positionOffset = -view.top
val scrollPosition = position * itemHeight + positionOffset
return scrollPosition.toFloat() / (range - recyclerView.height)
}
private fun scroll(
itemHeight: Int,
thumbHeight: Int,
range: Int,
fastScrolling: FastScrolling
) {
val offset = calculateOffset(thumbHeight, fastScrolling)
val scrollPosition = ((range - recyclerView.height) * offset).roundToInt()
val position = scrollPosition / itemHeight
val positionOffset = scrollPosition - position * itemHeight
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(position, -positionOffset)
}
private val touchListener = object : RecyclerView.OnItemTouchListener {
private var disallowIntercept = false
private fun handleTouchEvent(event: MotionEvent, intercept: Boolean): Boolean {
val recyclerView = recyclerView
val lastFastScrolling = fastScrolling
return when {
intercept && disallowIntercept -> {
false
}
event.action == MotionEvent.ACTION_DOWN -> {
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
val trackWidth = max(
minTrackSize,
max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicWidth)
)
val atThumbVertical =
if (rtl) event.x <= trackWidth else event.x >= recyclerView.width - trackWidth
atThumbVertical && run {
withScroll { itemHeight, thumbHeight, range ->
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(
true
)
val offset = currentOffset(itemHeight, range)
val thumbY = ((recyclerView.height - thumbHeight) * offset).roundToInt()
val atThumb = event.y >= thumbY && event.y <= thumbY + thumbHeight
val fastScrolling =
FastScrolling(if (atThumb) offset else null, event.y, event.y)
scroll(itemHeight, thumbHeight, range, fastScrolling)
updateState(scrolling, fastScrolling)
recyclerView.invalidate()
}
}
}
else -> lastFastScrolling != null && run {
val success = withScroll { itemHeight, thumbHeight, range ->
val fastScrolling = lastFastScrolling.copy(currentY = event.y)
scroll(itemHeight, thumbHeight, range, fastScrolling)
updateState(scrolling, fastScrolling)
recyclerView.invalidate()
}
val cancel =
event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL
if (!success || cancel) {
(recyclerView.parent as? ViewGroup)?.requestDisallowInterceptTouchEvent(
false
)
updateState(scrolling, null)
recyclerView.invalidate()
}
true
}
}
}
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
return handleTouchEvent(e, true)
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
handleTouchEvent(e, false)
}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
this.disallowIntercept = disallowIntercept
if (disallowIntercept && fastScrolling != null) {
updateState(scrolling, null)
recyclerView.invalidate()
}
}
}
private fun handleDraw(canvas: Canvas) {
withScroll { itemHeight, thumbHeight, range ->
val display = display
val time = SystemClock.elapsedRealtime()
val passed = time - display.first
val shouldInvalidate = display.second && passed < TRANSITION_IN ||
!display.second && passed >= TRANSITION_OUT_DELAY && passed < TRANSITION_OUT_DELAY + TRANSITION_OUT
val stateValue = (if (display.second) {
passed.toFloat() / TRANSITION_IN
} else {
1f - (passed - TRANSITION_OUT_DELAY).toFloat() / TRANSITION_OUT
}).coerceIn(0f, 1f)
if (stateValue > 0f) {
val rtl = recyclerView.layoutDirection == RecyclerView.LAYOUT_DIRECTION_RTL
val thumbDrawable = thumbDrawable
val trackDrawable = trackDrawable
val maxWidth = max(thumbDrawable.intrinsicWidth, trackDrawable.intrinsicHeight)
val translateX = (maxWidth * (1f - stateValue)).roundToInt()
val fastScrolling = fastScrolling
val scrollValue = (if (fastScrolling != null) {
calculateOffset(thumbHeight, fastScrolling)
} else {
currentOffset(itemHeight, range)
}).coerceIn(0f, 1f)
val thumbY = ((recyclerView.height - thumbHeight) * scrollValue).roundToInt()
trackDrawable.state = if (fastScrolling != null) statePressed else stateNormal
val trackExtra = (maxWidth - trackDrawable.intrinsicWidth) / 2
if (rtl) {
trackDrawable.setBounds(
trackExtra - translateX, 0,
trackExtra + trackDrawable.intrinsicWidth - translateX, recyclerView.height
)
} else {
trackDrawable.setBounds(
recyclerView.width - trackExtra - trackDrawable.intrinsicWidth + translateX,
0, recyclerView.width - trackExtra + translateX, recyclerView.height
)
}
trackDrawable.draw(canvas)
val thumbExtra = (maxWidth - thumbDrawable.intrinsicWidth) / 2
thumbDrawable.state = if (fastScrolling != null) statePressed else stateNormal
if (rtl) {
thumbDrawable.setBounds(
thumbExtra - translateX, thumbY,
thumbExtra + thumbDrawable.intrinsicWidth - translateX, thumbY + thumbHeight
)
} else {
thumbDrawable.setBounds(
recyclerView.width - thumbExtra - thumbDrawable.intrinsicWidth + translateX,
thumbY, recyclerView.width - thumbExtra + translateX, thumbY + thumbHeight
)
}
thumbDrawable.draw(canvas)
}
if (shouldInvalidate) {
recyclerView.invalidate()
}
}
}
init {
recyclerView.addOnScrollListener(scrollListener)
recyclerView.addOnItemTouchListener(touchListener)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) =
handleDraw(c)
})
}
}

View File

@ -2,26 +2,27 @@ package com.looker.droidify.widget
import androidx.recyclerview.widget.RecyclerView
abstract class StableRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() {
private var nextId = 1L
private val descriptorToId = mutableMapOf<String, Long>()
abstract class StableRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
EnumRecyclerAdapter<VT, VH>() {
private var nextId = 1L
private val descriptorToId = mutableMapOf<String, Long>()
init {
super.setHasStableIds(true)
}
final override fun setHasStableIds(hasStableIds: Boolean) {
throw UnsupportedOperationException()
}
override fun getItemId(position: Int): Long {
val descriptor = getItemDescriptor(position)
return descriptorToId[descriptor] ?: run {
val id = nextId++
descriptorToId[descriptor] = id
id
init {
super.setHasStableIds(true)
}
}
abstract fun getItemDescriptor(position: Int): String
final override fun setHasStableIds(hasStableIds: Boolean) {
throw UnsupportedOperationException()
}
override fun getItemId(position: Int): Long {
val descriptor = getItemDescriptor(position)
return descriptorToId[descriptor] ?: run {
val id = nextId++
descriptorToId[descriptor] = id
id
}
}
abstract fun getItemDescriptor(position: Int): String
}

View File

@ -4,30 +4,35 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.Toolbar
class Toolbar: Toolbar {
constructor(context: Context): super(context)
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,
defStyleRes: Int): super(context, attrs, defStyleAttr, defStyleRes)
class Toolbar : Toolbar {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
private var initalized = false
private var layoutDirectionChanged: Int? = null
constructor(
context: Context, attrs: AttributeSet?, defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
init {
initalized = true
val layoutDirection = layoutDirectionChanged
layoutDirectionChanged = null
if (layoutDirection != null) {
onRtlPropertiesChanged(layoutDirection)
private var initalized = false
private var layoutDirectionChanged: Int? = null
init {
initalized = true
val layoutDirection = layoutDirectionChanged
layoutDirectionChanged = null
if (layoutDirection != null) onRtlPropertiesChanged(layoutDirection)
}
}
override fun onRtlPropertiesChanged(layoutDirection: Int) {
if (initalized) {
super.onRtlPropertiesChanged(layoutDirection)
} else {
layoutDirectionChanged = layoutDirection
override fun onRtlPropertiesChanged(layoutDirection: Int) {
if (initalized) {
super.onRtlPropertiesChanged(layoutDirection)
} else {
layoutDirectionChanged = layoutDirection
}
}
}
}