diff --git a/.gitignore b/.gitignore index 4aea7f9..569393f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /* !/.gitignore !/build.gradle +!/settings.gradle !/COPYING !/extra !/gradle diff --git a/README.md b/README.md index 1eda0de..cdddf61 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ -# Foxy Droid +# Michas Droid Yet another F-Droid client. -[![Release](https://img.shields.io/github/v/release/kitsunyan/foxy-droid)](https://github.com/kitsunyan/foxy-droid/releases) -[![F-Droid](https://img.shields.io/f-droid/v/nya.kitsunyan.foxydroid)](https://f-droid.org/packages/nya.kitsunyan.foxydroid/) +[![Release](https://img.shields.io/github/v/release/michatec/michas-droid)](https://github.com/michatec/michas-droid/releases/latest) ## Description @@ -48,4 +47,4 @@ Run `./gradlew assembleRelease` to build the package, which can be installed usi ## License -Foxy Droid is available under the terms of the GNU General Public License v3 or later. Copyright © 2020 kitsunyan. +Michas Droid is available under the terms of the GNU General Public License v3 or later. Copyright © 2026 Michatec. diff --git a/build.gradle b/build.gradle index 4c8edca..8974baa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,52 +1,46 @@ buildscript { - ext.versions = [ - android: '3.4.1', - kotlin: '1.3.72' - ] - repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:' + versions.android - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin + classpath 'com.android.tools.build:gradle:9.0.1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10' } } apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' android { - compileSdkVersion 29 - buildToolsVersion '29.0.3' + namespace 'nya.kitsunyan.foxydroid' + compileSdk 36 defaultConfig { - archivesBaseName = 'foxy-droid' applicationId 'nya.kitsunyan.foxydroid' - minSdkVersion 21 - targetSdkVersion 29 - versionCode 4 - versionName '1.3' + minSdk 30 + targetSdk 36 + versionCode 15 + versionName '1.5' def languages = [ 'en' ] buildConfigField 'String[]', 'LANGUAGES', '{ "' + languages.join('", "') + '" }' resConfigs languages } - sourceSets.all { - def javaDir = it.java.srcDirs.find { it.name == 'java' } - it.java.srcDirs += new File(javaDir.parentFile, 'kotlin') + buildFeatures { + buildConfig = true + } + + sourceSets { + main { + java.srcDirs += 'src/main/kotlin' + } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = compileOptions.sourceCompatibility.toString() + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } buildTypes { @@ -58,25 +52,6 @@ android { minifyEnabled true shrinkResources false } - all { - crunchPngs false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.pro' - } - } - - lintOptions { - warning 'InvalidPackage' - ignore 'InvalidVectorPath' - } - - packagingOptions { - exclude '/DebugProbesKt.bin' - exclude '/kotlin/**.kotlin_builtins' - exclude '/kotlin/**.kotlin_metadata' - exclude '/META-INF/**.kotlin_module' - exclude '/META-INF/**.pro' - exclude '/META-INF/**.version' - exclude '/okhttp3/internal/publicsuffix/*' } def keystorePropertiesFile = rootProject.file('keystore.properties') @@ -98,31 +73,26 @@ android { storePassword signing.storePassword keyAlias signing.keyAlias keyPassword signing.keyPassword - v2SigningEnabled false + enableV2Signing false } } - - buildTypes { - debug.signingConfig signingConfigs.primary - release.signingConfig signingConfigs.primary - } } } } repositories { google() - jcenter() + mavenCentral() } dependencies { - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin - implementation 'androidx.fragment:fragment:1.2.5' - implementation 'androidx.viewpager2:viewpager2:1.0.0' - implementation 'androidx.vectordrawable:vectordrawable:1.1.0' - implementation 'com.squareup.okhttp3:okhttp:4.7.2' - implementation 'io.reactivex.rxjava3:rxjava:3.0.4' - implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' - implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.3.10' + implementation 'androidx.fragment:fragment-ktx:1.8.9' + implementation 'androidx.viewpager2:viewpager2:1.1.0' + implementation 'androidx.vectordrawable:vectordrawable:1.2.0' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + implementation 'io.reactivex.rxjava3:rxjava:3.1.12' + implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' + implementation 'com.fasterxml.jackson.core:jackson-core:2.21.1' implementation 'com.squareup.picasso:picasso:2.71828' } diff --git a/extra/launcher.svg b/extra/launcher.svg index e9f1150..6f4bd8b 100644 --- a/extra/launcher.svg +++ b/extra/launcher.svg @@ -1,273 +1,87 @@ - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + image/svg+xml + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 558870d..d706aba 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/proguard.pro b/proguard.pro index c4b4024..b11478f 100644 --- a/proguard.pro +++ b/proguard.pro @@ -1,4 +1,3 @@ --dontobfuscate # Disable ServiceLoader reproducibility-breaking optimizations -keep class kotlinx.coroutines.CoroutineExceptionHandler diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9d8d6b8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "michas-droid" +include ':' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 11237ad..190ee09 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,26 +1,33 @@ - + - + + + + + + + + + + + + + android:theme="@style/Theme.Main.Light"> + android:name=".MainApplication$BootReceiver" + android:exported="true"> @@ -30,7 +37,8 @@ @@ -52,7 +60,7 @@ - + @@ -69,7 +77,8 @@ + android:name=".service.SyncService" + android:foregroundServiceType="dataSync" /> + android:name=".service.DownloadService" + android:foregroundServiceType="dataSync" /> diff --git a/src/main/ic_launcher-playstore.png b/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..76ded4c Binary files /dev/null and b/src/main/ic_launcher-playstore.png differ diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt index 4a2de84..fbbd8b5 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/MainApplication.kt @@ -12,6 +12,7 @@ import android.content.IntentFilter import android.content.pm.PackageInfo import com.squareup.picasso.OkHttp3Downloader import com.squareup.picasso.Picasso +import io.reactivex.rxjava3.disposables.Disposable import nya.kitsunyan.foxydroid.content.Cache import nya.kitsunyan.foxydroid.content.Preferences import nya.kitsunyan.foxydroid.content.ProductPreferences @@ -27,7 +28,6 @@ import nya.kitsunyan.foxydroid.utility.extension.android.* 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() @@ -38,13 +38,15 @@ class MainApplication: Application() { super.attachBaseContext(Utils.configureLocale(base)) } + private var preferencesDisposable: Disposable? = null + override fun onCreate() { super.onCreate() val databaseUpdated = Database.init(this) Preferences.init(this) ProductPreferences.init(this) - RepositoryUpdater.init(this) + RepositoryUpdater.init() listenApplications() listenPreferences() @@ -69,7 +71,7 @@ class MainApplication: Application() { Intent.ACTION_PACKAGE_REMOVED -> { val packageInfo = try { packageManager.getPackageInfo(packageName, Android.PackageManager.signaturesFlag) - } catch (e: Exception) { + } catch (_: Exception) { null } if (packageInfo != null) { @@ -93,22 +95,21 @@ class MainApplication: Application() { private fun listenPreferences() { updateProxy() - var lastAutoSync = Preferences[Preferences.Key.AutoSync] - var lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable] - Preferences.observable.subscribe { + val lastAutoSync = Preferences[Preferences.Key.AutoSync] + val lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable] + preferencesDisposable?.dispose() + preferencesDisposable = 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) + updateSyncJob(true) } } else if (it == Preferences.Key.UpdateUnstable) { val updateUnstable = Preferences[Preferences.Key.UpdateUnstable] if (lastUpdateUnstable != updateUnstable) { - lastUpdateUnstable = updateUnstable - forceSyncAll() + forceSyncAll() } } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt index 3fa94eb..b35dd65 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Cache.kt @@ -61,7 +61,7 @@ object Cache { 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 + 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() } @@ -106,8 +106,8 @@ object Cache { try { val stat = Os.lstat(it.path) stat.st_atime < olderThan - } catch (e: Exception) { - false + } catch (_: Exception) { + false } } if (older) { @@ -138,7 +138,7 @@ object Cache { override fun onCreate(): Boolean = true override fun query(uri: Uri, projection: Array?, - selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor { val file = getFileAndTypeForUri(uri).first val columns = (projection ?: defaultColumns).mapNotNull { when (it) { @@ -150,12 +150,12 @@ object Cache { return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) } } - override fun getType(uri: Uri): String? = getFileAndTypeForUri(uri).second + override fun getType(uri: Uri): String = getFileAndTypeForUri(uri).second private val unsupported: Nothing get() = throw UnsupportedOperationException() - override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = unsupported + override fun insert(uri: Uri, contentValues: ContentValues?): Uri = unsupported override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = unsupported override fun update(uri: Uri, contentValues: ContentValues?, selection: String?, selectionArgs: Array?): Int = unsupported diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt index 470fe68..cc37017 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/Preferences.kt @@ -9,6 +9,7 @@ import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.entity.ProductItem import nya.kitsunyan.foxydroid.utility.extension.android.* import java.net.Proxy +import androidx.core.content.edit object Preferences { private lateinit var preferences: SharedPreferences @@ -18,9 +19,13 @@ object Preferences { 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 listener = SharedPreferences.OnSharedPreferenceChangeListener { _, keyString -> + keys[keyString]?.let(subject::onNext) + } + fun init(context: Context) { preferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) - preferences.registerOnSharedPreferenceChangeListener { _, keyString -> keys[keyString]?.let(subject::onNext) } + preferences.registerOnSharedPreferenceChangeListener(listener) } val observable: Observable> @@ -38,7 +43,7 @@ object Preferences { } override fun set(preferences: SharedPreferences, key: String, value: Boolean) { - preferences.edit().putBoolean(key, value).apply() + preferences.edit(commit = true) { putBoolean(key, value) } } } @@ -48,7 +53,7 @@ object Preferences { } override fun set(preferences: SharedPreferences, key: String, value: Int) { - preferences.edit().putInt(key, value).apply() + preferences.edit(commit = true) { putInt(key, value) } } } @@ -58,7 +63,7 @@ object Preferences { } override fun set(preferences: SharedPreferences, key: String, value: String) { - preferences.edit().putString(key, value).apply() + preferences.edit(commit = true) { putString(key, value) } } } @@ -69,7 +74,7 @@ object Preferences { } override fun set(preferences: SharedPreferences, key: String, value: T) { - preferences.edit().putString(key, value.valueString).apply() + preferences.edit(commit = true) { putString(key, value.valueString) } } } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt index 4dcf578..21a1ec8 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/content/ProductPreferences.kt @@ -2,6 +2,7 @@ package nya.kitsunyan.foxydroid.content import android.content.Context import android.content.SharedPreferences +import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.PublishSubject import nya.kitsunyan.foxydroid.database.Database @@ -9,17 +10,20 @@ import nya.kitsunyan.foxydroid.entity.ProductPreference import nya.kitsunyan.foxydroid.utility.extension.json.* import java.io.ByteArrayOutputStream import java.nio.charset.Charset +import androidx.core.content.edit object ProductPreferences { private val defaultProductPreference = ProductPreference(false, 0L) private lateinit var preferences: SharedPreferences private val subject = PublishSubject.create>() + private var disposable: Disposable? = null 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 + disposable?.dispose() + disposable = subject .observeOn(Schedulers.io()) .subscribe { (packageName, versionCode) -> if (versionCode != null) { @@ -53,9 +57,15 @@ object ProductPreferences { 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() + preferences.edit { + putString( + packageName, ByteArrayOutputStream() + .apply { + Json.factory.createGenerator(this) + .use { it.writeDictionary(productPreference::serialize) } + } + .toByteArray().toString(Charset.defaultCharset())) + } if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates || oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) { subject.onNext(Pair(packageName, productPreference.databaseVersionCode)) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt index 7a6dc2c..c6172c3 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/CursorOwner.kt @@ -3,6 +3,8 @@ package nya.kitsunyan.foxydroid.database import android.database.Cursor import android.os.Bundle import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import nya.kitsunyan.foxydroid.entity.ProductItem @@ -39,13 +41,20 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks { fun onCursorData(request: Request, cursor: Cursor?) } - private data class ActiveRequest(val request: Request, val callback: Callback?, val cursor: Cursor?) + data class ActiveRequest(val request: Request, val callback: Callback?, val cursor: Cursor?) - init { - retainInstance = true + class CursorViewModel : ViewModel() { + internal val activeRequests = mutableMapOf() + + override fun onCleared() { + activeRequests.values.forEach { it.cursor?.close() } + activeRequests.clear() + } } - private val activeRequests = mutableMapOf() + private val viewModel by lazy { ViewModelProvider(this)[CursorViewModel::class.java] } + private val activeRequests: MutableMap + get() = viewModel.activeRequests fun attach(callback: Callback, request: Request) { val oldActiveRequest = activeRequests[request.id] @@ -79,11 +88,32 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks { return QueryLoader(requireContext()) { when (request) { is Request.ProductsAvailable -> Database.ProductAdapter - .query(false, false, request.searchQuery, request.section, request.order, it) + .query( + installed = false, + updates = false, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) is Request.ProductsInstalled -> Database.ProductAdapter - .query(true, false, request.searchQuery, request.section, request.order, it) + .query( + installed = true, + updates = false, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) is Request.ProductsUpdates -> Database.ProductAdapter - .query(true, true, request.searchQuery, request.section, request.order, it) + .query( + installed = true, + updates = true, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it + ) is Request.Repositories -> Database.RepositoryAdapter.query(it) } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt index 7b7a24d..268a8ab 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/Database.kt @@ -16,6 +16,7 @@ import nya.kitsunyan.foxydroid.entity.Repository import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.json.* import java.io.ByteArrayOutputStream +import androidx.core.database.sqlite.transaction object Database { fun init(context: Context): Boolean { @@ -170,6 +171,7 @@ object Database { } override fun onOpen(db: SQLiteDatabase) { + db.enableWriteAheadLogging() val create = handleTables(db, false, Schema.Repository) val updated = handleTables(db, create, Schema.Product, Schema.Category) db.execSQL("ATTACH DATABASE ':memory:' AS memory") @@ -182,9 +184,11 @@ object Database { } 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))) + val shouldRecreate = recreate || tables.any { it -> + 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 } @@ -202,10 +206,12 @@ object Database { } 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() } + val shouldVacuum = tables.map { it -> + val sqls = db.query( + "${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), + selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName)) + ) + .use { it -> 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 { @@ -224,11 +230,13 @@ object Database { } 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() } + val tables = db.query( + "sqlite_master", columns = arrayOf("name"), + selection = Pair("type = ?", arrayOf("table")) + ) + .use { it -> it.asSequence().mapNotNull { it.getString(0) }.toList() } .filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } - .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name } + .toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet() if (tables.isNotEmpty()) { for (table in tables) { db.execSQL("DROP TABLE IF EXISTS $table") @@ -281,9 +289,11 @@ object Database { 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 { + 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) } @@ -313,33 +323,40 @@ object Database { }) } - fun put(repository: Repository): Repository { + fun put(repository: Repository): Long { 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 + return id } 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()))) + 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() } + 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() } + 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 -> + it.asSequence().map { Pair(it.getLong(it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)), + it.getInt(it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED)) != 0) }.toSet() } } fun markAsDeleted(id: Long) { @@ -350,8 +367,8 @@ object Database { } fun cleanup(pairs: Set>) { - val result = pairs.windowed(10, 10, true).map { - val idsString = it.joinToString(separator = ", ") { it.first.toString() } + val result = pairs.windowed(10, 10, true).map { it -> + 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, @@ -369,28 +386,34 @@ object Database { } fun query(signal: CancellationSignal?): Cursor { - return db.query(Schema.Repository.name, - selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), - signal = signal).observable(Subject.Repositories) + 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) } + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)) + .jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(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() } + 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()))) + 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 } } @@ -464,28 +487,28 @@ object Database { 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) } 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) } + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA)) + .jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(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) } + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM)) + .jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)).orEmpty(), + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0, + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)), it) } } } @@ -500,18 +523,21 @@ object Database { 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() } + return builder.query(db, signal).use { it -> + it.asSequence() + .map { it.getString(it.getColumnIndexOrThrow(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 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) } } private fun put(installedItem: InstalledItem, notify: Boolean) { @@ -529,13 +555,12 @@ object Database { 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() + db.transaction { + try { + delete(Schema.Installed.name, null, null) + installedItems.forEach { put(it, false) } + } finally { + } } } @@ -547,10 +572,10 @@ object Database { } 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))) + return InstalledItem(cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)), + cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)), + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE))) } } @@ -568,13 +593,12 @@ object Database { 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() + db.transaction { + try { + delete(Schema.Lock.name, null, null) + locks.forEach { put(it, false) } + } finally { + } } } @@ -596,60 +620,60 @@ object Database { } 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.transaction { + 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 "" } + 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) { + 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) + }) + } + } + } finally { } - } - 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() + db.transaction { + try { + delete( + Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + delete( + Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?", + arrayOf(repository.id.toString()) + ) + execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}") + execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}") + RepositoryAdapter.putWithoutNotification(repository, true) + execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") + execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") + } finally { + } } - 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}") diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt index db3bf18..79a630f 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/ObservableCursor.kt @@ -1,57 +1,37 @@ package nya.kitsunyan.foxydroid.database -import android.database.ContentObservable import android.database.ContentObserver import android.database.Cursor import android.database.CursorWrapper +import java.util.concurrent.CopyOnWriteArraySet class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean, observer: () -> Unit) -> Unit): CursorWrapper(cursor) { - private var registered = false - private val contentObservable = ContentObservable() + private val observers = CopyOnWriteArraySet() private val onChange: () -> Unit = { - contentObservable.dispatchChange(false, null) + for (observer in observers) { + observer.dispatchChange(false, null) + } } init { observable(true, onChange) - registered = true } override fun registerContentObserver(observer: ContentObserver) { super.registerContentObserver(observer) - contentObservable.registerObserver(observer) + observers.add(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 - } - return super.requery() - } - - @Suppress("DEPRECATION") - override fun deactivate() { - super.deactivate() - deactivateOrClose() + observers.remove(observer) } override fun close() { super.close() - contentObservable.unregisterAll() - deactivateOrClose() - } - - private fun deactivateOrClose() { + observers.clear() observable(false, onChange) - registered = false } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt index b3c7ce6..584fd45 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/database/QueryBuilder.kt @@ -32,14 +32,15 @@ class QueryBuilder { this.arguments += arguments } - fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor { + fun query(db: SQLiteDatabase, signal: CancellationSignal? = null): Cursor { val query = builder.toString() val arguments = arguments.toTypedArray() if (BuildConfig.DEBUG) { synchronized(QueryBuilder::class.java) { debug(query) - db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { it.asSequence() - .forEach { debug(":: ${it.getString(it.getColumnIndex("detail"))}") } } + db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { it -> + it.asSequence() + .forEach { debug(":: ${it.getString(it.getColumnIndexOrThrow("detail"))}") } } } } return db.rawQuery(query, arguments, signal) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt index 133e9b5..e36ff6a 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Product.kt @@ -156,8 +156,8 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St var donates = emptyList() var screenshots = emptyList() var releases = emptyList() - parser.forEachKey { - when { + parser.forEachKey { it -> + when { it.string("packageName") -> packageName = valueAsString it.string("name") -> name = valueAsString it.string("summary") -> summary = valueAsString @@ -213,7 +213,7 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St else -> skipChildren() } } - Screenshot.Type.values().find { it.jsonName == type }?.let { Screenshot(locale, it, path) } + Screenshot.Type.entries.find { it.jsonName == type }?.let { Screenshot(locale, it, path) } } it.array("releases") -> releases = collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize) else -> skipChildren() diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt index c6930a9..1c85f49 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Release.kt @@ -1,10 +1,10 @@ package nya.kitsunyan.foxydroid.entity -import android.net.Uri 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 androidx.core.net.toUri 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, @@ -24,7 +24,7 @@ data class Release(val selected: Boolean, val version: String, val versionCode: get() = "$versionCode.$hash" fun getDownloadUrl(repository: Repository): String { - return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString() + return repository.address.toUri().buildUpon().appendPath(release).build().toString() } val cacheFileName: String @@ -102,8 +102,8 @@ data class Release(val selected: Boolean, val version: String, val versionCode: var features = emptyList() var platforms = emptyList() var incompatibilities = emptyList() - parser.forEachKey { - when { + parser.forEachKey { it -> + when { it.boolean("selected") -> selected = valueAsBoolean it.string("version") -> version = valueAsString it.number("versionCode") -> versionCode = valueAsLong diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt index 6debcf0..9292a80 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/entity/Repository.kt @@ -3,20 +3,13 @@ package nya.kitsunyan.foxydroid.entity import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import nya.kitsunyan.foxydroid.utility.extension.json.* -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 update(mirrors: List, name: String, description: String, version: Int, lastModified: String, entityTag: String, timestamp: Long): Repository { return copy(mirrors = mirrors, name = name, description = description, @@ -79,25 +72,21 @@ data class Repository(val id: Long, val address: String, val mirrors: 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) - } - 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) } } + 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 { it -> + 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 { it -> + 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() { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexV1Parser.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexV1Parser.kt index 00c5e0a..c5441ff 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexV1Parser.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/index/IndexV1Parser.kt @@ -37,8 +37,8 @@ object IndexV1Parser { if (jsonParser.nextToken() != JsonToken.START_OBJECT) { jsonParser.illegal() } else { - jsonParser.forEachKey { - when { + jsonParser.forEachKey { it -> + when { it.dictionary("repo") -> { var address = "" var mirrors = emptyList() @@ -100,8 +100,8 @@ object IndexV1Parser { val licenses = mutableListOf() val donates = mutableListOf() val localizedMap = mutableMapOf() - forEachKey { - when { + forEachKey { it -> + when { it.string("packageName") -> packageName = valueAsString it.string("name") -> nameFallback = valueAsString it.string("summary") -> summaryFallback = valueAsString @@ -125,8 +125,8 @@ object IndexV1Parser { 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) { + it.dictionary("localized") -> forEachKey { it -> + if (it.token == JsonToken.START_OBJECT) { val locale = it.key var name = "" var summary = "" diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt index 4ceff7e..1545470 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/index/RepositoryUpdater.kt @@ -1,9 +1,9 @@ package nya.kitsunyan.foxydroid.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.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import nya.kitsunyan.foxydroid.content.Cache import nya.kitsunyan.foxydroid.database.Database @@ -23,6 +23,7 @@ import java.util.Locale import java.util.jar.JarEntry import java.util.jar.JarFile import javax.xml.parsers.SAXParserFactory +import androidx.core.net.toUri object RepositoryUpdater { enum class Stage { @@ -50,23 +51,19 @@ object RepositoryUpdater { } } - private lateinit var context: Context private val updaterLock = Any() private val cleanupLock = Any() - fun init(context: Context) { - this.context = context - - var lastDisabled = setOf() - Observable.just(Unit) + fun init(): Disposable { + val lastDisabled = setOf() + return 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() + .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAllDisabledDeleted(signal) } } + .subscribe { result -> + val newDisabled = result.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() + val deleted = result.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() @@ -79,15 +76,15 @@ object RepositoryUpdater { synchronized(updaterLock) { } } - fun update(repository: Repository, unstable: Boolean, + fun update(context: Context, repository: Repository, unstable: Boolean, callback: (Stage, Long, Long?) -> Unit): Single { - return update(repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback) + return update(context, repository, listOf(IndexType.INDEX_V1, IndexType.INDEX), unstable, callback) } - private fun update(repository: Repository, indexTypes: List, unstable: Boolean, + private fun update(context: Context, repository: Repository, indexTypes: List, unstable: Boolean, callback: (Stage, Long, Long?) -> Unit): Single { val indexType = indexTypes[0] - return downloadIndex(repository, indexType, callback) + return downloadIndex(context, repository, indexType, callback) .flatMap { (result, file) -> when { result.isNotChanged -> { @@ -96,26 +93,27 @@ object RepositoryUpdater { } !result.success -> { file.delete() - if (result.code == 404 && indexTypes.isNotEmpty()) { - update(repository, indexTypes.subList(1, indexTypes.size), unstable, callback) + if (result.code == 404 && indexTypes.size > 1) { + update(context, 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, + RxUtils.managedSingle { processFile(context, repository, indexType, unstable, file, result.lastModified, result.entityTag, callback) } } } } } - private fun downloadIndex(repository: Repository, indexType: IndexType, + private fun downloadIndex(context: Context, 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() + .download( + repository.address.toUri().buildUpon() .appendPath(indexType.jarName).build().toString(), file, repository.lastModified, repository.entityTag, repository.authentication) { read, total -> callback(Stage.DOWNLOAD, read, total) } .subscribeOn(Schedulers.io()) @@ -130,7 +128,7 @@ object RepositoryUpdater { } } } - private fun processFile(repository: Repository, indexType: IndexType, unstable: Boolean, + private fun processFile(context: Context, repository: Repository, indexType: IndexType, unstable: Boolean, file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit): Boolean { var rollback = true return synchronized(updaterLock) { @@ -157,7 +155,7 @@ object RepositoryUpdater { certificate: String, version: Int, timestamp: Long) { changedRepository = repository.update(mirrors, name, description, version, lastModified, entityTag, timestamp) - certificateFromIndex = certificate.toLowerCase(Locale.US) + certificateFromIndex = certificate.lowercase(Locale.US) } override fun onProduct(product: Product) { @@ -191,8 +189,8 @@ object RepositoryUpdater { 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 { + 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, @@ -315,8 +313,8 @@ object RepositoryUpdater { } private fun transformProduct(product: Product, features: Set, unstable: Boolean): Product { - val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map { - val incompatibilities = mutableListOf() + 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 } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/network/Downloader.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/network/Downloader.kt index 12cd0ab..97c4d70 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/network/Downloader.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/network/Downloader.kt @@ -85,13 +85,13 @@ object Downloader { .callSingle { createCall(request, authentication, null) } .subscribeOn(Schedulers.io()) .flatMap { result -> RxUtils - .managedSingle { result.use { - if (result.code == 304) { + .managedSingle { result.use { it -> + if (result.code == 304) { Result(it.code, lastModified, entityTag) } else { - val body = it.body!! + val body = it.body val append = start != null && it.header("Content-Range") != null - val progressStart = if (append && start != null) start else 0L + val progressStart = if (append) start else 0L val progressTotal = body.contentLength().let { if (it >= 0L) it else null } ?.let { progressStart + it } val inputStream = ProgressInputStream(body.byteStream()) { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/network/PicassoDownloader.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/network/PicassoDownloader.kt index c55e1d8..9bfc413 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/network/PicassoDownloader.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/network/PicassoDownloader.kt @@ -65,7 +65,7 @@ object PicassoDownloader { } else { Downloader.createCall(request.newBuilder().url(address.toHttpUrl() .newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty()) - .addPathSegment(device.orEmpty()).addPathSegment(screenshot.orEmpty()).build()), + .addPathSegment(device.orEmpty()).addPathSegment(screenshot).build()), authentication.orEmpty(), cache) } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/EditRepositoryFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/EditRepositoryFragment.kt index 1729aeb..3137d0f 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/EditRepositoryFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/EditRepositoryFragment.kt @@ -5,7 +5,6 @@ import android.content.ClipboardManager import android.content.Context import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter -import android.net.Uri import android.os.Bundle import android.text.Editable import android.text.Selection @@ -42,6 +41,7 @@ import java.net.URL import java.nio.charset.Charset import java.util.Locale import kotlin.math.* +import androidx.core.net.toUri class EditRepositoryFragment(): ScreenFragment() { companion object { @@ -149,7 +149,7 @@ class EditRepositoryFragment(): ScreenFragment() { override fun afterTextChanged(s: Editable) { val inputString = s.toString() - val outputString = inputString.toUpperCase(Locale.US) + val outputString = inputString.uppercase(Locale.US) .filter(validChar).windowed(2, 2, true).take(32).joinToString(separator = " ") if (inputString != outputString) { val inputStart = logicalPosition(inputString, Selection.getSelectionStart(s)) @@ -161,19 +161,19 @@ class EditRepositoryFragment(): ScreenFragment() { }) if (savedInstanceState == null) { - val repository = repositoryId?.let(Database.RepositoryAdapter::get) + val repository = repositoryId?.let { Database.RepositoryAdapter.get(it) } if (repository == null) { val clipboardManager = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val text = clipboardManager.primaryClip ?.let { if (it.itemCount > 0) it else null } ?.getItemAt(0)?.text?.toString().orEmpty() val (addressText, fingerprintText) = try { - val uri = Uri.parse(URL(text).toString()) + val uri = URL(text).toString().toUri() val fingerprintText = uri.getQueryParameter("fingerprint")?.nullIfEmpty() ?: uri.getQueryParameter("FINGERPRINT")?.nullIfEmpty() Pair(uri.buildUpon().path(uri.path?.pathCropped) .query(null).fragment(null).build().toString(), fingerprintText) - } catch (e: Exception) { + } catch (_: Exception) { Pair(null, null) } layout.address.setText(addressText?.nullIfEmpty() ?: layout.address.hint) @@ -209,10 +209,26 @@ class EditRepositoryFragment(): ScreenFragment() { } } - layout.address.addTextChangedListener(SimpleTextWatcher { invalidateAddress() }) - layout.fingerprint.addTextChangedListener(SimpleTextWatcher { invalidateFingerprint() }) - layout.username.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() }) - layout.password.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() }) + layout.address.addTextChangedListener(object: TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable) = invalidateAddress() + }) + layout.fingerprint.addTextChangedListener(object: TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable) = invalidateFingerprint() + }) + layout.username.addTextChangedListener(object: TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable) = invalidateUsernamePassword() + }) + layout.password.addTextChangedListener(object: TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable) = invalidateUsernamePassword() + }) (layout.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L) layout.overlay.background!!.apply { @@ -230,14 +246,18 @@ class EditRepositoryFragment(): ScreenFragment() { repositoriesDisposable = Observable.just(Unit) .concatWith(Database.observable(Database.Subject.Repositories)) .observeOn(Schedulers.io()) - .flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } + .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } } .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - takenAddresses = it.asSequence().filter { it.id != repositoryId } + .subscribe { result -> + takenAddresses = result.asSequence().filter { it.id != repositoryId } .flatMap { (it.mirrors + it.address).asSequence() } .map { it.withoutKnownPath }.toSet() invalidateAddress() } + + invalidateAddress() + invalidateFingerprint() + invalidateUsernamePassword() } override fun onDestroyView() { @@ -253,14 +273,6 @@ class EditRepositoryFragment(): ScreenFragment() { checkDisposable = null } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - - invalidateAddress() - invalidateFingerprint() - invalidateUsernamePassword() - } - private var addressError = false private var fingerprintError = false private var usernamePasswordError = false @@ -328,7 +340,7 @@ class EditRepositoryFragment(): ScreenFragment() { private fun invalidateState() { val layout = layout!! - saveMenuItem!!.isEnabled = !addressError && !fingerprintError && + saveMenuItem?.isEnabled = !addressError && !fingerprintError && !usernamePasswordError && checkDisposable == null layout.apply { sequenceOf(address, addressMirror, fingerprint, username, password) .forEach { it.isEnabled = checkDisposable == null } } @@ -346,21 +358,21 @@ class EditRepositoryFragment(): ScreenFragment() { val cropped = pathCropped val endsWith = checkPaths.asSequence().filter { it.isNotEmpty() } .sortedByDescending { it.length }.find { cropped.endsWith("/$it") } - return if (endsWith != null) cropped.substring(0, cropped.length - endsWith.length - 1) else cropped + return if (endsWith != null) cropped.take(cropped.length - endsWith.length - 1) else cropped } private fun normalizeAddress(address: String): String? { val uri = try { val uri = URI(address) if (uri.isAbsolute) uri.normalize() else null - } catch (e: Exception) { + } catch (_: Exception) { null } val path = uri?.path?.pathCropped return if (uri != null && path != null) { try { URI(uri.scheme, uri.userInfo, uri.host, uri.port, path, uri.query, uri.fragment).toString() - } catch (e: Exception) { + } catch (_: Exception) { null } } else { @@ -394,7 +406,7 @@ class EditRepositoryFragment(): ScreenFragment() { .fold(Single.just("")) { oldAddressSingle, checkPath -> oldAddressSingle .flatMap { oldAddress -> if (oldAddress.isEmpty()) { - val builder = Uri.parse(address).buildUpon() + val builder = address.toUri().buildUpon() .let { if (checkPath.isEmpty()) it else it.appendEncodedPath(checkPath) } val newAddress = builder.build() val indexAddress = builder.appendPath("index.jar").build() @@ -413,71 +425,67 @@ class EditRepositoryFragment(): ScreenFragment() { .subscribe { result, throwable -> checkDisposable = null throwable?.printStackTrace() - val resultAddress = result?.let { if (it.isEmpty()) null else it } ?: address - val allow = resultAddress == address || run { - layout.address.setText(resultAddress) - invalidateAddress(resultAddress) - !addressError - } - if (allow) { - onSaveRepositoryProceedInvalidate(resultAddress, fingerprint, authentication) + val resultAddress = result?.let { it.ifEmpty { null } } ?: address + val allow = resultAddress == address || resultAddress == "$address/" + if (!allow) { + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.address_redirect_FORMAT, resultAddress)) + .setPositiveButton(android.R.string.ok) { _, _ -> saveRepository(resultAddress, fingerprint, authentication) } + .setNegativeButton(android.R.string.cancel, null) + .show() } else { - invalidateState() + saveRepository(resultAddress, fingerprint, authentication) } } - invalidateState() } else { - onSaveRepositoryProceedInvalidate(address, fingerprint, authentication) + saveRepository(address, fingerprint, authentication) } - } - } - - private fun onSaveRepositoryProceedInvalidate(address: String, fingerprint: String, authentication: String) { - val binder = syncConnection.binder - if (binder != null) { - val repositoryId = repositoryId - if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) { - MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager) - invalidateState() - } else { - val repository = repositoryId?.let(Database.RepositoryAdapter::get) - ?.edit(address, fingerprint, authentication) - ?: Repository.newRepository(address, fingerprint, authentication) - val changedRepository = Database.RepositoryAdapter.put(repository) - if (repositoryId == null && changedRepository.enabled) { - binder.sync(changedRepository) - } - requireActivity().onBackPressed() - } - } else { invalidateState() } } - private class SimpleTextWatcher(private val callback: (Editable) -> Unit): TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit - override fun afterTextChanged(s: Editable) = callback(s) + private fun saveRepository(address: String, fingerprint: String, authentication: String) { + val repository = repositoryId?.let { Database.RepositoryAdapter.get(it) } + if (repository != null) { + Database.RepositoryAdapter.put(repository.copy(address = address, fingerprint = fingerprint, + authentication = authentication)) + } else { + val id = Database.RepositoryAdapter.put(Repository( + id = 0, + address = address, + mirrors = emptyList(), + name = "", + description = "", + version = 0, + enabled = true, + fingerprint = fingerprint, + lastModified = "", + entityTag = "", + updated = 0L, + timestamp = 0L, + authentication = authentication + )) + Database.RepositoryAdapter.get(id)?.let { syncConnection.binder?.sync(it) } + } + screenActivity.onBackPressedDispatcher.onBackPressed() } class SelectMirrorDialog(): DialogFragment() { - companion object { - private const val EXTRA_MIRRORS = "mirrors" - } + private val mirrors: List + get() = requireArguments().getStringArrayList("mirrors")!! constructor(mirrors: List): this() { arguments = Bundle().apply { - putStringArrayList(EXTRA_MIRRORS, ArrayList(mirrors)) + putStringArrayList("mirrors", ArrayList(mirrors)) } } override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { - val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!! return AlertDialog.Builder(requireContext()) .setTitle(R.string.select_mirror) - .setItems(mirrors.toTypedArray()) { _, position -> (parentFragment as EditRepositoryFragment) - .setMirror(mirrors[position]) } - .setNegativeButton(R.string.cancel, null) + .setItems(mirrors.toTypedArray()) { _, which -> + (parentFragment as EditRepositoryFragment).setMirror(mirrors[which]) + } .create() } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/MessageDialog.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/MessageDialog.kt index 1ac9a61..c482ab0 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/MessageDialog.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/MessageDialog.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcel +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import nya.kitsunyan.foxydroid.R @@ -14,6 +15,7 @@ import nya.kitsunyan.foxydroid.utility.KParcelable import nya.kitsunyan.foxydroid.utility.PackageItemResolver import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.text.* +import androidx.core.net.toUri class MessageDialog(): DialogFragment() { companion object { @@ -36,7 +38,7 @@ class MessageDialog(): DialogFragment() { companion object { @Suppress("unused") @JvmField val CREATOR = KParcelable.creator { - val uri = Uri.parse(it.readString()!!) + val uri = it.readString()!!.toUri() Link(uri) } } @@ -124,7 +126,8 @@ class MessageDialog(): DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { val dialog = AlertDialog.Builder(requireContext()) - when (val message = requireArguments().getParcelable(EXTRA_MESSAGE)!!) { + val message = BundleCompat.getParcelable(requireArguments(), EXTRA_MESSAGE, Message::class.java)!! + when (message) { is Message.DeleteRepositoryConfirm -> { dialog.setTitle(R.string.confirmation) dialog.setMessage(R.string.delete_repository_DESC) @@ -157,7 +160,7 @@ class MessageDialog(): DialogFragment() { val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0) PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo) ?.nullIfEmpty()?.let { if (it == message.group) null else it } - } catch (e: Exception) { + } catch (_: Exception) { null } name ?: getString(R.string.unknown) @@ -169,7 +172,7 @@ class MessageDialog(): DialogFragment() { val permissionInfo = packageManager.getPermissionInfo(permission, 0) PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo) ?.nullIfEmpty()?.let { if (it == permission) null else it } - } catch (e: Exception) { + } catch (_: Exception) { null } description?.let { builder.append(it).append("\n\n") } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/PreferencesFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/PreferencesFragment.kt index 383f753..01a0693 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/PreferencesFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/PreferencesFragment.kt @@ -18,11 +18,12 @@ import android.widget.Switch import android.widget.TextView import android.widget.Toolbar import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.content.Preferences import nya.kitsunyan.foxydroid.utility.extension.resources.* +import androidx.core.view.isNotEmpty class PreferencesFragment: ScreenFragment() { private val preferences = mutableMapOf, Preference<*>>() @@ -46,11 +47,11 @@ class PreferencesFragment: ScreenFragment() { content.addView(scroll, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) val scrollLayout = FrameLayout(content.context) scroll.addView(scrollLayout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - val preferences = LinearLayout(scrollLayout.context) - preferences.orientation = LinearLayout.VERTICAL - scrollLayout.addView(preferences, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + val preferencesLayout = LinearLayout(scrollLayout.context) + preferencesLayout.orientation = LinearLayout.VERTICAL + scrollLayout.addView(preferencesLayout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - preferences.addCategory(getString(R.string.updates)) { + preferencesLayout.addCategory(getString(R.string.updates)) { addEnumeration(Preferences.Key.AutoSync, getString(R.string.sync_repositories_automatically)) { when (it) { Preferences.AutoSync.Never -> getString(R.string.never) @@ -63,7 +64,7 @@ class PreferencesFragment: ScreenFragment() { addSwitch(Preferences.Key.UpdateUnstable, getString(R.string.unstable_updates), getString(R.string.unstable_updates_summary)) } - preferences.addCategory(getString(R.string.proxy)) { + preferencesLayout.addCategory(getString(R.string.proxy)) { addEnumeration(Preferences.Key.ProxyType, getString(R.string.proxy_type)) { when (it) { is Preferences.ProxyType.Direct -> getString(R.string.no_proxy) @@ -74,7 +75,7 @@ class PreferencesFragment: ScreenFragment() { addEditString(Preferences.Key.ProxyHost, getString(R.string.proxy_host)) addEditInt(Preferences.Key.ProxyPort, getString(R.string.proxy_port), 1 .. 65535) } - preferences.addCategory(getString(R.string.other)) { + preferencesLayout.addCategory(getString(R.string.other)) { addEnumeration(Preferences.Key.Theme, getString(R.string.theme)) { when (it) { is Preferences.Theme.System -> getString(R.string.system) @@ -86,7 +87,9 @@ class PreferencesFragment: ScreenFragment() { getString(R.string.incompatible_versions_summary)) } - disposable = Preferences.observable.subscribe(this::updatePreference) + disposable = Preferences.observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updatePreference) updatePreference(null) } @@ -138,12 +141,12 @@ class PreferencesFragment: ScreenFragment() { callback() val divider = addDivider(true) // Negative margin for last divider - (layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = -divider.layoutParams.height + (divider.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = -divider.layoutParams.height } private fun LinearLayout.addPreference(key: Preferences.Key, title: String, summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?): Preference { - if (childCount > 0 && getChildAt(childCount - 1) !is TextView) { + if (isNotEmpty() && getChildAt(childCount - 1) !is TextView) { addDivider(false) } val preference = Preference(key, this@PreferencesFragment, this, title, summaryProvider, dialogProvider) @@ -160,10 +163,10 @@ class PreferencesFragment: ScreenFragment() { private fun LinearLayout.addEdit(key: Preferences.Key, title: String, valueToString: (T) -> String, stringToValue: (String) -> T?, configureEdit: (EditText) -> Unit) { - addPreference(key, title, { valueToString(Preferences[key]) }) { - val scroll = ScrollView(it) + addPreference(key, title, { valueToString(Preferences[key]) }) { context -> + val scroll = ScrollView(context) scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) } - val edit = EditText(it) + val edit = EditText(context) configureEdit(edit) edit.id = android.R.id.edit edit.setTextSizeScaled(16) @@ -173,12 +176,12 @@ class PreferencesFragment: ScreenFragment() { edit.setSelection(edit.text.length) edit.requestFocus() scroll.addView(edit, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - AlertDialog.Builder(it) + AlertDialog.Builder(context) .setTitle(title) .setView(scroll) .setPositiveButton(R.string.ok) { _, _ -> val value = stringToValue(edit.text.toString()) ?: key.default.value - post { Preferences[key] = value } + Preferences[key] = value } .setNegativeButton(R.string.cancel, null) .create() @@ -194,11 +197,10 @@ class PreferencesFragment: ScreenFragment() { private fun LinearLayout.addEditInt(key: Preferences.Key, title: String, range: IntRange?) { addEdit(key, title, { it.toString() }, { it.toIntOrNull() }) { - it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + it.inputType = InputType.TYPE_CLASS_NUMBER if (range != null) { it.filters = arrayOf(InputFilter { source, start, end, dest, dstart, dend -> - val value = (dest.substring(0, dstart) + source.substring(start, end) + - dest.substring(dend, dest.length)).toIntOrNull() + val value = "${dest.subSequence(0, dstart)}${source.subSequence(start, end)}${dest.subSequence(dend, dest.length)}".toIntOrNull() if (value != null && value in range) null else "" }) } @@ -207,14 +209,14 @@ class PreferencesFragment: ScreenFragment() { private fun > LinearLayout .addEnumeration(key: Preferences.Key, title: String, valueToString: (T) -> String) { - addPreference(key, title, { valueToString(Preferences[key]) }) { + addPreference(key, title, { valueToString(Preferences[key]) }) { context -> val values = key.default.value.values - AlertDialog.Builder(it) + AlertDialog.Builder(context) .setTitle(title) .setSingleChoiceItems(values.map(valueToString).toTypedArray(), values.indexOf(Preferences[key])) { dialog, which -> dialog.dismiss() - post { Preferences[key] = values[which] } + Preferences[key] = values[which] } .setNegativeButton(R.string.cancel, null) .create() @@ -222,7 +224,7 @@ class PreferencesFragment: ScreenFragment() { } private class Preference(private val key: Preferences.Key, - fragment: Fragment, parent: ViewGroup, titleText: String, + private val fragment: PreferencesFragment, parent: ViewGroup, titleText: String, private val summaryProvider: () -> String, private val dialogProvider: ((Context) -> AlertDialog)?) { val view = parent.inflate(R.layout.preference_item) val title = view.findViewById(R.id.title)!! @@ -276,10 +278,10 @@ class PreferencesFragment: ScreenFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val preferences = (parentFragment as PreferencesFragment).preferences + val preferencesFragment = parentFragment as PreferencesFragment val key = requireArguments().getString(EXTRA_KEY)!! - .let { name -> preferences.keys.find { it.name == name }!! } - val preference = preferences[key]!! + .let { name -> preferencesFragment.preferences.keys.find { it.name == name }!! } + val preference = preferencesFragment.preferences[key]!! return preference.createDialog(requireContext()) } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt index 42beb87..6a959ac 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductAdapter.kt @@ -38,6 +38,7 @@ import android.widget.Toast import androidx.core.graphics.ColorUtils import androidx.core.text.HtmlCompat import androidx.core.text.util.LinkifyCompat +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.content.Preferences @@ -61,6 +62,7 @@ import nya.kitsunyan.foxydroid.widget.StableRecyclerAdapter import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.* +import androidx.core.net.toUri class ProductAdapter(private val callbacks: Callbacks, private val columns: Int): StableRecyclerAdapter() { @@ -130,7 +132,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) abstract val descriptor: String abstract val viewType: ViewType - class HeaderItem(val repository: Repository, val product: Product): Item() { + data class HeaderItem(val repository: Repository, val product: Product): Item() { override val descriptor: String get() = "header" @@ -138,7 +140,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.HEADER } - class SwitchItem(val switchType: SwitchType, val packageName: String, val versionCode: Long): Item() { + data class SwitchItem(val switchType: SwitchType, val packageName: String, val versionCode: Long): Item() { override val descriptor: String get() = "switch.${switchType.name}" @@ -146,7 +148,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.SWITCH } - class SectionItem(val sectionType: SectionType, val expandType: ExpandType, + data class SectionItem(val sectionType: SectionType, val expandType: ExpandType, val items: List, val collapseCount: Int): Item() { constructor(sectionType: SectionType): this(sectionType, ExpandType.NOTHING, emptyList(), 0) @@ -157,7 +159,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.SECTION } - class ExpandItem(val expandType: ExpandType, val replace: Boolean, val items: List): Item() { + data class ExpandItem(val expandType: ExpandType, val replace: Boolean, val items: List): Item() { override val descriptor: String get() = "expand.${expandType.name}" @@ -165,7 +167,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.EXPAND } - class TextItem(val textType: TextType, val text: CharSequence): Item() { + data class TextItem(val textType: TextType, val text: CharSequence): Item() { override val descriptor: String get() = "text.${textType.name}" @@ -185,7 +187,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = uri?.schemeSpecificPart?.nullIfEmpty() ?.let { if (it.startsWith("//")) null else it } ?: uri?.toString() - class Typed(val linkType: LinkType, val text: String, override val uri: Uri?): LinkItem() { + data class Typed(val linkType: LinkType, val text: String, override val uri: Uri?): LinkItem() { override val descriptor: String get() = "link.typed.${linkType.name}" @@ -198,7 +200,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } } - class Donate(val donate: Product.Donate): LinkItem() { + data class Donate(val donate: Product.Donate): LinkItem() { override val descriptor: String get() = "link.donate.$donate" @@ -222,17 +224,17 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } override val uri: Uri? = when (donate) { - is Product.Donate.Regular -> Uri.parse(donate.url) - is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}") - is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}") - is Product.Donate.Flattr -> Uri.parse("https://flattr.com/thing/${donate.id}") - is Product.Donate.Liberapay -> Uri.parse("https://liberapay.com/~${donate.id}") - is Product.Donate.OpenCollective -> Uri.parse("https://opencollective.com/${donate.id}") + is Product.Donate.Regular -> donate.url.toUri() + is Product.Donate.Bitcoin -> "bitcoin:${donate.address}".toUri() + is Product.Donate.Litecoin -> "litecoin:${donate.address}".toUri() + is Product.Donate.Flattr -> "https://flattr.com/thing/${donate.id}".toUri() + is Product.Donate.Liberapay -> "https://liberapay.com/~${donate.id}".toUri() + is Product.Donate.OpenCollective -> "https://opencollective.com/${donate.id}".toUri() } } } - class PermissionsItem(val group: PermissionGroupInfo?, val permissions: List): Item() { + data class PermissionsItem(val group: PermissionGroupInfo?, val permissions: List): Item() { override val descriptor: String get() = "permissions.${group?.name}.${permissions.joinToString(separator = ".") { it.name }}" @@ -240,7 +242,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.PERMISSIONS } - class ScreenshotItem(val repository: Repository, val packageName: String, + data class ScreenshotItem(val repository: Repository, val packageName: String, val screenshot: Product.Screenshot): Item() { override val descriptor: String get() = "screenshot.${repository.id}.${screenshot.identifier}" @@ -249,7 +251,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.SCREENSHOT } - class ReleaseItem(val repository: Repository, val release: Release, val selectedRepository: Boolean, + data 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}" @@ -258,7 +260,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) get() = ViewType.RELEASE } - class EmptyItem(val packageName: String): Item() { + data class EmptyItem(val packageName: String): Item() { override val descriptor: String get() = "empty" @@ -269,7 +271,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) private class Measurement { private var density = 0f - private var scaledDensity = 0f + private var fontScale = 0f private lateinit var metric: T fun measure(view: View) { @@ -277,10 +279,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } fun invalidate(resources: Resources, callback: () -> T): T { - val (density, scaledDensity) = resources.displayMetrics.let { Pair(it.density, it.scaledDensity) } - if (this.density != density || this.scaledDensity != scaledDensity) { + val (density, fontScale) = resources.displayMetrics.density to resources.configuration.fontScale + if (this.density != density || this.fontScale != fontScale) { this.density = density - this.scaledDensity = scaledDensity + this.fontScale = fontScale metric = callback() } return metric @@ -334,12 +336,12 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) init { itemView as TextView - itemView.typeface = TypefaceExtra.medium - itemView.setTextSizeScaled(14) - itemView.setTextColor(itemView.context.getColorFromAttr(android.R.attr.colorAccent)) + (itemView as TextView).typeface = TypefaceExtra.medium + (itemView as TextView).setTextSizeScaled(14) + (itemView as TextView).setTextColor(itemView.context.getColorFromAttr(android.R.attr.colorAccent)) itemView.background = itemView.context.getDrawableFromAttr(android.R.attr.selectableItemBackground) - itemView.gravity = Gravity.CENTER - itemView.isAllCaps = true + (itemView as TextView).gravity = Gravity.CENTER + (itemView as TextView).isAllCaps = true itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.resources.sizeScaled(48)).apply { topMargin = -itemView.resources.sizeScaled(16) } } @@ -351,10 +353,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) init { itemView as TextView - itemView.setTextSizeScaled(14) - itemView.setTextColor(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary)) + (itemView as TextView).setTextSizeScaled(14) + (itemView as TextView).setTextColor(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary)) itemView.resources.sizeScaled(16).let { itemView.setPadding(it, it, it, it) } - itemView.movementMethod = ClickableMovementMethod + (itemView as TextView).movementMethod = ClickableMovementMethod itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } @@ -363,11 +365,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) private open class OverlappingViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { init { // Block touch events if touched above negative margin - itemView.setOnTouchListener { _, event -> - event.action == MotionEvent.ACTION_DOWN && run { - val top = (itemView.layoutParams as ViewGroup.MarginLayoutParams).topMargin - top < 0 && event.y < -top - } + @SuppressLint("ClickableViewAccessibility") + itemView.setOnTouchListener { v, event -> + val top = (v.layoutParams as ViewGroup.MarginLayoutParams).topMargin + event.action == MotionEvent.ACTION_DOWN && top < 0 && event.y < -top } } } @@ -441,7 +442,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } image.scaleType = ImageView.ScaleType.CENTER_CROP image.setBackgroundColor(ColorUtils.blendARGB(backgroundColor, accentColor, 0.1f)) - itemView.addView(image, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) + (itemView as ViewGroup).addView(image, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) @@ -482,8 +483,8 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) init { itemView as LinearLayout - itemView.orientation = LinearLayout.VERTICAL - itemView.gravity = Gravity.CENTER + (itemView as LinearLayout).orientation = LinearLayout.VERTICAL + (itemView as LinearLayout).gravity = Gravity.CENTER itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) } val title = TextView(itemView.context) title.gravity = Gravity.CENTER @@ -491,12 +492,12 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) title.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) title.setTextSizeScaled(20) title.setText(R.string.application_not_found) - itemView.addView(title, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + (itemView as ViewGroup).addView(title, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) val packageName = TextView(itemView.context) packageName.gravity = Gravity.CENTER packageName.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) packageName.setTextSizeScaled(16) - itemView.addView(packageName, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + (itemView as ViewGroup).addView(packageName, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) this.packageName = packageName @@ -529,7 +530,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val holder = parent.getChildViewHolder(view) if (holder is ScreenshotViewHolder) { - val position = holder.adapterPosition + val position = holder.bindingAdapterPosition if (position >= 0) { val first = items.subList(0, position).indexOfLast { it !is Item.ScreenshotItem } + 1 val gridCount = items.subList(first, items.size).indexOfFirst { it !is Item.ScreenshotItem } @@ -555,21 +556,31 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) when { nextItem == null || currentItem is Item.HeaderItem || nextItem is Item.TextItem && nextItem.textType == TextType.DESCRIPTION -> { - configuration.set(true, false, 0, 0) + configuration.set(needDivider = true, toTop = false, paddingStart = 0, paddingEnd = 0) } nextItem is Item.SectionItem -> { - configuration.set(true, true, 0, 0) + configuration.set(needDivider = true, toTop = true, paddingStart = 0, paddingEnd = 0) } currentItem is Item.LinkItem && nextItem is Item.LinkItem || currentItem is Item.PermissionsItem && nextItem is Item.PermissionsItem -> { - configuration.set(true, false, context.resources.sizeScaled(72), 0) + configuration.set( + needDivider = true, + toTop = false, + paddingStart = context.resources.sizeScaled(72), + paddingEnd = 0 + ) } currentItem is Item.SwitchItem && nextItem is Item.SwitchItem || currentItem is Item.ReleaseItem && nextItem is Item.ReleaseItem -> { - configuration.set(true, false, context.resources.sizeScaled(16), 0) + configuration.set( + needDivider = true, + toTop = false, + paddingStart = context.resources.sizeScaled(16), + paddingEnd = 0 + ) } else -> { - configuration.set(false, false, 0, 0) + configuration.set(needDivider = false, toTop = false, paddingStart = 0, paddingEnd = 0) } } } @@ -579,24 +590,37 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) private var product: Product? = null private var installedItem: InstalledItem? = null + private class ItemDiffCallback(private val oldList: List, private val newList: List): DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition].descriptor == newList[newItemPosition].descriptor + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } + } + fun setProducts(context: Context, packageName: String, products: List>, installedItem: InstalledItem?) { val productRepository = Product.findSuggested(products, installedItem) { it.first } - items.clear() + val newItems = mutableListOf() if (productRepository != null) { - items += Item.HeaderItem(productRepository.second, productRepository.first) + newItems += Item.HeaderItem(productRepository.second, productRepository.first) if (installedItem != null) { - items.add(Item.SwitchItem(SwitchType.IGNORE_ALL_UPDATES, packageName, productRepository.first.versionCode)) + newItems.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)) + newItems.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) } + val textViewWidthSpec = View.MeasureSpec.makeMeasureSpec( + context.resources.displayMetrics.widthPixels, View.MeasureSpec.EXACTLY) val textViewHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) fun CharSequence.lineCropped(maxLines: Int, cropLines: Int): CharSequence? { @@ -638,10 +662,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) val cropped = if (ExpandType.DESCRIPTION !in expanded) description.lineCropped(12, 10) else null val item = Item.TextItem(TextType.DESCRIPTION, description) if (cropped != null) { - items += listOf(Item.TextItem(TextType.DESCRIPTION, cropped), + newItems += listOf(Item.TextItem(TextType.DESCRIPTION, cropped), Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item))) } else { - items += item + newItems += item } } @@ -662,20 +686,20 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } }.joinToString(separator = "\n") { "\u2022 $it" } if (antiFeatures.isNotEmpty()) { - items += Item.SectionItem(SectionType.ANTI_FEATURES) - items += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures) + newItems += Item.SectionItem(SectionType.ANTI_FEATURES) + newItems += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures) } val changes = formatHtml(productRepository.first.whatsNew) if (changes.isNotEmpty()) { - items += Item.SectionItem(SectionType.CHANGES) + newItems += Item.SectionItem(SectionType.CHANGES) val cropped = if (ExpandType.CHANGES !in expanded) changes.lineCropped(12, 10) else null val item = Item.TextItem(TextType.CHANGES, changes) if (cropped != null) { - items += listOf(Item.TextItem(TextType.CHANGES, cropped), + newItems += listOf(Item.TextItem(TextType.CHANGES, cropped), Item.ExpandItem(ExpandType.CHANGES, true, listOf(item))) } else { - items += item + newItems += item } } @@ -685,30 +709,32 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) linkItems += Item.LinkItem.Typed(LinkType.AUTHOR, author.name, author.web.nullIfEmpty()?.let(Uri::parse)) } author.email.nullIfEmpty()?.let { linkItems += Item.LinkItem - .Typed(LinkType.EMAIL, "", Uri.parse("mailto:$it")) } + .Typed(LinkType.EMAIL, "", "mailto:$it".toUri()) } linkItems += licenses.asSequence().map { Item.LinkItem.Typed(LinkType.LICENSE, it, - Uri.parse("https://spdx.org/licenses/$it.html")) } - source.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.SOURCE, "", Uri.parse(it)) } - tracker.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "", Uri.parse(it)) } - changelog.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.CHANGELOG, "", Uri.parse(it)) } - web.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.WEB, "", Uri.parse(it)) } + "https://spdx.org/licenses/$it.html".toUri()) } + source.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.SOURCE, "", it.toUri()) } + tracker.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "", + it.toUri()) } + changelog.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.CHANGELOG, "", + it.toUri()) } + web.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.WEB, "", it.toUri()) } } if (linkItems.isNotEmpty()) { if (ExpandType.LINKS in expanded) { - items += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, emptyList(), linkItems.size) - items += linkItems + newItems += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, emptyList(), linkItems.size) + newItems += linkItems } else { - items += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, linkItems, 0) + newItems += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, linkItems, 0) } } val donateItems = productRepository.first.donates.map(Item.LinkItem::Donate) if (donateItems.isNotEmpty()) { if (ExpandType.DONATES in expanded) { - items += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, emptyList(), donateItems.size) - items += donateItems + newItems += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, emptyList(), donateItems.size) + newItems += donateItems } else { - items += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, donateItems, 0) + newItems += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, donateItems, 0) } } @@ -719,7 +745,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) .asSequence().mapNotNull { try { packageManager.getPermissionInfo(it, 0) - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -727,7 +753,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) .asSequence().map { (group, permissionInfo) -> val permissionGroupInfo = try { group?.let { packageManager.getPermissionGroupInfo(it, 0) } - } catch (e: Exception) { + } catch (_: Exception) { null } Pair(permissionGroupInfo, permissionInfo) @@ -740,11 +766,11 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) permissions.asSequence().find { it.key == null } ?.let { permissionsItems += Item.PermissionsItem(null, it.value.flatten()) } if (ExpandType.PERMISSIONS in expanded) { - items += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS, + newItems += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS, emptyList(), permissionsItems.size) - items += permissionsItems + newItems += permissionsItems } else { - items += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS, permissionsItems, 0) + newItems += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS, permissionsItems, 0) } } } @@ -753,10 +779,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) .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) - items += screenshotItems + newItems += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, emptyList(), screenshotItems.size) + newItems += screenshotItems } else { - items += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, screenshotItems, 0) + newItems += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, screenshotItems, 0) } } } @@ -778,22 +804,26 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) .sortedByDescending { it.release.versionCode } .toList() if (releaseItems.isNotEmpty()) { - items += Item.SectionItem(SectionType.VERSIONS) + newItems += Item.SectionItem(SectionType.VERSIONS) val maxReleases = 5 if (releaseItems.size > maxReleases && ExpandType.VERSIONS !in expanded) { - items += releaseItems.take(maxReleases) - items += Item.ExpandItem(ExpandType.VERSIONS, false, releaseItems.takeLast(releaseItems.size - maxReleases)) + newItems += releaseItems.take(maxReleases) + newItems += Item.ExpandItem(ExpandType.VERSIONS, false, releaseItems.takeLast(releaseItems.size - maxReleases)) } else { - items += releaseItems + newItems += releaseItems } } - if (items.isEmpty()) { - items += Item.EmptyItem(packageName) + if (newItems.isEmpty()) { + newItems += Item.EmptyItem(packageName) } + + val diffResult = DiffUtil.calculateDiff(ItemDiffCallback(items, newItems)) + items.clear() + items.addAll(newItems) this.product = productRepository?.first this.installedItem = installedItem - notifyDataSetChanged() + diffResult.dispatchUpdatesTo(this) } private var action: Action? = null @@ -850,7 +880,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } ViewType.SWITCH -> SwitchViewHolder(parent.inflate(R.layout.switch_item)).apply { itemView.setOnClickListener { - val switchItem = items[adapterPosition] as Item.SwitchItem + val switchItem = items[bindingAdapterPosition] as Item.SwitchItem val productPreference = when (switchItem.switchType) { SwitchType.IGNORE_ALL_UPDATES -> { ProductPreferences[switchItem.packageName].let { it.copy(ignoreUpdates = !it.ignoreUpdates) } @@ -868,20 +898,20 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } ViewType.SECTION -> SectionViewHolder(parent.inflate(R.layout.section_item)).apply { itemView.setOnClickListener { - val position = adapterPosition + val position = bindingAdapterPosition val sectionItem = items[position] as Item.SectionItem if (sectionItem.items.isNotEmpty()) { expanded += sectionItem.expandType items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType, emptyList(), sectionItem.items.size + sectionItem.collapseCount) - notifyItemChanged(adapterPosition, Payload.REFRESH) + notifyItemChanged(bindingAdapterPosition, Payload.REFRESH) items.addAll(position + 1, sectionItem.items) notifyItemRangeInserted(position + 1, sectionItem.items.size) } else if (sectionItem.collapseCount > 0) { expanded -= sectionItem.expandType items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType, items.subList(position + 1, position + 1 + sectionItem.collapseCount).toList(), 0) - notifyItemChanged(adapterPosition, Payload.REFRESH) + notifyItemChanged(bindingAdapterPosition, Payload.REFRESH) repeat(sectionItem.collapseCount) { items.removeAt(position + 1) } notifyItemRangeRemoved(position + 1, sectionItem.collapseCount) } @@ -889,7 +919,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } ViewType.EXPAND -> ExpandViewHolder(parent.context).apply { itemView.setOnClickListener { - val position = adapterPosition + val position = bindingAdapterPosition val expandItem = items[position] as Item.ExpandItem items.removeAt(position) expanded += expandItem.expandType @@ -913,32 +943,32 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) ViewType.TEXT -> TextViewHolder(parent.context) ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply { itemView.setOnClickListener { - val linkItem = items[adapterPosition] as Item.LinkItem + val linkItem = items[bindingAdapterPosition] as Item.LinkItem if (linkItem.uri?.let { callbacks.onUriClick(it, false) } != true) { linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) } } } itemView.setOnLongClickListener { - val linkItem = items[adapterPosition] as Item.LinkItem + val linkItem = items[bindingAdapterPosition] as Item.LinkItem linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) } true } } ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)).apply { itemView.setOnClickListener { - val permissionsItem = items[adapterPosition] as Item.PermissionsItem + val permissionsItem = items[bindingAdapterPosition] as Item.PermissionsItem callbacks.onPermissionsClick(permissionsItem.group?.name, permissionsItem.permissions.map { it.name }) } } ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context).apply { itemView.setOnClickListener { - val screenshotItem = items[adapterPosition] as Item.ScreenshotItem + val screenshotItem = items[bindingAdapterPosition] as Item.ScreenshotItem callbacks.onScreenshotClick(screenshotItem.screenshot) } } ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply { itemView.setOnClickListener { - val releaseItem = items[adapterPosition] as Item.ReleaseItem + val releaseItem = items[bindingAdapterPosition] as Item.ReleaseItem callbacks.onReleaseClick(releaseItem.release) } } @@ -989,7 +1019,6 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) holder.actionTintCancel else holder.actionTintNormal } } - if (updateAll || updateStatus) { val status = status holder.statusLayout.visibility = if (status != null) View.VISIBLE else View.GONE if (status != null) { @@ -1013,8 +1042,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) } }::class } - } - Unit + Unit } ViewType.SWITCH -> { holder as SwitchViewHolder @@ -1094,10 +1122,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) val labelFromPackage = PackageItemResolver.loadLabel(context, localCache, permission) val label = labelFromPackage ?: run { val prefixes = listOf("android.permission.", "com.android.browser.permission.") - prefixes.find { permission.name.startsWith(it) }?.let { - val transform = permission.name.substring(it.length) + prefixes.find { permission.name.startsWith(it) }?.let { it -> + val transform = permission.name.substring(it.length) if (transform.matches("[A-Z_]+".toRegex())) { - transform.split("_").joinToString(separator = " ") { it.toLowerCase(Locale.US) } + transform.split("_").joinToString(separator = " ") { it.lowercase(Locale.US) } } else { null } @@ -1106,7 +1134,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) if (label == null) { Pair(false, permission.name) } else { - Pair(true, label.first().toUpperCase() + label.substring(1, label.length)) + Pair(true, label.first().uppercaseChar() + label.substring(1, label.length)) } } val builder = SpannableStringBuilder() @@ -1171,7 +1199,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) 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 bytes = item.release.signature.uppercase(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) @@ -1255,7 +1283,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int) override fun onClick(view: View) { val productAdapter = productAdapterReference.get() val uri = try { - Uri.parse(url) + url.toUri() } catch (e: Exception) { e.printStackTrace() null diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt index deb28c9..7917ebb 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductFragment.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Bundle +import android.os.Parcelable import android.provider.Settings import android.view.LayoutInflater import android.view.MenuItem @@ -14,6 +15,7 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.Toolbar +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -36,6 +38,7 @@ 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 +import androidx.core.net.toUri class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { companion object { @@ -67,7 +70,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { val packageName: String get() = requireArguments().getString(EXTRA_PACKAGE_NAME)!! - private var layoutManagerState: LinearLayoutManager.SavedState? = null + private var layoutManagerState: Parcelable? = null private var actions = Pair(emptySet(), null as Action?) private var products = emptyList>() @@ -100,7 +103,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { this.toolbar = toolbar toolbar.menu.apply { - for (action in Action.values()) { + for (action in Action.entries) { add(0, action.id, 0, action.adapterAction.titleResId) .setIcon(Utils.getToolbarIcon(toolbar.context, action.iconResId)) .setVisible(false) @@ -130,42 +133,43 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { addOnScrollListener(scrollListener) addItemDecoration(adapter.gridItemDecoration) addItemDecoration(DividerItemDecoration(context, adapter::configureDivider)) - savedInstanceState?.getParcelable(STATE_ADAPTER)?.let(adapter::restoreState) - layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) + savedInstanceState?.let { bundle -> + BundleCompat.getParcelable(bundle, STATE_ADAPTER, ProductAdapter.SavedState::class.java)?.let(adapter::restoreState) + layoutManagerState = BundleCompat.getParcelable(bundle, STATE_LAYOUT_MANAGER, Parcelable::class.java) + } recyclerView = this }, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - var first = true + val first = true productDisposable = Observable.just(Unit) .concatWith(Database.observable(Database.Subject.Products)) .observeOn(Schedulers.io()) - .flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } } + .flatMapSingle { RxUtils.querySingle { signal -> Database.ProductAdapter.get(packageName, signal) } } .flatMapSingle { products -> RxUtils - .querySingle { Database.RepositoryAdapter.getAll(it) } - .map { it.asSequence().map { Pair(it.id, it) }.toMap() - .let { products.mapNotNull { product -> it[product.repositoryId]?.let { Pair(product, it) } } } } } + .querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } + .map { result -> + result.asSequence().map { Pair(it.id, it) }.toMap() + .let { map -> products.mapNotNull { product -> map[product.repositoryId]?.let { Pair(product, it) } } } } } .flatMapSingle { products -> RxUtils - .querySingle { Nullable(Database.InstalledAdapter.get(packageName, it)) } - .map { Pair(products, it) } } + .querySingle { signal -> Nullable(Database.InstalledAdapter.get(packageName, signal)) } + .map { result -> Pair(products, result) } } .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - val (products, installedItem) = it - val firstChanged = first - first = false - val productChanged = this.products != products + .subscribe { result -> + val (products, installedItem) = result + val productChanged = this.products != products val installedItemChanged = this.installed?.installedItem != installedItem.value - if (firstChanged || productChanged || installedItemChanged) { + if (first || productChanged || installedItemChanged) { layoutManagerState?.let { recyclerView?.layoutManager!!.onRestoreInstanceState(it) } layoutManagerState = null - if (firstChanged || productChanged) { + if (first || productChanged) { this.products = products } - if (firstChanged || installedItemChanged) { - installed = installedItem.value?.let { - val isSystem = try { + if (first || installedItemChanged) { + installed = installedItem.value?.let { it -> + val isSystem = try { ((requireContext().packageManager.getApplicationInfo(packageName, 0).flags) and ApplicationInfo.FLAG_SYSTEM) != 0 - } catch (e: Exception) { + } catch (_: Exception) { false } val launcherActivities = if (packageName == requireContext().packageName) { @@ -173,9 +177,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { emptyList() } else { val packageManager = requireContext().packageManager - packageManager + val activities = packageManager .queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0) - .asSequence().mapNotNull { it.activityInfo }.filter { it.packageName == packageName } + activities.asSequence().mapNotNull { it.activityInfo }.filter { it.packageName == packageName } .mapNotNull { activityInfo -> val label = try { activityInfo.loadLabel(packageManager).toString() @@ -190,10 +194,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { Installed(it, isSystem, launcherActivities) } } - val recyclerView = recyclerView!! - val adapter = recyclerView.adapter as ProductAdapter - if (firstChanged || productChanged || installedItemChanged) { - adapter.setProducts(recyclerView.context, packageName, products, installedItem.value) + recyclerView?.let { + val adapter = it.adapter as ProductAdapter + adapter.setProducts(it.context, packageName, products, installedItem.value) } updateButtons() } @@ -289,7 +292,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { } val toolbar = toolbar if (toolbar != null) { - for (action in Action.values()) { + for (action in Action.entries) { toolbar.menu.findItem(action.id).isVisible = action in displayActions } } @@ -336,11 +339,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { 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() + compatibleReleases + .filter { it.platforms.contains(Android.primaryPlatform) } + .minBy { it.platforms.size } } else { compatibleReleases.firstOrNull() } @@ -361,13 +362,11 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { } ProductAdapter.Action.DETAILS -> { startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.parse("package:$packageName"))) + .setData("package:$packageName".toUri())) } ProductAdapter.Action.UNINSTALL -> { - // TODO Handle deprecation - @Suppress("DEPRECATION") - startActivity(Intent(Intent.ACTION_UNINSTALL_PACKAGE) - .setData(Uri.parse("package:$packageName"))) + startActivity(Intent(Intent.ACTION_DELETE) + .setData("package:$packageName".toUri())) } ProductAdapter.Action.CANCEL -> { val binder = downloadConnection.binder @@ -400,7 +399,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { override fun onScreenshotClick(screenshot: Product.Screenshot) { val pair = products.asSequence() - .map { Pair(it.second, it.first.screenshots.find { it === screenshot }?.identifier) } + .map { it -> Pair(it.second, it.first.screenshots.find { it === screenshot }?.identifier) } .filter { it.second != null }.firstOrNull() if (pair != null) { val (repository, identifier) = pair @@ -424,7 +423,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(childFragmentManager) } else -> { - val productRepository = products.asSequence().filter { it.first.releases.any { it === release } }.firstOrNull() + val productRepository = products.asSequence().filter { it -> it.first.releases.any { it === release } }.firstOrNull() if (productRepository != null) { downloadConnection.binder?.enqueue(packageName, productRepository.first.name, productRepository.second, release) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsAdapter.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsAdapter.kt index d72a115..8c4f7d7 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsAdapter.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsAdapter.kt @@ -1,6 +1,8 @@ package nya.kitsunyan.foxydroid.screen +import android.annotation.SuppressLint import android.content.Context +import android.database.Cursor import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.view.Gravity @@ -46,7 +48,7 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): init { itemView as FrameLayout val progressBar = ProgressBar(itemView.context) - itemView.addView(progressBar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, + (itemView as ViewGroup).addView(progressBar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER }) itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) @@ -59,11 +61,11 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): init { itemView as TextView - itemView.gravity = Gravity.CENTER + (itemView as TextView).gravity = Gravity.CENTER itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) } - itemView.typeface = TypefaceExtra.light - itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) - itemView.setTextSizeScaled(20) + (itemView as TextView).typeface = TypefaceExtra.light + (itemView as TextView).setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) + (itemView as TextView).setTextSizeScaled(20) itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) } @@ -75,18 +77,25 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): getProductItem(position + 1) else null when { currentItem != null && nextItem != null && currentItem.matchRank != nextItem.matchRank -> { - configuration.set(true, false, 0, 0) + configuration.set(needDivider = true, toTop = false, paddingStart = 0, paddingEnd = 0) } else -> { - configuration.set(true, false, context.resources.sizeScaled(72), 0) + configuration.set( + needDivider = true, + toTop = false, + paddingStart = context.resources.sizeScaled(72), + paddingEnd = 0 + ) } } } var repositories: Map = emptyMap() set(value) { - field = value - notifyDataSetChanged() + if (field != value) { + field = value + notifyItemRangeChanged(0, itemCount) + } } var emptyText: String = "" @@ -94,7 +103,7 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): if (field != value) { field = value if (isEmpty) { - notifyDataSetChanged() + notifyItemChanged(0) } } } @@ -123,7 +132,12 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { return when (viewType) { ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply { - itemView.setOnClickListener { onClick(getProductItem(adapterPosition)) } + itemView.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onClick(getProductItem(position)) + } + } } ViewType.LOADING -> LoadingViewHolder(parent.context) ViewType.EMPTY -> EmptyViewHolder(parent.context) @@ -170,7 +184,7 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): } } val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty() - sequenceOf(holder.name, holder.status, holder.summary).forEach { it.isEnabled = enabled } + sequenceOf(holder.name, holder.status, holder.summary).forEach { view -> view.isEnabled = enabled } } ViewType.LOADING -> { // Do nothing @@ -179,6 +193,25 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit): holder as EmptyViewHolder holder.text.text = emptyText } - }::class + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onCursorChanged(oldCursor: Cursor?, newCursor: Cursor?) { + val oldSize = oldCursor?.count ?: 0 + val newSize = newCursor?.count ?: 0 + + val oldIsVirtual = oldSize == 0 + val newIsVirtual = newSize == 0 + + if (oldIsVirtual != newIsVirtual) { + notifyDataSetChanged() + } else if (oldIsVirtual) { + if (oldCursor != newCursor) { + notifyItemChanged(0) + } + } else { + super.onCursorChanged(oldCursor, newCursor) + } } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsFragment.kt index 7af8a19..04579c4 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ProductsFragment.kt @@ -6,6 +6,7 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.os.BundleCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -90,17 +91,21 @@ class ProductsFragment(): ScreenFragment(), CursorOwner.Callback { super.onViewCreated(view, savedInstanceState) currentSearchQuery = savedInstanceState?.getString(STATE_CURRENT_SEARCH_QUERY).orEmpty() - currentSection = savedInstanceState?.getParcelable(STATE_CURRENT_SECTION) ?: ProductItem.Section.All + currentSection = savedInstanceState?.let { bundle -> + BundleCompat.getParcelable(bundle, STATE_CURRENT_SECTION, ProductItem.Section::class.java) + } ?: ProductItem.Section.All currentOrder = savedInstanceState?.getString(STATE_CURRENT_ORDER) ?.let(ProductItem.Order::valueOf) ?: ProductItem.Order.NAME - layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) + layoutManagerState = savedInstanceState?.let { bundle -> + BundleCompat.getParcelable(bundle, STATE_LAYOUT_MANAGER, Parcelable::class.java) + } screenActivity.cursorOwner.attach(this, request) repositoriesDisposable = Observable.just(Unit) .concatWith(Database.observable(Database.Subject.Repositories)) .observeOn(Schedulers.io()) - .flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } - .map { it.asSequence().map { Pair(it.id, it) }.toMap() } + .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } } + .map { result -> result.asSequence().map { Pair(it.id, it) }.toMap() } .observeOn(AndroidSchedulers.mainThread()) .subscribe { (recyclerView?.adapter as? ProductsAdapter)?.repositories = it } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoriesAdapter.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoriesAdapter.kt index bec39b1..b49720b 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoriesAdapter.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoriesAdapter.kt @@ -34,20 +34,28 @@ class RepositoriesAdapter(private val onClick: (Repository) -> Unit, return Database.RepositoryAdapter.transform(moveTo(position)) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { - return ViewHolder(parent.inflate(R.layout.repository_item)).apply { - itemView.setOnClickListener { onClick(getRepository(adapterPosition)) } - enabled.setOnCheckedChangeListener { _, isChecked -> - if (listenSwitch) { - if (!onSwitch(getRepository(adapterPosition), isChecked)) { - listenSwitch = false - enabled.isChecked = !isChecked - listenSwitch = true - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { + return ViewHolder(parent.inflate(R.layout.repository_item)).apply { + itemView.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onClick(getRepository(position)) + } + } + enabled.setOnCheckedChangeListener { _, isChecked -> + if (listenSwitch) { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + if (!onSwitch(getRepository(position), isChecked)) { + listenSwitch = false + enabled.isChecked = !isChecked + listenSwitch = true + } + } + } + } } - } } - } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { holder as ViewHolder diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoryFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoryFragment.kt index 4275b7b..33122a5 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoryFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/RepositoryFragment.kt @@ -113,15 +113,13 @@ class RepositoryFragment(): ScreenFragment() { layout.addTitleText(R.string.name, repository.name) layout.addTitleText(R.string.description, repository.description.replace('\n', ' ')) layout.addTitleText(R.string.last_update, run { - val lastUpdated = repository.updated - if (lastUpdated > 0L) { - val date = Date(repository.updated) - val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else - DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE - DateUtils.formatDateTime(layout.context, date.time, format) - } else { - getString(R.string.unknown) - } + repository.updated + run { + val date = Date(repository.updated) + val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE + DateUtils.formatDateTime(layout.context, date.time, format) + } }) if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) { layout.addTitleText(R.string.number_of_applications, @@ -139,7 +137,7 @@ class RepositoryFragment(): ScreenFragment() { } } else { val fingerprint = SpannableStringBuilder(repository.fingerprint.windowed(2, 2, false) - .take(32).joinToString(separator = " ") { it.toUpperCase(Locale.US) }) + .take(32).joinToString(separator = " ") { it.uppercase(Locale.US) }) fingerprint.setSpan(TypefaceSpan("monospace"), 0, fingerprint.length, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE) layout.addTitleText(R.string.fingerprint, fingerprint) @@ -158,9 +156,7 @@ class RepositoryFragment(): ScreenFragment() { } } - internal fun onDeleteConfirm() { - if (syncConnection.binder?.deleteRepository(repositoryId) == true) { - requireActivity().onBackPressed() - } - } + internal fun onDeleteConfirm() {if (syncConnection.binder?.deleteRepository(repositoryId) == true) { + screenActivity.onBackPressedDispatcher.onBackPressed() + }} } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenActivity.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenActivity.kt index 2466e1c..2bc9930 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenActivity.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenActivity.kt @@ -5,11 +5,13 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcel -import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.Toolbar +import androidx.activity.OnBackPressedCallback +import androidx.core.os.BundleCompat +import androidx.core.view.WindowCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import nya.kitsunyan.foxydroid.R @@ -72,8 +74,7 @@ abstract class ScreenActivity: FragmentActivity() { setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration)) super.onCreate(savedInstanceState) - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + WindowCompat.setDecorFitsSystemWindows(window, false) addContentView(FrameLayout(this).apply { id = R.id.main_content }, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) @@ -87,14 +88,34 @@ abstract class ScreenActivity: FragmentActivity() { .findFragmentByTag(CursorOwner::class.java.name) as CursorOwner } - savedInstanceState?.getParcelableArrayList(STATE_FRAGMENT_STACK) - ?.let { fragmentStack += it } + savedInstanceState?.let { + BundleCompat.getParcelableArrayList(it, STATE_FRAGMENT_STACK, FragmentStackItem::class.java) + ?.let { list -> fragmentStack += list } + } if (savedInstanceState == null) { replaceFragment(TabsFragment(), null) if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { handleIntent(intent) } } + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val currentFragment = currentFragment + if (!(currentFragment is ScreenFragment && currentFragment.onBackPressed())) { + hideKeyboard() + if (!popFragment()) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + } + }) + + supportFragmentManager.addFragmentOnAttachListener { _, _ -> + hideKeyboard() + } } override fun onSaveInstanceState(outState: Bundle) { @@ -102,16 +123,6 @@ abstract class ScreenActivity: FragmentActivity() { outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack)) } - override fun onBackPressed() { - val currentFragment = currentFragment - if (!(currentFragment is ScreenFragment && currentFragment.onBackPressed())) { - hideKeyboard() - if (!popFragment()) { - super.onBackPressed() - } - } - } - private fun replaceFragment(fragment: Fragment, open: Boolean?) { if (open != null) { currentFragment?.view?.translationZ = (if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat() @@ -137,7 +148,7 @@ abstract class ScreenActivity: FragmentActivity() { private fun popFragment(): Boolean { return fragmentStack.isNotEmpty() && run { val stackItem = fragmentStack.removeAt(fragmentStack.size - 1) - val fragment = Class.forName(stackItem.className).newInstance() as Fragment + val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, stackItem.className) stackItem.arguments?.let(fragment::setArguments) stackItem.savedState?.let(fragment::setInitialSavedState) replaceFragment(fragment, false) @@ -150,15 +161,10 @@ abstract class ScreenActivity: FragmentActivity() { ?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0) } - override fun onAttachFragment(fragment: Fragment) { - super.onAttachFragment(fragment) - hideKeyboard() - } - internal fun onToolbarCreated(toolbar: Toolbar) { if (fragmentStack.isNotEmpty()) { toolbar.navigationIcon = toolbar.context.getDrawableFromAttr(android.R.attr.homeAsUpIndicator) - toolbar.setNavigationOnClickListener { onBackPressed() } + toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } } } @@ -235,10 +241,10 @@ abstract class ScreenActivity: FragmentActivity() { } else { Pair(Uri.fromFile(Cache.getReleaseFile(this, cacheFileName)), 0) } - // TODO Handle deprecation - @Suppress("DEPRECATION") - startActivity(Intent(Intent.ACTION_INSTALL_PACKAGE) - .setDataAndType(uri, "application/vnd.android.package-archive").setFlags(flags)) + + startActivity(Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(flags or Intent.FLAG_ACTIVITY_NEW_TASK)) } internal fun navigateProduct(packageName: String) = pushFragment(ProductFragment(packageName)) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenshotsFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenshotsFragment.kt index 55b2b2b..e7247c2 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenshotsFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/ScreenshotsFragment.kt @@ -5,13 +5,16 @@ import android.content.Context import android.graphics.PixelFormat import android.graphics.drawable.Drawable import android.os.Bundle -import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.ImageView import androidx.core.graphics.ColorUtils +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.ViewPager2 @@ -65,10 +68,11 @@ class ScreenshotsFragment(): DialogFragment() { 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 - } + + WindowCompat.setDecorFitsSystemWindows(window, false) + val insetsController = WindowCompat.getInsetsController(window, decorView) + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + window.attributes = window.attributes.apply { title = ScreenshotsFragment::class.java.name format = PixelFormat.TRANSLUCENT @@ -86,17 +90,16 @@ class ScreenshotsFragment(): DialogFragment() { } } - 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 applyHide = Runnable { insetsController.hide(WindowInsetsCompat.Type.systemBars()) } val handleClick = { decorView.removeCallbacks(applyHide) - if ((decorView.systemUiVisibility and hideFlags) == hideFlags) { - decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv() + val isVisible = decorView.rootWindowInsets?.let { + it.isVisible(WindowInsetsCompat.Type.statusBars()) || it.isVisible(WindowInsetsCompat.Type.navigationBars()) + } ?: true + if (isVisible) { + insetsController.hide(WindowInsetsCompat.Type.systemBars()) } else { - decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags + insetsController.show(WindowInsetsCompat.Type.systemBars()) } } decorView.postDelayed(applyHide, 2000L) @@ -112,21 +115,20 @@ class ScreenshotsFragment(): DialogFragment() { ViewGroup.LayoutParams.MATCH_PARENT)) this.viewPager = viewPager - var restored = false + val 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)) } + .flatMapSingle { RxUtils.querySingle { signal -> Database.ProductAdapter.get(packageName, signal) } } + .map { result -> Pair(result.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) } .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - val (product, repository) = it + .subscribe { result -> + val (product, repository) = result 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) + val identifier = savedInstanceState?.getString(STATE_IDENTIFIER) + ?: requireArguments().getString(EXTRA_IDENTIFIER) if (identifier != null) { val index = screenshots.indexOfFirst { it.identifier == identifier } if (index >= 0) { @@ -160,9 +162,11 @@ class ScreenshotsFragment(): DialogFragment() { private class Adapter(private val packageName: String, private val onClick: () -> Unit): StableRecyclerAdapter() { - enum class ViewType { SCREENSHOT } + enum class ViewType { + SECTION + } - private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) { + private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) { val image: ImageView get() = itemView as ImageView @@ -182,17 +186,28 @@ class ScreenshotsFragment(): DialogFragment() { private var repository: Repository? = null private var screenshots = emptyList() + private class ScreenshotDiffCallback(private val oldList: List, + private val newList: List): DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition].identifier == newList[newItemPosition].identifier + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition] == newList[newItemPosition] + } + fun update(repository: Repository?, screenshots: List) { this.repository = repository + val diffResult = DiffUtil.calculateDiff(ScreenshotDiffCallback(this.screenshots, screenshots)) this.screenshots = screenshots - notifyDataSetChanged() + diffResult.dispatchUpdatesTo(this) } var size = Pair(0, 0) set(value) { if (field != value) { field = value - notifyDataSetChanged() + notifyItemRangeChanged(0, itemCount) } } @@ -206,7 +221,7 @@ class ScreenshotsFragment(): DialogFragment() { 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 getItemEnumViewType(position: Int): ViewType = ViewType.SECTION override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { return ViewHolder(parent.context).apply { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/TabsFragment.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/TabsFragment.kt index 61a2b6a..673664d 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/screen/TabsFragment.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/screen/TabsFragment.kt @@ -22,7 +22,10 @@ import android.widget.LinearLayout import android.widget.SearchView import android.widget.TextView import android.widget.Toolbar +import androidx.core.os.BundleCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter @@ -75,10 +78,10 @@ class TabsFragment: ScreenFragment() { if (field != value) { field = value val layout = layout - layout?.tabs?.let { (0 until it.childCount) - .forEach { index -> it.getChildAt(index)!!.isEnabled = !value } } + layout?.tabs?.let { tabs -> (0 until tabs.childCount) + .forEach { index -> tabs.getChildAt(index)!!.isEnabled = !value } } layout?.sectionIcon?.scaleY = if (value) -1f else 1f - if ((sectionsList?.parent as? View)?.height ?: 0 > 0) { + if (((sectionsList?.parent as? View)?.height ?: 0) > 0) { animateSectionsList() } } @@ -89,8 +92,8 @@ class TabsFragment: ScreenFragment() { private var section: ProductItem.Section = ProductItem.Section.All private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ -> - viewPager?.let { - val source = ProductsFragment.Source.values()[it.currentItem] + viewPager?.let { pager -> + val source = ProductsFragment.Source.entries[pager.currentItem] updateUpdateNotificationBlocker(source) } }) @@ -195,7 +198,7 @@ class TabsFragment: ScreenFragment() { layout.tabs.background = TabsBackgroundDrawable(layout.tabs.context, layout.tabs.layoutDirection == View.LAYOUT_DIRECTION_RTL) - ProductsFragment.Source.values().forEach { + ProductsFragment.Source.entries.forEach { source -> val tab = TextView(layout.tabs.context) val selectedColor = tab.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor val normalColor = tab.context.getColorFromAttr(android.R.attr.textColorSecondary).defaultColor @@ -205,25 +208,25 @@ class TabsFragment: ScreenFragment() { intArrayOf(selectedColor, normalColor))) tab.setTextSizeScaled(14) tab.isAllCaps = true - tab.text = getString(it.titleResId) + tab.text = getString(source.titleResId) tab.background = tab.context.getDrawableFromAttr(android.R.attr.selectableItemBackground) - tab.setOnClickListener { _ -> - setSelectedTab(it) - viewPager!!.setCurrentItem(it.ordinal, Utils.areAnimationsEnabled(tab.context)) + tab.setOnClickListener { + setSelectedTab(source) + viewPager!!.setCurrentItem(source.ordinal, Utils.areAnimationsEnabled(tab.context)) } layout.tabs.addView(tab, 0, LinearLayout.LayoutParams.MATCH_PARENT) (tab.layoutParams as LinearLayout.LayoutParams).weight = 1f } - showSections = savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0 != 0 - sections = savedInstanceState?.getParcelableArrayList(STATE_SECTIONS).orEmpty() - section = savedInstanceState?.getParcelable(STATE_SECTION) ?: ProductItem.Section.All + showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0 + sections = savedInstanceState?.let { BundleCompat.getParcelableArrayList(it, STATE_SECTIONS, ProductItem.Section::class.java) }.orEmpty() + section = savedInstanceState?.let { BundleCompat.getParcelable(it, STATE_SECTION, ProductItem.Section::class.java) } ?: ProductItem.Section.All layout.sectionChange.setOnClickListener { showSections = sections .any { it !is ProductItem.Section.All } && !showSections } updateOrder() - sortOrderDisposable = Preferences.observable.subscribe { - if (it == Preferences.Key.SortOrder) { + sortOrderDisposable = Preferences.observable.subscribe { key -> + if (key == Preferences.Key.SortOrder) { updateOrder() } } @@ -233,9 +236,8 @@ class TabsFragment: ScreenFragment() { viewPager = ViewPager2(content.context).apply { id = R.id.fragment_pager adapter = object: FragmentStateAdapter(this@TabsFragment) { - override fun getItemCount(): Int = ProductsFragment.Source.values().size - override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment - .Source.values()[position]) + override fun getItemCount(): Int = ProductsFragment.Source.entries.size + override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment.Source.entries[position]) } content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) registerOnPageChangeCallback(pageChangeCallback) @@ -245,16 +247,17 @@ class TabsFragment: ScreenFragment() { categoriesDisposable = Observable.just(Unit) .concatWith(Database.observable(Database.Subject.Products)) .observeOn(Schedulers.io()) - .flatMapSingle { RxUtils.querySingle { Database.CategoryAdapter.getAll(it) } } + .flatMapSingle { RxUtils.querySingle { signal -> Database.CategoryAdapter.getAll(signal) } } .observeOn(AndroidSchedulers.mainThread()) - .subscribe { setSectionsAndUpdate(it.asSequence().sorted() + .subscribe { result -> setSectionsAndUpdate(result.asSequence().sorted() .map(ProductItem.Section::Category).toList(), null) } repositoriesDisposable = Observable.just(Unit) .concatWith(Database.observable(Database.Subject.Repositories)) .observeOn(Schedulers.io()) - .flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } + .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } } .observeOn(AndroidSchedulers.mainThread()) - .subscribe { setSectionsAndUpdate(null, it.asSequence().filter { it.enabled } + .subscribe { result -> + setSectionsAndUpdate(null, result.asSequence().filter { it.enabled } .map { ProductItem.Section.Repository(it.id, it.name) }.toList()) } updateSection() @@ -264,10 +267,10 @@ class TabsFragment: ScreenFragment() { isMotionEventSplittingEnabled = false isVerticalScrollBarEnabled = false setHasFixedSize(true) - val adapter = SectionsAdapter({ sections }) { + val adapter = SectionsAdapter({ sections }) { newSection -> if (showSections) { showSections = false - section = it + section = newSection updateSection() } } @@ -280,14 +283,13 @@ class TabsFragment: ScreenFragment() { } this.sectionsList = sectionsList - var lastContentHeight = -1 + val lastContentHeight = -1 content.viewTreeObserver.addOnGlobalLayoutListener { if (this.view != null) { - val initial = lastContentHeight <= 0 + val initial = true val contentHeight = content.height if (lastContentHeight != contentHeight) { - lastContentHeight = contentHeight - if (initial) { + if (initial) { sectionsList.layoutParams.height = if (showSections) contentHeight else 0 sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE sectionsList.requestLayout() @@ -340,7 +342,9 @@ class TabsFragment: ScreenFragment() { } } + @Deprecated("Deprecated in Java") override fun onAttachFragment(childFragment: Fragment) { + @Suppress("DEPRECATION") super.onAttachFragment(childFragment) if (view != null && childFragment is ProductsFragment) { @@ -368,7 +372,7 @@ class TabsFragment: ScreenFragment() { private fun setSelectedTab(source: ProductsFragment.Source) { val layout = layout!! - (0 until layout.tabs.childCount).forEach { layout.tabs.getChildAt(it).isSelected = it == source.ordinal } + (0 until layout.tabs.childCount).forEach { index -> layout.tabs.getChildAt(index).isSelected = index == source.ordinal } } internal fun selectUpdates() = selectUpdatesInternal(true) @@ -407,10 +411,20 @@ class TabsFragment: ScreenFragment() { val oldCategories = collectOldSections(categories) val oldRepositories = collectOldSections(repositories) if (oldCategories == null || oldRepositories == null) { + val oldSections = sections sections = listOf(ProductItem.Section.All) + (categories ?: oldCategories).orEmpty() + (repositories ?: oldRepositories).orEmpty() + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldSections.size + override fun getNewListSize(): Int = sections.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldSections[oldItemPosition] == sections[newItemPosition] + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldSections[oldItemPosition] == sections[newItemPosition] + }) updateSection() + sectionsList?.adapter?.let { adapter -> diffResult.dispatchUpdatesTo(adapter) } } } @@ -418,14 +432,19 @@ class TabsFragment: ScreenFragment() { if (section !in sections) { section = ProductItem.Section.All } - layout?.sectionName?.text = when (val section = section) { + layout?.sectionName?.text = when (val s = section) { is ProductItem.Section.All -> getString(R.string.all_applications) - is ProductItem.Section.Category -> section.name - is ProductItem.Section.Repository -> section.name + is ProductItem.Section.Category -> s.name + is ProductItem.Section.Repository -> s.name } layout?.sectionIcon?.visibility = if (sections.any { it !is ProductItem.Section.All }) View.VISIBLE else View.GONE productFragments.forEach { it.setSection(section) } - sectionsList?.adapter?.notifyDataSetChanged() + sectionsList?.adapter?.let { adapter -> + val index = sections.indexOf(section) + if (index >= 0) { + adapter.notifyItemRangeChanged(0, sections.size) + } + } } private fun animateSectionsList() { @@ -440,16 +459,16 @@ class TabsFragment: ScreenFragment() { sectionsAnimator = ValueAnimator.ofFloat(value, target).apply { duration = (250 * abs(target - value)).toLong() interpolator = if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f) - addUpdateListener { - val newValue = animatedValue as Float + addUpdateListener { animator -> + val newValue = animator.animatedValue as Float sectionsList.apply { - val height = ((parent as View).height * newValue).toInt() - val visible = height > 0 - if ((visibility == View.VISIBLE) != visible) { + val h = ((parent as View).height * newValue).toInt() + val visible = h > 0 + if ((isVisible) != visible) { visibility = if (visible) View.VISIBLE else View.GONE } - if (layoutParams.height != height) { - layoutParams.height = height + if (layoutParams.height != h) { + layoutParams.height = h requestLayout() } } @@ -464,30 +483,30 @@ class TabsFragment: ScreenFragment() { private val pageChangeCallback = object: ViewPager2.OnPageChangeCallback() { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - val layout = layout!! - val fromSections = ProductsFragment.Source.values()[position].sections + val l = layout!! + val fromSections = ProductsFragment.Source.entries[position].sections val toSections = if (positionOffset <= 0f) fromSections else - ProductsFragment.Source.values()[position + 1].sections + ProductsFragment.Source.entries[position + 1].sections val offset = if (fromSections != toSections) { if (fromSections) 1f - positionOffset else positionOffset } else { if (fromSections) 1f else 0f } - (layout.tabs.background as TabsBackgroundDrawable) - .update(position + positionOffset, layout.tabs.childCount) - assert(layout.sectionLayout.childCount == 1) - val child = layout.sectionLayout.getChildAt(0) - val height = child.layoutParams.height - assert(height > 0) - val currentHeight = (offset * height).roundToInt() - if (layout.sectionLayout.layoutParams.height != currentHeight) { - layout.sectionLayout.layoutParams.height = currentHeight - layout.sectionLayout.requestLayout() + (l.tabs.background as TabsBackgroundDrawable) + .update(position + positionOffset, l.tabs.childCount) + assert(l.sectionLayout.childCount == 1) + val child = l.sectionLayout.getChildAt(0) + val h = child.layoutParams.height + assert(h > 0) + val currentHeight = (offset * h).roundToInt() + if (l.sectionLayout.layoutParams.height != currentHeight) { + l.sectionLayout.layoutParams.height = currentHeight + l.sectionLayout.requestLayout() } } override fun onPageSelected(position: Int) { - val source = ProductsFragment.Source.values()[position] + val source = ProductsFragment.Source.entries[position] updateUpdateNotificationBlocker(source) sortOrderMenu!!.first.isVisible = source.order syncRepositoriesMenuItem!!.setShowAsActionFlags(if (!source.order || @@ -499,7 +518,7 @@ class TabsFragment: ScreenFragment() { } override fun onPageScrollStateChanged(state: Int) { - val source = ProductsFragment.Source.values()[viewPager!!.currentItem] + val source = ProductsFragment.Source.entries[viewPager!!.currentItem] layout!!.sectionChange.isEnabled = state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections if (state == ViewPager2.SCROLL_STATE_IDLE) { // onPageSelected can be called earlier than fragments created @@ -509,7 +528,7 @@ class TabsFragment: ScreenFragment() { } private class TabsBackgroundDrawable(context: Context, private val rtl: Boolean): Drawable() { - private val height = context.resources.sizeScaled(2) + private val h = context.resources.sizeScaled(2) private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = context.getColorFromAttr(android.R.attr.colorAccent).defaultColor } @@ -525,15 +544,15 @@ class TabsFragment: ScreenFragment() { override fun draw(canvas: Canvas) { if (total > 0) { - val bounds = bounds - val width = bounds.width() / total.toFloat() - val x = width * position + val b = bounds + val w = b.width() / total.toFloat() + val x = w * position if (rtl) { - canvas.drawRect(bounds.right - width - x, (bounds.bottom - height).toFloat(), - bounds.right - x, bounds.bottom.toFloat(), paint) + canvas.drawRect(b.right - w - x, (b.bottom - h).toFloat(), + b.right - x, b.bottom.toFloat(), paint) } else { - canvas.drawRect(bounds.left + x, (bounds.bottom - height).toFloat(), - bounds.left + x + width, bounds.bottom.toFloat(), paint) + canvas.drawRect(b.left + x, (b.bottom - h).toFloat(), + b.left + x + w, b.bottom.toFloat(), paint) } } } @@ -554,10 +573,10 @@ class TabsFragment: ScreenFragment() { init { itemView as TextView - itemView.gravity = Gravity.CENTER_VERTICAL + (itemView as TextView).gravity = Gravity.CENTER_VERTICAL itemView.resources.sizeScaled(16).let { itemView.setPadding(it, 0, it, 0) } - itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) - itemView.setTextSizeScaled(16) + (itemView as TextView).setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) + (itemView as TextView).setTextSizeScaled(16) itemView.background = context.getDrawableFromAttr(android.R.attr.selectableItemBackground) itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.resources.sizeScaled(48)) @@ -570,10 +589,15 @@ class TabsFragment: ScreenFragment() { when { nextSection != null && currentSection.javaClass != nextSection.javaClass -> { val padding = context.resources.sizeScaled(16) - configuration.set(true, false, padding, padding) + configuration.set( + needDivider = true, + toTop = false, + paddingStart = padding, + paddingEnd = padding + ) } else -> { - configuration.set(false, false, 0, 0) + configuration.set(needDivider = false, toTop = false, paddingStart = 0, paddingEnd = 0) } } } @@ -587,7 +611,12 @@ class TabsFragment: ScreenFragment() { override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { return SectionViewHolder(parent.context).apply { - itemView.setOnClickListener { onClick(sections()[adapterPosition]) } + itemView.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onClick(sections()[pos]) + } + } } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/service/DownloadService.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/service/DownloadService.kt index 7c4c4e2..535753a 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/service/DownloadService.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/service/DownloadService.kt @@ -6,9 +6,9 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.net.Uri import android.view.ContextThemeWrapper import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.Disposable @@ -29,6 +29,7 @@ import java.io.File import java.security.MessageDigest import java.util.concurrent.TimeUnit import kotlin.math.* +import androidx.core.net.toUri class DownloadService: ConnectionService() { companion object { @@ -47,14 +48,14 @@ class DownloadService: ConnectionService() { 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")) + .setAction(Intent.ACTION_VIEW).setData("package:$packageName".toUri()) .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")) + .setAction(MainActivity.ACTION_INSTALL).setData("package:$packageName".toUri()) .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } @@ -189,8 +190,10 @@ class DownloadService: ConnectionService() { .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)) + .setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW).setData("package:${task.packageName}".toUri()) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), PendingIntent.FLAG_UPDATE_CURRENT or + Android.PendingIntent.FLAG_IMMUTABLE)) .apply { when (errorType) { is ErrorType.Network -> { @@ -223,9 +226,11 @@ class DownloadService: ConnectionService() { .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)) + .setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_INSTALL).setData("package:${task.packageName}".toUri()) + .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, task.release.cacheFileName) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), PendingIntent.FLAG_UPDATE_CURRENT or + Android.PendingIntent.FLAG_IMMUTABLE)) .setContentTitle(getString(R.string.downloaded_FORMAT, task.name)) .setContentText(getString(R.string.tap_to_install_DESC)) .build()) @@ -243,12 +248,12 @@ class DownloadService: ConnectionService() { val hash = try { val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256" val digest = MessageDigest.getInstance(hashType) - file.inputStream().use { - val bytes = ByteArray(8 * 1024) + file.inputStream().use { it -> + val bytes = ByteArray(8 * 1024) generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) } digest.digest().hex() } - } catch (e: Exception) { + } catch (_: Exception) { "" } return if (hash.isEmpty() || hash != task.release.hash) { @@ -287,7 +292,8 @@ class DownloadService: ConnectionService() { .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)) } + Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT or + Android.PendingIntent.FLAG_IMMUTABLE)) } private fun publishForegroundState(force: Boolean, state: State) { if (force || currentTask != null) { @@ -313,7 +319,7 @@ class DownloadService: ConnectionService() { throw IllegalStateException() } }::class - }.build()) + }.build(), Android.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) stateSubject.onNext(state) } } @@ -361,7 +367,7 @@ class DownloadService: ConnectionService() { currentTask = CurrentTask(task, disposable, initialState) } else if (started) { started = false - stopForeground(true) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/service/SyncService.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/service/SyncService.kt index 037ea6a..b96562e 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/service/SyncService.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/service/SyncService.kt @@ -11,6 +11,7 @@ import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.view.ContextThemeWrapper import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import androidx.fragment.app.Fragment import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable @@ -123,11 +124,7 @@ class SyncService: ConnectionService() { return true } - fun isCurrentlySyncing(repositoryId: Long): Boolean { - return currentTask?.task?.repositoryId == repositoryId - } - - fun deleteRepository(repositoryId: Long): Boolean { + fun deleteRepository(repositoryId: Long): Boolean { val repository = Database.RepositoryAdapter.get(repositoryId) return repository != null && run { setEnabled(repository, false) @@ -220,7 +217,8 @@ class SyncService: ConnectionService() { .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)) } + Intent(this, this::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT or + Android.PendingIntent.FLAG_IMMUTABLE)) } private fun publishForegroundState(force: Boolean, state: State) { if (force || currentTask?.lastState != state) { @@ -267,7 +265,7 @@ class SyncService: ConnectionService() { setProgress(0, 0, true) } }::class - }.build()) + }.build(), Android.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } } } @@ -294,7 +292,7 @@ class SyncService: ConnectionService() { val unstable = Preferences[Preferences.Key.UpdateUnstable] lateinit var disposable: Disposable disposable = RepositoryUpdater - .update(repository, unstable) { stage, progress, total -> + .update(this, repository, unstable) { stage, progress, total -> if (!disposable.isDisposed) { stateSubject.onNext(State.Syncing(repository.name, stage, progress, total)) } @@ -315,8 +313,16 @@ class SyncService: ConnectionService() { } 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) + .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()) @@ -335,7 +341,7 @@ class SyncService: ConnectionService() { val needStop = started == Started.MANUAL started = Started.NO if (needStop) { - stopForeground(true) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } } @@ -355,7 +361,8 @@ class SyncService: ConnectionService() { .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)) + .setAction(MainActivity.ACTION_UPDATES), PendingIntent.FLAG_UPDATE_CURRENT or + Android.PendingIntent.FLAG_IMMUTABLE)) .setStyle(NotificationCompat.InboxStyle().applyHack { for (productItem in productItems.take(maxUpdates)) { val builder = SpannableStringBuilder(productItem.name) diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/PackageItemResolver.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/PackageItemResolver.kt index 5808d43..91eb5ca 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/PackageItemResolver.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/PackageItemResolver.kt @@ -40,7 +40,7 @@ object PackageItemResolver { @Suppress("DEPRECATION") resources.updateConfiguration(context.resources.configuration, null) resources - } catch (e: Exception) { + } catch (_: Exception) { null } resources?.let { localCache.resources[packageName] = it } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/RxUtils.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/RxUtils.kt index 91fd040..12afd37 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/RxUtils.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/RxUtils.kt @@ -21,63 +21,63 @@ object RxUtils { } } - private fun managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single { + 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)) + 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 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 { + fun querySingle(query: (CancellationSignal?) -> T): Single { return Single.create { - val cancellationSignal = CancellationSignal() - it.setCancellable { - try { - cancellationSignal.cancel() - } catch (e: OperationCanceledException) { - // Do nothing + val cancellationSignal = CancellationSignal() + it.setCancellable { + try { + cancellationSignal.cancel() + } catch (_: OperationCanceledException) { + // Do nothing + } + } + val result = try { + query(cancellationSignal) + } catch (_: OperationCanceledException) { + null + } + if (result != null) { + it.onSuccess(result) } - } - val result = try { - query(cancellationSignal) - } catch (e: OperationCanceledException) { - null - } - if (result != null) { - it.onSuccess(result) - } } } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/Utils.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/Utils.kt index e6d72d6..ee9b91d 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/Utils.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/Utils.kt @@ -1,6 +1,7 @@ package nya.kitsunyan.foxydroid.utility import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.content.Context import android.content.pm.Signature import android.content.res.Configuration @@ -35,14 +36,14 @@ object Utils { return drawable } - fun calculateHash(signature: Signature): String? { + 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) { + } catch (_: CertificateEncodingException) { null } return encoded?.let(::calculateFingerprint).orEmpty() @@ -66,6 +67,7 @@ object Utils { } } + @SuppressLint("SuspiciousIndentation") fun configureLocale(context: Context): Context { val supportedLanguages = BuildConfig.LANGUAGES.toSet() val configuration = context.resources.configuration @@ -78,15 +80,10 @@ object Utils { } val compatibleLocales = currentLocales .filter { it.language in supportedLanguages } - .let { if (it.isEmpty()) listOf(Locale.US) else it } + .let { it.ifEmpty { listOf(Locale.US) } } 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) } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Android.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Android.kt index fdb7c21..d47a9a1 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Android.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Android.kt @@ -34,8 +34,7 @@ val PackageInfo.singleSignature: Signature? 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 } + null } } @@ -55,11 +54,20 @@ object Android { return Build.VERSION.SDK_INT >= sdk } + object PendingIntent { + val FLAG_IMMUTABLE: Int + get() = if (sdk(23)) android.app.PendingIntent.FLAG_IMMUTABLE else 0 + + } + 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 + get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) + } + + object ServiceInfo { + val FOREGROUND_SERVICE_TYPE_DATA_SYNC: Int + get() = if (sdk(29)) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 } object Device { @@ -68,7 +76,7 @@ object Android { return try { Class.forName("com.huawei.android.os.BuildEx") true - } catch (e: Exception) { + } catch (_: Exception) { false } } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Json.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Json.kt index 1123d27..a3329ee 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Json.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Json.kt @@ -38,7 +38,7 @@ inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) { while (true) { val token = nextToken() if (token == JsonToken.FIELD_NAME) { - passKey = currentName + passKey = currentName() passToken = nextToken() callback(keyToken) } else if (token == JsonToken.END_OBJECT) { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Resources.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Resources.kt index 142a5e3..6f7a990 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Resources.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/utility/extension/Resources.kt @@ -31,8 +31,8 @@ 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() } + 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) { @@ -77,8 +77,7 @@ fun Resources.sizeScaled(size: Int): Int { } fun TextView.setTextSizeScaled(size: Int) { - val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt() - setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, size.toFloat()) } fun ViewGroup.inflate(layoutResId: Int): View { diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/widget/CursorRecyclerAdapter.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/widget/CursorRecyclerAdapter.kt index 2ca5d4c..658325f 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/widget/CursorRecyclerAdapter.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/widget/CursorRecyclerAdapter.kt @@ -1,6 +1,8 @@ package nya.kitsunyan.foxydroid.widget +import android.annotation.SuppressLint import android.database.Cursor +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView abstract class CursorRecyclerAdapter, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter() { @@ -13,13 +15,58 @@ abstract class CursorRecyclerAdapter, VH: RecyclerView.ViewHolder>: var cursor: Cursor? = null set(value) { if (field != value) { - field?.close() + val oldCursor = field field = value rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0 - notifyDataSetChanged() + onCursorChanged(oldCursor, value) } } + @SuppressLint("NotifyDataSetChanged") + protected open fun onCursorChanged(oldCursor: Cursor?, newCursor: Cursor?) { + val oldSize = oldCursor?.count ?: 0 + val newSize = newCursor?.count ?: 0 + + if (oldCursor == null || newCursor == null) { + if (oldSize > 0) notifyItemRangeRemoved(0, oldSize) + if (newSize > 0) notifyItemRangeInserted(0, newSize) + return + } + + // Further reduced threshold to 100 for DiffUtil to avoid any noticeable frame drops on the main thread. + // JSON parsing and DB access during diffing are slow. + if (oldSize > 100 || newSize > 100) { + notifyDataSetChanged() + return + } + + val oldIdIndex = oldCursor.getColumnIndexOrThrow("_id") + val newIdIndex = newCursor.getColumnIndexOrThrow("_id") + + try { + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldSize + override fun getNewListSize(): Int = newSize + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + if (!oldCursor.moveToPosition(oldItemPosition) || !newCursor.moveToPosition(newItemPosition)) return false + return oldCursor.getLong(oldIdIndex) == newCursor.getLong(newIdIndex) + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + if (!oldCursor.moveToPosition(oldItemPosition) || !newCursor.moveToPosition(newItemPosition)) return false + return areContentsTheSame(oldCursor, newCursor) + } + }) + diffResult.dispatchUpdatesTo(this) + } catch (_: Exception) { + // Fallback in case of cursor issues during diffing + notifyDataSetChanged() + } + } + + protected open fun areContentsTheSame(oldCursor: Cursor, newCursor: Cursor): Boolean = false + final override fun setHasStableIds(hasStableIds: Boolean) { throw UnsupportedOperationException() } diff --git a/src/main/kotlin/nya/kitsunyan/foxydroid/widget/RecyclerFastScroller.kt b/src/main/kotlin/nya/kitsunyan/foxydroid/widget/RecyclerFastScroller.kt index a84dd97..22725f9 100644 --- a/src/main/kotlin/nya/kitsunyan/foxydroid/widget/RecyclerFastScroller.kt +++ b/src/main/kotlin/nya/kitsunyan/foxydroid/widget/RecyclerFastScroller.kt @@ -1,6 +1,5 @@ package nya.kitsunyan.foxydroid.widget -import android.annotation.SuppressLint import android.graphics.Canvas import android.graphics.Rect import android.os.SystemClock @@ -11,7 +10,6 @@ import androidx.recyclerview.widget.RecyclerView import nya.kitsunyan.foxydroid.utility.extension.resources.* import kotlin.math.* -@SuppressLint("ClickableViewAccessibility") class RecyclerFastScroller(private val recyclerView: RecyclerView) { companion object { private const val TRANSITION_IN = 100L diff --git a/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index de74035..0000000 Binary files a/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 80eb5b2..0000000 Binary files a/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index fd16125..0000000 Binary files a/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 6fba4c4..0000000 Binary files a/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 69b5c55..0000000 Binary files a/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 8d2856c..036d09b 100644 --- a/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,8 +1,5 @@ - - - - - - + + + + \ No newline at end of file diff --git a/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/mipmap-hdpi/ic_launcher.png b/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index b97e739..0000000 Binary files a/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src/main/res/mipmap-hdpi/ic_launcher.webp b/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..6363b42 Binary files /dev/null and b/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d78b49e Binary files /dev/null and b/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f20d2d2 Binary files /dev/null and b/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/src/main/res/mipmap-mdpi/ic_launcher.png b/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index a9e37df..0000000 Binary files a/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src/main/res/mipmap-mdpi/ic_launcher.webp b/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..38cbb92 Binary files /dev/null and b/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..565ed83 Binary files /dev/null and b/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..0668230 Binary files /dev/null and b/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/src/main/res/mipmap-xhdpi/ic_launcher.png b/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 854055f..0000000 Binary files a/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src/main/res/mipmap-xhdpi/ic_launcher.webp b/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..72337d2 Binary files /dev/null and b/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e0b9b02 Binary files /dev/null and b/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c9f237a Binary files /dev/null and b/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index b10087a..0000000 Binary files a/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..eccc1fe Binary files /dev/null and b/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4d03643 Binary files /dev/null and b/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..53c622a Binary files /dev/null and b/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index a446d8c..0000000 Binary files a/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7ee7c94 Binary files /dev/null and b/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4178df1 Binary files /dev/null and b/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d3a9134 Binary files /dev/null and b/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/src/main/res/values/ic_launcher_background.xml b/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..e0bee58 --- /dev/null +++ b/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #00E4FF + \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 6dcf864..abd3e8a 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -163,5 +163,6 @@ Versions Waiting to start download Website + The repository address was redirected to %s. Do you want to use it instead?