-- BIG Update --

This commit is contained in:
2026-03-05 18:25:52 +01:00
parent d7b3476a4a
commit d5789bb124
78 changed files with 1190 additions and 1132 deletions
+1
View File
@@ -1,6 +1,7 @@
/*
!/.gitignore
!/build.gradle
!/settings.gradle
!/COPYING
!/extra
!/gradle
+3 -4
View File
@@ -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.
+29 -59
View File
@@ -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'
}
+59 -245
View File
@@ -1,273 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
style="enable-background:new"
id="svg3"
version="1.1"
viewBox="0 0 108 108"
height="108"
width="108">
<metadata
id="metadata55">
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg" style="enable-background:new" id="svg3" version="1.1" viewBox="0 0 108 108"
height="108" width="108" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://purl.org/dc/elements/1.1/">
<metadata id="metadata55">
<rdf:RDF>
<cc:Work
rdf:about="">
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>-</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs17">
<clipPath
id="paper-clip"
clipPathUnits="userSpaceOnUse">
<path
id="paper-clip-path"
d="m 67.759585,28.469613 -18.355472,18.146485 -21.453125,-4.732422 9.087891,24.064453 25.894536,12.974609 12.37695,-23.86914 -1.93359,-0.66211 z" />
<defs id="defs17">
<clipPath id="paper-clip" clipPathUnits="userSpaceOnUse">
<path id="paper-clip-path" d="m 67.759585,28.469613 -18.355472,18.146485 -21.453125,-4.732422 9.087891,24.064453 25.894536,12.974609 12.37695,-23.86914 -1.93359,-0.66211 z" />
</clipPath>
<filter
id="paper-inner-shadow">
<feFlood
id="feFlood6"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.2" />
<feComposite
id="feComposite8"
result="composite1"
operator="in"
in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur10"
result="blur"
stdDeviation="2"
in="composite1" />
<feOffset
id="feOffset12"
result="offset"
dy="0"
dx="0" />
<feComposite
id="feComposite14"
result="composite2"
operator="over"
in2="offset"
in="SourceGraphic" />
<filter id="paper-inner-shadow">
<feFlood id="feFlood6" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2" />
<feComposite id="feComposite8" result="composite1" operator="in" in2="SourceGraphic" in="flood" />
<feGaussianBlur id="feGaussianBlur10" result="blur" stdDeviation="2" in="composite1" />
<feOffset id="feOffset12" result="offset" dy="0" dx="0" />
<feComposite id="feComposite14" result="composite2" operator="over" in2="offset" in="SourceGraphic" />
</filter>
<filter
id="paper-edge-1"
style="color-interpolation-filters:sRGB">
<feFlood
id="feFlood958"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.2" />
<feComposite
id="feComposite960"
result="composite1"
operator="out"
in2="SourceGraphic"
<filter id="paper-edge-1" style="color-interpolation-filters:sRGB">
<feFlood id="feFlood958" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2" />
<feComposite id="feComposite960" result="composite1" operator="out" in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur962"
result="blur"
stdDeviation="0"
in="composite1" />
<feOffset
id="feOffset964"
result="offset"
dy="0.2"
dx="0.2" />
<feComposite
id="feComposite966"
result="composite2"
operator="atop"
in2="SourceGraphic"
<feGaussianBlur id="feGaussianBlur962" result="blur" stdDeviation="0" in="composite1" />
<feOffset id="feOffset964" result="offset" dy="0.2" dx="0.2" />
<feComposite id="feComposite966" result="composite2" operator="atop" in2="SourceGraphic"
in="offset" />
</filter>
<filter
id="paper-edge-2"
style="color-interpolation-filters:sRGB">
<feFlood
id="feFlood1388"
result="flood"
flood-color="rgb(255,255,255)"
flood-opacity="0.2" />
<feComposite
id="feComposite1390"
result="composite1"
operator="out"
in2="SourceGraphic"
<filter id="paper-edge-2" style="color-interpolation-filters:sRGB">
<feFlood id="feFlood1388" result="flood" flood-color="rgb(255,255,255)" flood-opacity="0.2" />
<feComposite id="feComposite1390" result="composite1" operator="out" in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1392"
result="blur"
stdDeviation="0"
in="composite1" />
<feOffset
id="feOffset1394"
result="offset"
dy="-0.1"
dx="0.3" />
<feComposite
id="feComposite1396"
result="composite2"
operator="atop"
in2="SourceGraphic"
<feGaussianBlur id="feGaussianBlur1392" result="blur" stdDeviation="0" in="composite1" />
<feOffset id="feOffset1394" result="offset" dy="-0.1" dx="0.3" />
<feComposite id="feComposite1396" result="composite2" operator="atop" in2="SourceGraphic"
in="offset" />
</filter>
<filter
id="paper-shadow"
style="color-interpolation-filters:sRGB">
<feFlood
id="feFlood1506"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.4" />
<feComposite
id="feComposite1508"
result="composite1"
operator="in"
in2="SourceGraphic"
<filter id="paper-shadow" style="color-interpolation-filters:sRGB">
<feFlood id="feFlood1506" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.4" />
<feComposite id="feComposite1508" result="composite1" operator="in" in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1510"
result="blur"
stdDeviation="2"
in="composite1" />
<feOffset
id="feOffset1512"
result="offset"
dy="0"
dx="0" />
<feComposite
id="feComposite1514"
result="composite2"
operator="over"
in2="offset"
in="SourceGraphic" />
<feGaussianBlur id="feGaussianBlur1510" result="blur" stdDeviation="2" in="composite1" />
<feOffset id="feOffset1512" result="offset" dy="0" dx="0" />
<feComposite id="feComposite1514" result="composite2" operator="over" in2="offset" in="SourceGraphic" />
</filter>
<filter
id="circle-shadow"
style="color-interpolation-filters:sRGB">
<feFlood
id="feFlood1626"
result="flood"
flood-color="rgb(255,255,255)"
flood-opacity="0.2" />
<feComposite
id="feComposite1628"
result="composite1"
operator="out"
in2="SourceGraphic"
<filter id="circle-shadow" style="color-interpolation-filters:sRGB">
<feFlood id="feFlood1626" result="flood" flood-color="rgb(255,255,255)" flood-opacity="0.2" />
<feComposite id="feComposite1628" result="composite1" operator="out" in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1630"
result="blur"
stdDeviation="0"
in="composite1" />
<feOffset
id="feOffset1632"
result="offset"
dy="0.5"
dx="0" />
<feComposite
id="feComposite1634"
result="fbSourceGraphic"
operator="atop"
in2="SourceGraphic"
<feGaussianBlur id="feGaussianBlur1630" result="blur" stdDeviation="0" in="composite1" />
<feOffset id="feOffset1632" result="offset" dy="0.5" dx="0" />
<feComposite id="feComposite1634" result="fbSourceGraphic" operator="atop" in2="SourceGraphic"
in="offset" />
<feFlood
in="fbSourceGraphic"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.2"
<feFlood in="fbSourceGraphic" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2"
id="feFlood1640" />
<feComposite
result="composite1"
operator="out"
in="flood"
id="feComposite1642"
<feComposite result="composite1" operator="out" in="flood" id="feComposite1642" in2="fbSourceGraphic" />
<feGaussianBlur result="blur" stdDeviation="0" in="composite1" id="feGaussianBlur1644" />
<feOffset result="offset" dy="-0.5" dx="0" id="feOffset1646" />
<feComposite result="fbSourceGraphic" operator="atop" in="offset" id="feComposite1648"
in2="fbSourceGraphic" />
<feGaussianBlur
result="blur"
stdDeviation="0"
in="composite1"
id="feGaussianBlur1644" />
<feOffset
result="offset"
dy="-0.5"
dx="0"
id="feOffset1646" />
<feComposite
result="fbSourceGraphic"
operator="atop"
in="offset"
id="feComposite1648"
in2="fbSourceGraphic" />
<feFlood
in="fbSourceGraphic"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.2"
<feFlood in="fbSourceGraphic" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2"
id="feFlood1664" />
<feComposite
result="composite1"
operator="in"
in="flood"
id="feComposite1666"
in2="fbSourceGraphic" />
<feGaussianBlur
result="blur"
stdDeviation="1"
in="composite1"
id="feGaussianBlur1668" />
<feOffset
result="offset"
dy="1"
dx="0"
id="feOffset1670" />
<feComposite
result="composite2"
operator="over"
in="fbSourceGraphic"
id="feComposite1672"
<feComposite result="composite1" operator="in" in="flood" id="feComposite1666" in2="fbSourceGraphic" />
<feGaussianBlur result="blur" stdDeviation="1" in="composite1" id="feGaussianBlur1668" />
<feOffset result="offset" dy="1" dx="0" id="feOffset1670" />
<feComposite result="composite2" operator="over" in="fbSourceGraphic" id="feComposite1672"
in2="offset" />
</filter>
</defs>
<circle
r="36"
cy="54"
cx="54"
id="circle"
style="fill:#262c38;filter:url(#circle-shadow)" />
<g
style="filter:url(#paper-shadow)"
id="paper-group">
<path
d="m 67.75836,28.470764 -23.382292,23.115602 -0.0887,1.254642 29.189963,2.017946 z"
style="fill:#1976d2"
id="paper-4" />
<path
style="fill:#47a2fc;filter:url(#paper-inner-shadow)"
d="m 27.949219,41.884766 9.08789,24.064453 25.894532,12.974609 12.376953,-23.86914 -22.298828,-7.638672 v -0.002 z"
clip-path="url(#paper-clip)"
id="paper-3" />
<path
d="m 53.009473,47.414648 -15.970174,18.53113 25.894116,12.97696 z"
style="fill:#1976d2;filter:url(#paper-edge-1)"
<circle r="36" cy="54" cx="54" id="circle" style="fill:#262c38;filter:url(#circle-shadow)" />
<g style="filter:url(#paper-shadow)" id="paper-group">
<path d="m 67.75836,28.470764 -23.382292,23.115602 -0.0887,1.254642 29.189963,2.017946 z"
style="fill:#1976d2" id="paper-4" />
<path style="fill:#47a2fc;filter:url(#paper-inner-shadow)" d="m 27.949219,41.884766 9.08789,24.064453 25.894532,12.974609 12.376953,-23.86914 -22.298828,-7.638672 v -0.002 z"
clip-path="url(#paper-clip)" id="paper-3" />
<path d="m 53.009473,47.414648 -15.970174,18.53113 25.894116,12.97696 z" style="fill:#1976d2;filter:url(#paper-edge-1)"
id="paper-2" />
<path
style="fill:#47a2fc;filter:url(#paper-edge-2)"
d="m 53.009766,47.414016 9.923828,31.503906 12.375,-23.865234 z"
<path style="fill:#47a2fc;filter:url(#paper-edge-2)" d="m 53.009766,47.414016 9.923828,31.503906 12.375,-23.865234 z"
id="paper-1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

+1 -1
View File
@@ -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
-1
View File
@@ -1,4 +1,3 @@
-dontobfuscate
# Disable ServiceLoader reproducibility-breaking optimizations
-keep class kotlinx.coroutines.CoroutineExceptionHandler
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "michas-droid"
include ':'
+21 -11
View File
@@ -1,26 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="nya.kitsunyan.foxydroid">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".MainApplication"
android:label="@string/application_name"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:allowBackup="false"
android:theme="@style/Theme.Main.Light"
tools:ignore="GoogleAppIndexingWarning">
android:theme="@style/Theme.Main.Light">
<receiver
android:name=".MainApplication$BootReceiver">
android:name=".MainApplication$BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
@@ -30,6 +37,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
@@ -52,7 +60,7 @@
<data android:scheme="market" android:host="details" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -69,7 +77,8 @@
</activity>
<service
android:name=".service.SyncService" />
android:name=".service.SyncService"
android:foregroundServiceType="dataSync" />
<service
android:name=".service.SyncService$Job"
@@ -77,7 +86,8 @@
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".service.DownloadService" />
android:name=".service.DownloadService"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".service.DownloadService$Receiver" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -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,21 +95,20 @@ 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)
}
} else if (it == Preferences.Key.UpdateUnstable) {
val updateUnstable = Preferences[Preferences.Key.UpdateUnstable]
if (lastUpdateUnstable != updateUnstable) {
lastUpdateUnstable = updateUnstable
forceSyncAll()
}
}
@@ -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,7 +106,7 @@ object Cache {
try {
val stat = Os.lstat(it.path)
stat.st_atime < olderThan
} catch (e: Exception) {
} catch (_: Exception) {
false
}
}
@@ -138,7 +138,7 @@ object Cache {
override fun onCreate(): Boolean = true
override fun query(uri: Uri, projection: Array<String>?,
selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
selection: String?, selectionArgs: Array<out String>?, 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<out String>?): Int = unsupported
override fun update(uri: Uri, contentValues: ContentValues?,
selection: String?, selectionArgs: Array<out String>?): Int = unsupported
@@ -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<Key<*>>
@@ -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) }
}
}
}
@@ -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<Pair<String, Long?>>()
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))
@@ -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<Cursor> {
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<Int, ActiveRequest>()
override fun onCleared() {
activeRequests.values.forEach { it.cursor?.close() }
activeRequests.clear()
}
}
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
private val viewModel by lazy { ViewModelProvider(this)[CursorViewModel::class.java] }
private val activeRequests: MutableMap<Int, ActiveRequest>
get() = viewModel.activeRequests
fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id]
@@ -79,11 +88,32 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
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)
}
}
@@ -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<String>? = null,
private fun SQLiteDatabase.query(
table: String, columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null, orderBy: String? = null,
signal: CancellationSignal? = null): Cursor {
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,
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
arrayOf(id.toString())))
arrayOf(id.toString()))
)
.use { it.firstOrNull()?.let(::transform) }
}
fun getAll(signal: CancellationSignal?): List<Repository> {
return db.query(Schema.Repository.name,
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal).use { it.asSequence().map(::transform).toList() }
signal = signal
).use { it.asSequence().map(::transform).toList() }
}
fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<Long, Boolean>> {
return db.query(Schema.Repository.name,
return db.query(
Schema.Repository.name,
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
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() }
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,7 +367,7 @@ object Database {
}
fun cleanup(pairs: Set<Pair<Long, Boolean>>) {
val result = pairs.windowed(10, 10, true).map {
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)
@@ -369,28 +386,34 @@ object Database {
}
fun query(signal: CancellationSignal?): Cursor {
return db.query(Schema.Repository.name,
return db.query(
Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal).observable(Subject.Repositories)
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<Product> {
return db.query(Schema.Product.name,
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() }
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,
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) }
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<InstalledItem>) {
db.beginTransaction()
db.transaction {
try {
db.delete(Schema.Installed.name, null, null)
delete(Schema.Installed.name, null, null)
installedItems.forEach { put(it, false) }
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
@@ -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<String, Long>) = put(lock, true)
fun putAll(locks: List<Pair<String, Long>>) {
db.beginTransaction()
db.transaction {
try {
db.delete(Schema.Lock.name, null, null)
delete(Schema.Lock.name, null, null)
locks.forEach { put(it, false) }
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
@@ -596,13 +620,13 @@ object Database {
}
fun putTemporary(products: List<Product>) {
db.beginTransaction()
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 "" }
db.insertOrReplace(true, Schema.Product.temporaryName, ContentValues().apply {
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)
@@ -617,39 +641,39 @@ object Database {
put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize))
})
for (category in product.categories) {
db.insertOrReplace(true, Schema.Category.temporaryName, ContentValues().apply {
insertOrReplace(true, Schema.Category.temporaryName, ContentValues().apply {
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Category.ROW_NAME, category)
})
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}
fun finishTemporary(repository: Repository, success: Boolean) {
if (success) {
db.beginTransaction()
db.transaction {
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}")
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)
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
db.setTransactionSuccessful()
execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
} finally {
db.endTransaction()
}
if (success) {
}
notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products)
}
} else {
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
@@ -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<ContentObserver>()
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
}
}
@@ -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)
@@ -156,7 +156,7 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
var donates = emptyList<Donate>()
var screenshots = emptyList<Screenshot>()
var releases = emptyList<Release>()
parser.forEachKey {
parser.forEachKey { it ->
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = 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()
@@ -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,7 +102,7 @@ data class Release(val selected: Boolean, val version: String, val versionCode:
var features = emptyList<String>()
var platforms = emptyList<String>()
var incompatibilities = emptyList<Incompatibility>()
parser.forEachKey {
parser.forEachKey { it ->
when {
it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString
@@ -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<String>,
data class Repository(
val id: Long, val address: String, val mirrors: List<String>,
val name: String, val description: String, val version: Int, val enabled: Boolean,
val 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)
}
val updated: Long, val timestamp: Long, val authentication: String
) {
fun update(mirrors: List<String>, name: String, description: String, version: Int,
lastModified: String, entityTag: String, timestamp: Long): Repository {
return copy(mirrors = mirrors, name = name, description = description,
@@ -79,15 +72,6 @@ data class Repository(val id: Long, val address: String, val mirrors: List<Strin
lastModified, entityTag, updated, timestamp, authentication)
}
fun newRepository(address: String, fingerprint: String, authentication: String): Repository {
val name = try {
URL(address).let { "${it.host}${it.path}" }
} catch (e: Exception) {
address
}
return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
}
private fun defaultRepository(address: String, name: String, description: String,
version: Int, enabled: Boolean, fingerprint: String, authentication: String): Repository {
return Repository(-1, address, emptyList(), name, description, version, enabled,
@@ -95,9 +79,14 @@ data class Repository(val id: Long, val address: String, val mirrors: List<Strin
}
val defaultRepositories = listOf(run {
defaultRepository("https://repo.dgplayser.duckdns.org/fdroid/repo", "Michachatz F-Droid Repo", "Michachatz official repository. " +
"Everything in this repository is always built from the source code.",
21, true, "3546DCBDD900F280EE2161CC163C1156BE2C2F3EB810415115039E0C7D3242C0", "")
},
run {
defaultRepository("https://f-droid.org/repo", "F-Droid", "The official F-Droid Free Software repository. " +
"Everything in this repository is always built from the source code.",
21, true, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
21, false, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
}, run {
defaultRepository("https://f-droid.org/archive", "F-Droid Archive", "The archive of the official F-Droid Free " +
"Software repository. Apps here are old and can contain known vulnerabilities and security issues!",
@@ -1,9 +1,12 @@
package nya.kitsunyan.foxydroid.graphics
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.DrawableCompat
open class DrawableWrapper(val drawable: Drawable): Drawable() {
init {
@@ -36,7 +39,7 @@ open class DrawableWrapper(val drawable: Drawable): Drawable() {
}
override fun getAlpha(): Int {
return drawable.alpha
return DrawableCompat.getAlpha(drawable)
}
override fun setAlpha(alpha: Int) {
@@ -44,13 +47,32 @@ open class DrawableWrapper(val drawable: Drawable): Drawable() {
}
override fun getColorFilter(): ColorFilter? {
return drawable.colorFilter
return DrawableCompat.getColorFilter(drawable)
}
override fun setColorFilter(colorFilter: ColorFilter?) {
drawable.colorFilter = colorFilter
}
@Suppress("DEPRECATION")
override fun setTint(tintColor: Int) {
DrawableCompat.setTint(drawable, tintColor)
}
override fun setTintList(tint: ColorStateList?) {
DrawableCompat.setTintList(drawable, tint)
}
override fun setTintMode(tintMode: PorterDuff.Mode?) {
DrawableCompat.setTintMode(drawable, tintMode ?: PorterDuff.Mode.SRC_IN)
}
override fun setHotspot(x: Float, y: Float) {
DrawableCompat.setHotspot(drawable, x, y)
}
override fun setHotspotBounds(left: Int, top: Int, right: Int, bottom: Int) {
DrawableCompat.setHotspotBounds(drawable, left, top, right, bottom)
}
override fun getOpacity(): Int = drawable.opacity
}
@@ -17,7 +17,7 @@ class IndexHandler(private val repositoryId: Long, private val callback: Callbac
private fun String.parseDate(): Long {
return try {
dateFormat.parse(this)?.time ?: 0L
} catch (e: Exception) {
} catch (_: Exception) {
0L
}
}
@@ -63,13 +63,15 @@ class IndexMerger(file: File): Closeable {
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 {
.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 { Json.factory.createParser(it).use {
val releases = it.getBlob(2)?.let { it ->
Json.factory.createParser(it).use {
it.nextToken()
it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
} }.orEmpty()
@@ -37,7 +37,7 @@ object IndexV1Parser {
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal()
} else {
jsonParser.forEachKey {
jsonParser.forEachKey { it ->
when {
it.dictionary("repo") -> {
var address = ""
@@ -100,7 +100,7 @@ object IndexV1Parser {
val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>()
forEachKey {
forEachKey { it ->
when {
it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString
@@ -125,7 +125,7 @@ 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 {
it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) {
val locale = it.key
var name = ""
@@ -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<Long>()
Observable.just(Unit)
fun init(): Disposable {
val lastDisabled = setOf<Long>()
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<Boolean> {
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<IndexType>, unstable: Boolean,
private fun update(context: Context, repository: Repository, indexTypes: List<IndexType>, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
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<Pair<Downloader.Result, File>> {
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,7 +189,7 @@ object RepositoryUpdater {
val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
IndexMerger(mergerFile).use { indexMerger ->
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }.use {
ProgressInputStream(jarFile.getInputStream(indexEntry)) { callback(Stage.PROCESS, it, total) }.use { it ->
IndexV1Parser.parse(repository.id, it, object: IndexV1Parser.Callback {
override fun onRepository(mirrors: List<String>, name: String, description: String,
version: Int, timestamp: Long) {
@@ -315,7 +313,7 @@ object RepositoryUpdater {
}
private fun transformProduct(product: Product, features: Set<String>, unstable: Boolean): Product {
val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map {
val releasePairs = product.releases.distinctBy { it.identifier }.sortedByDescending { it.versionCode }.map { it ->
val incompatibilities = mutableListOf<Release.Incompatibility>()
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
incompatibilities += Release.Incompatibility.MinSdk
@@ -85,13 +85,13 @@ object Downloader {
.callSingle { createCall(request, authentication, null) }
.subscribeOn(Schedulers.io())
.flatMap { result -> RxUtils
.managedSingle { result.use {
.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()) {
@@ -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)
}
}
@@ -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)
}
}
}
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 {
saveRepository(address, fingerprint, authentication)
}
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<String>
get() = requireArguments().getStringArrayList("mirrors")!!
constructor(mirrors: List<String>): 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()
}
}
@@ -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<Message>(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") }
@@ -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<Preferences.Key<*>, 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 <T> LinearLayout.addPreference(key: Preferences.Key<T>, title: String,
summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?): Preference<T> {
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 <T> LinearLayout.addEdit(key: Preferences.Key<T>, 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<Int>, 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 <T: Preferences.Enumeration<T>> LinearLayout
.addEnumeration(key: Preferences.Key<T>, 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<T>(private val key: Preferences.Key<T>,
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<TextView>(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())
}
}
@@ -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<ProductAdapter.ViewType, RecyclerView.ViewHolder>() {
@@ -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<Item>, 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>): Item() {
data class ExpandItem(val expandType: ExpandType, val replace: Boolean, val items: List<Item>): 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<PermissionInfo>): Item() {
data class PermissionsItem(val group: PermissionGroupInfo?, val permissions: List<PermissionInfo>): 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<T: Any> {
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<Item>, private val newList: List<Item>): 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<Pair<Product, Repository>>, installedItem: InstalledItem?) {
val productRepository = Product.findSuggested(products, installedItem) { it.first }
items.clear()
val newItems = mutableListOf<Item>()
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,7 +1042,6 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
}
}::class
}
}
Unit
}
ViewType.SWITCH -> {
@@ -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 {
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
@@ -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<Action>(), null as Action?)
private var products = emptyList<Pair<Product, Repository>>()
@@ -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<ProductAdapter.SavedState>(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
.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 {
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
}
}
@@ -339,8 +342,6 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
compatibleReleases
.filter { it.platforms.contains(Android.primaryPlatform) }
.minBy { it.platforms.size }
?: compatibleReleases.minBy { it.platforms.size }
?: compatibleReleases.firstOrNull()
} 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)
@@ -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<Long, Repository> = emptyMap()
set(value) {
if (field != value) {
field = value
notifyDataSetChanged()
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)
}
}
}
@@ -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 }
}
@@ -36,10 +36,17 @@ class RepositoriesAdapter(private val onClick: (Repository) -> Unit,
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return ViewHolder(parent.inflate(R.layout.repository_item)).apply {
itemView.setOnClickListener { onClick(getRepository(adapterPosition)) }
itemView.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onClick(getRepository(position))
}
}
enabled.setOnCheckedChangeListener { _, isChecked ->
if (listenSwitch) {
if (!onSwitch(getRepository(adapterPosition), isChecked)) {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
if (!onSwitch(getRepository(position), isChecked)) {
listenSwitch = false
enabled.isChecked = !isChecked
listenSwitch = true
@@ -48,6 +55,7 @@ class RepositoriesAdapter(private val onClick: (Repository) -> Unit,
}
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as ViewHolder
@@ -113,14 +113,12 @@ 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) {
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)
} else {
getString(R.string.unknown)
}
})
if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) {
@@ -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()
}}
}
@@ -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<FragmentStackItem>(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))
@@ -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)
?: requireArguments().getString(EXTRA_IDENTIFIER)
if (identifier != null) {
val index = screenshots.indexOfFirst { it.identifier == identifier }
if (index >= 0) {
@@ -160,7 +162,9 @@ class ScreenshotsFragment(): DialogFragment() {
private class Adapter(private val packageName: String, private val onClick: () -> Unit):
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT }
enum class ViewType {
SECTION
}
private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) {
val image: ImageView
@@ -182,17 +186,28 @@ class ScreenshotsFragment(): DialogFragment() {
private var repository: Repository? = null
private var screenshots = emptyList<Product.Screenshot>()
private class ScreenshotDiffCallback(private val oldList: List<Product.Screenshot>,
private val newList: List<Product.Screenshot>): 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<Product.Screenshot>) {
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 {
@@ -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<ProductItem.Section>(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,13 +283,12 @@ 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) {
sectionsList.layoutParams.height = if (showSections) contentHeight else 0
sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE
@@ -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])
}
}
}
}
@@ -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<DownloadService.Binder>() {
companion object {
@@ -47,14 +48,14 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
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<DownloadService.Binder>() {
.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<DownloadService.Binder>() {
.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<DownloadService.Binder>() {
val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType)
file.inputStream().use {
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<DownloadService.Binder>() {
.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<DownloadService.Binder>() {
throw IllegalStateException()
}
}::class
}.build())
}.build(), Android.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
stateSubject.onNext(state)
}
}
@@ -361,7 +367,7 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
currentTask = CurrentTask(task, disposable, initialState)
} else if (started) {
started = false
stopForeground(true)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
@@ -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,10 +124,6 @@ class SyncService: ConnectionService<SyncService.Binder>() {
return true
}
fun isCurrentlySyncing(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run {
@@ -220,7 +217,8 @@ class SyncService: ConnectionService<SyncService.Binder>() {
.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<SyncService.Binder>() {
setProgress(0, 0, true)
}
}::class
}.build())
}.build(), Android.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
}
}
@@ -294,7 +292,7 @@ class SyncService: ConnectionService<SyncService.Binder>() {
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<SyncService.Binder>() {
} 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<SyncService.Binder>() {
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<SyncService.Binder>() {
.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)
@@ -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 }
@@ -21,7 +21,7 @@ object RxUtils {
}
}
private fun <T, R> managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single<R> {
private fun <T, R : Any> managedSingle(create: () -> T, cancel: (T) -> Unit, execute: (T) -> R): Single<R> {
return Single.create {
val task = create()
val thread = Thread.currentThread()
@@ -52,27 +52,27 @@ object RxUtils {
}
}
fun <R> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ Unit }, { }, { execute() })
fun <R : Any> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ }, { }, { execute() })
}
fun callSingle(create: () -> Call): Single<Response> {
return managedSingle(create, Call::cancel, Call::execute)
}
fun <T> querySingle(query: (CancellationSignal) -> T): Single<T> {
fun <T : Any> querySingle(query: (CancellationSignal?) -> T): Single<T> {
return Single.create {
val cancellationSignal = CancellationSignal()
it.setCancellable {
try {
cancellationSignal.cancel()
} catch (e: OperationCanceledException) {
} catch (_: OperationCanceledException) {
// Do nothing
}
}
val result = try {
query(cancellationSignal)
} catch (e: OperationCanceledException) {
} catch (_: OperationCanceledException) {
null
}
if (result != null) {
@@ -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)
}
@@ -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
}
}
@@ -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) {
@@ -31,7 +31,7 @@ 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 {
resources.getXml(resId).use { it ->
val eventType = generateSequence { it.next() }
.find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT }
if (eventType == XmlPullParser.START_TAG) {
@@ -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 {
@@ -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<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() {
@@ -13,13 +15,58 @@ abstract class CursorRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>:
var cursor: Cursor? = null
set(value) {
if (field != value) {
field?.close()
val oldCursor = field
field = value
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
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()
}
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary_light" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#00E4FF</color>
</resources>
+1
View File
@@ -163,5 +163,6 @@
<string name="versions">Versions</string>
<string name="waiting_to_start_download">Waiting to start download</string>
<string name="website">Website</string>
<string name="address_redirect_FORMAT">The repository address was redirected to %s. Do you want to use it instead?</string>
</resources>