diff --git a/src/main/kotlin/com/looker/droidify/Common.kt b/src/main/kotlin/com/looker/droidify/Common.kt index 3888250e..a943ba42 100644 --- a/src/main/kotlin/com/looker/droidify/Common.kt +++ b/src/main/kotlin/com/looker/droidify/Common.kt @@ -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 } diff --git a/src/main/kotlin/com/looker/droidify/MainActivity.kt b/src/main/kotlin/com/looker/droidify/MainActivity.kt index 71fbe1fa..b18f9479 100644 --- a/src/main/kotlin/com/looker/droidify/MainActivity.kt +++ b/src/main/kotlin/com/looker/droidify/MainActivity.kt @@ -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) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/MainApplication.kt b/src/main/kotlin/com/looker/droidify/MainApplication.kt index 2585c5bd..8f4ccb8b 100644 --- a/src/main/kotlin/com/looker/droidify/MainApplication.kt +++ b/src/main/kotlin/com/looker/droidify/MainApplication.kt @@ -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 + } } diff --git a/src/main/kotlin/com/looker/droidify/content/Cache.kt b/src/main/kotlin/com/looker/droidify/content/Cache.kt index 79d69ca0..cb4ee581 100644 --- a/src/main/kotlin/com/looker/droidify/content/Cache.kt +++ b/src/main/kotlin/com/looker/droidify/content/Cache.kt @@ -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) { - 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) { + 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 { - 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?, - selection: String?, selectionArgs: Array?, 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?): Int = unsupported - override fun update(uri: Uri, contentValues: ContentValues?, - selection: String?, selectionArgs: Array?): 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 { + 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?, + selection: String?, selectionArgs: Array?, 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?): Int = + unsupported + + override fun update( + uri: Uri, contentValues: ContentValues?, + selection: String?, selectionArgs: Array? + ): 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) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/content/Preferences.kt b/src/main/kotlin/com/looker/droidify/content/Preferences.kt index ad4f9a65..5c86d670 100644 --- a/src/main/kotlin/com/looker/droidify/content/Preferences.kt +++ b/src/main/kotlin/com/looker/droidify/content/Preferences.kt @@ -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>() + private val subject = PublishSubject.create>() - 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> - get() = subject - - sealed class Value { - abstract val value: T - - internal abstract fun get(preferences: SharedPreferences, key: String, defaultValue: Value): T - internal abstract fun set(preferences: SharedPreferences, key: String, value: T) - - class BooleanValue(override val value: Boolean): Value() { - override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): 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() { - override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): Int { - return preferences.getInt(key, defaultValue.value) - } + val observable: Observable> + get() = subject - override fun set(preferences: SharedPreferences, key: String, value: Int) { - preferences.edit().putInt(key, value).apply() - } + sealed class Value { + abstract val value: T + + internal abstract fun get( + preferences: SharedPreferences, + key: String, + defaultValue: Value + ): T + + internal abstract fun set(preferences: SharedPreferences, key: String, value: T) + + class BooleanValue(override val value: Boolean) : Value() { + override fun get( + preferences: SharedPreferences, + key: String, + defaultValue: Value + ): 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() { + override fun get( + preferences: SharedPreferences, + key: String, + defaultValue: Value + ): 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() { + override fun get( + preferences: SharedPreferences, + key: String, + defaultValue: Value + ): 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>(override val value: T) : Value() { + override fun get( + preferences: SharedPreferences, + key: String, + defaultValue: Value + ): 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() { - override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): 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 { + val values: List + val valueString: String } - class EnumerationValue>(override val value: T): Value() { - override fun get(preferences: SharedPreferences, key: String, defaultValue: Value): T { - val value = preferences.getString(key, defaultValue.value.valueString) - return defaultValue.value.values.find { it.valueString == value } ?: defaultValue.value - } + sealed class Key(val name: String, val default: Value) { + object AutoSync : Key( + "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("incompatible_versions", Value.BooleanValue(false)) - interface Enumeration { - val values: List - val valueString: String - } + object ProxyHost : Key("proxy_host", Value.StringValue("localhost")) + object ProxyPort : Key("proxy_port", Value.IntValue(9050)) + object ProxyType : Key( + "proxy_type", + Value.EnumerationValue(Preferences.ProxyType.Direct) + ) - sealed class Key(val name: String, val default: Value) { - object AutoSync: Key("auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi)) - object IncompatibleVersions: Key("incompatible_versions", Value.BooleanValue(false)) - object ProxyHost: Key("proxy_host", Value.StringValue("localhost")) - object ProxyPort: Key("proxy_port", Value.IntValue(9050)) - object ProxyType: Key("proxy_type", Value.EnumerationValue(Preferences.ProxyType.Direct)) - object SortOrder: Key("sort_order", Value.EnumerationValue(Preferences.SortOrder.Update)) - object Theme: Key("theme", Value.EnumerationValue(if (Android.sdk(29)) - Preferences.Theme.System else Preferences.Theme.Light)) - object UpdateNotify: Key("update_notify", Value.BooleanValue(true)) - object UpdateUnstable: Key("update_unstable", Value.BooleanValue(false)) - } + object SortOrder : Key( + "sort_order", + Value.EnumerationValue(Preferences.SortOrder.Update) + ) - sealed class AutoSync(override val valueString: String): Enumeration { - override val values: List - get() = listOf(Never, Wifi, Always) + object Theme : Key( + "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 { - override val values: List - 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 { - override val values: List - 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 { - override val values: List - 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("update_notify", Value.BooleanValue(true)) + object UpdateUnstable : Key("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 { + override val values: List + 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 { + override val values: List + 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 get(key: Key): T { - return key.default.get(preferences, key.name, key.default) - } + sealed class SortOrder(override val valueString: String, val order: ProductItem.Order) : + Enumeration { + override val values: List + get() = listOf(Name, Added, Update) - operator fun set(key: Key, 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 { + override val values: List + 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 get(key: Key): T { + return key.default.get(preferences, key.name, key.default) + } + + operator fun set(key: Key, value: T) { + key.default.set(preferences, key.name, value) + } } diff --git a/src/main/kotlin/com/looker/droidify/content/ProductPreferences.kt b/src/main/kotlin/com/looker/droidify/content/ProductPreferences.kt index aa0ddb77..fb6ef278 100644 --- a/src/main/kotlin/com/looker/droidify/content/ProductPreferences.kt +++ b/src/main/kotlin/com/looker/droidify/content/ProductPreferences.kt @@ -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>() + private val defaultProductPreference = ProductPreference(false, 0L) + private lateinit var preferences: SharedPreferences + private val subject = PublishSubject.create>() - 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)) - } - } } diff --git a/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt b/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt index d0ab2d12..b1e47842 100644 --- a/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt +++ b/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt @@ -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 { - sealed class Request { - internal abstract val id: Int +class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { + 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() + + 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() - - 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 { + 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 { - 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, 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, 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) = onLoadFinished(loader, null) + override fun onLoaderReset(loader: Loader) = onLoadFinished(loader, null) } diff --git a/src/main/kotlin/com/looker/droidify/database/Database.kt b/src/main/kotlin/com/looker/droidify/database/Database.kt index 96e12def..ba6057e6 100644 --- a/src/main/kotlin/com/looker/droidify/database/Database.kt +++ b/src/main/kotlin/com/looker/droidify/database/Database.kt @@ -8,85 +8,92 @@ import android.database.sqlite.SQLiteOpenHelper import android.os.CancellationSignal import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser -import io.reactivex.rxjava3.core.Observable import com.looker.droidify.entity.InstalledItem import com.looker.droidify.entity.Product import com.looker.droidify.entity.ProductItem import com.looker.droidify.entity.Repository -import com.looker.droidify.utility.extension.android.* -import com.looker.droidify.utility.extension.json.* +import com.looker.droidify.utility.extension.android.asSequence +import com.looker.droidify.utility.extension.android.firstOrNull +import com.looker.droidify.utility.extension.json.Json +import com.looker.droidify.utility.extension.json.parseDictionary +import com.looker.droidify.utility.extension.json.writeDictionary +import io.reactivex.rxjava3.core.Observable import java.io.ByteArrayOutputStream object Database { - fun init(context: Context): Boolean { - val helper = Helper(context) - db = helper.writableDatabase - if (helper.created) { - for (repository in Repository.defaultRepositories) { - RepositoryAdapter.put(repository) - } - } - return helper.created || helper.updated - } - - private lateinit var db: SQLiteDatabase - - private interface Table { - val memory: Boolean - val innerName: String - val createTable: String - val createIndex: String? - get() = null - - val databasePrefix: String - get() = if (memory) "memory." else "" - - val name: String - get() = "$databasePrefix$innerName" - - fun formatCreateTable(name: String): String { - return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})" + fun init(context: Context): Boolean { + val helper = Helper(context) + db = helper.writableDatabase + if (helper.created) { + for (repository in Repository.defaultRepositories) { + RepositoryAdapter.put(repository) + } + } + return helper.created || helper.updated } - val createIndexPairFormatted: Pair? - get() = createIndex?.let { Pair("CREATE INDEX ${innerName}_index ON $innerName ($it)", - "CREATE INDEX ${name}_index ON $innerName ($it)") } - } + private lateinit var db: SQLiteDatabase - private object Schema { - object Repository: Table { - const val ROW_ID = "_id" - const val ROW_ENABLED = "enabled" - const val ROW_DELETED = "deleted" - const val ROW_DATA = "data" + private interface Table { + val memory: Boolean + val innerName: String + val createTable: String + val createIndex: String? + get() = null - override val memory = false - override val innerName = "repository" - override val createTable = """ + val databasePrefix: String + get() = if (memory) "memory." else "" + + val name: String + get() = "$databasePrefix$innerName" + + fun formatCreateTable(name: String): String { + return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})" + } + + val createIndexPairFormatted: Pair? + get() = createIndex?.let { + Pair( + "CREATE INDEX ${innerName}_index ON $innerName ($it)", + "CREATE INDEX ${name}_index ON $innerName ($it)" + ) + } + } + + private object Schema { + object Repository : Table { + const val ROW_ID = "_id" + const val ROW_ENABLED = "enabled" + const val ROW_DELETED = "deleted" + const val ROW_DATA = "data" + + override val memory = false + override val innerName = "repository" + override val createTable = """ $ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT, $ROW_ENABLED INTEGER NOT NULL, $ROW_DELETED INTEGER NOT NULL, $ROW_DATA BLOB NOT NULL """ - } + } - object Product: Table { - const val ROW_REPOSITORY_ID = "repository_id" - const val ROW_PACKAGE_NAME = "package_name" - const val ROW_NAME = "name" - const val ROW_SUMMARY = "summary" - const val ROW_DESCRIPTION = "description" - const val ROW_ADDED = "added" - const val ROW_UPDATED = "updated" - const val ROW_VERSION_CODE = "version_code" - const val ROW_SIGNATURES = "signatures" - const val ROW_COMPATIBLE = "compatible" - const val ROW_DATA = "data" - const val ROW_DATA_ITEM = "data_item" + object Product : Table { + const val ROW_REPOSITORY_ID = "repository_id" + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_NAME = "name" + const val ROW_SUMMARY = "summary" + const val ROW_DESCRIPTION = "description" + const val ROW_ADDED = "added" + const val ROW_UPDATED = "updated" + const val ROW_VERSION_CODE = "version_code" + const val ROW_SIGNATURES = "signatures" + const val ROW_COMPATIBLE = "compatible" + const val ROW_DATA = "data" + const val ROW_DATA_ITEM = "data_item" - override val memory = false - override val innerName = "product" - override val createTable = """ + override val memory = false + override val innerName = "product" + override val createTable = """ $ROW_REPOSITORY_ID INTEGER NOT NULL, $ROW_PACKAGE_NAME TEXT NOT NULL, $ROW_NAME TEXT NOT NULL, @@ -101,308 +108,395 @@ object Database { $ROW_DATA_ITEM BLOB NOT NULL, PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME) """ - override val createIndex = ROW_PACKAGE_NAME - } + override val createIndex = ROW_PACKAGE_NAME + } - object Category: Table { - const val ROW_REPOSITORY_ID = "repository_id" - const val ROW_PACKAGE_NAME = "package_name" - const val ROW_NAME = "name" + object Category : Table { + const val ROW_REPOSITORY_ID = "repository_id" + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_NAME = "name" - override val memory = false - override val innerName = "category" - override val createTable = """ + override val memory = false + override val innerName = "category" + override val createTable = """ $ROW_REPOSITORY_ID INTEGER NOT NULL, $ROW_PACKAGE_NAME TEXT NOT NULL, $ROW_NAME TEXT NOT NULL, PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME) """ - override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME" - } + override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME" + } - object Installed: Table { - const val ROW_PACKAGE_NAME = "package_name" - const val ROW_VERSION = "version" - const val ROW_VERSION_CODE = "version_code" - const val ROW_SIGNATURE = "signature" + object Installed : Table { + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_VERSION = "version" + const val ROW_VERSION_CODE = "version_code" + const val ROW_SIGNATURE = "signature" - override val memory = true - override val innerName = "installed" - override val createTable = """ + override val memory = true + override val innerName = "installed" + override val createTable = """ $ROW_PACKAGE_NAME TEXT PRIMARY KEY, $ROW_VERSION TEXT NOT NULL, $ROW_VERSION_CODE INTEGER NOT NULL, $ROW_SIGNATURE TEXT NOT NULL """ - } + } - object Lock: Table { - const val ROW_PACKAGE_NAME = "package_name" - const val ROW_VERSION_CODE = "version_code" + object Lock : Table { + const val ROW_PACKAGE_NAME = "package_name" + const val ROW_VERSION_CODE = "version_code" - override val memory = true - override val innerName = "lock" - override val createTable = """ + override val memory = true + override val innerName = "lock" + override val createTable = """ $ROW_PACKAGE_NAME TEXT PRIMARY KEY, $ROW_VERSION_CODE INTEGER NOT NULL """ - } - - object Synthetic { - const val ROW_CAN_UPDATE = "can_update" - const val ROW_MATCH_RANK = "match_rank" - } - } - - private class Helper(context: Context): SQLiteOpenHelper(context, "droidify", null, 1) { - var created = false - private set - var updated = false - private set - - override fun onCreate(db: SQLiteDatabase) = Unit - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) - override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = onVersionChange(db) - - private fun onVersionChange(db: SQLiteDatabase) { - handleTables(db, true, Schema.Product, Schema.Category) - this.updated = true - } - - override fun onOpen(db: SQLiteDatabase) { - val create = handleTables(db, false, Schema.Repository) - val updated = handleTables(db, create, Schema.Product, Schema.Category) - db.execSQL("ATTACH DATABASE ':memory:' AS memory") - handleTables(db, false, Schema.Installed, Schema.Lock) - handleIndexes(db, Schema.Repository, Schema.Product, Schema.Category, Schema.Installed, Schema.Lock) - dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) - this.created = this.created || create - this.updated = this.updated || create || updated - } - } - - private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { - val shouldRecreate = recreate || tables.any { - val sql = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("sql"), - selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))) - .use { it.firstOrNull()?.getString(0) }.orEmpty() - it.formatCreateTable(it.innerName) != sql - } - return shouldRecreate && run { - val shouldVacuum = tables.map { - db.execSQL("DROP TABLE IF EXISTS ${it.name}") - db.execSQL(it.formatCreateTable(it.name)) - !it.memory - } - if (shouldVacuum.any { it } && !db.inTransaction()) { - db.execSQL("VACUUM") - } - true - } - } - - private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { - val shouldVacuum = tables.map { - val sqls = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), - selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName))) - .use { it.asSequence().mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }.toList() } - .filter { !it.first.startsWith("sqlite_") } - val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty() - createIndexes.map { it.first } != sqls.map { it.second } && run { - for (name in sqls.map { it.first }) { - db.execSQL("DROP INDEX IF EXISTS $name") } - for (createIndexPair in createIndexes) { - db.execSQL(createIndexPair.second) + + object Synthetic { + const val ROW_CAN_UPDATE = "can_update" + const val ROW_MATCH_RANK = "match_rank" } - !it.memory - } } - if (shouldVacuum.any { it } && !db.inTransaction()) { - db.execSQL("VACUUM") - } - } - private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { - val tables = db.query("sqlite_master", columns = arrayOf("name"), - selection = Pair("type = ?", arrayOf("table"))) - .use { it.asSequence().mapNotNull { it.getString(0) }.toList() } - .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } - .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name } - if (tables.isNotEmpty()) { - for (table in tables) { - db.execSQL("DROP TABLE IF EXISTS $table") - } - if (!db.inTransaction()) { - db.execSQL("VACUUM") - } - } - } + private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 1) { + var created = false + private set + var updated = false + private set - sealed class Subject { - object Repositories: Subject() - data class Repository(val id: Long): Subject() - object Products: Subject() - } + override fun onCreate(db: SQLiteDatabase) = Unit + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + onVersionChange(db) - private val observers = mutableMapOf Unit>>() + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + onVersionChange(db) - private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = { register, observer -> - synchronized(observers) { - val set = observers[subject] ?: run { - val set = mutableSetOf<() -> Unit>() - observers[subject] = set - set - } - if (register) { - set += observer - } else { - set -= observer - } - } - } - - fun observable(subject: Subject): Observable { - return Observable.create { - val callback: () -> Unit = { it.onNext(Unit) } - val dataObservable = dataObservable(subject) - dataObservable(true, callback) - it.setCancellable { dataObservable(false, callback) } - } - } - - private fun notifyChanged(vararg subjects: Subject) { - synchronized(observers) { - subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() } - } - } - - private fun SQLiteDatabase.insertOrReplace(replace: Boolean, table: String, contentValues: ContentValues): Long { - return if (replace) replace(table, null, contentValues) else insert(table, null, contentValues) - } - - private fun SQLiteDatabase.query(table: String, columns: Array? = null, - selection: Pair>? = null, orderBy: String? = null, - signal: CancellationSignal? = null): Cursor { - return query(false, table, columns, selection?.first, selection?.second, null, null, orderBy, null, signal) - } - - private fun Cursor.observable(subject: Subject): ObservableCursor { - return ObservableCursor(this, dataObservable(subject)) - } - - private fun ByteArray.jsonParse(callback: (JsonParser) -> T): T { - return Json.factory.createParser(this).use { it.parseDictionary(callback) } - } - - private fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray { - val outputStream = ByteArrayOutputStream() - Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) } - return outputStream.toByteArray() - } - - object RepositoryAdapter { - internal fun putWithoutNotification(repository: Repository, shouldReplace: Boolean): Long { - return db.insertOrReplace(shouldReplace, Schema.Repository.name, ContentValues().apply { - if (shouldReplace) { - put(Schema.Repository.ROW_ID, repository.id) + private fun onVersionChange(db: SQLiteDatabase) { + handleTables(db, true, Schema.Product, Schema.Category) + this.updated = true } - put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0) - put(Schema.Repository.ROW_DELETED, 0) - put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize)) - }) - } - fun put(repository: Repository): Repository { - val shouldReplace = repository.id >= 0L - val newId = putWithoutNotification(repository, shouldReplace) - val id = if (shouldReplace) repository.id else newId - notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) - return if (newId != repository.id) repository.copy(id = newId) else repository - } - - fun get(id: Long): Repository? { - return db.query(Schema.Repository.name, - selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", - arrayOf(id.toString()))) - .use { it.firstOrNull()?.let(::transform) } - } - - fun getAll(signal: CancellationSignal?): List { - return db.query(Schema.Repository.name, - selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), - signal = signal).use { it.asSequence().map(::transform).toList() } - } - - fun getAllDisabledDeleted(signal: CancellationSignal?): Set> { - return db.query(Schema.Repository.name, - columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), - selection = Pair("${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", emptyArray()), - signal = signal).use { it.asSequence().map { Pair(it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)), - it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0) }.toSet() } - } - - fun markAsDeleted(id: Long) { - db.update(Schema.Repository.name, ContentValues().apply { - put(Schema.Repository.ROW_DELETED, 1) - }, "${Schema.Repository.ROW_ID} = ?", arrayOf(id.toString())) - notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) - } - - fun cleanup(pairs: Set>) { - val result = pairs.windowed(10, 10, true).map { - val idsString = it.joinToString(separator = ", ") { it.first.toString() } - val productsCount = db.delete(Schema.Product.name, - "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null) - val categoriesCount = db.delete(Schema.Category.name, - "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", null) - val deleteIdsString = it.asSequence().filter { it.second } - .joinToString(separator = ", ") { it.first.toString() } - if (deleteIdsString.isNotEmpty()) { - db.delete(Schema.Repository.name, "${Schema.Repository.ROW_ID} IN ($deleteIdsString)", null) + override fun onOpen(db: SQLiteDatabase) { + val create = handleTables(db, false, Schema.Repository) + val updated = handleTables(db, create, Schema.Product, Schema.Category) + db.execSQL("ATTACH DATABASE ':memory:' AS memory") + handleTables(db, false, Schema.Installed, Schema.Lock) + handleIndexes( + db, + Schema.Repository, + Schema.Product, + Schema.Category, + Schema.Installed, + Schema.Lock + ) + dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category) + this.created = this.created || create + this.updated = this.updated || create || updated } - productsCount != 0 || categoriesCount != 0 - } - if (result.any { it }) { - notifyChanged(Subject.Products) - } } - fun query(signal: CancellationSignal?): Cursor { - return db.query(Schema.Repository.name, - selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), - signal = signal).observable(Subject.Repositories) + private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { + val shouldRecreate = recreate || tables.any { + val sql = db.query( + "${it.databasePrefix}sqlite_master", columns = arrayOf("sql"), + selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName)) + ) + .use { it.firstOrNull()?.getString(0) }.orEmpty() + it.formatCreateTable(it.innerName) != sql + } + return shouldRecreate && run { + val shouldVacuum = tables.map { + db.execSQL("DROP TABLE IF EXISTS ${it.name}") + db.execSQL(it.formatCreateTable(it.name)) + !it.memory + } + if (shouldVacuum.any { it } && !db.inTransaction()) { + db.execSQL("VACUUM") + } + true + } } - fun transform(cursor: Cursor): Repository { - return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA)) - .jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), it) } - } - } - - object ProductAdapter { - fun get(packageName: String, signal: CancellationSignal?): List { - return db.query(Schema.Product.name, - columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DESCRIPTION, Schema.Product.ROW_DATA), - selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), - signal = signal).use { it.asSequence().map(::transform).toList() } + private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { + val shouldVacuum = tables.map { + val sqls = db.query( + "${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), + selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName)) + ) + .use { + it.asSequence() + .mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } } + .toList() + } + .filter { !it.first.startsWith("sqlite_") } + val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty() + createIndexes.map { it.first } != sqls.map { it.second } && run { + for (name in sqls.map { it.first }) { + db.execSQL("DROP INDEX IF EXISTS $name") + } + for (createIndexPair in createIndexes) { + db.execSQL(createIndexPair.second) + } + !it.memory + } + } + if (shouldVacuum.any { it } && !db.inTransaction()) { + db.execSQL("VACUUM") + } } - fun getCount(repositoryId: Long): Int { - return db.query(Schema.Product.name, columns = arrayOf("COUNT (*)"), - selection = Pair("${Schema.Product.ROW_REPOSITORY_ID} = ?", arrayOf(repositoryId.toString()))) - .use { it.firstOrNull()?.getInt(0) ?: 0 } + private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { + val tables = db.query( + "sqlite_master", columns = arrayOf("name"), + selection = Pair("type = ?", arrayOf("table")) + ) + .use { it.asSequence().mapNotNull { it.getString(0) }.toList() } + .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } + .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name } + if (tables.isNotEmpty()) { + for (table in tables) { + db.execSQL("DROP TABLE IF EXISTS $table") + } + if (!db.inTransaction()) { + db.execSQL("VACUUM") + } + } } - fun query(installed: Boolean, updates: Boolean, searchQuery: String, - section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal?): Cursor { - val builder = QueryBuilder() + sealed class Subject { + object Repositories : Subject() + data class Repository(val id: Long) : Subject() + object Products : Subject() + } - val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND + private val observers = mutableMapOf Unit>>() + + private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit = + { register, observer -> + synchronized(observers) { + val set = observers[subject] ?: run { + val set = mutableSetOf<() -> Unit>() + observers[subject] = set + set + } + if (register) { + set += observer + } else { + set -= observer + } + } + } + + fun observable(subject: Subject): Observable { + return Observable.create { + val callback: () -> Unit = { it.onNext(Unit) } + val dataObservable = dataObservable(subject) + dataObservable(true, callback) + it.setCancellable { dataObservable(false, callback) } + } + } + + private fun notifyChanged(vararg subjects: Subject) { + synchronized(observers) { + subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() } + } + } + + private fun SQLiteDatabase.insertOrReplace( + replace: Boolean, + table: String, + contentValues: ContentValues + ): Long { + return if (replace) replace(table, null, contentValues) else insert( + table, + null, + contentValues + ) + } + + private fun SQLiteDatabase.query( + table: String, columns: Array? = null, + selection: Pair>? = null, orderBy: String? = null, + signal: CancellationSignal? = null + ): Cursor { + return query( + false, + table, + columns, + selection?.first, + selection?.second, + null, + null, + orderBy, + null, + signal + ) + } + + private fun Cursor.observable(subject: Subject): ObservableCursor { + return ObservableCursor(this, dataObservable(subject)) + } + + private fun ByteArray.jsonParse(callback: (JsonParser) -> T): T { + return Json.factory.createParser(this).use { it.parseDictionary(callback) } + } + + private fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray { + val outputStream = ByteArrayOutputStream() + Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) } + return outputStream.toByteArray() + } + + object RepositoryAdapter { + internal fun putWithoutNotification(repository: Repository, shouldReplace: Boolean): Long { + return db.insertOrReplace(shouldReplace, Schema.Repository.name, ContentValues().apply { + if (shouldReplace) { + put(Schema.Repository.ROW_ID, repository.id) + } + put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0) + put(Schema.Repository.ROW_DELETED, 0) + put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize)) + }) + } + + fun put(repository: Repository): Repository { + val shouldReplace = repository.id >= 0L + val newId = putWithoutNotification(repository, shouldReplace) + val id = if (shouldReplace) repository.id else newId + notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) + return if (newId != repository.id) repository.copy(id = newId) else repository + } + + fun get(id: Long): Repository? { + return db.query( + Schema.Repository.name, + selection = Pair( + "${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", + arrayOf(id.toString()) + ) + ) + .use { it.firstOrNull()?.let(::transform) } + } + + fun getAll(signal: CancellationSignal?): List { + return db.query( + Schema.Repository.name, + selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), + signal = signal + ).use { it.asSequence().map(::transform).toList() } + } + + fun getAllDisabledDeleted(signal: CancellationSignal?): Set> { + return db.query( + Schema.Repository.name, + columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), + selection = Pair( + "${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", + emptyArray() + ), + signal = signal + ).use { + it.asSequence().map { + Pair( + it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)), + it.getInt(it.getColumnIndex(Schema.Repository.ROW_DELETED)) != 0 + ) + }.toSet() + } + } + + fun markAsDeleted(id: Long) { + db.update(Schema.Repository.name, ContentValues().apply { + put(Schema.Repository.ROW_DELETED, 1) + }, "${Schema.Repository.ROW_ID} = ?", arrayOf(id.toString())) + notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) + } + + fun cleanup(pairs: Set>) { + val result = pairs.windowed(10, 10, true).map { + val idsString = it.joinToString(separator = ", ") { it.first.toString() } + val productsCount = db.delete( + Schema.Product.name, + "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null + ) + val categoriesCount = db.delete( + Schema.Category.name, + "${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)", null + ) + val deleteIdsString = it.asSequence().filter { it.second } + .joinToString(separator = ", ") { it.first.toString() } + if (deleteIdsString.isNotEmpty()) { + db.delete( + Schema.Repository.name, + "${Schema.Repository.ROW_ID} IN ($deleteIdsString)", + null + ) + } + productsCount != 0 || categoriesCount != 0 + } + if (result.any { it }) { + notifyChanged(Subject.Products) + } + } + + fun query(signal: CancellationSignal?): Cursor { + return db.query( + Schema.Repository.name, + selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), + signal = signal + ).observable(Subject.Repositories) + } + + fun transform(cursor: Cursor): Repository { + return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA)) + .jsonParse { + Repository.deserialize( + cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), + it + ) + } + } + } + + object ProductAdapter { + fun get(packageName: String, signal: CancellationSignal?): List { + return db.query( + Schema.Product.name, + columns = arrayOf( + Schema.Product.ROW_REPOSITORY_ID, + Schema.Product.ROW_DESCRIPTION, + Schema.Product.ROW_DATA + ), + selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), + signal = signal + ).use { it.asSequence().map(::transform).toList() } + } + + fun getCount(repositoryId: Long): Int { + return db.query( + Schema.Product.name, columns = arrayOf("COUNT (*)"), + selection = Pair( + "${Schema.Product.ROW_REPOSITORY_ID} = ?", + arrayOf(repositoryId.toString()) + ) + ) + .use { it.firstOrNull()?.getInt(0) ?: 0 } + } + + fun query( + installed: Boolean, updates: Boolean, searchQuery: String, + section: ProductItem.Section, order: ProductItem.Order, signal: CancellationSignal? + ): Cursor { + val builder = QueryBuilder() + + val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND product.${Schema.Product.ROW_SIGNATURES} != ''""" - builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID}, + builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID}, product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME}, product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION}, (COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND @@ -411,249 +505,281 @@ object Database { AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE}, product.${Schema.Product.ROW_DATA_ITEM},""" - if (searchQuery.isNotEmpty()) { - builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR + if (searchQuery.isNotEmpty()) { + builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) | ((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) | (product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},""" - builder %= List(4) { "%$searchQuery%" } - } else { - builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK}," - } + builder %= List(4) { "%$searchQuery%" } + } else { + builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK}," + } - builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND + builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND (installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) || PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product""" - builder += """JOIN ${Schema.Repository.name} AS repository + builder += """JOIN ${Schema.Repository.name} AS repository ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}""" - builder += """LEFT JOIN ${Schema.Lock.name} AS lock + builder += """LEFT JOIN ${Schema.Lock.name} AS lock ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}""" - if (!installed && !updates) { - builder += "LEFT" - } - builder += """JOIN ${Schema.Installed.name} AS installed + if (!installed && !updates) { + builder += "LEFT" + } + builder += """JOIN ${Schema.Installed.name} AS installed ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}""" - if (section is ProductItem.Section.Category) { - builder += """JOIN ${Schema.Category.name} AS category + if (section is ProductItem.Section.Category) { + builder += """JOIN ${Schema.Category.name} AS category ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}""" - } + } - builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND + builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND repository.${Schema.Repository.ROW_DELETED} == 0""" - if (section is ProductItem.Section.Category) { - builder += "AND category.${Schema.Category.ROW_NAME} = ?" - builder %= section.name - } else if (section is ProductItem.Section.Repository) { - builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?" - builder %= section.id.toString() - } - if (searchQuery.isNotEmpty()) { - builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0""" - } + if (section is ProductItem.Section.Category) { + builder += "AND category.${Schema.Category.ROW_NAME} = ?" + builder %= section.name + } else if (section is ProductItem.Section.Repository) { + builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?" + builder %= section.id.toString() + } + if (searchQuery.isNotEmpty()) { + builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0""" + } - builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" - if (updates) { - builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}" - } - builder += "ORDER BY" - if (searchQuery.isNotEmpty()) { - builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,""" - } - when (order) { - ProductItem.Order.NAME -> Unit - ProductItem.Order.DATE_ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," - ProductItem.Order.LAST_UPDATE -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," - }::class - builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" + builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" + if (updates) { + builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}" + } + builder += "ORDER BY" + if (searchQuery.isNotEmpty()) { + builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,""" + } + when (order) { + ProductItem.Order.NAME -> Unit + ProductItem.Order.DATE_ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," + ProductItem.Order.LAST_UPDATE -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," + }::class + builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" - return builder.query(db, signal).observable(Subject.Products) + return builder.query(db, signal).observable(Subject.Products) + } + + private fun transform(cursor: Cursor): Product { + return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA)) + .jsonParse { + Product.deserialize( + cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), + cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION)), it + ) + } + } + + fun transformItem(cursor: Cursor): ProductItem { + return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM)) + .jsonParse { + ProductItem.deserialize( + cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), + cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)), + cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)), + cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)), + cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)) + .orEmpty(), + cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0, + cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, + cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK)), it + ) + } + } } - private fun transform(cursor: Cursor): Product { - return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA)) - .jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), - cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION)), it) } - } + object CategoryAdapter { + fun getAll(signal: CancellationSignal?): Set { + val builder = QueryBuilder() - fun transformItem(cursor: Cursor): ProductItem { - return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM)) - .jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), - cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)), - cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)), - cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)), - cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)).orEmpty(), - cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0, - cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, - cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK)), it) } - } - } - - object CategoryAdapter { - fun getAll(signal: CancellationSignal?): Set { - val builder = QueryBuilder() - - builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME} + builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME} FROM ${Schema.Category.name} AS category JOIN ${Schema.Repository.name} AS repository ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID} WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND repository.${Schema.Repository.ROW_DELETED} == 0""" - return builder.query(db, signal).use { it.asSequence() - .map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() } - } - } - - object InstalledAdapter { - fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { - return db.query(Schema.Installed.name, - columns = arrayOf(Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION, - Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE), - selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), - signal = signal).use { it.firstOrNull()?.let(::transform) } + return builder.query(db, signal).use { + it.asSequence() + .map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() + } + } } - private fun put(installedItem: InstalledItem, notify: Boolean) { - db.insertOrReplace(true, Schema.Installed.name, ContentValues().apply { - put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName) - put(Schema.Installed.ROW_VERSION, installedItem.version) - put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode) - put(Schema.Installed.ROW_SIGNATURE, installedItem.signature) - }) - if (notify) { - notifyChanged(Subject.Products) - } - } + object InstalledAdapter { + fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { + return db.query( + Schema.Installed.name, + columns = arrayOf( + Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION, + Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE + ), + selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), + signal = signal + ).use { it.firstOrNull()?.let(::transform) } + } - fun put(installedItem: InstalledItem) = put(installedItem, true) - - fun putAll(installedItems: List) { - db.beginTransaction() - try { - db.delete(Schema.Installed.name, null, null) - installedItems.forEach { put(it, false) } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - fun delete(packageName: String) { - val count = db.delete(Schema.Installed.name, "${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) - if (count > 0) { - notifyChanged(Subject.Products) - } - } - - private fun transform(cursor: Cursor): InstalledItem { - return InstalledItem(cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)), - cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)), - cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)), - cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE))) - } - } - - object LockAdapter { - private fun put(lock: Pair, notify: Boolean) { - db.insertOrReplace(true, Schema.Lock.name, ContentValues().apply { - put(Schema.Lock.ROW_PACKAGE_NAME, lock.first) - put(Schema.Lock.ROW_VERSION_CODE, lock.second) - }) - if (notify) { - notifyChanged(Subject.Products) - } - } - - fun put(lock: Pair) = put(lock, true) - - fun putAll(locks: List>) { - db.beginTransaction() - try { - db.delete(Schema.Lock.name, null, null) - locks.forEach { put(it, false) } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - fun delete(packageName: String) { - db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) - notifyChanged(Subject.Products) - } - } - - object UpdaterAdapter { - private val Table.temporaryName: String - get() = "${name}_temporary" - - fun createTemporaryTable() { - db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") - db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") - db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName)) - db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName)) - } - - fun putTemporary(products: List) { - db.beginTransaction() - try { - for (product in products) { - // Format signatures like ".signature1.signature2." for easier select - val signatures = product.signatures.joinToString { ".$it" } - .let { if (it.isNotEmpty()) "$it." else "" } - db.insertOrReplace(true, Schema.Product.temporaryName, ContentValues().apply { - put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId) - put(Schema.Product.ROW_PACKAGE_NAME, product.packageName) - put(Schema.Product.ROW_NAME, product.name) - put(Schema.Product.ROW_SUMMARY, product.summary) - put(Schema.Product.ROW_DESCRIPTION, product.description) - put(Schema.Product.ROW_ADDED, product.added) - put(Schema.Product.ROW_UPDATED, product.updated) - put(Schema.Product.ROW_VERSION_CODE, product.versionCode) - put(Schema.Product.ROW_SIGNATURES, signatures) - put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0) - put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) - put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize)) - }) - for (category in product.categories) { - db.insertOrReplace(true, Schema.Category.temporaryName, ContentValues().apply { - put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) - put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) - put(Schema.Category.ROW_NAME, category) + private fun put(installedItem: InstalledItem, notify: Boolean) { + db.insertOrReplace(true, Schema.Installed.name, ContentValues().apply { + put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName) + put(Schema.Installed.ROW_VERSION, installedItem.version) + put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode) + put(Schema.Installed.ROW_SIGNATURE, installedItem.signature) }) - } + if (notify) { + notifyChanged(Subject.Products) + } + } + + fun put(installedItem: InstalledItem) = put(installedItem, true) + + fun putAll(installedItems: List) { + db.beginTransaction() + try { + db.delete(Schema.Installed.name, null, null) + installedItems.forEach { put(it, false) } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun delete(packageName: String) { + val count = db.delete( + Schema.Installed.name, + "${Schema.Installed.ROW_PACKAGE_NAME} = ?", + arrayOf(packageName) + ) + if (count > 0) { + notifyChanged(Subject.Products) + } + } + + private fun transform(cursor: Cursor): InstalledItem { + return InstalledItem( + cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)), + cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)), + cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)), + cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE)) + ) } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } } - fun finishTemporary(repository: Repository, success: Boolean) { - if (success) { - db.beginTransaction() - try { - db.delete(Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?", - arrayOf(repository.id.toString())) - db.delete(Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?", - arrayOf(repository.id.toString())) - db.execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}") - db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}") - RepositoryAdapter.putWithoutNotification(repository, true) - db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") - db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") - db.setTransactionSuccessful() - } finally { - db.endTransaction() + object LockAdapter { + private fun put(lock: Pair, notify: Boolean) { + db.insertOrReplace(true, Schema.Lock.name, ContentValues().apply { + put(Schema.Lock.ROW_PACKAGE_NAME, lock.first) + put(Schema.Lock.ROW_VERSION_CODE, lock.second) + }) + if (notify) { + notifyChanged(Subject.Products) + } } - if (success) { - notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products) + + fun put(lock: Pair) = put(lock, true) + + fun putAll(locks: List>) { + db.beginTransaction() + try { + db.delete(Schema.Lock.name, null, null) + locks.forEach { put(it, false) } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun delete(packageName: String) { + db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)) + notifyChanged(Subject.Products) + } + } + + object UpdaterAdapter { + private val Table.temporaryName: String + get() = "${name}_temporary" + + fun createTemporaryTable() { + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName)) + db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName)) + } + + fun putTemporary(products: List) { + db.beginTransaction() + try { + for (product in products) { + // Format signatures like ".signature1.signature2." for easier select + val signatures = product.signatures.joinToString { ".$it" } + .let { if (it.isNotEmpty()) "$it." else "" } + db.insertOrReplace(true, Schema.Product.temporaryName, ContentValues().apply { + put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId) + put(Schema.Product.ROW_PACKAGE_NAME, product.packageName) + put(Schema.Product.ROW_NAME, product.name) + put(Schema.Product.ROW_SUMMARY, product.summary) + put(Schema.Product.ROW_DESCRIPTION, product.description) + put(Schema.Product.ROW_ADDED, product.added) + put(Schema.Product.ROW_UPDATED, product.updated) + put(Schema.Product.ROW_VERSION_CODE, product.versionCode) + put(Schema.Product.ROW_SIGNATURES, signatures) + put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0) + put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) + put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize)) + }) + for (category in product.categories) { + db.insertOrReplace( + true, + Schema.Category.temporaryName, + ContentValues().apply { + put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId) + put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) + put(Schema.Category.ROW_NAME, category) + }) + } + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun finishTemporary(repository: Repository, success: Boolean) { + if (success) { + db.beginTransaction() + try { + db.delete( + Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + db.delete( + Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + db.execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}") + db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}") + RepositoryAdapter.putWithoutNotification(repository, true) + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + if (success) { + notifyChanged( + Subject.Repositories, + Subject.Repository(repository.id), + Subject.Products + ) + } + } else { + db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + } } - } else { - db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") - db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") - } } - } } diff --git a/src/main/kotlin/com/looker/droidify/database/ObservableCursor.kt b/src/main/kotlin/com/looker/droidify/database/ObservableCursor.kt index 6de7e114..67d55987 100644 --- a/src/main/kotlin/com/looker/droidify/database/ObservableCursor.kt +++ b/src/main/kotlin/com/looker/droidify/database/ObservableCursor.kt @@ -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 + } } diff --git a/src/main/kotlin/com/looker/droidify/database/QueryBuilder.kt b/src/main/kotlin/com/looker/droidify/database/QueryBuilder.kt index 11d9bec5..b8bef5a2 100644 --- a/src/main/kotlin/com/looker/droidify/database/QueryBuilder.kt +++ b/src/main/kotlin/com/looker/droidify/database/QueryBuilder.kt @@ -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 { diff --git a/src/main/kotlin/com/looker/droidify/database/QueryLoader.kt b/src/main/kotlin/com/looker/droidify/database/QueryLoader.kt index b648aa24..9b151b49 100644 --- a/src/main/kotlin/com/looker/droidify/database/QueryLoader.kt +++ b/src/main/kotlin/com/looker/droidify/database/QueryLoader.kt @@ -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(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(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() + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/entity/InstalledItem.kt b/src/main/kotlin/com/looker/droidify/entity/InstalledItem.kt index 2608f68c..c01bab14 100644 --- a/src/main/kotlin/com/looker/droidify/entity/InstalledItem.kt +++ b/src/main/kotlin/com/looker/droidify/entity/InstalledItem.kt @@ -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 +) diff --git a/src/main/kotlin/com/looker/droidify/entity/Product.kt b/src/main/kotlin/com/looker/droidify/entity/Product.kt index c61a73e8..4d1d24bc 100644 --- a/src/main/kotlin/com/looker/droidify/entity/Product.kt +++ b/src/main/kotlin/com/looker/droidify/entity/Product.kt @@ -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, val antiFeatures: List, val licenses: List, - val donates: List, val screenshots: List, val releases: List) { - 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, + val antiFeatures: List, + val licenses: List, + val donates: List, + val screenshots: List, + val releases: List +) { + 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 - 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 - 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 findSuggested(products: List, 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() - var antiFeatures = emptyList() - var licenses = emptyList() - var donates = emptyList() - var screenshots = emptyList() - var releases = emptyList() - 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 + 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 + 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 findSuggested( + products: List, + 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() + var antiFeatures = emptyList() + var licenses = emptyList() + var donates = emptyList() + var screenshots = emptyList() + var releases = emptyList() + 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 + ) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/entity/ProductItem.kt b/src/main/kotlin/com/looker/droidify/entity/ProductItem.kt index 2d53aa4f..fe84ccfa 100644 --- a/src/main/kotlin/com/looker/droidify/entity/ProductItem.kt +++ b/src/main/kotlin/com/looker/droidify/entity/ProductItem.kt @@ -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) } - } } diff --git a/src/main/kotlin/com/looker/droidify/entity/ProductPreference.kt b/src/main/kotlin/com/looker/droidify/entity/ProductPreference.kt index 700b14e8..5ff311c9 100644 --- a/src/main/kotlin/com/looker/droidify/entity/ProductPreference.kt +++ b/src/main/kotlin/com/looker/droidify/entity/ProductPreference.kt @@ -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) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/entity/Release.kt b/src/main/kotlin/com/looker/droidify/entity/Release.kt index 6a0ad1a2..3db89f1c 100644 --- a/src/main/kotlin/com/looker/droidify/entity/Release.kt +++ b/src/main/kotlin/com/looker/droidify/entity/Release.kt @@ -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, val features: List, val platforms: List, - val incompatibilities: List) { - 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, + val features: List, + val platforms: List, + val incompatibilities: List +) { + 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() - var features = emptyList() - var platforms = emptyList() - var incompatibilities = emptyList() - 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() + var features = emptyList() + var platforms = emptyList() + var incompatibilities = emptyList() + 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 + ) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/entity/Repository.kt b/src/main/kotlin/com/looker/droidify/entity/Repository.kt index eeb9374c..a0965236 100644 --- a/src/main/kotlin/com/looker/droidify/entity/Repository.kt +++ b/src/main/kotlin/com/looker/droidify/entity/Repository.kt @@ -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, - 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, + 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, 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, 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() - 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() + 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", "" + ) + }) + } } diff --git a/src/main/kotlin/com/looker/droidify/graphics/DrawableWrapper.kt b/src/main/kotlin/com/looker/droidify/graphics/DrawableWrapper.kt index 472e4f53..3d334957 100644 --- a/src/main/kotlin/com/looker/droidify/graphics/DrawableWrapper.kt +++ b/src/main/kotlin/com/looker/droidify/graphics/DrawableWrapper.kt @@ -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 } diff --git a/src/main/kotlin/com/looker/droidify/graphics/PaddingDrawable.kt b/src/main/kotlin/com/looker/droidify/graphics/PaddingDrawable.kt index 3d1cc5d3..b60a9dd8 100644 --- a/src/main/kotlin/com/looker/droidify/graphics/PaddingDrawable.kt +++ b/src/main/kotlin/com/looker/droidify/graphics/PaddingDrawable.kt @@ -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 + ) + } } diff --git a/src/main/kotlin/com/looker/droidify/index/IndexHandler.kt b/src/main/kotlin/com/looker/droidify/index/IndexHandler.kt index 4f19f525..e0cd0770 100644 --- a/src/main/kotlin/com/looker/droidify/index/IndexHandler.kt +++ b/src/main/kotlin/com/looker/droidify/index/IndexHandler.kt @@ -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, name: String, description: String, - certificate: String, version: Int, timestamp: Long) - fun onProduct(product: Product) - } - - internal object DonateComparator: Comparator { - 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() - 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() - val antiFeatures = linkedSetOf() - val licenses = mutableListOf() - val donates = mutableListOf() - val releases = mutableListOf() - - 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() - val features = linkedSetOf() - val platforms = linkedSetOf() - - 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, 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 = "

$content

" - "desc" -> productBuilder.description = content.replace("\n", "
") - "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 { + 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() + 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() + val antiFeatures = linkedSetOf() + val licenses = mutableListOf() + val donates = mutableListOf() + val releases = mutableListOf() + + 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() + val features = linkedSetOf() + val platforms = linkedSetOf() + + 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 = "

$content

" + "desc" -> productBuilder.description = content.replace("\n", "
") + "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) + } } diff --git a/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt b/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt index c629c7bf..5720b8ae 100644 --- a/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt +++ b/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt @@ -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) { - 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>>) { - 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) { + 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, 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>>) { + 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, 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() } + } } diff --git a/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt b/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt index 2f527f34..68705c83 100644 --- a/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt +++ b/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt @@ -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, name: String, description: String, version: Int, timestamp: Long) - fun onProduct(product: Product) - fun onReleases(packageName: String, releases: List) - } + interface Callback { + fun onRepository( + mirrors: List, + name: String, + description: String, + version: Int, + timestamp: Long + ) - private class Screenshots(val phone: List, val smallTablet: List, val largeTablet: List) - 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) + } - private fun Map.getAndCall(key: String, callback: (String, Localized) -> T?): T? { - return this[key]?.let { callback(key, it) } - } + private class Screenshots( + val phone: List, + val smallTablet: List, + val largeTablet: List + ) - private fun Map.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.findString(fallback: String, callback: (Localized) -> String): String { - return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim() - } + private fun Map.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() - 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 Map.find(callback: (String, Localized) -> T?): T? { + return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall( + "en", + callback + ) + } + + private fun Map.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() + 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() + var antiFeatures = emptyList() + val licenses = mutableListOf() + val donates = mutableListOf() + val localizedMap = mutableMapOf() + 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() + var smallTablet = emptyList() + var largeTablet = emptyList() + 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", "
") + val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "
") + 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() - var antiFeatures = emptyList() - val licenses = mutableListOf() - val donates = mutableListOf() - val localizedMap = mutableMapOf() - 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() - var smallTablet = emptyList() - var largeTablet = emptyList() - 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() + var features = emptyList() + var platforms = emptyList() + 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", "
") - val whatsNew = localizedMap.findString("") { it.whatsNew }.replace("\n", "
") - 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() - var features = emptyList() - var platforms = emptyList() - 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, 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, 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() + } + } + } } - } } - } } - } } diff --git a/src/main/kotlin/com/looker/droidify/index/RepositoryUpdater.kt b/src/main/kotlin/com/looker/droidify/index/RepositoryUpdater.kt index 5dec9d14..4091ccd3 100644 --- a/src/main/kotlin/com/looker/droidify/index/RepositoryUpdater.kt +++ b/src/main/kotlin/com/looker/droidify/index/RepositoryUpdater.kt @@ -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() - 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 { - return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback) - } - - private fun update(repository: Repository, indexTypes: List, unstable: Boolean, - callback: (Stage, Long, Long?) -> Unit): Single { - 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> { - 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() - - reader.contentHandler = IndexHandler(repository.id, object: IndexHandler.Callback { - override fun onRepository(mirrors: List, 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() + 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 { + return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback) + } + + private fun update( + repository: Repository, indexTypes: List, unstable: Boolean, + callback: (Stage, Long, Long?) -> Unit + ): Single { + 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> { + 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() - val unmergedReleases = mutableListOf>>() - 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, 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() - override fun onReleases(packageName: String, releases: List) { - 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, 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() + val unmergedReleases = mutableListOf>>() + 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, + 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 + ) { + 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, unstable: Boolean): Product { - val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map { - val incompatibilities = mutableListOf() - 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) - }.toMutableList() + private fun transformProduct( + product: Product, + features: Set, + unstable: Boolean + ): Product { + val releasePairs = + product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode } + .map { it -> + val incompatibilities = mutableListOf() + 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) + }.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) + } } diff --git a/src/main/kotlin/com/looker/droidify/network/Downloader.kt b/src/main/kotlin/com/looker/droidify/network/Downloader.kt index d4c1323e..d40bc3d7 100644 --- a/src/main/kotlin/com/looker/droidify/network/Downloader.kt +++ b/src/main/kotlin/com/looker/droidify/network/Downloader.kt @@ -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() - private val onionProxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) + private val clients = mutableMapOf() + 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 { - 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 { + 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() + ) + } + } + } + } + } } diff --git a/src/main/kotlin/com/looker/droidify/network/PicassoDownloader.kt b/src/main/kotlin/com/looker/droidify/network/PicassoDownloader.kt index 2845561d..d2c3ecc6 100644 --- a/src/main/kotlin/com/looker/droidify/network/PicassoDownloader.kt +++ b/src/main/kotlin/com/looker/droidify/network/PicassoDownloader.kt @@ -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() + } } diff --git a/src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt b/src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt index 37b4342f..f3901479 100644 --- a/src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt +++ b/src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt @@ -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): 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, - val platforms: List, 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) : 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, + val platforms: List, 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(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(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() - } } diff --git a/src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt b/src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt index ab1904f6..88fea135 100644 --- a/src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt +++ b/src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt @@ -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 } diff --git a/src/main/kotlin/com/looker/droidify/screen/ScreenshotsFragment.kt b/src/main/kotlin/com/looker/droidify/screen/ScreenshotsFragment.kt index 1b0ce44d..fea4383c 100644 --- a/src/main/kotlin/com/looker/droidify/screen/ScreenshotsFragment.kt +++ b/src/main/kotlin/com/looker/droidify/screen/ScreenshotsFragment.kt @@ -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() { - 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() - - fun update(repository: Repository?, screenshots: List) { - 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 - 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() { + 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() + + fun update(repository: Repository?, screenshots: List) { + 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 + 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() - } } - } } diff --git a/src/main/kotlin/com/looker/droidify/service/Connection.kt b/src/main/kotlin/com/looker/droidify/service/Connection.kt index f3b14e88..9865e2d5 100644 --- a/src/main/kotlin/com/looker/droidify/service/Connection.kt +++ b/src/main/kotlin/com/looker/droidify/service/Connection.kt @@ -6,36 +6,38 @@ import android.content.Intent import android.content.ServiceConnection import android.os.IBinder -class Connection>(private val serviceClass: Class, - private val onBind: ((Connection, B) -> Unit)? = null, - private val onUnbind: ((Connection, B) -> Unit)? = null): ServiceConnection { - var binder: B? = null - private set +class Connection>( + private val serviceClass: Class, + private val onBind: ((Connection, B) -> Unit)? = null, + private val onUnbind: ((Connection, 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() + } } diff --git a/src/main/kotlin/com/looker/droidify/service/ConnectionService.kt b/src/main/kotlin/com/looker/droidify/service/ConnectionService.kt index 776cfc6c..286c3ef1 100644 --- a/src/main/kotlin/com/looker/droidify/service/ConnectionService.kt +++ b/src/main/kotlin/com/looker/droidify/service/ConnectionService.kt @@ -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: Service() { - abstract override fun onBind(intent: Intent): T +abstract class ConnectionService : 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) + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index 307cfdac..740899d3 100644 --- a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -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() { - 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() { + 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() - } - - 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() - - 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() - private var currentTask: CurrentTask? = null - - inner class Binder: android.os.Binder() { - fun events(packageName: String): Observable { - return stateSubject.filter { it.packageName == packageName } + private val downloadingSubject = PublishSubject.create() } - 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() + + 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() + private var currentTask: CurrentTask? = null + + inner class Binder : android.os.Binder() { + fun events(packageName: String): Observable { + 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() + } + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/service/SyncService.kt b/src/main/kotlin/com/looker/droidify/service/SyncService.kt index 29005b06..1be7a856 100644 --- a/src/main/kotlin/com/looker/droidify/service/SyncService.kt +++ b/src/main/kotlin/com/looker/droidify/service/SyncService.kt @@ -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() { - companion object { - private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" +class SyncService : ConnectionService() { + companion object { + private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" - private val stateSubject = PublishSubject.create() - private val finishSubject = PublishSubject.create() - } - - 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() - private var currentTask: CurrentTask? = null - - private var updateNotificationBlockerFragment: WeakReference? = null - - enum class SyncRequest { AUTO, MANUAL, FORCE } - - inner class Binder: android.os.Binder() { - val finish: Observable - get() = finishSubject - - private fun sync(ids: List, 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() + private val finishSubject = PublishSubject.create() } - 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() + 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? = 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 + 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, 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) { + val maxUpdates = 5 + fun 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) { - val maxUpdates = 5 - fun 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 - } - } } diff --git a/src/main/kotlin/com/looker/droidify/utility/KParcelable.kt b/src/main/kotlin/com/looker/droidify/utility/KParcelable.kt index 9f774e35..1f0297f6 100644 --- a/src/main/kotlin/com/looker/droidify/utility/KParcelable.kt +++ b/src/main/kotlin/com/looker/droidify/utility/KParcelable.kt @@ -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 creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator { - return object: Parcelable.Creator { - override fun createFromParcel(source: Parcel): T = create(source) - override fun newArray(size: Int): Array = arrayOfNulls(size) - } + companion object { + inline fun creator(crossinline create: (source: Parcel) -> T): Parcelable.Creator { + return object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): T = create(source) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/utility/PackageItemResolver.kt b/src/main/kotlin/com/looker/droidify/utility/PackageItemResolver.kt index a0be3521..67692594 100644 --- a/src/main/kotlin/com/looker/droidify/utility/PackageItemResolver.kt +++ b/src/main/kotlin/com/looker/droidify/utility/PackageItemResolver.kt @@ -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() - } + class LocalCache { + internal val resources = mutableMapOf() + } - private data class CacheKey(val locales: List, val packageName: String, val resId: Int) + private data class CacheKey(val locales: List, val packageName: String, val resId: Int) - private val cache = mutableMapOf() + private val cache = mutableMapOf() - 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 + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/utility/ProgressInputStream.kt b/src/main/kotlin/com/looker/droidify/utility/ProgressInputStream.kt index 1335be0e..b4e06114 100644 --- a/src/main/kotlin/com/looker/droidify/utility/ProgressInputStream.kt +++ b/src/main/kotlin/com/looker/droidify/utility/ProgressInputStream.kt @@ -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 notify(one: Boolean, read: () -> T): T { - val result = read() - count += if (one) 1L else result.toLong() - callback(count) - return result - } + private inline fun 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() + } } diff --git a/src/main/kotlin/com/looker/droidify/utility/RxUtils.kt b/src/main/kotlin/com/looker/droidify/utility/RxUtils.kt index 61072a30..50feb57d 100644 --- a/src/main/kotlin/com/looker/droidify/utility/RxUtils.kt +++ b/src/main/kotlin/com/looker/droidify/utility/RxUtils.kt @@ -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 managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single { - 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 managedSingle( + create: () -> T, + cancel: (T) -> Unit, + execute: (T) -> R + ): Single { + 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 managedSingle(execute: () -> R): Single { - return managedSingle({ Unit }, { }, { execute() }) - } - - fun callSingle(create: () -> Call): Single { - return managedSingle(create, Call::cancel, Call::execute) - } - - fun querySingle(query: (CancellationSignal) -> T): Single { - 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 managedSingle(execute: () -> R): Single { + return managedSingle({ }, { }, { execute() }) + } + + fun callSingle(create: () -> Call): Single { + return managedSingle(create, Call::cancel, Call::execute) + } + + fun querySingle(query: (CancellationSignal) -> T): Single { + 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) + } + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/utility/Utils.kt b/src/main/kotlin/com/looker/droidify/utility/Utils.kt index 28dba8ec..607630a5 100644 --- a/src/main/kotlin/com/looker/droidify/utility/Utils.kt +++ b/src/main/kotlin/com/looker/droidify/utility/Utils.kt @@ -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 { - 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 { + 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 + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/utility/extension/Android.kt b/src/main/kotlin/com/looker/droidify/utility/extension/Android.kt index ca991908..19e80cdf 100644 --- a/src/main/kotlin/com/looker/droidify/utility/extension/Android.kt +++ b/src/main/kotlin/com/looker/droidify/utility/extension/Android.kt @@ -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 { - 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 + } + } + } } diff --git a/src/main/kotlin/com/looker/droidify/utility/extension/Json.kt b/src/main/kotlin/com/looker/droidify/utility/extension/Json.kt index 30aea92d..543d073f 100644 --- a/src/main/kotlin/com/looker/droidify/utility/extension/Json.kt +++ b/src/main/kotlin/com/looker/droidify/utility/extension/Json.kt @@ -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 JsonParser.collectNotNull(requiredToken: JsonToken, callback: JsonParser.() -> T?): List { - val list = mutableListOf() - forEach(requiredToken) { - val result = callback() - if (result != null) { - list += result +fun JsonParser.collectNotNull( + requiredToken: JsonToken, + callback: JsonParser.() -> T? +): List { + val list = mutableListOf() + forEach(requiredToken) { + val result = callback() + if (result != null) { + list += result + } } - } - return list + return list } fun JsonParser.collectNotNullStrings(): List { - return collectNotNull(JsonToken.VALUE_STRING) { valueAsString } + return collectNotNull(JsonToken.VALUE_STRING) { valueAsString } } fun JsonParser.collectDistinctNotEmptyStrings(): List { - return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList() + return collectNotNullStrings().asSequence().filter { it.isNotEmpty() }.distinct().toList() } inline fun 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() } diff --git a/src/main/kotlin/com/looker/droidify/utility/extension/Resources.kt b/src/main/kotlin/com/looker/droidify/utility/extension/Resources.kt index b0215e1e..5de62429 100644 --- a/src/main/kotlin/com/looker/droidify/utility/extension/Resources.kt +++ b/src/main/kotlin/com/looker/droidify/utility/extension/Resources.kt @@ -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) } diff --git a/src/main/kotlin/com/looker/droidify/utility/extension/Text.kt b/src/main/kotlin/com/looker/droidify/utility/extension/Text.kt index c93b3818..ee5383b1 100644 --- a/src/main/kotlin/com/looker/droidify/utility/extension/Text.kt +++ b/src/main/kotlin/com/looker/droidify/utility/extension/Text.kt @@ -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.nullIfEmpty(): T? { - return if (isNullOrEmpty()) null else this +fun 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) } diff --git a/src/main/kotlin/com/looker/droidify/widget/ClickableMovementMethod.kt b/src/main/kotlin/com/looker/droidify/widget/ClickableMovementMethod.kt index 22b89df4..5bf9329a 100644 --- a/src/main/kotlin/com/looker/droidify/widget/ClickableMovementMethod.kt +++ b/src/main/kotlin/com/looker/droidify/widget/ClickableMovementMethod.kt @@ -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 } diff --git a/src/main/kotlin/com/looker/droidify/widget/CursorRecyclerAdapter.kt b/src/main/kotlin/com/looker/droidify/widget/CursorRecyclerAdapter.kt index d3b39043..17e1329f 100644 --- a/src/main/kotlin/com/looker/droidify/widget/CursorRecyclerAdapter.kt +++ b/src/main/kotlin/com/looker/droidify/widget/CursorRecyclerAdapter.kt @@ -3,33 +3,34 @@ package com.looker.droidify.widget import android.database.Cursor import androidx.recyclerview.widget.RecyclerView -abstract class CursorRecyclerAdapter, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter() { - 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, VH : RecyclerView.ViewHolder> : + EnumRecyclerAdapter() { + 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 + } } diff --git a/src/main/kotlin/com/looker/droidify/widget/DividerItemDecoration.kt b/src/main/kotlin/com/looker/droidify/widget/DividerItemDecoration.kt index 502d4f4b..053268e6 100644 --- a/src/main/kotlin/com/looker/droidify/widget/DividerItemDecoration.kt +++ b/src/main/kotlin/com/looker/droidify/widget/DividerItemDecoration.kt @@ -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) - } } diff --git a/src/main/kotlin/com/looker/droidify/widget/EnumRecyclerAdapter.kt b/src/main/kotlin/com/looker/droidify/widget/EnumRecyclerAdapter.kt index df84c83e..abdebcb6 100644 --- a/src/main/kotlin/com/looker/droidify/widget/EnumRecyclerAdapter.kt +++ b/src/main/kotlin/com/looker/droidify/widget/EnumRecyclerAdapter.kt @@ -4,25 +4,26 @@ import android.util.SparseArray import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -abstract class EnumRecyclerAdapter, VH: RecyclerView.ViewHolder>: RecyclerView.Adapter() { - abstract val viewTypeClass: Class +abstract class EnumRecyclerAdapter, VH : RecyclerView.ViewHolder> : + RecyclerView.Adapter() { + abstract val viewTypeClass: Class - private val names = SparseArray() + private val names = SparseArray() - 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 } diff --git a/src/main/kotlin/com/looker/droidify/widget/FocusSearchView.kt b/src/main/kotlin/com/looker/droidify/widget/FocusSearchView.kt index 946e970e..16f8c8ce 100644 --- a/src/main/kotlin/com/looker/droidify/widget/FocusSearchView.kt +++ b/src/main/kotlin/com/looker/droidify/widget/FocusSearchView.kt @@ -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() + } } - } } diff --git a/src/main/kotlin/com/looker/droidify/widget/FragmentLinearLayout.kt b/src/main/kotlin/com/looker/droidify/widget/FragmentLinearLayout.kt index 0e781219..7dfdda91 100644 --- a/src/main/kotlin/com/looker/droidify/widget/FragmentLinearLayout.kt +++ b/src/main/kotlin/com/looker/droidify/widget/FragmentLinearLayout.kt @@ -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 + } } diff --git a/src/main/kotlin/com/looker/droidify/widget/RecyclerFastScroller.kt b/src/main/kotlin/com/looker/droidify/widget/RecyclerFastScroller.kt index 759cb683..74cda3c9 100644 --- a/src/main/kotlin/com/looker/droidify/widget/RecyclerFastScroller.kt +++ b/src/main/kotlin/com/looker/droidify/widget/RecyclerFastScroller.kt @@ -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) + }) + } } diff --git a/src/main/kotlin/com/looker/droidify/widget/StableRecyclerAdapter.kt b/src/main/kotlin/com/looker/droidify/widget/StableRecyclerAdapter.kt index d8d99482..d7e68e01 100644 --- a/src/main/kotlin/com/looker/droidify/widget/StableRecyclerAdapter.kt +++ b/src/main/kotlin/com/looker/droidify/widget/StableRecyclerAdapter.kt @@ -2,26 +2,27 @@ package com.looker.droidify.widget import androidx.recyclerview.widget.RecyclerView -abstract class StableRecyclerAdapter, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter() { - private var nextId = 1L - private val descriptorToId = mutableMapOf() +abstract class StableRecyclerAdapter, VH : RecyclerView.ViewHolder> : + EnumRecyclerAdapter() { + private var nextId = 1L + private val descriptorToId = mutableMapOf() - 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 } diff --git a/src/main/kotlin/com/looker/droidify/widget/Toolbar.kt b/src/main/kotlin/com/looker/droidify/widget/Toolbar.kt index cf721a7c..71321d39 100644 --- a/src/main/kotlin/com/looker/droidify/widget/Toolbar.kt +++ b/src/main/kotlin/com/looker/droidify/widget/Toolbar.kt @@ -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 + } } - } }