diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt index ecff14e..6be0329 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt @@ -78,7 +78,7 @@ object Database { const val ROW_ADDED = "added" const val ROW_UPDATED = "updated" const val ROW_VERSION_CODE = "version_code" - const val ROW_SIGNATURE = "signature" + const val ROW_SIGNATURES = "signatures" const val ROW_COMPATIBLE = "compatible" const val ROW_DATA = "data" const val ROW_DATA_ITEM = "data_item" @@ -93,7 +93,7 @@ object Database { $ROW_ADDED INTEGER NOT NULL, $ROW_UPDATED INTEGER NOT NULL, $ROW_VERSION_CODE INTEGER NOT NULL, - $ROW_SIGNATURE TEXT NOT NULL, + $ROW_SIGNATURES TEXT NOT NULL, $ROW_COMPATIBLE INTEGER NOT NULL, $ROW_DATA BLOB NOT NULL, $ROW_DATA_ITEM BLOB NOT NULL, @@ -393,17 +393,20 @@ object Database { category: String, order: ProductItem.Order, signal: CancellationSignal?): Cursor { val builder = QueryBuilder() + val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND + product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND + product.${Schema.Product.ROW_SIGNATURES} != ''""" + builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID}, product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME}, product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION}, (COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} > - COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND - product.${Schema.Product.ROW_SIGNATURE} = installed.${Schema.Installed.ROW_SIGNATURE} AND - product.${Schema.Product.ROW_SIGNATURE} != '') AS ${Schema.Synthetic.ROW_CAN_UPDATE}, - product.${Schema.Product.ROW_COMPATIBLE}, product.${Schema.Product.ROW_DATA_ITEM}, - MAX((product.${Schema.Product.ROW_COMPATIBLE} << 32) | product.${Schema.Product.ROW_VERSION_CODE}) - FROM ${Schema.Product.name} AS product""" + COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches) + AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE}, + product.${Schema.Product.ROW_DATA_ITEM}, MAX((product.${Schema.Product.ROW_COMPATIBLE} AND + (installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) || + PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product""" builder += """JOIN ${Schema.Repository.name} AS repository ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}""" @@ -574,6 +577,9 @@ object Database { 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) @@ -582,7 +588,7 @@ object Database { 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_SIGNATURE, product.signature) + 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)) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt index 4f2af4e..066744d 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import nya.kitsunyan.foxydroid.utility.extension.json.* +import nya.kitsunyan.foxydroid.utility.extension.text.* 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 author: Author, @@ -33,23 +34,24 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St get() = "$locale.${type.name}.$path" } - val selectedRelease: Release? - get() = releases.find { it.selected } + // Same releases with different signatures + val selectedReleases: List + get() = releases.filter { it.selected } val displayRelease: Release? - get() = selectedRelease ?: releases.firstOrNull() + get() = selectedReleases.firstOrNull() ?: releases.firstOrNull() val version: String get() = displayRelease?.version.orEmpty() val versionCode: Long - get() = selectedRelease?.versionCode ?: 0L + get() = selectedReleases.firstOrNull()?.versionCode ?: 0L val compatible: Boolean - get() = selectedRelease?.incompatibilities?.isEmpty() == true + get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true - val signature: String - get() = selectedRelease?.signature.orEmpty() + val signatures: List + get() = selectedReleases.mapNotNull { it.signature.nullIfEmpty() }.distinct().toList() fun item(): ProductItem { return ProductItem(repositoryId, packageName, name, summary, icon, version, "", compatible, false) @@ -57,7 +59,7 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St fun canUpdate(installedItem: InstalledItem?): Boolean { return installedItem != null && compatible && versionCode > installedItem.versionCode && - signature.isNotEmpty() && signature == installedItem.signature + installedItem.signature in signatures } fun serialize(generator: JsonGenerator) { @@ -126,8 +128,9 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St } companion object { - fun findSuggested(products: List, extract: (T) -> Product): T? { - return products.maxWith(compareBy({ extract(it).compatible }, { extract(it).versionCode })) + 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 })) } fun deserialize(repositoryId: Long, parser: JsonParser): Product { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt index 6556b98..c6930a9 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt @@ -17,7 +17,7 @@ data class Release(val selected: Boolean, val version: String, val versionCode: object MinSdk: Incompatibility() object MaxSdk: Incompatibility() object Platform: Incompatibility() - class Feature(val feature: String): Incompatibility() + data class Feature(val feature: String): Incompatibility() } val identifier: String diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt index 4400061..4ceff7e 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt @@ -326,40 +326,20 @@ object RepositoryUpdater { if (it.platforms.isNotEmpty() && it.platforms.intersect(Android.platforms).isEmpty()) { incompatibilities += Release.Incompatibility.Platform } - incompatibilities += (it.features - features).map { Release.Incompatibility.Feature(it) } + 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 releaseIndex = if (firstCompatibleReleaseIndex >= 0) { - val versionCode = releasePairs[firstCompatibleReleaseIndex].first.versionCode - val candidates = releasePairs.mapIndexedNotNull { index, (product, incompatibilities) -> - if (product.versionCode == versionCode && incompatibilities.isEmpty()) index else null - } - if (product.packageName == "org.telegram.messenger") { - val x = candidates - .filter { releasePairs[it].first.platforms.contains(Android.primaryPlatform) } - .minBy { releasePairs[it].first.platforms.size } - val y = candidates.minBy { releasePairs[it].first.platforms.size } - debug("telegram $firstCompatibleReleaseIndex $candidates $x $y") - } - if (candidates.size <= 1) { - firstCompatibleReleaseIndex - } else { - candidates - .filter { releasePairs[it].first.platforms.contains(Android.primaryPlatform) } - .minBy { releasePairs[it].first.platforms.size } - ?: candidates.minBy { releasePairs[it].first.platforms.size } - ?: firstCompatibleReleaseIndex - } - } else { + val firstReleaseIndex = if (firstCompatibleReleaseIndex >= 0) firstCompatibleReleaseIndex else releasePairs.indexOfFirst { predicate(it.first) } - } + val firstSelected = if (firstReleaseIndex >= 0) releasePairs[firstReleaseIndex] else null - val releases = releasePairs.mapIndexed { index, (release, incompatibilities) -> release - .copy(incompatibilities = incompatibilities, selected = index == releaseIndex) } + 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/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt index f9ddf28..07ba1b1 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt @@ -249,7 +249,8 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.SCREENSHOT } - class ReleaseItem(val repository: Repository, val release: Release, val selectedRepository: Boolean): Item() { + class ReleaseItem(val repository: Repository, val release: Release, val selectedRepository: Boolean, + val showSignature: Boolean): Item() { override val descriptor: String get() = "release.${repository.id}.${release.identifier}" @@ -459,10 +460,11 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) val source = itemView.findViewById(R.id.source)!! val added = itemView.findViewById(R.id.added)!! val size = itemView.findViewById(R.id.size)!! + val signature = itemView.findViewById(R.id.signature)!! val compatibility = itemView.findViewById(R.id.compatibility)!! val statefulViews: Sequence - get() = sequenceOf(itemView, version, status, source, added, size, compatibility) + get() = sequenceOf(itemView, version, status, source, added, size, signature, compatibility) val setStatusActive: (Boolean) -> Unit @@ -575,14 +577,23 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) private val items = mutableListOf() private val expanded = mutableSetOf() private var product: Product? = null + private var installedItem: InstalledItem? = null - fun setProducts(context: Context, products: List>, packageName: String) { - val productRepository = Product.findSuggested(products) { it.first } + fun setProducts(context: Context, packageName: String, + products: List>, installedItem: InstalledItem?) { + val productRepository = Product.findSuggested(products, installedItem) { it.first } items.clear() if (productRepository != null) { items += Item.HeaderItem(productRepository.second, productRepository.first) + if (installedItem != null) { + items.add(Item.SwitchItem(SwitchType.IGNORE_ALL_UPDATES, packageName, productRepository.first.versionCode)) + if (productRepository.first.canUpdate(installedItem)) { + items.add(Item.SwitchItem(SwitchType.IGNORE_THIS_UPDATE, packageName, productRepository.first.versionCode)) + } + } + val textViewHolder = TextViewHolder(context) val textViewWidthSpec = context.resources.displayMetrics.widthPixels .let { View.MeasureSpec.makeMeasureSpec(it, View.MeasureSpec.EXACTLY) } @@ -739,7 +750,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } val screenshotItems = productRepository.first.screenshots - .map { Item.ScreenshotItem(productRepository.second, productRepository.first.packageName, it) } + .map { Item.ScreenshotItem(productRepository.second, packageName, it) } if (screenshotItems.isNotEmpty()) { if (ExpandType.SCREENSHOTS in expanded) { items += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, emptyList(), screenshotItems.size) @@ -751,10 +762,19 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } val incompatible = Preferences[Preferences.Key.IncompatibleVersions] - val releaseItems = products.asSequence() + val compatibleReleasePairs = products.asSequence() .flatMap { (product, repository) -> product.releases.asSequence() .filter { incompatible || it.incompatibilities.isEmpty() } - .map { Item.ReleaseItem(repository, it, repository.id == productRepository?.second?.id) } } + .map { Pair(it, repository) } } + .toList() + val signaturesForVersionCode = compatibleReleasePairs.asSequence() + .mapNotNull { (release, _) -> if (release.signature.isEmpty()) null else + Pair(release.versionCode, release.signature) } + .distinct().groupBy { it.first }.toMap() + val releaseItems = compatibleReleasePairs.asSequence() + .map { (release, repository) -> Item.ReleaseItem(repository, release, + repository.id == productRepository?.second?.id, + signaturesForVersionCode.getValue(release.versionCode).size >= 2) } .sortedByDescending { it.release.versionCode } .toList() if (releaseItems.isNotEmpty()) { @@ -772,17 +792,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) items += Item.EmptyItem(packageName) } this.product = productRepository?.first - updateSwitches() + this.installedItem = installedItem notifyDataSetChanged() } - var installedItem: InstalledItem? = null - set(value) { - field = value - updateSwitches() - notifyDataSetChanged() - } - private var action: Action? = null fun setAction(action: Action?) { @@ -823,19 +836,6 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } } - private fun updateSwitches() { - val product = product - val installedItem = installedItem - items.removeAll { it is Item.SwitchItem } - val index = items.indexOfFirst { it is Item.HeaderItem } - if (index >= 0 && product != null && installedItem != null) { - items.add(index + 1, Item.SwitchItem(SwitchType.IGNORE_ALL_UPDATES, product.packageName, product.versionCode)) - if (product.canUpdate(installedItem)) { - items.add(index + 2, Item.SwitchItem(SwitchType.IGNORE_THIS_UPDATE, product.packageName, product.versionCode)) - } - } - } - override val viewTypeClass: Class get() = ViewType::class.java @@ -1167,6 +1167,19 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) holder.added.text = holder.dateFormat.format(item.release.added) holder.added.setTextColor(primarySecondaryColor) holder.size.text = item.release.size.formatSize() + holder.signature.visibility = if (item.showSignature && item.release.signature.isNotEmpty()) + View.VISIBLE else View.GONE + if (item.showSignature && item.release.signature.isNotEmpty()) { + val bytes = item.release.signature.toUpperCase(Locale.US).windowed(2, 2, false).take(8) + val signature = bytes.joinToString(separator = " ") + val builder = SpannableStringBuilder(context.getString(R.string.signature_FORMAT, signature)) + val index = builder.indexOf(signature) + if (index >= 0) { + bytes.forEachIndexed { i, _ -> builder.setSpan(TypefaceSpan("monospace"), + index + 3 * i, index + 3 * i + 2, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE) } + } + holder.signature.text = builder + } holder.compatibility.visibility = if (incompatibility != null || singlePlatform != null) View.VISIBLE else View.GONE if (incompatibility != null) { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt index 631631c..deb28c9 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt @@ -34,6 +34,7 @@ import nya.kitsunyan.foxydroid.service.Connection import nya.kitsunyan.foxydroid.service.DownloadService import nya.kitsunyan.foxydroid.utility.RxUtils import nya.kitsunyan.foxydroid.utility.Utils +import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.widget.DividerItemDecoration class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { @@ -191,11 +192,8 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { } val recyclerView = recyclerView!! val adapter = recyclerView.adapter as ProductAdapter - if (firstChanged || productChanged) { - adapter.setProducts(recyclerView.context, products, packageName) - } - if (installedItemChanged) { - adapter.installedItem = installedItem.value + if (firstChanged || productChanged || installedItemChanged) { + adapter.setProducts(recyclerView.context, packageName, products, installedItem.value) } updateButtons() } @@ -231,9 +229,10 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { } private fun updateButtons(preference: ProductPreference) { - val product = Product.findSuggested(products) { it.first }?.first val installed = installed - val compatible = product != null && product.selectedRelease.let { it != null && it.incompatibilities.isEmpty() } + val product = Product.findSuggested(products, installed?.installedItem) { it.first }?.first + val compatible = product != null && product.selectedReleases.firstOrNull() + .let { it != null && it.incompatibilities.isEmpty() } val canInstall = product != null && installed == null && compatible val canUpdate = product != null && compatible && product.canUpdate(installed?.installedItem) && !preference.shouldIgnoreUpdate(product.versionCode) @@ -332,10 +331,21 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { when (action) { ProductAdapter.Action.INSTALL, ProductAdapter.Action.UPDATE -> { - val productRepository = Product.findSuggested(products) { it.first } - val release = productRepository?.first?.selectedRelease + val installedItem = installed?.installedItem + val productRepository = Product.findSuggested(products, installedItem) { it.first } + val compatibleReleases = productRepository?.first?.selectedReleases.orEmpty() + .filter { installedItem == null || installedItem.signature == it.signature } + val release = if (compatibleReleases.size >= 2) { + compatibleReleases + .filter { it.platforms.contains(Android.primaryPlatform) } + .minBy { it.platforms.size } + ?: compatibleReleases.minBy { it.platforms.size } + ?: compatibleReleases.firstOrNull() + } else { + compatibleReleases.firstOrNull() + } val binder = downloadConnection.binder - if (release != null && binder != null) { + if (productRepository != null && release != null && binder != null) { binder.enqueue(packageName, productRepository.first.name, productRepository.second, release) } Unit diff --git a/src/main/res/layout/release_item.xml b/src/main/res/layout/release_item.xml index 17d5967..4d5c4b7 100644 --- a/src/main/res/layout/release_item.xml +++ b/src/main/res/layout/release_item.xml @@ -95,6 +95,14 @@ + + Select a mirror Show more Show older versions + Signature %s Signed using an unsafe algorithm Skip SOCKS proxy