-- 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 !/.gitignore
!/build.gradle !/build.gradle
!/settings.gradle
!/COPYING !/COPYING
!/extra !/extra
!/gradle !/gradle
+3 -4
View File
@@ -1,9 +1,8 @@
# Foxy Droid # Michas Droid
Yet another F-Droid client. Yet another F-Droid client.
[![Release](https://img.shields.io/github/v/release/kitsunyan/foxy-droid)](https://github.com/kitsunyan/foxy-droid/releases) [![Release](https://img.shields.io/github/v/release/michatec/michas-droid)](https://github.com/michatec/michas-droid/releases/latest)
[![F-Droid](https://img.shields.io/f-droid/v/nya.kitsunyan.foxydroid)](https://f-droid.org/packages/nya.kitsunyan.foxydroid/)
## Description ## Description
@@ -48,4 +47,4 @@ Run `./gradlew assembleRelease` to build the package, which can be installed usi
## License ## 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 { buildscript {
ext.versions = [
android: '3.4.1',
kotlin: '1.3.72'
]
repositories { repositories {
google() google()
jcenter() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:' + versions.android classpath 'com.android.tools.build:gradle:9.0.1'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10'
} }
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android { android {
compileSdkVersion 29 namespace 'nya.kitsunyan.foxydroid'
buildToolsVersion '29.0.3' compileSdk 36
defaultConfig { defaultConfig {
archivesBaseName = 'foxy-droid'
applicationId 'nya.kitsunyan.foxydroid' applicationId 'nya.kitsunyan.foxydroid'
minSdkVersion 21 minSdk 30
targetSdkVersion 29 targetSdk 36
versionCode 4 versionCode 15
versionName '1.3' versionName '1.5'
def languages = [ 'en' ] def languages = [ 'en' ]
buildConfigField 'String[]', 'LANGUAGES', '{ "' + languages.join('", "') + '" }' buildConfigField 'String[]', 'LANGUAGES', '{ "' + languages.join('", "') + '" }'
resConfigs languages resConfigs languages
} }
sourceSets.all { buildFeatures {
def javaDir = it.java.srcDirs.find { it.name == 'java' } buildConfig = true
it.java.srcDirs += new File(javaDir.parentFile, 'kotlin') }
sourceSets {
main {
java.srcDirs += 'src/main/kotlin'
}
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = compileOptions.sourceCompatibility.toString()
} }
buildTypes { buildTypes {
@@ -58,25 +52,6 @@ android {
minifyEnabled true minifyEnabled true
shrinkResources false 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') def keystorePropertiesFile = rootProject.file('keystore.properties')
@@ -98,31 +73,26 @@ android {
storePassword signing.storePassword storePassword signing.storePassword
keyAlias signing.keyAlias keyAlias signing.keyAlias
keyPassword signing.keyPassword keyPassword signing.keyPassword
v2SigningEnabled false enableV2Signing false
} }
} }
buildTypes {
debug.signingConfig signingConfigs.primary
release.signingConfig signingConfigs.primary
}
} }
} }
} }
repositories { repositories {
google() google()
jcenter() mavenCentral()
} }
dependencies { dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.3.10'
implementation 'androidx.fragment:fragment:1.2.5' implementation 'androidx.fragment:fragment-ktx:1.8.9'
implementation 'androidx.viewpager2:viewpager2:1.0.0' implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.vectordrawable:vectordrawable:1.1.0' implementation 'androidx.vectordrawable:vectordrawable:1.2.0'
implementation 'com.squareup.okhttp3:okhttp:4.7.2' implementation 'com.squareup.okhttp3:okhttp:5.3.2'
implementation 'io.reactivex.rxjava3:rxjava:3.0.4' implementation 'io.reactivex.rxjava3:rxjava:3.1.12'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'com.fasterxml.jackson.core:jackson-core:2.11.0' implementation 'com.fasterxml.jackson.core:jackson-core:2.21.1'
implementation 'com.squareup.picasso:picasso:2.71828' implementation 'com.squareup.picasso:picasso:2.71828'
} }
+85 -271
View File
@@ -1,273 +1,87 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg <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:dc="http://purl.org/dc/elements/1.1/" xmlns="http://www.w3.org/2000/svg" style="enable-background:new" id="svg3" version="1.1" viewBox="0 0 108 108"
xmlns:cc="http://creativecommons.org/ns#" height="108" width="108" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xsi:schemaLocation="http://purl.org/dc/elements/1.1/">
xmlns:svg="http://www.w3.org/2000/svg" <metadata id="metadata55">
xmlns="http://www.w3.org/2000/svg" <rdf:RDF>
style="enable-background:new" <cc:Work rdf:about="">
id="svg3" <dc:format>image/svg+xml</dc:format>
version="1.1" <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
viewBox="0 0 108 108" <dc:title>-</dc:title>
height="108" </cc:Work>
width="108"> </rdf:RDF>
<metadata </metadata>
id="metadata55"> <defs id="defs17">
<rdf:RDF> <clipPath id="paper-clip" clipPathUnits="userSpaceOnUse">
<cc:Work <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" />
rdf:about=""> </clipPath>
<dc:format>image/svg+xml</dc:format> <filter id="paper-inner-shadow">
<dc:type <feFlood id="feFlood6" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2" />
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> <feComposite id="feComposite8" result="composite1" operator="in" in2="SourceGraphic" in="flood" />
<dc:title></dc:title> <feGaussianBlur id="feGaussianBlur10" result="blur" stdDeviation="2" in="composite1" />
</cc:Work> <feOffset id="feOffset12" result="offset" dy="0" dx="0" />
</rdf:RDF> <feComposite id="feComposite14" result="composite2" operator="over" in2="offset" in="SourceGraphic" />
</metadata> </filter>
<defs <filter id="paper-edge-1" style="color-interpolation-filters:sRGB">
id="defs17"> <feFlood id="feFlood958" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2" />
<clipPath <feComposite id="feComposite960" result="composite1" operator="out" in2="SourceGraphic"
id="paper-clip" in="flood" />
clipPathUnits="userSpaceOnUse"> <feGaussianBlur id="feGaussianBlur962" result="blur" stdDeviation="0" in="composite1" />
<path <feOffset id="feOffset964" result="offset" dy="0.2" dx="0.2" />
id="paper-clip-path" <feComposite id="feComposite966" result="composite2" operator="atop" in2="SourceGraphic"
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" /> in="offset" />
</clipPath> </filter>
<filter <filter id="paper-edge-2" style="color-interpolation-filters:sRGB">
id="paper-inner-shadow"> <feFlood id="feFlood1388" result="flood" flood-color="rgb(255,255,255)" flood-opacity="0.2" />
<feFlood <feComposite id="feComposite1390" result="composite1" operator="out" in2="SourceGraphic"
id="feFlood6" in="flood" />
result="flood" <feGaussianBlur id="feGaussianBlur1392" result="blur" stdDeviation="0" in="composite1" />
flood-color="rgb(0,0,0)" <feOffset id="feOffset1394" result="offset" dy="-0.1" dx="0.3" />
flood-opacity="0.2" /> <feComposite id="feComposite1396" result="composite2" operator="atop" in2="SourceGraphic"
<feComposite in="offset" />
id="feComposite8" </filter>
result="composite1" <filter id="paper-shadow" style="color-interpolation-filters:sRGB">
operator="in" <feFlood id="feFlood1506" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.4" />
in2="SourceGraphic" <feComposite id="feComposite1508" result="composite1" operator="in" in2="SourceGraphic"
in="flood" /> in="flood" />
<feGaussianBlur <feGaussianBlur id="feGaussianBlur1510" result="blur" stdDeviation="2" in="composite1" />
id="feGaussianBlur10" <feOffset id="feOffset1512" result="offset" dy="0" dx="0" />
result="blur" <feComposite id="feComposite1514" result="composite2" operator="over" in2="offset" in="SourceGraphic" />
stdDeviation="2" </filter>
in="composite1" /> <filter id="circle-shadow" style="color-interpolation-filters:sRGB">
<feOffset <feFlood id="feFlood1626" result="flood" flood-color="rgb(255,255,255)" flood-opacity="0.2" />
id="feOffset12" <feComposite id="feComposite1628" result="composite1" operator="out" in2="SourceGraphic"
result="offset" in="flood" />
dy="0" <feGaussianBlur id="feGaussianBlur1630" result="blur" stdDeviation="0" in="composite1" />
dx="0" /> <feOffset id="feOffset1632" result="offset" dy="0.5" dx="0" />
<feComposite <feComposite id="feComposite1634" result="fbSourceGraphic" operator="atop" in2="SourceGraphic"
id="feComposite14" in="offset" />
result="composite2" <feFlood in="fbSourceGraphic" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2"
operator="over" id="feFlood1640" />
in2="offset" <feComposite result="composite1" operator="out" in="flood" id="feComposite1642" in2="fbSourceGraphic" />
in="SourceGraphic" /> <feGaussianBlur result="blur" stdDeviation="0" in="composite1" id="feGaussianBlur1644" />
</filter> <feOffset result="offset" dy="-0.5" dx="0" id="feOffset1646" />
<filter <feComposite result="fbSourceGraphic" operator="atop" in="offset" id="feComposite1648"
id="paper-edge-1" in2="fbSourceGraphic" />
style="color-interpolation-filters:sRGB"> <feFlood in="fbSourceGraphic" result="flood" flood-color="rgb(0,0,0)" flood-opacity="0.2"
<feFlood id="feFlood1664" />
id="feFlood958" <feComposite result="composite1" operator="in" in="flood" id="feComposite1666" in2="fbSourceGraphic" />
result="flood" <feGaussianBlur result="blur" stdDeviation="1" in="composite1" id="feGaussianBlur1668" />
flood-color="rgb(0,0,0)" <feOffset result="offset" dy="1" dx="0" id="feOffset1670" />
flood-opacity="0.2" /> <feComposite result="composite2" operator="over" in="fbSourceGraphic" id="feComposite1672"
<feComposite in2="offset" />
id="feComposite960" </filter>
result="composite1" </defs>
operator="out" <circle r="36" cy="54" cx="54" id="circle" style="fill:#262c38;filter:url(#circle-shadow)" />
in2="SourceGraphic" <g style="filter:url(#paper-shadow)" id="paper-group">
in="flood" /> <path d="m 67.75836,28.470764 -23.382292,23.115602 -0.0887,1.254642 29.189963,2.017946 z"
<feGaussianBlur style="fill:#1976d2" id="paper-4" />
id="feGaussianBlur962" <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"
result="blur" clip-path="url(#paper-clip)" id="paper-3" />
stdDeviation="0" <path d="m 53.009473,47.414648 -15.970174,18.53113 25.894116,12.97696 z" style="fill:#1976d2;filter:url(#paper-edge-1)"
in="composite1" /> id="paper-2" />
<feOffset <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="feOffset964" id="paper-1" />
result="offset" </g>
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"
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"
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"
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" />
</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"
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"
in="offset" />
<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"
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"
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"
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)"
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"
id="paper-1" />
</g>
</svg> </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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
-1
View File
@@ -1,4 +1,3 @@
-dontobfuscate
# Disable ServiceLoader reproducibility-breaking optimizations # Disable ServiceLoader reproducibility-breaking optimizations
-keep class kotlinx.coroutines.CoroutineExceptionHandler -keep class kotlinx.coroutines.CoroutineExceptionHandler
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "michas-droid"
include ':'
+23 -13
View File
@@ -1,26 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="nya.kitsunyan.foxydroid">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <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_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_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 <application
android:name=".MainApplication" android:name=".MainApplication"
android:label="@string/application_name" android:label="@string/application_name"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:allowBackup="false" android:theme="@style/Theme.Main.Light">
android:theme="@style/Theme.Main.Light"
tools:ignore="GoogleAppIndexingWarning">
<receiver <receiver
android:name=".MainApplication$BootReceiver"> android:name=".MainApplication$BootReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
@@ -30,7 +37,8 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTask" android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
@@ -52,7 +60,7 @@
<data android:scheme="market" android:host="details" /> <data android:scheme="market" android:host="details" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
@@ -69,7 +77,8 @@
</activity> </activity>
<service <service
android:name=".service.SyncService" /> android:name=".service.SyncService"
android:foregroundServiceType="dataSync" />
<service <service
android:name=".service.SyncService$Job" android:name=".service.SyncService$Job"
@@ -77,7 +86,8 @@
android:permission="android.permission.BIND_JOB_SERVICE" /> android:permission="android.permission.BIND_JOB_SERVICE" />
<service <service
android:name=".service.DownloadService" /> android:name=".service.DownloadService"
android:foregroundServiceType="dataSync" />
<receiver <receiver
android:name=".service.DownloadService$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 android.content.pm.PackageInfo
import com.squareup.picasso.OkHttp3Downloader import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import io.reactivex.rxjava3.disposables.Disposable
import nya.kitsunyan.foxydroid.content.Cache import nya.kitsunyan.foxydroid.content.Cache
import nya.kitsunyan.foxydroid.content.Preferences import nya.kitsunyan.foxydroid.content.Preferences
import nya.kitsunyan.foxydroid.content.ProductPreferences import nya.kitsunyan.foxydroid.content.ProductPreferences
@@ -27,7 +28,6 @@ import nya.kitsunyan.foxydroid.utility.extension.android.*
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
@Suppress("unused")
class MainApplication: Application() { class MainApplication: Application() {
private fun PackageInfo.toInstalledItem(): InstalledItem { private fun PackageInfo.toInstalledItem(): InstalledItem {
val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty() val signatureString = singleSignature?.let(Utils::calculateHash).orEmpty()
@@ -38,13 +38,15 @@ class MainApplication: Application() {
super.attachBaseContext(Utils.configureLocale(base)) super.attachBaseContext(Utils.configureLocale(base))
} }
private var preferencesDisposable: Disposable? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val databaseUpdated = Database.init(this) val databaseUpdated = Database.init(this)
Preferences.init(this) Preferences.init(this)
ProductPreferences.init(this) ProductPreferences.init(this)
RepositoryUpdater.init(this) RepositoryUpdater.init()
listenApplications() listenApplications()
listenPreferences() listenPreferences()
@@ -69,7 +71,7 @@ class MainApplication: Application() {
Intent.ACTION_PACKAGE_REMOVED -> { Intent.ACTION_PACKAGE_REMOVED -> {
val packageInfo = try { val packageInfo = try {
packageManager.getPackageInfo(packageName, Android.PackageManager.signaturesFlag) packageManager.getPackageInfo(packageName, Android.PackageManager.signaturesFlag)
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
if (packageInfo != null) { if (packageInfo != null) {
@@ -93,22 +95,21 @@ class MainApplication: Application() {
private fun listenPreferences() { private fun listenPreferences() {
updateProxy() updateProxy()
var lastAutoSync = Preferences[Preferences.Key.AutoSync] val lastAutoSync = Preferences[Preferences.Key.AutoSync]
var lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable] val lastUpdateUnstable = Preferences[Preferences.Key.UpdateUnstable]
Preferences.observable.subscribe { preferencesDisposable?.dispose()
preferencesDisposable = Preferences.observable.subscribe {
if (it == Preferences.Key.ProxyType || it == Preferences.Key.ProxyHost || it == Preferences.Key.ProxyPort) { if (it == Preferences.Key.ProxyType || it == Preferences.Key.ProxyHost || it == Preferences.Key.ProxyPort) {
updateProxy() updateProxy()
} else if (it == Preferences.Key.AutoSync) { } else if (it == Preferences.Key.AutoSync) {
val autoSync = Preferences[Preferences.Key.AutoSync] val autoSync = Preferences[Preferences.Key.AutoSync]
if (lastAutoSync != autoSync) { if (lastAutoSync != autoSync) {
lastAutoSync = autoSync updateSyncJob(true)
updateSyncJob(true)
} }
} else if (it == Preferences.Key.UpdateUnstable) { } else if (it == Preferences.Key.UpdateUnstable) {
val updateUnstable = Preferences[Preferences.Key.UpdateUnstable] val updateUnstable = Preferences[Preferences.Key.UpdateUnstable]
if (lastUpdateUnstable != updateUnstable) { if (lastUpdateUnstable != updateUnstable) {
lastUpdateUnstable = updateUnstable forceSyncAll()
forceSyncAll()
} }
} }
} }
@@ -61,7 +61,7 @@ object Cache {
fun getReleaseUri(context: Context, cacheFileName: String): Uri { fun getReleaseUri(context: Context, cacheFileName: String): Uri {
val file = getReleaseFile(context, cacheFileName) val file = getReleaseFile(context, cacheFileName)
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PROVIDERS) 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) return Uri.Builder().scheme("content").authority(authority)
.encodedPath(subPath(context.cacheDir, file)).build() .encodedPath(subPath(context.cacheDir, file)).build()
} }
@@ -106,8 +106,8 @@ object Cache {
try { try {
val stat = Os.lstat(it.path) val stat = Os.lstat(it.path)
stat.st_atime < olderThan stat.st_atime < olderThan
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} }
if (older) { if (older) {
@@ -138,7 +138,7 @@ object Cache {
override fun onCreate(): Boolean = true override fun onCreate(): Boolean = true
override fun query(uri: Uri, projection: Array<String>?, 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 file = getFileAndTypeForUri(uri).first
val columns = (projection ?: defaultColumns).mapNotNull { val columns = (projection ?: defaultColumns).mapNotNull {
when (it) { when (it) {
@@ -150,12 +150,12 @@ object Cache {
return MatrixCursor(columns.first.toTypedArray()).apply { addRow(columns.second.toTypedArray()) } 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 private val unsupported: Nothing
get() = throw UnsupportedOperationException() 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 delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = unsupported
override fun update(uri: Uri, contentValues: ContentValues?, override fun update(uri: Uri, contentValues: ContentValues?,
selection: String?, selectionArgs: Array<out String>?): Int = unsupported 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.entity.ProductItem
import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.android.*
import java.net.Proxy import java.net.Proxy
import androidx.core.content.edit
object Preferences { object Preferences {
private lateinit var preferences: SharedPreferences 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, 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() 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) { fun init(context: Context) {
preferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) preferences = context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
preferences.registerOnSharedPreferenceChangeListener { _, keyString -> keys[keyString]?.let(subject::onNext) } preferences.registerOnSharedPreferenceChangeListener(listener)
} }
val observable: Observable<Key<*>> val observable: Observable<Key<*>>
@@ -38,7 +43,7 @@ object Preferences {
} }
override fun set(preferences: SharedPreferences, key: String, value: Boolean) { 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) { 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) { 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) { 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
import nya.kitsunyan.foxydroid.database.Database import nya.kitsunyan.foxydroid.database.Database
@@ -9,17 +10,20 @@ import nya.kitsunyan.foxydroid.entity.ProductPreference
import nya.kitsunyan.foxydroid.utility.extension.json.* import nya.kitsunyan.foxydroid.utility.extension.json.*
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.nio.charset.Charset import java.nio.charset.Charset
import androidx.core.content.edit
object ProductPreferences { object ProductPreferences {
private val defaultProductPreference = ProductPreference(false, 0L) private val defaultProductPreference = ProductPreference(false, 0L)
private lateinit var preferences: SharedPreferences private lateinit var preferences: SharedPreferences
private val subject = PublishSubject.create<Pair<String, Long?>>() private val subject = PublishSubject.create<Pair<String, Long?>>()
private var disposable: Disposable? = null
fun init(context: Context) { fun init(context: Context) {
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE) preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
Database.LockAdapter.putAll(preferences.all.keys Database.LockAdapter.putAll(preferences.all.keys
.mapNotNull { packageName -> this[packageName].databaseVersionCode?.let { Pair(packageName, it) } }) .mapNotNull { packageName -> this[packageName].databaseVersionCode?.let { Pair(packageName, it) } })
subject disposable?.dispose()
disposable = subject
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.subscribe { (packageName, versionCode) -> .subscribe { (packageName, versionCode) ->
if (versionCode != null) { if (versionCode != null) {
@@ -53,9 +57,15 @@ object ProductPreferences {
operator fun set(packageName: String, productPreference: ProductPreference) { operator fun set(packageName: String, productPreference: ProductPreference) {
val oldProductPreference = this[packageName] val oldProductPreference = this[packageName]
preferences.edit().putString(packageName, ByteArrayOutputStream() preferences.edit {
.apply { Json.factory.createGenerator(this).use { it.writeDictionary(productPreference::serialize) } } putString(
.toByteArray().toString(Charset.defaultCharset())).apply() packageName, ByteArrayOutputStream()
.apply {
Json.factory.createGenerator(this)
.use { it.writeDictionary(productPreference::serialize) }
}
.toByteArray().toString(Charset.defaultCharset()))
}
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates || if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) { oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode) {
subject.onNext(Pair(packageName, productPreference.databaseVersionCode)) subject.onNext(Pair(packageName, productPreference.databaseVersionCode))
@@ -3,6 +3,8 @@ package nya.kitsunyan.foxydroid.database
import android.database.Cursor import android.database.Cursor
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import nya.kitsunyan.foxydroid.entity.ProductItem import nya.kitsunyan.foxydroid.entity.ProductItem
@@ -39,13 +41,20 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
fun onCursorData(request: Request, cursor: 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 { class CursorViewModel : ViewModel() {
retainInstance = true 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) { fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id] val oldActiveRequest = activeRequests[request.id]
@@ -79,11 +88,32 @@ class CursorOwner: Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
return QueryLoader(requireContext()) { return QueryLoader(requireContext()) {
when (request) { when (request) {
is Request.ProductsAvailable -> Database.ProductAdapter 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 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 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) 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.android.*
import nya.kitsunyan.foxydroid.utility.extension.json.* import nya.kitsunyan.foxydroid.utility.extension.json.*
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import androidx.core.database.sqlite.transaction
object Database { object Database {
fun init(context: Context): Boolean { fun init(context: Context): Boolean {
@@ -170,6 +171,7 @@ object Database {
} }
override fun onOpen(db: SQLiteDatabase) { override fun onOpen(db: SQLiteDatabase) {
db.enableWriteAheadLogging()
val create = handleTables(db, false, Schema.Repository) val create = handleTables(db, false, Schema.Repository)
val updated = handleTables(db, create, Schema.Product, Schema.Category) val updated = handleTables(db, create, Schema.Product, Schema.Category)
db.execSQL("ATTACH DATABASE ':memory:' AS memory") db.execSQL("ATTACH DATABASE ':memory:' AS memory")
@@ -182,9 +184,11 @@ object Database {
} }
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean { private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
val shouldRecreate = recreate || tables.any { val shouldRecreate = recreate || tables.any { it ->
val sql = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("sql"), val sql = db.query(
selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))) "${it.databasePrefix}sqlite_master", columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", it.innerName))
)
.use { it.firstOrNull()?.getString(0) }.orEmpty() .use { it.firstOrNull()?.getString(0) }.orEmpty()
it.formatCreateTable(it.innerName) != sql it.formatCreateTable(it.innerName) != sql
} }
@@ -202,10 +206,12 @@ object Database {
} }
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) { private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { val shouldVacuum = tables.map { it ->
val sqls = db.query("${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"), val sqls = db.query(
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", it.innerName))) "${it.databasePrefix}sqlite_master", columns = arrayOf("name", "sql"),
.use { it.asSequence().mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }.toList() } 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_") } .filter { !it.first.startsWith("sqlite_") }
val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty() val createIndexes = it.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run { createIndexes.map { it.first } != sqls.map { it.second } && run {
@@ -224,11 +230,13 @@ object Database {
} }
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) { private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query("sqlite_master", columns = arrayOf("name"), val tables = db.query(
selection = Pair("type = ?", arrayOf("table"))) "sqlite_master", columns = arrayOf("name"),
.use { it.asSequence().mapNotNull { it.getString(0) }.toList() } selection = Pair("type = ?", arrayOf("table"))
)
.use { it -> it.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") } .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()) { if (tables.isNotEmpty()) {
for (table in tables) { for (table in tables) {
db.execSQL("DROP TABLE IF EXISTS $table") 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) 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(
selection: Pair<String, Array<String>>? = null, orderBy: String? = null, table: String, columns: Array<String>? = null,
signal: CancellationSignal? = null): Cursor { selection: Pair<String, Array<String>>? = null, orderBy: String? = null,
signal: CancellationSignal? = null
): Cursor {
return query(false, table, columns, selection?.first, selection?.second, null, null, orderBy, null, signal) 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 shouldReplace = repository.id >= 0L
val newId = putWithoutNotification(repository, shouldReplace) val newId = putWithoutNotification(repository, shouldReplace)
val id = if (shouldReplace) repository.id else newId val id = if (shouldReplace) repository.id else newId
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products) 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? { fun get(id: Long): Repository? {
return db.query(Schema.Repository.name, return db.query(
selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0", Schema.Repository.name,
arrayOf(id.toString()))) selection = Pair("${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
arrayOf(id.toString()))
)
.use { it.firstOrNull()?.let(::transform) } .use { it.firstOrNull()?.let(::transform) }
} }
fun getAll(signal: CancellationSignal?): List<Repository> { fun getAll(signal: CancellationSignal?): List<Repository> {
return db.query(Schema.Repository.name, return db.query(
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), Schema.Repository.name,
signal = signal).use { it.asSequence().map(::transform).toList() } selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal
).use { it.asSequence().map(::transform).toList() }
} }
fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<Long, Boolean>> { fun getAllDisabledDeleted(signal: CancellationSignal?): Set<Pair<Long, Boolean>> {
return db.query(Schema.Repository.name, return db.query(
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), Schema.Repository.name,
selection = Pair("${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", emptyArray()), columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
signal = signal).use { it.asSequence().map { Pair(it.getLong(it.getColumnIndex(Schema.Repository.ROW_ID)), selection = Pair("${Schema.Repository.ROW_ENABLED} == 0 OR ${Schema.Repository.ROW_DELETED} != 0", emptyArray()),
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) { fun markAsDeleted(id: Long) {
@@ -350,8 +367,8 @@ object Database {
} }
fun cleanup(pairs: Set<Pair<Long, Boolean>>) { 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 idsString = it.joinToString(separator = ", ") { it.first.toString() }
val productsCount = db.delete(Schema.Product.name, val productsCount = db.delete(Schema.Product.name,
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null) "${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)", null)
val categoriesCount = db.delete(Schema.Category.name, val categoriesCount = db.delete(Schema.Category.name,
@@ -369,28 +386,34 @@ object Database {
} }
fun query(signal: CancellationSignal?): Cursor { fun query(signal: CancellationSignal?): Cursor {
return db.query(Schema.Repository.name, return db.query(
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()), Schema.Repository.name,
signal = signal).observable(Subject.Repositories) selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
signal = signal
).observable(Subject.Repositories)
} }
fun transform(cursor: Cursor): Repository { fun transform(cursor: Cursor): Repository {
return cursor.getBlob(cursor.getColumnIndex(Schema.Repository.ROW_DATA)) return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA))
.jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Repository.ROW_ID)), it) } .jsonParse { Repository.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID)), it) }
} }
} }
object ProductAdapter { object ProductAdapter {
fun get(packageName: String, signal: CancellationSignal?): List<Product> { fun get(packageName: String, signal: CancellationSignal?): List<Product> {
return db.query(Schema.Product.name, return db.query(
columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DESCRIPTION, Schema.Product.ROW_DATA), Schema.Product.name,
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), columns = arrayOf(Schema.Product.ROW_REPOSITORY_ID, Schema.Product.ROW_DESCRIPTION, Schema.Product.ROW_DATA),
signal = signal).use { it.asSequence().map(::transform).toList() } selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal
).use { it.asSequence().map(::transform).toList() }
} }
fun getCount(repositoryId: Long): Int { fun getCount(repositoryId: Long): Int {
return db.query(Schema.Product.name, columns = arrayOf("COUNT (*)"), return db.query(
selection = Pair("${Schema.Product.ROW_REPOSITORY_ID} = ?", arrayOf(repositoryId.toString()))) Schema.Product.name, columns = arrayOf("COUNT (*)"),
selection = Pair("${Schema.Product.ROW_REPOSITORY_ID} = ?", arrayOf(repositoryId.toString()))
)
.use { it.firstOrNull()?.getInt(0) ?: 0 } .use { it.firstOrNull()?.getInt(0) ?: 0 }
} }
@@ -464,28 +487,28 @@ object Database {
ProductItem.Order.NAME -> Unit ProductItem.Order.NAME -> Unit
ProductItem.Order.DATE_ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," ProductItem.Order.DATE_ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
ProductItem.Order.LAST_UPDATE -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," ProductItem.Order.LAST_UPDATE -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
}::class }
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
return builder.query(db, signal).observable(Subject.Products) return builder.query(db, signal).observable(Subject.Products)
} }
private fun transform(cursor: Cursor): Product { private fun transform(cursor: Cursor): Product {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA)) return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA))
.jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), .jsonParse { Product.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_DESCRIPTION)), it) } cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION)), it) }
} }
fun transformItem(cursor: Cursor): ProductItem { fun transformItem(cursor: Cursor): ProductItem {
return cursor.getBlob(cursor.getColumnIndex(Schema.Product.ROW_DATA_ITEM)) return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
.jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndex(Schema.Product.ROW_REPOSITORY_ID)), .jsonParse { ProductItem.deserialize(cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_PACKAGE_NAME)), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_NAME)), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Product.ROW_SUMMARY)), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)).orEmpty(), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)).orEmpty(),
cursor.getInt(cursor.getColumnIndex(Schema.Product.ROW_COMPATIBLE)) != 0, cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0,
cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_CAN_UPDATE)) != 0, cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0,
cursor.getInt(cursor.getColumnIndex(Schema.Synthetic.ROW_MATCH_RANK)), it) } cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)), it) }
} }
} }
@@ -500,18 +523,21 @@ object Database {
WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
repository.${Schema.Repository.ROW_DELETED} == 0""" repository.${Schema.Repository.ROW_DELETED} == 0"""
return builder.query(db, signal).use { it.asSequence() return builder.query(db, signal).use { it ->
.map { it.getString(it.getColumnIndex(Schema.Category.ROW_NAME)) }.toSet() } it.asSequence()
.map { it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME)) }.toSet() }
} }
} }
object InstalledAdapter { object InstalledAdapter {
fun get(packageName: String, signal: CancellationSignal?): InstalledItem? { fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
return db.query(Schema.Installed.name, return db.query(
columns = arrayOf(Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION, Schema.Installed.name,
Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE), columns = arrayOf(Schema.Installed.ROW_PACKAGE_NAME, Schema.Installed.ROW_VERSION,
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)), Schema.Installed.ROW_VERSION_CODE, Schema.Installed.ROW_SIGNATURE),
signal = signal).use { it.firstOrNull()?.let(::transform) } selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
signal = signal
).use { it.firstOrNull()?.let(::transform) }
} }
private fun put(installedItem: InstalledItem, notify: Boolean) { private fun put(installedItem: InstalledItem, notify: Boolean) {
@@ -529,13 +555,12 @@ object Database {
fun put(installedItem: InstalledItem) = put(installedItem, true) fun put(installedItem: InstalledItem) = put(installedItem, true)
fun putAll(installedItems: List<InstalledItem>) { fun putAll(installedItems: List<InstalledItem>) {
db.beginTransaction() db.transaction {
try { try {
db.delete(Schema.Installed.name, null, null) delete(Schema.Installed.name, null, null)
installedItems.forEach { put(it, false) } installedItems.forEach { put(it, false) }
db.setTransactionSuccessful() } finally {
} finally { }
db.endTransaction()
} }
} }
@@ -547,10 +572,10 @@ object Database {
} }
private fun transform(cursor: Cursor): InstalledItem { private fun transform(cursor: Cursor): InstalledItem {
return InstalledItem(cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_PACKAGE_NAME)), return InstalledItem(cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_VERSION)), cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
cursor.getLong(cursor.getColumnIndex(Schema.Installed.ROW_VERSION_CODE)), cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
cursor.getString(cursor.getColumnIndex(Schema.Installed.ROW_SIGNATURE))) cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)))
} }
} }
@@ -568,13 +593,12 @@ object Database {
fun put(lock: Pair<String, Long>) = put(lock, true) fun put(lock: Pair<String, Long>) = put(lock, true)
fun putAll(locks: List<Pair<String, Long>>) { fun putAll(locks: List<Pair<String, Long>>) {
db.beginTransaction() db.transaction {
try { try {
db.delete(Schema.Lock.name, null, null) delete(Schema.Lock.name, null, null)
locks.forEach { put(it, false) } locks.forEach { put(it, false) }
db.setTransactionSuccessful() } finally {
} finally { }
db.endTransaction()
} }
} }
@@ -596,60 +620,60 @@ object Database {
} }
fun putTemporary(products: List<Product>) { fun putTemporary(products: List<Product>) {
db.beginTransaction() db.transaction {
try { try {
for (product in products) { for (product in products) {
// Format signatures like ".signature1.signature2." for easier select // Format signatures like ".signature1.signature2." for easier select
val signatures = product.signatures.joinToString { ".$it" } val signatures = product.signatures.joinToString { ".$it" }
.let { if (it.isNotEmpty()) "$it." else "" } .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_REPOSITORY_ID, product.repositoryId)
put(Schema.Product.ROW_PACKAGE_NAME, product.packageName) put(Schema.Product.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Product.ROW_NAME, product.name) put(Schema.Product.ROW_NAME, product.name)
put(Schema.Product.ROW_SUMMARY, product.summary) put(Schema.Product.ROW_SUMMARY, product.summary)
put(Schema.Product.ROW_DESCRIPTION, product.description) put(Schema.Product.ROW_DESCRIPTION, product.description)
put(Schema.Product.ROW_ADDED, product.added) put(Schema.Product.ROW_ADDED, product.added)
put(Schema.Product.ROW_UPDATED, product.updated) put(Schema.Product.ROW_UPDATED, product.updated)
put(Schema.Product.ROW_VERSION_CODE, product.versionCode) put(Schema.Product.ROW_VERSION_CODE, product.versionCode)
put(Schema.Product.ROW_SIGNATURES, signatures) put(Schema.Product.ROW_SIGNATURES, signatures)
put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0) put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0)
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize)) put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize)) put(Schema.Product.ROW_DATA_ITEM, jsonGenerate(product.item()::serialize))
}) })
for (category in product.categories) { 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_REPOSITORY_ID, product.repositoryId)
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName) put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
put(Schema.Category.ROW_NAME, category) put(Schema.Category.ROW_NAME, category)
}) })
}
}
} finally {
} }
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
} }
} }
fun finishTemporary(repository: Repository, success: Boolean) { fun finishTemporary(repository: Repository, success: Boolean) {
if (success) { if (success) {
db.beginTransaction() db.transaction {
try { try {
db.delete(Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?", delete(
arrayOf(repository.id.toString())) Schema.Product.name, "${Schema.Product.ROW_REPOSITORY_ID} = ?",
db.delete(Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?", arrayOf(repository.id.toString())
arrayOf(repository.id.toString())) )
db.execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}") delete(
db.execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}") Schema.Category.name, "${Schema.Category.ROW_REPOSITORY_ID} = ?",
RepositoryAdapter.putWithoutNotification(repository, true) arrayOf(repository.id.toString())
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") )
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") execSQL("INSERT INTO ${Schema.Product.name} SELECT * FROM ${Schema.Product.temporaryName}")
db.setTransactionSuccessful() execSQL("INSERT INTO ${Schema.Category.name} SELECT * FROM ${Schema.Category.temporaryName}")
} finally { RepositoryAdapter.putWithoutNotification(repository, true)
db.endTransaction() execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
} finally {
}
} }
if (success) {
notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products) notifyChanged(Subject.Repositories, Subject.Repository(repository.id), Subject.Products)
}
} else { } else {
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}") db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
@@ -1,57 +1,37 @@
package nya.kitsunyan.foxydroid.database package nya.kitsunyan.foxydroid.database
import android.database.ContentObservable
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.database.CursorWrapper import android.database.CursorWrapper
import java.util.concurrent.CopyOnWriteArraySet
class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean, class ObservableCursor(cursor: Cursor, private val observable: (register: Boolean,
observer: () -> Unit) -> Unit): CursorWrapper(cursor) { observer: () -> Unit) -> Unit): CursorWrapper(cursor) {
private var registered = false private val observers = CopyOnWriteArraySet<ContentObserver>()
private val contentObservable = ContentObservable()
private val onChange: () -> Unit = { private val onChange: () -> Unit = {
contentObservable.dispatchChange(false, null) for (observer in observers) {
observer.dispatchChange(false, null)
}
} }
init { init {
observable(true, onChange) observable(true, onChange)
registered = true
} }
override fun registerContentObserver(observer: ContentObserver) { override fun registerContentObserver(observer: ContentObserver) {
super.registerContentObserver(observer) super.registerContentObserver(observer)
contentObservable.registerObserver(observer) observers.add(observer)
} }
override fun unregisterContentObserver(observer: ContentObserver) { override fun unregisterContentObserver(observer: ContentObserver) {
super.unregisterContentObserver(observer) super.unregisterContentObserver(observer)
contentObservable.unregisterObserver(observer) observers.remove(observer)
}
@Suppress("DEPRECATION")
override fun requery(): Boolean {
if (!registered) {
observable(true, onChange)
registered = true
}
return super.requery()
}
@Suppress("DEPRECATION")
override fun deactivate() {
super.deactivate()
deactivateOrClose()
} }
override fun close() { override fun close() {
super.close() super.close()
contentObservable.unregisterAll() observers.clear()
deactivateOrClose()
}
private fun deactivateOrClose() {
observable(false, onChange) observable(false, onChange)
registered = false
} }
} }
@@ -32,14 +32,15 @@ class QueryBuilder {
this.arguments += arguments this.arguments += arguments
} }
fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor { fun query(db: SQLiteDatabase, signal: CancellationSignal? = null): Cursor {
val query = builder.toString() val query = builder.toString()
val arguments = arguments.toTypedArray() val arguments = arguments.toTypedArray()
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
synchronized(QueryBuilder::class.java) { synchronized(QueryBuilder::class.java) {
debug(query) debug(query)
db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { it.asSequence() db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use { it ->
.forEach { debug(":: ${it.getString(it.getColumnIndex("detail"))}") } } it.asSequence()
.forEach { debug(":: ${it.getString(it.getColumnIndexOrThrow("detail"))}") } }
} }
} }
return db.rawQuery(query, arguments, signal) return db.rawQuery(query, arguments, signal)
@@ -156,8 +156,8 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
var donates = emptyList<Donate>() var donates = emptyList<Donate>()
var screenshots = emptyList<Screenshot>() var screenshots = emptyList<Screenshot>()
var releases = emptyList<Release>() var releases = emptyList<Release>()
parser.forEachKey { parser.forEachKey { it ->
when { when {
it.string("packageName") -> packageName = valueAsString it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString it.string("summary") -> summary = valueAsString
@@ -213,7 +213,7 @@ data class Product(val repositoryId: Long, val packageName: String, val name: St
else -> skipChildren() 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) it.array("releases") -> releases = collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
else -> skipChildren() else -> skipChildren()
@@ -1,10 +1,10 @@
package nya.kitsunyan.foxydroid.entity package nya.kitsunyan.foxydroid.entity
import android.net.Uri
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import nya.kitsunyan.foxydroid.utility.extension.json.* import nya.kitsunyan.foxydroid.utility.extension.json.*
import androidx.core.net.toUri
data class Release(val selected: Boolean, val version: String, val versionCode: Long, data class Release(val selected: Boolean, val version: String, val versionCode: Long,
val added: Long, val size: Long, val minSdkVersion: Int, val targetSdkVersion: Int, val maxSdkVersion: Int, val 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" get() = "$versionCode.$hash"
fun getDownloadUrl(repository: Repository): String { 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 val cacheFileName: String
@@ -102,8 +102,8 @@ data class Release(val selected: Boolean, val version: String, val versionCode:
var features = emptyList<String>() var features = emptyList<String>()
var platforms = emptyList<String>() var platforms = emptyList<String>()
var incompatibilities = emptyList<Incompatibility>() var incompatibilities = emptyList<Incompatibility>()
parser.forEachKey { parser.forEachKey { it ->
when { when {
it.boolean("selected") -> selected = valueAsBoolean it.boolean("selected") -> selected = valueAsBoolean
it.string("version") -> version = valueAsString it.string("version") -> version = valueAsString
it.number("versionCode") -> versionCode = valueAsLong it.number("versionCode") -> versionCode = valueAsLong
@@ -3,20 +3,13 @@ package nya.kitsunyan.foxydroid.entity
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import nya.kitsunyan.foxydroid.utility.extension.json.* import nya.kitsunyan.foxydroid.utility.extension.json.*
import java.net.URL
data class Repository(val id: Long, val address: String, val mirrors: List<String>,
val name: String, val description: String, val version: Int, val enabled: Boolean,
val fingerprint: String, val lastModified: String, val entityTag: String,
val updated: Long, val timestamp: Long, val authentication: String) {
fun edit(address: String, fingerprint: String, authentication: String): Repository {
val addressChanged = this.address != address
val fingerprintChanged = this.fingerprint != fingerprint
val changed = addressChanged || fingerprintChanged
return copy(address = address, fingerprint = fingerprint, lastModified = if (changed) "" else lastModified,
entityTag = if (changed) "" else entityTag, authentication = authentication)
}
data class Repository(
val id: Long, val address: String, val mirrors: List<String>,
val name: String, val description: String, val version: Int, val enabled: Boolean,
val fingerprint: String, val lastModified: String, val entityTag: String,
val updated: Long, val timestamp: Long, val authentication: String
) {
fun update(mirrors: List<String>, name: String, description: String, version: Int, fun update(mirrors: List<String>, name: String, description: String, version: Int,
lastModified: String, entityTag: String, timestamp: Long): Repository { lastModified: String, entityTag: String, timestamp: Long): Repository {
return copy(mirrors = mirrors, name = name, description = description, return copy(mirrors = mirrors, name = name, description = description,
@@ -79,25 +72,21 @@ data class Repository(val id: Long, val address: String, val mirrors: List<Strin
lastModified, entityTag, updated, timestamp, authentication) lastModified, entityTag, updated, timestamp, authentication)
} }
fun newRepository(address: String, fingerprint: String, authentication: String): Repository { private fun defaultRepository(address: String, name: String, description: String,
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 { version: Int, enabled: Boolean, fingerprint: String, authentication: String): Repository {
return Repository(-1, address, emptyList(), name, description, version, enabled, return Repository(-1, address, emptyList(), name, description, version, enabled,
fingerprint, "", "", 0L, 0L, authentication) fingerprint, "", "", 0L, 0L, authentication)
} }
val defaultRepositories = listOf(run { 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. " + 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.", "Everything in this repository is always built from the source code.",
21, true, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "") 21, false, "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB", "")
}, run { }, run {
defaultRepository("https://f-droid.org/archive", "F-Droid Archive", "The archive of the official F-Droid Free " + 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!", "Software repository. Apps here are old and can contain known vulnerabilities and security issues!",
@@ -1,9 +1,12 @@
package nya.kitsunyan.foxydroid.graphics package nya.kitsunyan.foxydroid.graphics
import android.content.res.ColorStateList
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.ColorFilter import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.DrawableCompat
open class DrawableWrapper(val drawable: Drawable): Drawable() { open class DrawableWrapper(val drawable: Drawable): Drawable() {
init { init {
@@ -36,7 +39,7 @@ open class DrawableWrapper(val drawable: Drawable): Drawable() {
} }
override fun getAlpha(): Int { override fun getAlpha(): Int {
return drawable.alpha return DrawableCompat.getAlpha(drawable)
} }
override fun setAlpha(alpha: Int) { override fun setAlpha(alpha: Int) {
@@ -44,13 +47,32 @@ open class DrawableWrapper(val drawable: Drawable): Drawable() {
} }
override fun getColorFilter(): ColorFilter? { override fun getColorFilter(): ColorFilter? {
return drawable.colorFilter return DrawableCompat.getColorFilter(drawable)
} }
override fun setColorFilter(colorFilter: ColorFilter?) { override fun setColorFilter(colorFilter: ColorFilter?) {
drawable.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 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 { private fun String.parseDate(): Long {
return try { return try {
dateFormat.parse(this)?.time ?: 0L dateFormat.parse(this)?.time ?: 0L
} catch (e: Exception) { } catch (_: Exception) {
0L 0L
} }
} }
@@ -61,20 +61,22 @@ class IndexMerger(file: File): Closeable {
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) { fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
closeTransaction() closeTransaction()
db.rawQuery("""SELECT product.description, product.data AS pd, releases.data AS rd FROM product 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) LEFT JOIN releases ON product.package_name = releases.package_name""", null)
?.use { it.asSequence().map { .use { it ->
val description = it.getString(0) it.asSequence().map { it ->
val product = Json.factory.createParser(it.getBlob(1)).use { val description = it.getString(0)
it.nextToken() val product = Json.factory.createParser(it.getBlob(1)).use {
Product.deserialize(repositoryId, description, it) it.nextToken()
} Product.deserialize(repositoryId, description, it)
val releases = it.getBlob(2)?.let { Json.factory.createParser(it).use { }
it.nextToken() val releases = it.getBlob(2)?.let { it ->
it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize) Json.factory.createParser(it).use {
} }.orEmpty() it.nextToken()
product.copy(releases = releases) it.collectNotNull(JsonToken.START_OBJECT, Release.Companion::deserialize)
}.windowed(windowSize, windowSize, true).forEach { products -> callback(products, it.count) } } } }.orEmpty()
product.copy(releases = releases)
}.windowed(windowSize, windowSize, true).forEach { products -> callback(products, it.count) } }
} }
override fun close() { override fun close() {
@@ -37,8 +37,8 @@ object IndexV1Parser {
if (jsonParser.nextToken() != JsonToken.START_OBJECT) { if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
jsonParser.illegal() jsonParser.illegal()
} else { } else {
jsonParser.forEachKey { jsonParser.forEachKey { it ->
when { when {
it.dictionary("repo") -> { it.dictionary("repo") -> {
var address = "" var address = ""
var mirrors = emptyList<String>() var mirrors = emptyList<String>()
@@ -100,8 +100,8 @@ object IndexV1Parser {
val licenses = mutableListOf<String>() val licenses = mutableListOf<String>()
val donates = mutableListOf<Product.Donate>() val donates = mutableListOf<Product.Donate>()
val localizedMap = mutableMapOf<String, Localized>() val localizedMap = mutableMapOf<String, Localized>()
forEachKey { forEachKey { it ->
when { when {
it.string("packageName") -> packageName = valueAsString it.string("packageName") -> packageName = valueAsString
it.string("name") -> nameFallback = valueAsString it.string("name") -> nameFallback = valueAsString
it.string("summary") -> summaryFallback = valueAsString it.string("summary") -> summaryFallback = valueAsString
@@ -125,8 +125,8 @@ object IndexV1Parser {
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString) it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString) it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
it.string("openCollective") -> donates += Product.Donate.OpenCollective(valueAsString) it.string("openCollective") -> donates += Product.Donate.OpenCollective(valueAsString)
it.dictionary("localized") -> forEachKey { it.dictionary("localized") -> forEachKey { it ->
if (it.token == JsonToken.START_OBJECT) { if (it.token == JsonToken.START_OBJECT) {
val locale = it.key val locale = it.key
var name = "" var name = ""
var summary = "" var summary = ""
@@ -1,9 +1,9 @@
package nya.kitsunyan.foxydroid.index package nya.kitsunyan.foxydroid.index
import android.content.Context import android.content.Context
import android.net.Uri
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import nya.kitsunyan.foxydroid.content.Cache import nya.kitsunyan.foxydroid.content.Cache
import nya.kitsunyan.foxydroid.database.Database import nya.kitsunyan.foxydroid.database.Database
@@ -23,6 +23,7 @@ import java.util.Locale
import java.util.jar.JarEntry import java.util.jar.JarEntry
import java.util.jar.JarFile import java.util.jar.JarFile
import javax.xml.parsers.SAXParserFactory import javax.xml.parsers.SAXParserFactory
import androidx.core.net.toUri
object RepositoryUpdater { object RepositoryUpdater {
enum class Stage { enum class Stage {
@@ -50,23 +51,19 @@ object RepositoryUpdater {
} }
} }
private lateinit var context: Context
private val updaterLock = Any() private val updaterLock = Any()
private val cleanupLock = Any() private val cleanupLock = Any()
fun init(context: Context) { fun init(): Disposable {
this.context = context val lastDisabled = setOf<Long>()
return Observable.just(Unit)
var lastDisabled = setOf<Long>()
Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories)) .concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAllDisabledDeleted(it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAllDisabledDeleted(signal) } }
.forEach { .subscribe { result ->
val newDisabled = it.asSequence().filter { !it.second }.map { it.first }.toSet() val newDisabled = result.asSequence().filter { !it.second }.map { it.first }.toSet()
val disabled = newDisabled - lastDisabled val disabled = newDisabled - lastDisabled
lastDisabled = newDisabled val deleted = result.asSequence().filter { it.second }.map { it.first }.toSet()
val deleted = it.asSequence().filter { it.second }.map { it.first }.toSet()
if (disabled.isNotEmpty() || deleted.isNotEmpty()) { if (disabled.isNotEmpty() || deleted.isNotEmpty()) {
val pairs = (disabled.asSequence().map { Pair(it, false) } + val pairs = (disabled.asSequence().map { Pair(it, false) } +
deleted.asSequence().map { Pair(it, true) }).toSet() deleted.asSequence().map { Pair(it, true) }).toSet()
@@ -79,15 +76,15 @@ object RepositoryUpdater {
synchronized(updaterLock) { } synchronized(updaterLock) { }
} }
fun update(repository: Repository, unstable: Boolean, fun update(context: Context, repository: Repository, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit): Single<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> { callback: (Stage, Long, Long?) -> Unit): Single<Boolean> {
val indexType = indexTypes[0] val indexType = indexTypes[0]
return downloadIndex(repository, indexType, callback) return downloadIndex(context, repository, indexType, callback)
.flatMap { (result, file) -> .flatMap { (result, file) ->
when { when {
result.isNotChanged -> { result.isNotChanged -> {
@@ -96,26 +93,27 @@ object RepositoryUpdater {
} }
!result.success -> { !result.success -> {
file.delete() file.delete()
if (result.code == 404 && indexTypes.isNotEmpty()) { if (result.code == 404 && indexTypes.size > 1) {
update(repository, indexTypes.subList(1, indexTypes.size), unstable, callback) update(context, repository, indexTypes.subList(1, indexTypes.size), unstable, callback)
} else { } else {
Single.error(UpdateException(ErrorType.HTTP, "Invalid response: HTTP ${result.code}")) Single.error(UpdateException(ErrorType.HTTP, "Invalid response: HTTP ${result.code}"))
} }
} }
else -> { else -> {
RxUtils.managedSingle { processFile(repository, indexType, unstable, RxUtils.managedSingle { processFile(context, repository, indexType, unstable,
file, result.lastModified, result.entityTag, callback) } 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>> { callback: (Stage, Long, Long?) -> Unit): Single<Pair<Downloader.Result, File>> {
return Single.just(Unit) return Single.just(Unit)
.map { Cache.getTemporaryFile(context) } .map { Cache.getTemporaryFile(context) }
.flatMap { file -> Downloader .flatMap { file -> Downloader
.download(Uri.parse(repository.address).buildUpon() .download(
repository.address.toUri().buildUpon()
.appendPath(indexType.jarName).build().toString(), file, repository.lastModified, repository.entityTag, .appendPath(indexType.jarName).build().toString(), file, repository.lastModified, repository.entityTag,
repository.authentication) { read, total -> callback(Stage.DOWNLOAD, read, total) } repository.authentication) { read, total -> callback(Stage.DOWNLOAD, read, total) }
.subscribeOn(Schedulers.io()) .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 { file: File, lastModified: String, entityTag: String, callback: (Stage, Long, Long?) -> Unit): Boolean {
var rollback = true var rollback = true
return synchronized(updaterLock) { return synchronized(updaterLock) {
@@ -157,7 +155,7 @@ object RepositoryUpdater {
certificate: String, version: Int, timestamp: Long) { certificate: String, version: Int, timestamp: Long) {
changedRepository = repository.update(mirrors, name, description, version, changedRepository = repository.update(mirrors, name, description, version,
lastModified, entityTag, timestamp) lastModified, entityTag, timestamp)
certificateFromIndex = certificate.toLowerCase(Locale.US) certificateFromIndex = certificate.lowercase(Locale.US)
} }
override fun onProduct(product: Product) { override fun onProduct(product: Product) {
@@ -191,8 +189,8 @@ object RepositoryUpdater {
val unmergedProducts = mutableListOf<Product>() val unmergedProducts = mutableListOf<Product>()
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>() val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
IndexMerger(mergerFile).use { indexMerger -> 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 { IndexV1Parser.parse(repository.id, it, object: IndexV1Parser.Callback {
override fun onRepository(mirrors: List<String>, name: String, description: String, override fun onRepository(mirrors: List<String>, name: String, description: String,
version: Int, timestamp: Long) { version: Int, timestamp: Long) {
changedRepository = repository.update(mirrors, name, description, version, changedRepository = repository.update(mirrors, name, description, version,
@@ -315,8 +313,8 @@ object RepositoryUpdater {
} }
private fun transformProduct(product: Product, features: Set<String>, unstable: Boolean): Product { 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>() val incompatibilities = mutableListOf<Release.Incompatibility>()
if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) { if (it.minSdkVersion > 0 && Android.sdk < it.minSdkVersion) {
incompatibilities += Release.Incompatibility.MinSdk incompatibilities += Release.Incompatibility.MinSdk
} }
@@ -85,13 +85,13 @@ object Downloader {
.callSingle { createCall(request, authentication, null) } .callSingle { createCall(request, authentication, null) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.flatMap { result -> RxUtils .flatMap { result -> RxUtils
.managedSingle { result.use { .managedSingle { result.use { it ->
if (result.code == 304) { if (result.code == 304) {
Result(it.code, lastModified, entityTag) Result(it.code, lastModified, entityTag)
} else { } else {
val body = it.body!! val body = it.body
val append = start != null && it.header("Content-Range") != null 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 } val progressTotal = body.contentLength().let { if (it >= 0L) it else null }
?.let { progressStart + it } ?.let { progressStart + it }
val inputStream = ProgressInputStream(body.byteStream()) { val inputStream = ProgressInputStream(body.byteStream()) {
@@ -65,7 +65,7 @@ object PicassoDownloader {
} else { } else {
Downloader.createCall(request.newBuilder().url(address.toHttpUrl() Downloader.createCall(request.newBuilder().url(address.toHttpUrl()
.newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty()) .newBuilder().addPathSegment(packageName.orEmpty()).addPathSegment(locale.orEmpty())
.addPathSegment(device.orEmpty()).addPathSegment(screenshot.orEmpty()).build()), .addPathSegment(device.orEmpty()).addPathSegment(screenshot).build()),
authentication.orEmpty(), cache) authentication.orEmpty(), cache)
} }
} }
@@ -5,7 +5,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.Selection import android.text.Selection
@@ -42,6 +41,7 @@ import java.net.URL
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.Locale import java.util.Locale
import kotlin.math.* import kotlin.math.*
import androidx.core.net.toUri
class EditRepositoryFragment(): ScreenFragment() { class EditRepositoryFragment(): ScreenFragment() {
companion object { companion object {
@@ -149,7 +149,7 @@ class EditRepositoryFragment(): ScreenFragment() {
override fun afterTextChanged(s: Editable) { override fun afterTextChanged(s: Editable) {
val inputString = s.toString() 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 = " ") .filter(validChar).windowed(2, 2, true).take(32).joinToString(separator = " ")
if (inputString != outputString) { if (inputString != outputString) {
val inputStart = logicalPosition(inputString, Selection.getSelectionStart(s)) val inputStart = logicalPosition(inputString, Selection.getSelectionStart(s))
@@ -161,19 +161,19 @@ class EditRepositoryFragment(): ScreenFragment() {
}) })
if (savedInstanceState == null) { if (savedInstanceState == null) {
val repository = repositoryId?.let(Database.RepositoryAdapter::get) val repository = repositoryId?.let { Database.RepositoryAdapter.get(it) }
if (repository == null) { if (repository == null) {
val clipboardManager = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboardManager = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = clipboardManager.primaryClip val text = clipboardManager.primaryClip
?.let { if (it.itemCount > 0) it else null } ?.let { if (it.itemCount > 0) it else null }
?.getItemAt(0)?.text?.toString().orEmpty() ?.getItemAt(0)?.text?.toString().orEmpty()
val (addressText, fingerprintText) = try { val (addressText, fingerprintText) = try {
val uri = Uri.parse(URL(text).toString()) val uri = URL(text).toString().toUri()
val fingerprintText = uri.getQueryParameter("fingerprint")?.nullIfEmpty() val fingerprintText = uri.getQueryParameter("fingerprint")?.nullIfEmpty()
?: uri.getQueryParameter("FINGERPRINT")?.nullIfEmpty() ?: uri.getQueryParameter("FINGERPRINT")?.nullIfEmpty()
Pair(uri.buildUpon().path(uri.path?.pathCropped) Pair(uri.buildUpon().path(uri.path?.pathCropped)
.query(null).fragment(null).build().toString(), fingerprintText) .query(null).fragment(null).build().toString(), fingerprintText)
} catch (e: Exception) { } catch (_: Exception) {
Pair(null, null) Pair(null, null)
} }
layout.address.setText(addressText?.nullIfEmpty() ?: layout.address.hint) layout.address.setText(addressText?.nullIfEmpty() ?: layout.address.hint)
@@ -209,10 +209,26 @@ class EditRepositoryFragment(): ScreenFragment() {
} }
} }
layout.address.addTextChangedListener(SimpleTextWatcher { invalidateAddress() }) layout.address.addTextChangedListener(object: TextWatcher {
layout.fingerprint.addTextChangedListener(SimpleTextWatcher { invalidateFingerprint() }) override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
layout.username.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() }) override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
layout.password.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() }) 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.parent as ViewGroup).layoutTransition?.setDuration(200L)
layout.overlay.background!!.apply { layout.overlay.background!!.apply {
@@ -230,14 +246,18 @@ class EditRepositoryFragment(): ScreenFragment() {
repositoriesDisposable = Observable.just(Unit) repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories)) .concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { .subscribe { result ->
takenAddresses = it.asSequence().filter { it.id != repositoryId } takenAddresses = result.asSequence().filter { it.id != repositoryId }
.flatMap { (it.mirrors + it.address).asSequence() } .flatMap { (it.mirrors + it.address).asSequence() }
.map { it.withoutKnownPath }.toSet() .map { it.withoutKnownPath }.toSet()
invalidateAddress() invalidateAddress()
} }
invalidateAddress()
invalidateFingerprint()
invalidateUsernamePassword()
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -253,14 +273,6 @@ class EditRepositoryFragment(): ScreenFragment() {
checkDisposable = null checkDisposable = null
} }
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
invalidateAddress()
invalidateFingerprint()
invalidateUsernamePassword()
}
private var addressError = false private var addressError = false
private var fingerprintError = false private var fingerprintError = false
private var usernamePasswordError = false private var usernamePasswordError = false
@@ -328,7 +340,7 @@ class EditRepositoryFragment(): ScreenFragment() {
private fun invalidateState() { private fun invalidateState() {
val layout = layout!! val layout = layout!!
saveMenuItem!!.isEnabled = !addressError && !fingerprintError && saveMenuItem?.isEnabled = !addressError && !fingerprintError &&
!usernamePasswordError && checkDisposable == null !usernamePasswordError && checkDisposable == null
layout.apply { sequenceOf(address, addressMirror, fingerprint, username, password) layout.apply { sequenceOf(address, addressMirror, fingerprint, username, password)
.forEach { it.isEnabled = checkDisposable == null } } .forEach { it.isEnabled = checkDisposable == null } }
@@ -346,21 +358,21 @@ class EditRepositoryFragment(): ScreenFragment() {
val cropped = pathCropped val cropped = pathCropped
val endsWith = checkPaths.asSequence().filter { it.isNotEmpty() } val endsWith = checkPaths.asSequence().filter { it.isNotEmpty() }
.sortedByDescending { it.length }.find { cropped.endsWith("/$it") } .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? { private fun normalizeAddress(address: String): String? {
val uri = try { val uri = try {
val uri = URI(address) val uri = URI(address)
if (uri.isAbsolute) uri.normalize() else null if (uri.isAbsolute) uri.normalize() else null
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
val path = uri?.path?.pathCropped val path = uri?.path?.pathCropped
return if (uri != null && path != null) { return if (uri != null && path != null) {
try { try {
URI(uri.scheme, uri.userInfo, uri.host, uri.port, path, uri.query, uri.fragment).toString() URI(uri.scheme, uri.userInfo, uri.host, uri.port, path, uri.query, uri.fragment).toString()
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} else { } else {
@@ -394,7 +406,7 @@ class EditRepositoryFragment(): ScreenFragment() {
.fold(Single.just("")) { oldAddressSingle, checkPath -> oldAddressSingle .fold(Single.just("")) { oldAddressSingle, checkPath -> oldAddressSingle
.flatMap { oldAddress -> .flatMap { oldAddress ->
if (oldAddress.isEmpty()) { if (oldAddress.isEmpty()) {
val builder = Uri.parse(address).buildUpon() val builder = address.toUri().buildUpon()
.let { if (checkPath.isEmpty()) it else it.appendEncodedPath(checkPath) } .let { if (checkPath.isEmpty()) it else it.appendEncodedPath(checkPath) }
val newAddress = builder.build() val newAddress = builder.build()
val indexAddress = builder.appendPath("index.jar").build() val indexAddress = builder.appendPath("index.jar").build()
@@ -413,71 +425,67 @@ class EditRepositoryFragment(): ScreenFragment() {
.subscribe { result, throwable -> .subscribe { result, throwable ->
checkDisposable = null checkDisposable = null
throwable?.printStackTrace() throwable?.printStackTrace()
val resultAddress = result?.let { if (it.isEmpty()) null else it } ?: address val resultAddress = result?.let { it.ifEmpty { null } } ?: address
val allow = resultAddress == address || run { val allow = resultAddress == address || resultAddress == "$address/"
layout.address.setText(resultAddress) if (!allow) {
invalidateAddress(resultAddress) AlertDialog.Builder(requireContext())
!addressError .setMessage(getString(R.string.address_redirect_FORMAT, resultAddress))
} .setPositiveButton(android.R.string.ok) { _, _ -> saveRepository(resultAddress, fingerprint, authentication) }
if (allow) { .setNegativeButton(android.R.string.cancel, null)
onSaveRepositoryProceedInvalidate(resultAddress, fingerprint, authentication) .show()
} else { } else {
invalidateState() saveRepository(resultAddress, fingerprint, authentication)
} }
} }
invalidateState()
} else { } else {
onSaveRepositoryProceedInvalidate(address, fingerprint, authentication) saveRepository(address, fingerprint, authentication)
} }
}
}
private fun onSaveRepositoryProceedInvalidate(address: String, fingerprint: String, authentication: String) {
val binder = syncConnection.binder
if (binder != null) {
val repositoryId = repositoryId
if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) {
MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager)
invalidateState()
} else {
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
?.edit(address, fingerprint, authentication)
?: Repository.newRepository(address, fingerprint, authentication)
val changedRepository = Database.RepositoryAdapter.put(repository)
if (repositoryId == null && changedRepository.enabled) {
binder.sync(changedRepository)
}
requireActivity().onBackPressed()
}
} else {
invalidateState() invalidateState()
} }
} }
private class SimpleTextWatcher(private val callback: (Editable) -> Unit): TextWatcher { private fun saveRepository(address: String, fingerprint: String, authentication: String) {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit val repository = repositoryId?.let { Database.RepositoryAdapter.get(it) }
override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit if (repository != null) {
override fun afterTextChanged(s: Editable) = callback(s) 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() { class SelectMirrorDialog(): DialogFragment() {
companion object { private val mirrors: List<String>
private const val EXTRA_MIRRORS = "mirrors" get() = requireArguments().getStringArrayList("mirrors")!!
}
constructor(mirrors: List<String>): this() { constructor(mirrors: List<String>): this() {
arguments = Bundle().apply { arguments = Bundle().apply {
putStringArrayList(EXTRA_MIRRORS, ArrayList(mirrors)) putStringArrayList("mirrors", ArrayList(mirrors))
} }
} }
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!!
return AlertDialog.Builder(requireContext()) return AlertDialog.Builder(requireContext())
.setTitle(R.string.select_mirror) .setTitle(R.string.select_mirror)
.setItems(mirrors.toTypedArray()) { _, position -> (parentFragment as EditRepositoryFragment) .setItems(mirrors.toTypedArray()) { _, which ->
.setMirror(mirrors[position]) } (parentFragment as EditRepositoryFragment).setMirror(mirrors[which])
.setNegativeButton(R.string.cancel, null) }
.create() .create()
} }
} }
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Parcel import android.os.Parcel
import androidx.core.os.BundleCompat
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import nya.kitsunyan.foxydroid.R 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.PackageItemResolver
import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.android.*
import nya.kitsunyan.foxydroid.utility.extension.text.* import nya.kitsunyan.foxydroid.utility.extension.text.*
import androidx.core.net.toUri
class MessageDialog(): DialogFragment() { class MessageDialog(): DialogFragment() {
companion object { companion object {
@@ -36,7 +38,7 @@ class MessageDialog(): DialogFragment() {
companion object { companion object {
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { @Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
val uri = Uri.parse(it.readString()!!) val uri = it.readString()!!.toUri()
Link(uri) Link(uri)
} }
} }
@@ -124,7 +126,8 @@ class MessageDialog(): DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val dialog = AlertDialog.Builder(requireContext()) 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 -> { is Message.DeleteRepositoryConfirm -> {
dialog.setTitle(R.string.confirmation) dialog.setTitle(R.string.confirmation)
dialog.setMessage(R.string.delete_repository_DESC) dialog.setMessage(R.string.delete_repository_DESC)
@@ -157,7 +160,7 @@ class MessageDialog(): DialogFragment() {
val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0) val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0)
PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo) PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo)
?.nullIfEmpty()?.let { if (it == message.group) null else it } ?.nullIfEmpty()?.let { if (it == message.group) null else it }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
name ?: getString(R.string.unknown) name ?: getString(R.string.unknown)
@@ -169,7 +172,7 @@ class MessageDialog(): DialogFragment() {
val permissionInfo = packageManager.getPermissionInfo(permission, 0) val permissionInfo = packageManager.getPermissionInfo(permission, 0)
PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo) PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo)
?.nullIfEmpty()?.let { if (it == permission) null else it } ?.nullIfEmpty()?.let { if (it == permission) null else it }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
description?.let { builder.append(it).append("\n\n") } description?.let { builder.append(it).append("\n\n") }
@@ -18,11 +18,12 @@ import android.widget.Switch
import android.widget.TextView import android.widget.TextView
import android.widget.Toolbar import android.widget.Toolbar
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.R
import nya.kitsunyan.foxydroid.content.Preferences import nya.kitsunyan.foxydroid.content.Preferences
import nya.kitsunyan.foxydroid.utility.extension.resources.* import nya.kitsunyan.foxydroid.utility.extension.resources.*
import androidx.core.view.isNotEmpty
class PreferencesFragment: ScreenFragment() { class PreferencesFragment: ScreenFragment() {
private val preferences = mutableMapOf<Preferences.Key<*>, Preference<*>>() 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) content.addView(scroll, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
val scrollLayout = FrameLayout(content.context) val scrollLayout = FrameLayout(content.context)
scroll.addView(scrollLayout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) scroll.addView(scrollLayout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val preferences = LinearLayout(scrollLayout.context) val preferencesLayout = LinearLayout(scrollLayout.context)
preferences.orientation = LinearLayout.VERTICAL preferencesLayout.orientation = LinearLayout.VERTICAL
scrollLayout.addView(preferences, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) 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)) { addEnumeration(Preferences.Key.AutoSync, getString(R.string.sync_repositories_automatically)) {
when (it) { when (it) {
Preferences.AutoSync.Never -> getString(R.string.never) Preferences.AutoSync.Never -> getString(R.string.never)
@@ -63,7 +64,7 @@ class PreferencesFragment: ScreenFragment() {
addSwitch(Preferences.Key.UpdateUnstable, getString(R.string.unstable_updates), addSwitch(Preferences.Key.UpdateUnstable, getString(R.string.unstable_updates),
getString(R.string.unstable_updates_summary)) 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)) { addEnumeration(Preferences.Key.ProxyType, getString(R.string.proxy_type)) {
when (it) { when (it) {
is Preferences.ProxyType.Direct -> getString(R.string.no_proxy) 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)) addEditString(Preferences.Key.ProxyHost, getString(R.string.proxy_host))
addEditInt(Preferences.Key.ProxyPort, getString(R.string.proxy_port), 1 .. 65535) 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)) { addEnumeration(Preferences.Key.Theme, getString(R.string.theme)) {
when (it) { when (it) {
is Preferences.Theme.System -> getString(R.string.system) is Preferences.Theme.System -> getString(R.string.system)
@@ -86,7 +87,9 @@ class PreferencesFragment: ScreenFragment() {
getString(R.string.incompatible_versions_summary)) getString(R.string.incompatible_versions_summary))
} }
disposable = Preferences.observable.subscribe(this::updatePreference) disposable = Preferences.observable
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updatePreference)
updatePreference(null) updatePreference(null)
} }
@@ -138,12 +141,12 @@ class PreferencesFragment: ScreenFragment() {
callback() callback()
val divider = addDivider(true) val divider = addDivider(true)
// Negative margin for last divider // 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, private fun <T> LinearLayout.addPreference(key: Preferences.Key<T>, title: String,
summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?): Preference<T> { summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?): Preference<T> {
if (childCount > 0 && getChildAt(childCount - 1) !is TextView) { if (isNotEmpty() && getChildAt(childCount - 1) !is TextView) {
addDivider(false) addDivider(false)
} }
val preference = Preference(key, this@PreferencesFragment, this, title, summaryProvider, dialogProvider) 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, private fun <T> LinearLayout.addEdit(key: Preferences.Key<T>, title: String, valueToString: (T) -> String,
stringToValue: (String) -> T?, configureEdit: (EditText) -> Unit) { stringToValue: (String) -> T?, configureEdit: (EditText) -> Unit) {
addPreference(key, title, { valueToString(Preferences[key]) }) { addPreference(key, title, { valueToString(Preferences[key]) }) { context ->
val scroll = ScrollView(it) val scroll = ScrollView(context)
scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) } scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) }
val edit = EditText(it) val edit = EditText(context)
configureEdit(edit) configureEdit(edit)
edit.id = android.R.id.edit edit.id = android.R.id.edit
edit.setTextSizeScaled(16) edit.setTextSizeScaled(16)
@@ -173,12 +176,12 @@ class PreferencesFragment: ScreenFragment() {
edit.setSelection(edit.text.length) edit.setSelection(edit.text.length)
edit.requestFocus() edit.requestFocus()
scroll.addView(edit, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) scroll.addView(edit, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
AlertDialog.Builder(it) AlertDialog.Builder(context)
.setTitle(title) .setTitle(title)
.setView(scroll) .setView(scroll)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
val value = stringToValue(edit.text.toString()) ?: key.default.value val value = stringToValue(edit.text.toString()) ?: key.default.value
post { Preferences[key] = value } Preferences[key] = value
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.create() .create()
@@ -194,11 +197,10 @@ class PreferencesFragment: ScreenFragment() {
private fun LinearLayout.addEditInt(key: Preferences.Key<Int>, title: String, range: IntRange?) { private fun LinearLayout.addEditInt(key: Preferences.Key<Int>, title: String, range: IntRange?) {
addEdit(key, title, { it.toString() }, { it.toIntOrNull() }) { 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) { if (range != null) {
it.filters = arrayOf(InputFilter { source, start, end, dest, dstart, dend -> it.filters = arrayOf(InputFilter { source, start, end, dest, dstart, dend ->
val value = (dest.substring(0, dstart) + source.substring(start, end) + val value = "${dest.subSequence(0, dstart)}${source.subSequence(start, end)}${dest.subSequence(dend, dest.length)}".toIntOrNull()
dest.substring(dend, dest.length)).toIntOrNull()
if (value != null && value in range) null else "" if (value != null && value in range) null else ""
}) })
} }
@@ -207,14 +209,14 @@ class PreferencesFragment: ScreenFragment() {
private fun <T: Preferences.Enumeration<T>> LinearLayout private fun <T: Preferences.Enumeration<T>> LinearLayout
.addEnumeration(key: Preferences.Key<T>, title: String, valueToString: (T) -> String) { .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 val values = key.default.value.values
AlertDialog.Builder(it) AlertDialog.Builder(context)
.setTitle(title) .setTitle(title)
.setSingleChoiceItems(values.map(valueToString).toTypedArray(), .setSingleChoiceItems(values.map(valueToString).toTypedArray(),
values.indexOf(Preferences[key])) { dialog, which -> values.indexOf(Preferences[key])) { dialog, which ->
dialog.dismiss() dialog.dismiss()
post { Preferences[key] = values[which] } Preferences[key] = values[which]
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.create() .create()
@@ -222,7 +224,7 @@ class PreferencesFragment: ScreenFragment() {
} }
private class Preference<T>(private val key: Preferences.Key<T>, 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)?) { private val summaryProvider: () -> String, private val dialogProvider: ((Context) -> AlertDialog)?) {
val view = parent.inflate(R.layout.preference_item) val view = parent.inflate(R.layout.preference_item)
val title = view.findViewById<TextView>(R.id.title)!! val title = view.findViewById<TextView>(R.id.title)!!
@@ -276,10 +278,10 @@ class PreferencesFragment: ScreenFragment() {
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val preferences = (parentFragment as PreferencesFragment).preferences val preferencesFragment = parentFragment as PreferencesFragment
val key = requireArguments().getString(EXTRA_KEY)!! val key = requireArguments().getString(EXTRA_KEY)!!
.let { name -> preferences.keys.find { it.name == name }!! } .let { name -> preferencesFragment.preferences.keys.find { it.name == name }!! }
val preference = preferences[key]!! val preference = preferencesFragment.preferences[key]!!
return preference.createDialog(requireContext()) return preference.createDialog(requireContext())
} }
} }
@@ -38,6 +38,7 @@ import android.widget.Toast
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.util.LinkifyCompat import androidx.core.text.util.LinkifyCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.R
import nya.kitsunyan.foxydroid.content.Preferences import nya.kitsunyan.foxydroid.content.Preferences
@@ -61,6 +62,7 @@ import nya.kitsunyan.foxydroid.widget.StableRecyclerAdapter
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import kotlin.math.* import kotlin.math.*
import androidx.core.net.toUri
class ProductAdapter(private val callbacks: Callbacks, private val columns: Int): class ProductAdapter(private val callbacks: Callbacks, private val columns: Int):
StableRecyclerAdapter<ProductAdapter.ViewType, RecyclerView.ViewHolder>() { 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 descriptor: String
abstract val viewType: ViewType 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 override val descriptor: String
get() = "header" get() = "header"
@@ -138,7 +140,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = ViewType.HEADER 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 override val descriptor: String
get() = "switch.${switchType.name}" get() = "switch.${switchType.name}"
@@ -146,7 +148,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = ViewType.SWITCH 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() { val items: List<Item>, val collapseCount: Int): Item() {
constructor(sectionType: SectionType): this(sectionType, ExpandType.NOTHING, emptyList(), 0) 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 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 override val descriptor: String
get() = "expand.${expandType.name}" get() = "expand.${expandType.name}"
@@ -165,7 +167,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = ViewType.EXPAND 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 override val descriptor: String
get() = "text.${textType.name}" get() = "text.${textType.name}"
@@ -185,7 +187,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = uri?.schemeSpecificPart?.nullIfEmpty() get() = uri?.schemeSpecificPart?.nullIfEmpty()
?.let { if (it.startsWith("//")) null else it } ?: uri?.toString() ?.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 override val descriptor: String
get() = "link.typed.${linkType.name}" 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 override val descriptor: String
get() = "link.donate.$donate" get() = "link.donate.$donate"
@@ -222,17 +224,17 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
} }
override val uri: Uri? = when (donate) { override val uri: Uri? = when (donate) {
is Product.Donate.Regular -> Uri.parse(donate.url) is Product.Donate.Regular -> donate.url.toUri()
is Product.Donate.Bitcoin -> Uri.parse("bitcoin:${donate.address}") is Product.Donate.Bitcoin -> "bitcoin:${donate.address}".toUri()
is Product.Donate.Litecoin -> Uri.parse("litecoin:${donate.address}") is Product.Donate.Litecoin -> "litecoin:${donate.address}".toUri()
is Product.Donate.Flattr -> Uri.parse("https://flattr.com/thing/${donate.id}") is Product.Donate.Flattr -> "https://flattr.com/thing/${donate.id}".toUri()
is Product.Donate.Liberapay -> Uri.parse("https://liberapay.com/~${donate.id}") is Product.Donate.Liberapay -> "https://liberapay.com/~${donate.id}".toUri()
is Product.Donate.OpenCollective -> Uri.parse("https://opencollective.com/${donate.id}") 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 override val descriptor: String
get() = "permissions.${group?.name}.${permissions.joinToString(separator = ".") { it.name }}" 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 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() { val screenshot: Product.Screenshot): Item() {
override val descriptor: String override val descriptor: String
get() = "screenshot.${repository.id}.${screenshot.identifier}" get() = "screenshot.${repository.id}.${screenshot.identifier}"
@@ -249,7 +251,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = ViewType.SCREENSHOT 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() { val showSignature: Boolean): Item() {
override val descriptor: String override val descriptor: String
get() = "release.${repository.id}.${release.identifier}" get() = "release.${repository.id}.${release.identifier}"
@@ -258,7 +260,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
get() = ViewType.RELEASE get() = ViewType.RELEASE
} }
class EmptyItem(val packageName: String): Item() { data class EmptyItem(val packageName: String): Item() {
override val descriptor: String override val descriptor: String
get() = "empty" get() = "empty"
@@ -269,7 +271,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
private class Measurement<T: Any> { private class Measurement<T: Any> {
private var density = 0f private var density = 0f
private var scaledDensity = 0f private var fontScale = 0f
private lateinit var metric: T private lateinit var metric: T
fun measure(view: View) { 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 { fun invalidate(resources: Resources, callback: () -> T): T {
val (density, scaledDensity) = resources.displayMetrics.let { Pair(it.density, it.scaledDensity) } val (density, fontScale) = resources.displayMetrics.density to resources.configuration.fontScale
if (this.density != density || this.scaledDensity != scaledDensity) { if (this.density != density || this.fontScale != fontScale) {
this.density = density this.density = density
this.scaledDensity = scaledDensity this.fontScale = fontScale
metric = callback() metric = callback()
} }
return metric return metric
@@ -334,12 +336,12 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
init { init {
itemView as TextView itemView as TextView
itemView.typeface = TypefaceExtra.medium (itemView as TextView).typeface = TypefaceExtra.medium
itemView.setTextSizeScaled(14) (itemView as TextView).setTextSizeScaled(14)
itemView.setTextColor(itemView.context.getColorFromAttr(android.R.attr.colorAccent)) (itemView as TextView).setTextColor(itemView.context.getColorFromAttr(android.R.attr.colorAccent))
itemView.background = itemView.context.getDrawableFromAttr(android.R.attr.selectableItemBackground) itemView.background = itemView.context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
itemView.gravity = Gravity.CENTER (itemView as TextView).gravity = Gravity.CENTER
itemView.isAllCaps = true (itemView as TextView).isAllCaps = true
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
itemView.resources.sizeScaled(48)).apply { topMargin = -itemView.resources.sizeScaled(16) } 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 { init {
itemView as TextView itemView as TextView
itemView.setTextSizeScaled(14) (itemView as TextView).setTextSizeScaled(14)
itemView.setTextColor(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary)) (itemView as TextView).setTextColor(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary))
itemView.resources.sizeScaled(16).let { itemView.setPadding(it, it, it, it) } 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, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT) 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) { private open class OverlappingViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
init { init {
// Block touch events if touched above negative margin // Block touch events if touched above negative margin
itemView.setOnTouchListener { _, event -> @SuppressLint("ClickableViewAccessibility")
event.action == MotionEvent.ACTION_DOWN && run { itemView.setOnTouchListener { v, event ->
val top = (itemView.layoutParams as ViewGroup.MarginLayoutParams).topMargin val top = (v.layoutParams as ViewGroup.MarginLayoutParams).topMargin
top < 0 && event.y < -top 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.scaleType = ImageView.ScaleType.CENTER_CROP
image.setBackgroundColor(ColorUtils.blendARGB(backgroundColor, accentColor, 0.1f)) 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, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT) RecyclerView.LayoutParams.WRAP_CONTENT)
@@ -482,8 +483,8 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
init { init {
itemView as LinearLayout itemView as LinearLayout
itemView.orientation = LinearLayout.VERTICAL (itemView as LinearLayout).orientation = LinearLayout.VERTICAL
itemView.gravity = Gravity.CENTER (itemView as LinearLayout).gravity = Gravity.CENTER
itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) } itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) }
val title = TextView(itemView.context) val title = TextView(itemView.context)
title.gravity = Gravity.CENTER 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.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
title.setTextSizeScaled(20) title.setTextSizeScaled(20)
title.setText(R.string.application_not_found) 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) val packageName = TextView(itemView.context)
packageName.gravity = Gravity.CENTER packageName.gravity = Gravity.CENTER
packageName.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) packageName.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
packageName.setTextSizeScaled(16) 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, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT) RecyclerView.LayoutParams.MATCH_PARENT)
this.packageName = packageName 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) { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val holder = parent.getChildViewHolder(view) val holder = parent.getChildViewHolder(view)
if (holder is ScreenshotViewHolder) { if (holder is ScreenshotViewHolder) {
val position = holder.adapterPosition val position = holder.bindingAdapterPosition
if (position >= 0) { if (position >= 0) {
val first = items.subList(0, position).indexOfLast { it !is Item.ScreenshotItem } + 1 val first = items.subList(0, position).indexOfLast { it !is Item.ScreenshotItem } + 1
val gridCount = items.subList(first, items.size).indexOfFirst { it !is Item.ScreenshotItem } 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 { when {
nextItem == null || currentItem is Item.HeaderItem || nextItem == null || currentItem is Item.HeaderItem ||
nextItem is Item.TextItem && nextItem.textType == TextType.DESCRIPTION -> { 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 -> { 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.LinkItem && nextItem is Item.LinkItem ||
currentItem is Item.PermissionsItem && nextItem is Item.PermissionsItem -> { 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.SwitchItem && nextItem is Item.SwitchItem ||
currentItem is Item.ReleaseItem && nextItem is Item.ReleaseItem -> { 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 -> { 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 product: Product? = null
private var installedItem: InstalledItem? = 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, fun setProducts(context: Context, packageName: String,
products: List<Pair<Product, Repository>>, installedItem: InstalledItem?) { products: List<Pair<Product, Repository>>, installedItem: InstalledItem?) {
val productRepository = Product.findSuggested(products, installedItem) { it.first } val productRepository = Product.findSuggested(products, installedItem) { it.first }
items.clear() val newItems = mutableListOf<Item>()
if (productRepository != null) { if (productRepository != null) {
items += Item.HeaderItem(productRepository.second, productRepository.first) newItems += Item.HeaderItem(productRepository.second, productRepository.first)
if (installedItem != null) { 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)) { 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 textViewHolder = TextViewHolder(context)
val textViewWidthSpec = context.resources.displayMetrics.widthPixels val textViewWidthSpec = View.MeasureSpec.makeMeasureSpec(
.let { View.MeasureSpec.makeMeasureSpec(it, View.MeasureSpec.EXACTLY) } context.resources.displayMetrics.widthPixels, View.MeasureSpec.EXACTLY)
val textViewHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) val textViewHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
fun CharSequence.lineCropped(maxLines: Int, cropLines: Int): CharSequence? { 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 cropped = if (ExpandType.DESCRIPTION !in expanded) description.lineCropped(12, 10) else null
val item = Item.TextItem(TextType.DESCRIPTION, description) val item = Item.TextItem(TextType.DESCRIPTION, description)
if (cropped != null) { if (cropped != null) {
items += listOf(Item.TextItem(TextType.DESCRIPTION, cropped), newItems += listOf(Item.TextItem(TextType.DESCRIPTION, cropped),
Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item))) Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item)))
} else { } else {
items += item newItems += item
} }
} }
@@ -662,20 +686,20 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
} }
}.joinToString(separator = "\n") { "\u2022 $it" } }.joinToString(separator = "\n") { "\u2022 $it" }
if (antiFeatures.isNotEmpty()) { if (antiFeatures.isNotEmpty()) {
items += Item.SectionItem(SectionType.ANTI_FEATURES) newItems += Item.SectionItem(SectionType.ANTI_FEATURES)
items += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures) newItems += Item.TextItem(TextType.ANTI_FEATURES, antiFeatures)
} }
val changes = formatHtml(productRepository.first.whatsNew) val changes = formatHtml(productRepository.first.whatsNew)
if (changes.isNotEmpty()) { 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 cropped = if (ExpandType.CHANGES !in expanded) changes.lineCropped(12, 10) else null
val item = Item.TextItem(TextType.CHANGES, changes) val item = Item.TextItem(TextType.CHANGES, changes)
if (cropped != null) { if (cropped != null) {
items += listOf(Item.TextItem(TextType.CHANGES, cropped), newItems += listOf(Item.TextItem(TextType.CHANGES, cropped),
Item.ExpandItem(ExpandType.CHANGES, true, listOf(item))) Item.ExpandItem(ExpandType.CHANGES, true, listOf(item)))
} else { } 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)) linkItems += Item.LinkItem.Typed(LinkType.AUTHOR, author.name, author.web.nullIfEmpty()?.let(Uri::parse))
} }
author.email.nullIfEmpty()?.let { linkItems += Item.LinkItem 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, linkItems += licenses.asSequence().map { Item.LinkItem.Typed(LinkType.LICENSE, it,
Uri.parse("https://spdx.org/licenses/$it.html")) } "https://spdx.org/licenses/$it.html".toUri()) }
source.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.SOURCE, "", Uri.parse(it)) } source.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.SOURCE, "", it.toUri()) }
tracker.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "", Uri.parse(it)) } tracker.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.TRACKER, "",
changelog.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.CHANGELOG, "", Uri.parse(it)) } it.toUri()) }
web.nullIfEmpty()?.let { linkItems += Item.LinkItem.Typed(LinkType.WEB, "", Uri.parse(it)) } 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 (linkItems.isNotEmpty()) {
if (ExpandType.LINKS in expanded) { if (ExpandType.LINKS in expanded) {
items += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, emptyList(), linkItems.size) newItems += Item.SectionItem(SectionType.LINKS, ExpandType.LINKS, emptyList(), linkItems.size)
items += linkItems newItems += linkItems
} else { } 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) val donateItems = productRepository.first.donates.map(Item.LinkItem::Donate)
if (donateItems.isNotEmpty()) { if (donateItems.isNotEmpty()) {
if (ExpandType.DONATES in expanded) { if (ExpandType.DONATES in expanded) {
items += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, emptyList(), donateItems.size) newItems += Item.SectionItem(SectionType.DONATE, ExpandType.DONATES, emptyList(), donateItems.size)
items += donateItems newItems += donateItems
} else { } 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 { .asSequence().mapNotNull {
try { try {
packageManager.getPermissionInfo(it, 0) packageManager.getPermissionInfo(it, 0)
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@@ -727,7 +753,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
.asSequence().map { (group, permissionInfo) -> .asSequence().map { (group, permissionInfo) ->
val permissionGroupInfo = try { val permissionGroupInfo = try {
group?.let { packageManager.getPermissionGroupInfo(it, 0) } group?.let { packageManager.getPermissionGroupInfo(it, 0) }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
Pair(permissionGroupInfo, permissionInfo) Pair(permissionGroupInfo, permissionInfo)
@@ -740,11 +766,11 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
permissions.asSequence().find { it.key == null } permissions.asSequence().find { it.key == null }
?.let { permissionsItems += Item.PermissionsItem(null, it.value.flatten()) } ?.let { permissionsItems += Item.PermissionsItem(null, it.value.flatten()) }
if (ExpandType.PERMISSIONS in expanded) { if (ExpandType.PERMISSIONS in expanded) {
items += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS, newItems += Item.SectionItem(SectionType.PERMISSIONS, ExpandType.PERMISSIONS,
emptyList(), permissionsItems.size) emptyList(), permissionsItems.size)
items += permissionsItems newItems += permissionsItems
} else { } 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) } .map { Item.ScreenshotItem(productRepository.second, packageName, it) }
if (screenshotItems.isNotEmpty()) { if (screenshotItems.isNotEmpty()) {
if (ExpandType.SCREENSHOTS in expanded) { if (ExpandType.SCREENSHOTS in expanded) {
items += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, emptyList(), screenshotItems.size) newItems += Item.SectionItem(SectionType.SCREENSHOTS, ExpandType.SCREENSHOTS, emptyList(), screenshotItems.size)
items += screenshotItems newItems += screenshotItems
} else { } 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 } .sortedByDescending { it.release.versionCode }
.toList() .toList()
if (releaseItems.isNotEmpty()) { if (releaseItems.isNotEmpty()) {
items += Item.SectionItem(SectionType.VERSIONS) newItems += Item.SectionItem(SectionType.VERSIONS)
val maxReleases = 5 val maxReleases = 5
if (releaseItems.size > maxReleases && ExpandType.VERSIONS !in expanded) { if (releaseItems.size > maxReleases && ExpandType.VERSIONS !in expanded) {
items += releaseItems.take(maxReleases) newItems += releaseItems.take(maxReleases)
items += Item.ExpandItem(ExpandType.VERSIONS, false, releaseItems.takeLast(releaseItems.size - maxReleases)) newItems += Item.ExpandItem(ExpandType.VERSIONS, false, releaseItems.takeLast(releaseItems.size - maxReleases))
} else { } else {
items += releaseItems newItems += releaseItems
} }
} }
if (items.isEmpty()) { if (newItems.isEmpty()) {
items += Item.EmptyItem(packageName) newItems += Item.EmptyItem(packageName)
} }
val diffResult = DiffUtil.calculateDiff(ItemDiffCallback(items, newItems))
items.clear()
items.addAll(newItems)
this.product = productRepository?.first this.product = productRepository?.first
this.installedItem = installedItem this.installedItem = installedItem
notifyDataSetChanged() diffResult.dispatchUpdatesTo(this)
} }
private var action: Action? = null 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 { ViewType.SWITCH -> SwitchViewHolder(parent.inflate(R.layout.switch_item)).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
val switchItem = items[adapterPosition] as Item.SwitchItem val switchItem = items[bindingAdapterPosition] as Item.SwitchItem
val productPreference = when (switchItem.switchType) { val productPreference = when (switchItem.switchType) {
SwitchType.IGNORE_ALL_UPDATES -> { SwitchType.IGNORE_ALL_UPDATES -> {
ProductPreferences[switchItem.packageName].let { it.copy(ignoreUpdates = !it.ignoreUpdates) } 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 { ViewType.SECTION -> SectionViewHolder(parent.inflate(R.layout.section_item)).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
val position = adapterPosition val position = bindingAdapterPosition
val sectionItem = items[position] as Item.SectionItem val sectionItem = items[position] as Item.SectionItem
if (sectionItem.items.isNotEmpty()) { if (sectionItem.items.isNotEmpty()) {
expanded += sectionItem.expandType expanded += sectionItem.expandType
items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType, emptyList(), items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType, emptyList(),
sectionItem.items.size + sectionItem.collapseCount) sectionItem.items.size + sectionItem.collapseCount)
notifyItemChanged(adapterPosition, Payload.REFRESH) notifyItemChanged(bindingAdapterPosition, Payload.REFRESH)
items.addAll(position + 1, sectionItem.items) items.addAll(position + 1, sectionItem.items)
notifyItemRangeInserted(position + 1, sectionItem.items.size) notifyItemRangeInserted(position + 1, sectionItem.items.size)
} else if (sectionItem.collapseCount > 0) { } else if (sectionItem.collapseCount > 0) {
expanded -= sectionItem.expandType expanded -= sectionItem.expandType
items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType, items[position] = Item.SectionItem(sectionItem.sectionType, sectionItem.expandType,
items.subList(position + 1, position + 1 + sectionItem.collapseCount).toList(), 0) 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) } repeat(sectionItem.collapseCount) { items.removeAt(position + 1) }
notifyItemRangeRemoved(position + 1, sectionItem.collapseCount) 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 { ViewType.EXPAND -> ExpandViewHolder(parent.context).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
val position = adapterPosition val position = bindingAdapterPosition
val expandItem = items[position] as Item.ExpandItem val expandItem = items[position] as Item.ExpandItem
items.removeAt(position) items.removeAt(position)
expanded += expandItem.expandType expanded += expandItem.expandType
@@ -913,32 +943,32 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
ViewType.TEXT -> TextViewHolder(parent.context) ViewType.TEXT -> TextViewHolder(parent.context)
ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply { ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply {
itemView.setOnClickListener { 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) { if (linkItem.uri?.let { callbacks.onUriClick(it, false) } != true) {
linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) } linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) }
} }
} }
itemView.setOnLongClickListener { itemView.setOnLongClickListener {
val linkItem = items[adapterPosition] as Item.LinkItem val linkItem = items[bindingAdapterPosition] as Item.LinkItem
linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) } linkItem.displayLink?.let { copyLinkToClipboard(itemView.context, it) }
true true
} }
} }
ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)).apply { ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)).apply {
itemView.setOnClickListener { 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 }) callbacks.onPermissionsClick(permissionsItem.group?.name, permissionsItem.permissions.map { it.name })
} }
} }
ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context).apply { ViewType.SCREENSHOT -> ScreenshotViewHolder(parent.context).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
val screenshotItem = items[adapterPosition] as Item.ScreenshotItem val screenshotItem = items[bindingAdapterPosition] as Item.ScreenshotItem
callbacks.onScreenshotClick(screenshotItem.screenshot) callbacks.onScreenshotClick(screenshotItem.screenshot)
} }
} }
ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply { ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
val releaseItem = items[adapterPosition] as Item.ReleaseItem val releaseItem = items[bindingAdapterPosition] as Item.ReleaseItem
callbacks.onReleaseClick(releaseItem.release) callbacks.onReleaseClick(releaseItem.release)
} }
} }
@@ -989,7 +1019,6 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
holder.actionTintCancel else holder.actionTintNormal holder.actionTintCancel else holder.actionTintNormal
} }
} }
if (updateAll || updateStatus) {
val status = status val status = status
holder.statusLayout.visibility = if (status != null) View.VISIBLE else View.GONE holder.statusLayout.visibility = if (status != null) View.VISIBLE else View.GONE
if (status != null) { if (status != null) {
@@ -1013,8 +1042,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
} }
}::class }::class
} }
} Unit
Unit
} }
ViewType.SWITCH -> { ViewType.SWITCH -> {
holder as SwitchViewHolder holder as SwitchViewHolder
@@ -1094,10 +1122,10 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
val labelFromPackage = PackageItemResolver.loadLabel(context, localCache, permission) val labelFromPackage = PackageItemResolver.loadLabel(context, localCache, permission)
val label = labelFromPackage ?: run { val label = labelFromPackage ?: run {
val prefixes = listOf("android.permission.", "com.android.browser.permission.") 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) val transform = permission.name.substring(it.length)
if (transform.matches("[A-Z_]+".toRegex())) { if (transform.matches("[A-Z_]+".toRegex())) {
transform.split("_").joinToString(separator = " ") { it.toLowerCase(Locale.US) } transform.split("_").joinToString(separator = " ") { it.lowercase(Locale.US) }
} else { } else {
null null
} }
@@ -1106,7 +1134,7 @@ class ProductAdapter(private val callbacks: Callbacks, private val columns: Int)
if (label == null) { if (label == null) {
Pair(false, permission.name) Pair(false, permission.name)
} else { } else {
Pair(true, label.first().toUpperCase() + label.substring(1, label.length)) Pair(true, label.first().uppercaseChar() + label.substring(1, label.length))
} }
} }
val builder = SpannableStringBuilder() 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()) holder.signature.visibility = if (item.showSignature && item.release.signature.isNotEmpty())
View.VISIBLE else View.GONE View.VISIBLE else View.GONE
if (item.showSignature && item.release.signature.isNotEmpty()) { 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 signature = bytes.joinToString(separator = " ")
val builder = SpannableStringBuilder(context.getString(R.string.signature_FORMAT, signature)) val builder = SpannableStringBuilder(context.getString(R.string.signature_FORMAT, signature))
val index = builder.indexOf(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) { override fun onClick(view: View) {
val productAdapter = productAdapterReference.get() val productAdapter = productAdapterReference.get()
val uri = try { val uri = try {
Uri.parse(url) url.toUri()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null
@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.provider.Settings import android.provider.Settings
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
@@ -14,6 +15,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toolbar import android.widget.Toolbar
import androidx.core.os.BundleCompat
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager 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.Utils
import nya.kitsunyan.foxydroid.utility.extension.android.* import nya.kitsunyan.foxydroid.utility.extension.android.*
import nya.kitsunyan.foxydroid.widget.DividerItemDecoration import nya.kitsunyan.foxydroid.widget.DividerItemDecoration
import androidx.core.net.toUri
class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks { class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
companion object { companion object {
@@ -67,7 +70,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
val packageName: String val packageName: String
get() = requireArguments().getString(EXTRA_PACKAGE_NAME)!! 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 actions = Pair(emptySet<Action>(), null as Action?)
private var products = emptyList<Pair<Product, Repository>>() private var products = emptyList<Pair<Product, Repository>>()
@@ -100,7 +103,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
this.toolbar = toolbar this.toolbar = toolbar
toolbar.menu.apply { toolbar.menu.apply {
for (action in Action.values()) { for (action in Action.entries) {
add(0, action.id, 0, action.adapterAction.titleResId) add(0, action.id, 0, action.adapterAction.titleResId)
.setIcon(Utils.getToolbarIcon(toolbar.context, action.iconResId)) .setIcon(Utils.getToolbarIcon(toolbar.context, action.iconResId))
.setVisible(false) .setVisible(false)
@@ -130,42 +133,43 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
addOnScrollListener(scrollListener) addOnScrollListener(scrollListener)
addItemDecoration(adapter.gridItemDecoration) addItemDecoration(adapter.gridItemDecoration)
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider)) addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
savedInstanceState?.getParcelable<ProductAdapter.SavedState>(STATE_ADAPTER)?.let(adapter::restoreState) savedInstanceState?.let { bundle ->
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) BundleCompat.getParcelable(bundle, STATE_ADAPTER, ProductAdapter.SavedState::class.java)?.let(adapter::restoreState)
layoutManagerState = BundleCompat.getParcelable(bundle, STATE_LAYOUT_MANAGER, Parcelable::class.java)
}
recyclerView = this recyclerView = this
}, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) }, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
var first = true val first = true
productDisposable = Observable.just(Unit) productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products)) .concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.ProductAdapter.get(packageName, signal) } }
.flatMapSingle { products -> RxUtils .flatMapSingle { products -> RxUtils
.querySingle { Database.RepositoryAdapter.getAll(it) } .querySingle { signal -> Database.RepositoryAdapter.getAll(signal) }
.map { it.asSequence().map { Pair(it.id, it) }.toMap() .map { result ->
.let { products.mapNotNull { product -> it[product.repositoryId]?.let { Pair(product, it) } } } } } result.asSequence().map { Pair(it.id, it) }.toMap()
.let { map -> products.mapNotNull { product -> map[product.repositoryId]?.let { Pair(product, it) } } } } }
.flatMapSingle { products -> RxUtils .flatMapSingle { products -> RxUtils
.querySingle { Nullable(Database.InstalledAdapter.get(packageName, it)) } .querySingle { signal -> Nullable(Database.InstalledAdapter.get(packageName, signal)) }
.map { Pair(products, it) } } .map { result -> Pair(products, result) } }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { .subscribe { result ->
val (products, installedItem) = it val (products, installedItem) = result
val firstChanged = first val productChanged = this.products != products
first = false
val productChanged = this.products != products
val installedItemChanged = this.installed?.installedItem != installedItem.value val installedItemChanged = this.installed?.installedItem != installedItem.value
if (firstChanged || productChanged || installedItemChanged) { if (first || productChanged || installedItemChanged) {
layoutManagerState?.let { recyclerView?.layoutManager!!.onRestoreInstanceState(it) } layoutManagerState?.let { recyclerView?.layoutManager!!.onRestoreInstanceState(it) }
layoutManagerState = null layoutManagerState = null
if (firstChanged || productChanged) { if (first || productChanged) {
this.products = products this.products = products
} }
if (firstChanged || installedItemChanged) { if (first || installedItemChanged) {
installed = installedItem.value?.let { installed = installedItem.value?.let { it ->
val isSystem = try { val isSystem = try {
((requireContext().packageManager.getApplicationInfo(packageName, 0).flags) ((requireContext().packageManager.getApplicationInfo(packageName, 0).flags)
and ApplicationInfo.FLAG_SYSTEM) != 0 and ApplicationInfo.FLAG_SYSTEM) != 0
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
val launcherActivities = if (packageName == requireContext().packageName) { val launcherActivities = if (packageName == requireContext().packageName) {
@@ -173,9 +177,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
emptyList() emptyList()
} else { } else {
val packageManager = requireContext().packageManager val packageManager = requireContext().packageManager
packageManager val activities = packageManager
.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0) .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 -> .mapNotNull { activityInfo ->
val label = try { val label = try {
activityInfo.loadLabel(packageManager).toString() activityInfo.loadLabel(packageManager).toString()
@@ -190,10 +194,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
Installed(it, isSystem, launcherActivities) Installed(it, isSystem, launcherActivities)
} }
} }
val recyclerView = recyclerView!! recyclerView?.let {
val adapter = recyclerView.adapter as ProductAdapter val adapter = it.adapter as ProductAdapter
if (firstChanged || productChanged || installedItemChanged) { adapter.setProducts(it.context, packageName, products, installedItem.value)
adapter.setProducts(recyclerView.context, packageName, products, installedItem.value)
} }
updateButtons() updateButtons()
} }
@@ -289,7 +292,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
} }
val toolbar = toolbar val toolbar = toolbar
if (toolbar != null) { if (toolbar != null) {
for (action in Action.values()) { for (action in Action.entries) {
toolbar.menu.findItem(action.id).isVisible = action in displayActions toolbar.menu.findItem(action.id).isVisible = action in displayActions
} }
} }
@@ -336,11 +339,9 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
val compatibleReleases = productRepository?.first?.selectedReleases.orEmpty() val compatibleReleases = productRepository?.first?.selectedReleases.orEmpty()
.filter { installedItem == null || installedItem.signature == it.signature } .filter { installedItem == null || installedItem.signature == it.signature }
val release = if (compatibleReleases.size >= 2) { val release = if (compatibleReleases.size >= 2) {
compatibleReleases compatibleReleases
.filter { it.platforms.contains(Android.primaryPlatform) } .filter { it.platforms.contains(Android.primaryPlatform) }
.minBy { it.platforms.size } .minBy { it.platforms.size }
?: compatibleReleases.minBy { it.platforms.size }
?: compatibleReleases.firstOrNull()
} else { } else {
compatibleReleases.firstOrNull() compatibleReleases.firstOrNull()
} }
@@ -361,13 +362,11 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
} }
ProductAdapter.Action.DETAILS -> { ProductAdapter.Action.DETAILS -> {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:$packageName"))) .setData("package:$packageName".toUri()))
} }
ProductAdapter.Action.UNINSTALL -> { ProductAdapter.Action.UNINSTALL -> {
// TODO Handle deprecation startActivity(Intent(Intent.ACTION_DELETE)
@Suppress("DEPRECATION") .setData("package:$packageName".toUri()))
startActivity(Intent(Intent.ACTION_UNINSTALL_PACKAGE)
.setData(Uri.parse("package:$packageName")))
} }
ProductAdapter.Action.CANCEL -> { ProductAdapter.Action.CANCEL -> {
val binder = downloadConnection.binder val binder = downloadConnection.binder
@@ -400,7 +399,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
override fun onScreenshotClick(screenshot: Product.Screenshot) { override fun onScreenshotClick(screenshot: Product.Screenshot) {
val pair = products.asSequence() 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() .filter { it.second != null }.firstOrNull()
if (pair != null) { if (pair != null) {
val (repository, identifier) = pair val (repository, identifier) = pair
@@ -424,7 +423,7 @@ class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(childFragmentManager) MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(childFragmentManager)
} }
else -> { 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) { if (productRepository != null) {
downloadConnection.binder?.enqueue(packageName, productRepository.first.name, downloadConnection.binder?.enqueue(packageName, productRepository.first.name,
productRepository.second, release) productRepository.second, release)
@@ -1,6 +1,8 @@
package nya.kitsunyan.foxydroid.screen package nya.kitsunyan.foxydroid.screen
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.database.Cursor
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.view.Gravity import android.view.Gravity
@@ -46,7 +48,7 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
init { init {
itemView as FrameLayout itemView as FrameLayout
val progressBar = ProgressBar(itemView.context) 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 }) FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER })
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT) RecyclerView.LayoutParams.MATCH_PARENT)
@@ -59,11 +61,11 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
init { init {
itemView as TextView 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.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) }
itemView.typeface = TypefaceExtra.light (itemView as TextView).typeface = TypefaceExtra.light
itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) (itemView as TextView).setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
itemView.setTextSizeScaled(20) (itemView as TextView).setTextSizeScaled(20)
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT) RecyclerView.LayoutParams.MATCH_PARENT)
} }
@@ -75,18 +77,25 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
getProductItem(position + 1) else null getProductItem(position + 1) else null
when { when {
currentItem != null && nextItem != null && currentItem.matchRank != nextItem.matchRank -> { 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 -> { 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() var repositories: Map<Long, Repository> = emptyMap()
set(value) { set(value) {
field = value if (field != value) {
notifyDataSetChanged() field = value
notifyItemRangeChanged(0, itemCount)
}
} }
var emptyText: String = "" var emptyText: String = ""
@@ -94,7 +103,7 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
if (field != value) { if (field != value) {
field = value field = value
if (isEmpty) { 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply { 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.LOADING -> LoadingViewHolder(parent.context)
ViewType.EMPTY -> EmptyViewHolder(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() 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 -> { ViewType.LOADING -> {
// Do nothing // Do nothing
@@ -179,6 +193,25 @@ class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
holder as EmptyViewHolder holder as EmptyViewHolder
holder.text.text = emptyText 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.BundleCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -90,17 +91,21 @@ class ProductsFragment(): ScreenFragment(), CursorOwner.Callback {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
currentSearchQuery = savedInstanceState?.getString(STATE_CURRENT_SEARCH_QUERY).orEmpty() 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) currentOrder = savedInstanceState?.getString(STATE_CURRENT_ORDER)
?.let(ProductItem.Order::valueOf) ?: ProductItem.Order.NAME ?.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) screenActivity.cursorOwner.attach(this, request)
repositoriesDisposable = Observable.just(Unit) repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories)) .concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } }
.map { it.asSequence().map { Pair(it.id, it) }.toMap() } .map { result -> result.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { (recyclerView?.adapter as? ProductsAdapter)?.repositories = it } .subscribe { (recyclerView?.adapter as? ProductsAdapter)?.repositories = it }
} }
@@ -34,20 +34,28 @@ class RepositoriesAdapter(private val onClick: (Repository) -> Unit,
return Database.RepositoryAdapter.transform(moveTo(position)) return Database.RepositoryAdapter.transform(moveTo(position))
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return ViewHolder(parent.inflate(R.layout.repository_item)).apply { return ViewHolder(parent.inflate(R.layout.repository_item)).apply {
itemView.setOnClickListener { onClick(getRepository(adapterPosition)) } itemView.setOnClickListener {
enabled.setOnCheckedChangeListener { _, isChecked -> val position = bindingAdapterPosition
if (listenSwitch) { if (position != RecyclerView.NO_POSITION) {
if (!onSwitch(getRepository(adapterPosition), isChecked)) { onClick(getRepository(position))
listenSwitch = false }
enabled.isChecked = !isChecked }
listenSwitch = true enabled.setOnCheckedChangeListener { _, isChecked ->
} if (listenSwitch) {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
if (!onSwitch(getRepository(position), isChecked)) {
listenSwitch = false
enabled.isChecked = !isChecked
listenSwitch = true
}
}
}
}
} }
}
} }
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as ViewHolder holder as ViewHolder
@@ -113,15 +113,13 @@ class RepositoryFragment(): ScreenFragment() {
layout.addTitleText(R.string.name, repository.name) layout.addTitleText(R.string.name, repository.name)
layout.addTitleText(R.string.description, repository.description.replace('\n', ' ')) layout.addTitleText(R.string.description, repository.description.replace('\n', ' '))
layout.addTitleText(R.string.last_update, run { layout.addTitleText(R.string.last_update, run {
val lastUpdated = repository.updated repository.updated
if (lastUpdated > 0L) { run {
val date = Date(repository.updated) val date = Date(repository.updated)
val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
DateUtils.formatDateTime(layout.context, date.time, format) DateUtils.formatDateTime(layout.context, date.time, format)
} else { }
getString(R.string.unknown)
}
}) })
if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) { if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) {
layout.addTitleText(R.string.number_of_applications, layout.addTitleText(R.string.number_of_applications,
@@ -139,7 +137,7 @@ class RepositoryFragment(): ScreenFragment() {
} }
} else { } else {
val fingerprint = SpannableStringBuilder(repository.fingerprint.windowed(2, 2, false) 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, fingerprint.setSpan(TypefaceSpan("monospace"), 0, fingerprint.length,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE) SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
layout.addTitleText(R.string.fingerprint, fingerprint) layout.addTitleText(R.string.fingerprint, fingerprint)
@@ -158,9 +156,7 @@ class RepositoryFragment(): ScreenFragment() {
} }
} }
internal fun onDeleteConfirm() { internal fun onDeleteConfirm() {if (syncConnection.binder?.deleteRepository(repositoryId) == true) {
if (syncConnection.binder?.deleteRepository(repositoryId) == true) { screenActivity.onBackPressedDispatcher.onBackPressed()
requireActivity().onBackPressed() }}
}
}
} }
@@ -5,11 +5,13 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Parcel import android.os.Parcel
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toolbar 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.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import nya.kitsunyan.foxydroid.R import nya.kitsunyan.foxydroid.R
@@ -72,8 +74,7 @@ abstract class ScreenActivity: FragmentActivity() {
setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration)) setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration))
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or WindowCompat.setDecorFitsSystemWindows(window, false)
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
addContentView(FrameLayout(this).apply { id = R.id.main_content }, addContentView(FrameLayout(this).apply { id = R.id.main_content },
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) 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 .findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
} }
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK) savedInstanceState?.let {
?.let { fragmentStack += it } BundleCompat.getParcelableArrayList(it, STATE_FRAGMENT_STACK, FragmentStackItem::class.java)
?.let { list -> fragmentStack += list }
}
if (savedInstanceState == null) { if (savedInstanceState == null) {
replaceFragment(TabsFragment(), null) replaceFragment(TabsFragment(), null)
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
handleIntent(intent) 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) { override fun onSaveInstanceState(outState: Bundle) {
@@ -102,16 +123,6 @@ abstract class ScreenActivity: FragmentActivity() {
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack)) 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?) { private fun replaceFragment(fragment: Fragment, open: Boolean?) {
if (open != null) { if (open != null) {
currentFragment?.view?.translationZ = (if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat() 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 { private fun popFragment(): Boolean {
return fragmentStack.isNotEmpty() && run { return fragmentStack.isNotEmpty() && run {
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1) 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.arguments?.let(fragment::setArguments)
stackItem.savedState?.let(fragment::setInitialSavedState) stackItem.savedState?.let(fragment::setInitialSavedState)
replaceFragment(fragment, false) replaceFragment(fragment, false)
@@ -150,15 +161,10 @@ abstract class ScreenActivity: FragmentActivity() {
?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0) ?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
} }
override fun onAttachFragment(fragment: Fragment) {
super.onAttachFragment(fragment)
hideKeyboard()
}
internal fun onToolbarCreated(toolbar: Toolbar) { internal fun onToolbarCreated(toolbar: Toolbar) {
if (fragmentStack.isNotEmpty()) { if (fragmentStack.isNotEmpty()) {
toolbar.navigationIcon = toolbar.context.getDrawableFromAttr(android.R.attr.homeAsUpIndicator) 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 { } else {
Pair(Uri.fromFile(Cache.getReleaseFile(this, cacheFileName)), 0) Pair(Uri.fromFile(Cache.getReleaseFile(this, cacheFileName)), 0)
} }
// TODO Handle deprecation
@Suppress("DEPRECATION") startActivity(Intent(Intent.ACTION_VIEW)
startActivity(Intent(Intent.ACTION_INSTALL_PACKAGE) .setDataAndType(uri, "application/vnd.android.package-archive")
.setDataAndType(uri, "application/vnd.android.package-archive").setFlags(flags)) .addFlags(flags or Intent.FLAG_ACTIVITY_NEW_TASK))
} }
internal fun navigateProduct(packageName: String) = pushFragment(ProductFragment(packageName)) internal fun navigateProduct(packageName: String) = pushFragment(ProductFragment(packageName))
@@ -5,13 +5,16 @@ import android.content.Context
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.ColorUtils 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.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
@@ -65,10 +68,11 @@ class ScreenshotsFragment(): DialogFragment() {
val background = dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor val background = dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor
decorView.setBackgroundColor(background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.9f) }) decorView.setBackgroundColor(background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.9f) })
decorView.setPadding(0, 0, 0, 0) decorView.setPadding(0, 0, 0, 0)
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
window.statusBarColor = it WindowCompat.setDecorFitsSystemWindows(window, false)
window.navigationBarColor = it val insetsController = WindowCompat.getInsetsController(window, decorView)
} insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
window.attributes = window.attributes.apply { window.attributes = window.attributes.apply {
title = ScreenshotsFragment::class.java.name title = ScreenshotsFragment::class.java.name
format = PixelFormat.TRANSLUCENT format = PixelFormat.TRANSLUCENT
@@ -86,17 +90,16 @@ class ScreenshotsFragment(): DialogFragment() {
} }
} }
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or val applyHide = Runnable { insetsController.hide(WindowInsetsCompat.Type.systemBars()) }
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
val applyHide = Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
val handleClick = { val handleClick = {
decorView.removeCallbacks(applyHide) decorView.removeCallbacks(applyHide)
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) { val isVisible = decorView.rootWindowInsets?.let {
decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv() it.isVisible(WindowInsetsCompat.Type.statusBars()) || it.isVisible(WindowInsetsCompat.Type.navigationBars())
} ?: true
if (isVisible) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
} else { } else {
decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags insetsController.show(WindowInsetsCompat.Type.systemBars())
} }
} }
decorView.postDelayed(applyHide, 2000L) decorView.postDelayed(applyHide, 2000L)
@@ -112,21 +115,20 @@ class ScreenshotsFragment(): DialogFragment() {
ViewGroup.LayoutParams.MATCH_PARENT)) ViewGroup.LayoutParams.MATCH_PARENT))
this.viewPager = viewPager this.viewPager = viewPager
var restored = false val restored = false
productDisposable = Observable.just(Unit) productDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products)) .concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.ProductAdapter.get(packageName, signal) } }
.map { Pair(it.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) } .map { result -> Pair(result.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { .subscribe { result ->
val (product, repository) = it val (product, repository) = result
val screenshots = product?.screenshots.orEmpty() val screenshots = product?.screenshots.orEmpty()
(viewPager.adapter as Adapter).update(repository, screenshots) (viewPager.adapter as Adapter).update(repository, screenshots)
if (!restored) { if (!restored) {
restored = true val identifier = savedInstanceState?.getString(STATE_IDENTIFIER)
val identifier = savedInstanceState?.getString(STATE_IDENTIFIER) ?: requireArguments().getString(EXTRA_IDENTIFIER)
?: requireArguments().getString(STATE_IDENTIFIER)
if (identifier != null) { if (identifier != null) {
val index = screenshots.indexOfFirst { it.identifier == identifier } val index = screenshots.indexOfFirst { it.identifier == identifier }
if (index >= 0) { if (index >= 0) {
@@ -160,9 +162,11 @@ class ScreenshotsFragment(): DialogFragment() {
private class Adapter(private val packageName: String, private val onClick: () -> Unit): private class Adapter(private val packageName: String, private val onClick: () -> Unit):
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() { StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SCREENSHOT } enum class ViewType {
SECTION
}
private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) { private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) {
val image: ImageView val image: ImageView
get() = itemView as ImageView get() = itemView as ImageView
@@ -182,17 +186,28 @@ class ScreenshotsFragment(): DialogFragment() {
private var repository: Repository? = null private var repository: Repository? = null
private var screenshots = emptyList<Product.Screenshot>() 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>) { fun update(repository: Repository?, screenshots: List<Product.Screenshot>) {
this.repository = repository this.repository = repository
val diffResult = DiffUtil.calculateDiff(ScreenshotDiffCallback(this.screenshots, screenshots))
this.screenshots = screenshots this.screenshots = screenshots
notifyDataSetChanged() diffResult.dispatchUpdatesTo(this)
} }
var size = Pair(0, 0) var size = Pair(0, 0)
set(value) { set(value) {
if (field != value) { if (field != value) {
field = value field = value
notifyDataSetChanged() notifyItemRangeChanged(0, itemCount)
} }
} }
@@ -206,7 +221,7 @@ class ScreenshotsFragment(): DialogFragment() {
override fun getItemCount(): Int = screenshots.size override fun getItemCount(): Int = screenshots.size
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return ViewHolder(parent.context).apply { return ViewHolder(parent.context).apply {
@@ -22,7 +22,10 @@ import android.widget.LinearLayout
import android.widget.SearchView import android.widget.SearchView
import android.widget.TextView import android.widget.TextView
import android.widget.Toolbar import android.widget.Toolbar
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
@@ -75,10 +78,10 @@ class TabsFragment: ScreenFragment() {
if (field != value) { if (field != value) {
field = value field = value
val layout = layout val layout = layout
layout?.tabs?.let { (0 until it.childCount) layout?.tabs?.let { tabs -> (0 until tabs.childCount)
.forEach { index -> it.getChildAt(index)!!.isEnabled = !value } } .forEach { index -> tabs.getChildAt(index)!!.isEnabled = !value } }
layout?.sectionIcon?.scaleY = if (value) -1f else 1f 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() animateSectionsList()
} }
} }
@@ -89,8 +92,8 @@ class TabsFragment: ScreenFragment() {
private var section: ProductItem.Section = ProductItem.Section.All private var section: ProductItem.Section = ProductItem.Section.All
private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ -> private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ ->
viewPager?.let { viewPager?.let { pager ->
val source = ProductsFragment.Source.values()[it.currentItem] val source = ProductsFragment.Source.entries[pager.currentItem]
updateUpdateNotificationBlocker(source) updateUpdateNotificationBlocker(source)
} }
}) })
@@ -195,7 +198,7 @@ class TabsFragment: ScreenFragment() {
layout.tabs.background = TabsBackgroundDrawable(layout.tabs.context, layout.tabs.background = TabsBackgroundDrawable(layout.tabs.context,
layout.tabs.layoutDirection == View.LAYOUT_DIRECTION_RTL) layout.tabs.layoutDirection == View.LAYOUT_DIRECTION_RTL)
ProductsFragment.Source.values().forEach { ProductsFragment.Source.entries.forEach { source ->
val tab = TextView(layout.tabs.context) val tab = TextView(layout.tabs.context)
val selectedColor = tab.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor val selectedColor = tab.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
val normalColor = tab.context.getColorFromAttr(android.R.attr.textColorSecondary).defaultColor val normalColor = tab.context.getColorFromAttr(android.R.attr.textColorSecondary).defaultColor
@@ -205,25 +208,25 @@ class TabsFragment: ScreenFragment() {
intArrayOf(selectedColor, normalColor))) intArrayOf(selectedColor, normalColor)))
tab.setTextSizeScaled(14) tab.setTextSizeScaled(14)
tab.isAllCaps = true tab.isAllCaps = true
tab.text = getString(it.titleResId) tab.text = getString(source.titleResId)
tab.background = tab.context.getDrawableFromAttr(android.R.attr.selectableItemBackground) tab.background = tab.context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
tab.setOnClickListener { _ -> tab.setOnClickListener {
setSelectedTab(it) setSelectedTab(source)
viewPager!!.setCurrentItem(it.ordinal, Utils.areAnimationsEnabled(tab.context)) viewPager!!.setCurrentItem(source.ordinal, Utils.areAnimationsEnabled(tab.context))
} }
layout.tabs.addView(tab, 0, LinearLayout.LayoutParams.MATCH_PARENT) layout.tabs.addView(tab, 0, LinearLayout.LayoutParams.MATCH_PARENT)
(tab.layoutParams as LinearLayout.LayoutParams).weight = 1f (tab.layoutParams as LinearLayout.LayoutParams).weight = 1f
} }
showSections = savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0 != 0 showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0
sections = savedInstanceState?.getParcelableArrayList<ProductItem.Section>(STATE_SECTIONS).orEmpty() sections = savedInstanceState?.let { BundleCompat.getParcelableArrayList(it, STATE_SECTIONS, ProductItem.Section::class.java) }.orEmpty()
section = savedInstanceState?.getParcelable(STATE_SECTION) ?: ProductItem.Section.All section = savedInstanceState?.let { BundleCompat.getParcelable(it, STATE_SECTION, ProductItem.Section::class.java) } ?: ProductItem.Section.All
layout.sectionChange.setOnClickListener { showSections = sections layout.sectionChange.setOnClickListener { showSections = sections
.any { it !is ProductItem.Section.All } && !showSections } .any { it !is ProductItem.Section.All } && !showSections }
updateOrder() updateOrder()
sortOrderDisposable = Preferences.observable.subscribe { sortOrderDisposable = Preferences.observable.subscribe { key ->
if (it == Preferences.Key.SortOrder) { if (key == Preferences.Key.SortOrder) {
updateOrder() updateOrder()
} }
} }
@@ -233,9 +236,8 @@ class TabsFragment: ScreenFragment() {
viewPager = ViewPager2(content.context).apply { viewPager = ViewPager2(content.context).apply {
id = R.id.fragment_pager id = R.id.fragment_pager
adapter = object: FragmentStateAdapter(this@TabsFragment) { adapter = object: FragmentStateAdapter(this@TabsFragment) {
override fun getItemCount(): Int = ProductsFragment.Source.values().size override fun getItemCount(): Int = ProductsFragment.Source.entries.size
override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment.Source.entries[position])
.Source.values()[position])
} }
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
registerOnPageChangeCallback(pageChangeCallback) registerOnPageChangeCallback(pageChangeCallback)
@@ -245,16 +247,17 @@ class TabsFragment: ScreenFragment() {
categoriesDisposable = Observable.just(Unit) categoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products)) .concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.CategoryAdapter.getAll(it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.CategoryAdapter.getAll(signal) } }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { setSectionsAndUpdate(it.asSequence().sorted() .subscribe { result -> setSectionsAndUpdate(result.asSequence().sorted()
.map(ProductItem.Section::Category).toList(), null) } .map(ProductItem.Section::Category).toList(), null) }
repositoriesDisposable = Observable.just(Unit) repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories)) .concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } } .flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } }
.observeOn(AndroidSchedulers.mainThread()) .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()) } .map { ProductItem.Section.Repository(it.id, it.name) }.toList()) }
updateSection() updateSection()
@@ -264,10 +267,10 @@ class TabsFragment: ScreenFragment() {
isMotionEventSplittingEnabled = false isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false isVerticalScrollBarEnabled = false
setHasFixedSize(true) setHasFixedSize(true)
val adapter = SectionsAdapter({ sections }) { val adapter = SectionsAdapter({ sections }) { newSection ->
if (showSections) { if (showSections) {
showSections = false showSections = false
section = it section = newSection
updateSection() updateSection()
} }
} }
@@ -280,14 +283,13 @@ class TabsFragment: ScreenFragment() {
} }
this.sectionsList = sectionsList this.sectionsList = sectionsList
var lastContentHeight = -1 val lastContentHeight = -1
content.viewTreeObserver.addOnGlobalLayoutListener { content.viewTreeObserver.addOnGlobalLayoutListener {
if (this.view != null) { if (this.view != null) {
val initial = lastContentHeight <= 0 val initial = true
val contentHeight = content.height val contentHeight = content.height
if (lastContentHeight != contentHeight) { if (lastContentHeight != contentHeight) {
lastContentHeight = contentHeight if (initial) {
if (initial) {
sectionsList.layoutParams.height = if (showSections) contentHeight else 0 sectionsList.layoutParams.height = if (showSections) contentHeight else 0
sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE
sectionsList.requestLayout() sectionsList.requestLayout()
@@ -340,7 +342,9 @@ class TabsFragment: ScreenFragment() {
} }
} }
@Deprecated("Deprecated in Java")
override fun onAttachFragment(childFragment: Fragment) { override fun onAttachFragment(childFragment: Fragment) {
@Suppress("DEPRECATION")
super.onAttachFragment(childFragment) super.onAttachFragment(childFragment)
if (view != null && childFragment is ProductsFragment) { if (view != null && childFragment is ProductsFragment) {
@@ -368,7 +372,7 @@ class TabsFragment: ScreenFragment() {
private fun setSelectedTab(source: ProductsFragment.Source) { private fun setSelectedTab(source: ProductsFragment.Source) {
val layout = layout!! 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) internal fun selectUpdates() = selectUpdatesInternal(true)
@@ -407,10 +411,20 @@ class TabsFragment: ScreenFragment() {
val oldCategories = collectOldSections(categories) val oldCategories = collectOldSections(categories)
val oldRepositories = collectOldSections(repositories) val oldRepositories = collectOldSections(repositories)
if (oldCategories == null || oldRepositories == null) { if (oldCategories == null || oldRepositories == null) {
val oldSections = sections
sections = listOf(ProductItem.Section.All) + sections = listOf(ProductItem.Section.All) +
(categories ?: oldCategories).orEmpty() + (categories ?: oldCategories).orEmpty() +
(repositories ?: oldRepositories).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() updateSection()
sectionsList?.adapter?.let { adapter -> diffResult.dispatchUpdatesTo(adapter) }
} }
} }
@@ -418,14 +432,19 @@ class TabsFragment: ScreenFragment() {
if (section !in sections) { if (section !in sections) {
section = ProductItem.Section.All 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.All -> getString(R.string.all_applications)
is ProductItem.Section.Category -> section.name is ProductItem.Section.Category -> s.name
is ProductItem.Section.Repository -> section.name is ProductItem.Section.Repository -> s.name
} }
layout?.sectionIcon?.visibility = if (sections.any { it !is ProductItem.Section.All }) View.VISIBLE else View.GONE layout?.sectionIcon?.visibility = if (sections.any { it !is ProductItem.Section.All }) View.VISIBLE else View.GONE
productFragments.forEach { it.setSection(section) } 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() { private fun animateSectionsList() {
@@ -440,16 +459,16 @@ class TabsFragment: ScreenFragment() {
sectionsAnimator = ValueAnimator.ofFloat(value, target).apply { sectionsAnimator = ValueAnimator.ofFloat(value, target).apply {
duration = (250 * abs(target - value)).toLong() duration = (250 * abs(target - value)).toLong()
interpolator = if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f) interpolator = if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f)
addUpdateListener { addUpdateListener { animator ->
val newValue = animatedValue as Float val newValue = animator.animatedValue as Float
sectionsList.apply { sectionsList.apply {
val height = ((parent as View).height * newValue).toInt() val h = ((parent as View).height * newValue).toInt()
val visible = height > 0 val visible = h > 0
if ((visibility == View.VISIBLE) != visible) { if ((isVisible) != visible) {
visibility = if (visible) View.VISIBLE else View.GONE visibility = if (visible) View.VISIBLE else View.GONE
} }
if (layoutParams.height != height) { if (layoutParams.height != h) {
layoutParams.height = height layoutParams.height = h
requestLayout() requestLayout()
} }
} }
@@ -464,30 +483,30 @@ class TabsFragment: ScreenFragment() {
private val pageChangeCallback = object: ViewPager2.OnPageChangeCallback() { private val pageChangeCallback = object: ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
val layout = layout!! val l = layout!!
val fromSections = ProductsFragment.Source.values()[position].sections val fromSections = ProductsFragment.Source.entries[position].sections
val toSections = if (positionOffset <= 0f) fromSections else val toSections = if (positionOffset <= 0f) fromSections else
ProductsFragment.Source.values()[position + 1].sections ProductsFragment.Source.entries[position + 1].sections
val offset = if (fromSections != toSections) { val offset = if (fromSections != toSections) {
if (fromSections) 1f - positionOffset else positionOffset if (fromSections) 1f - positionOffset else positionOffset
} else { } else {
if (fromSections) 1f else 0f if (fromSections) 1f else 0f
} }
(layout.tabs.background as TabsBackgroundDrawable) (l.tabs.background as TabsBackgroundDrawable)
.update(position + positionOffset, layout.tabs.childCount) .update(position + positionOffset, l.tabs.childCount)
assert(layout.sectionLayout.childCount == 1) assert(l.sectionLayout.childCount == 1)
val child = layout.sectionLayout.getChildAt(0) val child = l.sectionLayout.getChildAt(0)
val height = child.layoutParams.height val h = child.layoutParams.height
assert(height > 0) assert(h > 0)
val currentHeight = (offset * height).roundToInt() val currentHeight = (offset * h).roundToInt()
if (layout.sectionLayout.layoutParams.height != currentHeight) { if (l.sectionLayout.layoutParams.height != currentHeight) {
layout.sectionLayout.layoutParams.height = currentHeight l.sectionLayout.layoutParams.height = currentHeight
layout.sectionLayout.requestLayout() l.sectionLayout.requestLayout()
} }
} }
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
val source = ProductsFragment.Source.values()[position] val source = ProductsFragment.Source.entries[position]
updateUpdateNotificationBlocker(source) updateUpdateNotificationBlocker(source)
sortOrderMenu!!.first.isVisible = source.order sortOrderMenu!!.first.isVisible = source.order
syncRepositoriesMenuItem!!.setShowAsActionFlags(if (!source.order || syncRepositoriesMenuItem!!.setShowAsActionFlags(if (!source.order ||
@@ -499,7 +518,7 @@ class TabsFragment: ScreenFragment() {
} }
override fun onPageScrollStateChanged(state: Int) { 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 layout!!.sectionChange.isEnabled = state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections
if (state == ViewPager2.SCROLL_STATE_IDLE) { if (state == ViewPager2.SCROLL_STATE_IDLE) {
// onPageSelected can be called earlier than fragments created // 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 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 { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColorFromAttr(android.R.attr.colorAccent).defaultColor color = context.getColorFromAttr(android.R.attr.colorAccent).defaultColor
} }
@@ -525,15 +544,15 @@ class TabsFragment: ScreenFragment() {
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
if (total > 0) { if (total > 0) {
val bounds = bounds val b = bounds
val width = bounds.width() / total.toFloat() val w = b.width() / total.toFloat()
val x = width * position val x = w * position
if (rtl) { if (rtl) {
canvas.drawRect(bounds.right - width - x, (bounds.bottom - height).toFloat(), canvas.drawRect(b.right - w - x, (b.bottom - h).toFloat(),
bounds.right - x, bounds.bottom.toFloat(), paint) b.right - x, b.bottom.toFloat(), paint)
} else { } else {
canvas.drawRect(bounds.left + x, (bounds.bottom - height).toFloat(), canvas.drawRect(b.left + x, (b.bottom - h).toFloat(),
bounds.left + x + width, bounds.bottom.toFloat(), paint) b.left + x + w, b.bottom.toFloat(), paint)
} }
} }
} }
@@ -554,10 +573,10 @@ class TabsFragment: ScreenFragment() {
init { init {
itemView as TextView 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.resources.sizeScaled(16).let { itemView.setPadding(it, 0, it, 0) }
itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary)) (itemView as TextView).setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
itemView.setTextSizeScaled(16) (itemView as TextView).setTextSizeScaled(16)
itemView.background = context.getDrawableFromAttr(android.R.attr.selectableItemBackground) itemView.background = context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
itemView.resources.sizeScaled(48)) itemView.resources.sizeScaled(48))
@@ -570,10 +589,15 @@ class TabsFragment: ScreenFragment() {
when { when {
nextSection != null && currentSection.javaClass != nextSection.javaClass -> { nextSection != null && currentSection.javaClass != nextSection.javaClass -> {
val padding = context.resources.sizeScaled(16) val padding = context.resources.sizeScaled(16)
configuration.set(true, false, padding, padding) configuration.set(
needDivider = true,
toTop = false,
paddingStart = padding,
paddingEnd = padding
)
} }
else -> { 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return SectionViewHolder(parent.context).apply { 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.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
@@ -29,6 +29,7 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.* import kotlin.math.*
import androidx.core.net.toUri
class DownloadService: ConnectionService<DownloadService.Binder>() { class DownloadService: ConnectionService<DownloadService.Binder>() {
companion object { companion object {
@@ -47,14 +48,14 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
action.startsWith("$ACTION_OPEN.") -> { action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1) val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(Intent(context, MainActivity::class.java) 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)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} }
action.startsWith("$ACTION_INSTALL.") -> { action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1) val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME) val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(Intent(context, MainActivity::class.java) 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) .putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} }
@@ -189,8 +190,10 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
.setSmallIcon(android.R.drawable.stat_sys_warning) .setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) .getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java) .setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"), PendingIntent.FLAG_UPDATE_CURRENT)) .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 { .apply {
when (errorType) { when (errorType) {
is ErrorType.Network -> { is ErrorType.Network -> {
@@ -223,9 +226,11 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) .getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getBroadcast(this, 0, Intent(this, Receiver::class.java) .setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}") .setAction(MainActivity.ACTION_INSTALL).setData("package:${task.packageName}".toUri())
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName), PendingIntent.FLAG_UPDATE_CURRENT)) .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)) .setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC)) .setContentText(getString(R.string.tap_to_install_DESC))
.build()) .build())
@@ -243,12 +248,12 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
val hash = try { val hash = try {
val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256" val hashType = task.release.hashType.nullIfEmpty() ?: "SHA256"
val digest = MessageDigest.getInstance(hashType) val digest = MessageDigest.getInstance(hashType)
file.inputStream().use { file.inputStream().use { it ->
val bytes = ByteArray(8 * 1024) val bytes = ByteArray(8 * 1024)
generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) } generateSequence { it.read(bytes) }.takeWhile { it >= 0 }.forEach { digest.update(bytes, 0, it) }
digest.digest().hex() digest.digest().hex()
} }
} catch (e: Exception) { } catch (_: Exception) {
"" ""
} }
return if (hash.isEmpty() || hash != task.release.hash) { 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) .setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) .getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0, .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) { private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask != null) { if (force || currentTask != null) {
@@ -313,7 +319,7 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
throw IllegalStateException() throw IllegalStateException()
} }
}::class }::class
}.build()) }.build(), Android.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
stateSubject.onNext(state) stateSubject.onNext(state)
} }
} }
@@ -361,7 +367,7 @@ class DownloadService: ConnectionService<DownloadService.Binder>() {
currentTask = CurrentTask(task, disposable, initialState) currentTask = CurrentTask(task, disposable, initialState)
} else if (started) { } else if (started) {
started = false started = false
stopForeground(true) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
@@ -11,6 +11,7 @@ import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
@@ -123,11 +124,7 @@ class SyncService: ConnectionService<SyncService.Binder>() {
return true return true
} }
fun isCurrentlySyncing(repositoryId: Long): Boolean { fun deleteRepository(repositoryId: Long): Boolean {
return currentTask?.task?.repositoryId == repositoryId
}
fun deleteRepository(repositoryId: Long): Boolean {
val repository = Database.RepositoryAdapter.get(repositoryId) val repository = Database.RepositoryAdapter.get(repositoryId)
return repository != null && run { return repository != null && run {
setEnabled(repository, false) setEnabled(repository, false)
@@ -220,7 +217,8 @@ class SyncService: ConnectionService<SyncService.Binder>() {
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) .getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.addAction(0, getString(R.string.cancel), PendingIntent.getService(this, 0, .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) { private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask?.lastState != state) { if (force || currentTask?.lastState != state) {
@@ -267,7 +265,7 @@ class SyncService: ConnectionService<SyncService.Binder>() {
setProgress(0, 0, true) setProgress(0, 0, true)
} }
}::class }::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] val unstable = Preferences[Preferences.Key.UpdateUnstable]
lateinit var disposable: Disposable lateinit var disposable: Disposable
disposable = RepositoryUpdater disposable = RepositoryUpdater
.update(repository, unstable) { stage, progress, total -> .update(this, repository, unstable) { stage, progress, total ->
if (!disposable.isDisposed) { if (!disposable.isDisposed) {
stateSubject.onNext(State.Syncing(repository.name, stage, progress, total)) stateSubject.onNext(State.Syncing(repository.name, stage, progress, total))
} }
@@ -315,8 +313,16 @@ class SyncService: ConnectionService<SyncService.Binder>() {
} else if (started != Started.NO) { } else if (started != Started.NO) {
if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) { if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) {
val disposable = RxUtils val disposable = RxUtils
.querySingle { Database.ProductAdapter .querySingle { it ->
.query(true, true, "", ProductItem.Section.All, ProductItem.Order.NAME, 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() } } .use { it.asSequence().map(Database.ProductAdapter::transformItem).toList() } }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@@ -335,7 +341,7 @@ class SyncService: ConnectionService<SyncService.Binder>() {
val needStop = started == Started.MANUAL val needStop = started == Started.MANUAL
started = Started.NO started = Started.NO
if (needStop) { if (needStop) {
stopForeground(true) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
@@ -355,7 +361,8 @@ class SyncService: ConnectionService<SyncService.Binder>() {
.setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light) .setColor(ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(android.R.attr.colorAccent).defaultColor) .getColorFromAttr(android.R.attr.colorAccent).defaultColor)
.setContentIntent(PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java) .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 { .setStyle(NotificationCompat.InboxStyle().applyHack {
for (productItem in productItems.take(maxUpdates)) { for (productItem in productItems.take(maxUpdates)) {
val builder = SpannableStringBuilder(productItem.name) val builder = SpannableStringBuilder(productItem.name)
@@ -40,7 +40,7 @@ object PackageItemResolver {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
resources.updateConfiguration(context.resources.configuration, null) resources.updateConfiguration(context.resources.configuration, null)
resources resources
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
resources?.let { localCache.resources[packageName] = it } resources?.let { localCache.resources[packageName] = it }
@@ -21,63 +21,63 @@ 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 { return Single.create {
val task = create() val task = create()
val thread = Thread.currentThread() val thread = Thread.currentThread()
val disposable = ManagedDisposable { val disposable = ManagedDisposable {
thread.interrupt() thread.interrupt()
cancel(task) cancel(task)
} }
it.setDisposable(disposable) it.setDisposable(disposable)
if (!disposable.isDisposed) { if (!disposable.isDisposed) {
val result = try { val result = try {
execute(task) execute(task)
} catch (e: Throwable) { } catch (e: Throwable) {
Exceptions.throwIfFatal(e) Exceptions.throwIfFatal(e)
if (!disposable.isDisposed) { if (!disposable.isDisposed) {
try { try {
it.onError(e) it.onError(e)
} catch (inner: Throwable) { } catch (inner: Throwable) {
Exceptions.throwIfFatal(inner) Exceptions.throwIfFatal(inner)
RxJavaPlugins.onError(CompositeException(e, inner)) RxJavaPlugins.onError(CompositeException(e, inner))
}
}
null
}
if (result != null && !disposable.isDisposed) {
it.onSuccess(result)
} }
}
null
} }
if (result != null && !disposable.isDisposed) {
it.onSuccess(result)
}
}
} }
} }
fun <R> managedSingle(execute: () -> R): Single<R> { fun <R : Any> managedSingle(execute: () -> R): Single<R> {
return managedSingle({ Unit }, { }, { execute() }) return managedSingle({ }, { }, { execute() })
} }
fun callSingle(create: () -> Call): Single<Response> { fun callSingle(create: () -> Call): Single<Response> {
return managedSingle(create, Call::cancel, Call::execute) 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 { return Single.create {
val cancellationSignal = CancellationSignal() val cancellationSignal = CancellationSignal()
it.setCancellable { it.setCancellable {
try { try {
cancellationSignal.cancel() cancellationSignal.cancel()
} catch (e: OperationCanceledException) { } catch (_: OperationCanceledException) {
// Do nothing // Do nothing
}
}
val result = try {
query(cancellationSignal)
} catch (_: OperationCanceledException) {
null
}
if (result != null) {
it.onSuccess(result)
} }
}
val result = try {
query(cancellationSignal)
} catch (e: OperationCanceledException) {
null
}
if (result != null) {
it.onSuccess(result)
}
} }
} }
} }
@@ -1,6 +1,7 @@
package nya.kitsunyan.foxydroid.utility package nya.kitsunyan.foxydroid.utility
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.Signature import android.content.pm.Signature
import android.content.res.Configuration import android.content.res.Configuration
@@ -35,14 +36,14 @@ object Utils {
return drawable return drawable
} }
fun calculateHash(signature: Signature): String? { fun calculateHash(signature: Signature): String {
return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray()).hex() return MessageDigest.getInstance("MD5").digest(signature.toCharsString().toByteArray()).hex()
} }
fun calculateFingerprint(certificate: Certificate): String { fun calculateFingerprint(certificate: Certificate): String {
val encoded = try { val encoded = try {
certificate.encoded certificate.encoded
} catch (e: CertificateEncodingException) { } catch (_: CertificateEncodingException) {
null null
} }
return encoded?.let(::calculateFingerprint).orEmpty() return encoded?.let(::calculateFingerprint).orEmpty()
@@ -66,6 +67,7 @@ object Utils {
} }
} }
@SuppressLint("SuspiciousIndentation")
fun configureLocale(context: Context): Context { fun configureLocale(context: Context): Context {
val supportedLanguages = BuildConfig.LANGUAGES.toSet() val supportedLanguages = BuildConfig.LANGUAGES.toSet()
val configuration = context.resources.configuration val configuration = context.resources.configuration
@@ -78,15 +80,10 @@ object Utils {
} }
val compatibleLocales = currentLocales val compatibleLocales = currentLocales
.filter { it.language in supportedLanguages } .filter { it.language in supportedLanguages }
.let { if (it.isEmpty()) listOf(Locale.US) else it } .let { it.ifEmpty { listOf(Locale.US) } }
Locale.setDefault(compatibleLocales.first()) Locale.setDefault(compatibleLocales.first())
val newConfiguration = Configuration(configuration) val newConfiguration = Configuration(configuration)
if (Android.sdk(24)) {
newConfiguration.setLocales(LocaleList(*compatibleLocales.toTypedArray())) newConfiguration.setLocales(LocaleList(*compatibleLocales.toTypedArray()))
} else {
@Suppress("DEPRECATION")
newConfiguration.locale = compatibleLocales.first()
}
return context.createConfigurationContext(newConfiguration) return context.createConfigurationContext(newConfiguration)
} }
@@ -34,8 +34,7 @@ val PackageInfo.singleSignature: Signature?
if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners if (signingInfo?.hasMultipleSigners() == false) signingInfo.apkContentsSigners
?.let { if (it.size == 1) it[0] else null } else null ?.let { if (it.size == 1) it[0] else null } else null
} else { } else {
@Suppress("DEPRECATION") null
signatures?.let { if (it.size == 1) it[0] else null }
} }
} }
@@ -55,11 +54,20 @@ object Android {
return Build.VERSION.SDK_INT >= sdk 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 { object PackageManager {
// GET_SIGNATURES should always present for getPackageArchiveInfo
val signaturesFlag: Int val signaturesFlag: Int
get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0) or get() = (if (sdk(28)) android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES else 0)
@Suppress("DEPRECATION") android.content.pm.PackageManager.GET_SIGNATURES }
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 { object Device {
@@ -68,7 +76,7 @@ object Android {
return try { return try {
Class.forName("com.huawei.android.os.BuildEx") Class.forName("com.huawei.android.os.BuildEx")
true true
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} }
@@ -38,7 +38,7 @@ inline fun JsonParser.forEachKey(callback: JsonParser.(KeyToken) -> Unit) {
while (true) { while (true) {
val token = nextToken() val token = nextToken()
if (token == JsonToken.FIELD_NAME) { if (token == JsonToken.FIELD_NAME) {
passKey = currentName passKey = currentName()
passToken = nextToken() passToken = nextToken()
callback(keyToken) callback(keyToken)
} else if (token == JsonToken.END_OBJECT) { } else if (token == JsonToken.END_OBJECT) {
@@ -31,8 +31,8 @@ fun Context.getDrawableCompat(resId: Int): Drawable {
val drawable = if (!Android.sdk(24)) { val drawable = if (!Android.sdk(24)) {
val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string val fileName = TypedValue().apply { resources.getValue(resId, this, true) }.string
if (fileName.endsWith(".xml")) { if (fileName.endsWith(".xml")) {
resources.getXml(resId).use { resources.getXml(resId).use { it ->
val eventType = generateSequence { it.next() } val eventType = generateSequence { it.next() }
.find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT } .find { it == XmlPullParser.START_TAG || it == XmlPullParser.END_DOCUMENT }
if (eventType == XmlPullParser.START_TAG) { if (eventType == XmlPullParser.START_TAG) {
when (it.name) { when (it.name) {
@@ -77,8 +77,7 @@ fun Resources.sizeScaled(size: Int): Int {
} }
fun TextView.setTextSizeScaled(size: Int) { fun TextView.setTextSizeScaled(size: Int) {
val realSize = (size * resources.displayMetrics.scaledDensity).roundToInt() setTextSize(TypedValue.COMPLEX_UNIT_SP, size.toFloat())
setTextSize(TypedValue.COMPLEX_UNIT_PX, realSize.toFloat())
} }
fun ViewGroup.inflate(layoutResId: Int): View { fun ViewGroup.inflate(layoutResId: Int): View {
@@ -1,6 +1,8 @@
package nya.kitsunyan.foxydroid.widget package nya.kitsunyan.foxydroid.widget
import android.annotation.SuppressLint
import android.database.Cursor import android.database.Cursor
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class CursorRecyclerAdapter<VT: Enum<VT>, VH: RecyclerView.ViewHolder>: EnumRecyclerAdapter<VT, VH>() { 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 var cursor: Cursor? = null
set(value) { set(value) {
if (field != value) { if (field != value) {
field?.close() val oldCursor = field
field = value field = value
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0 rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
notifyDataSetChanged() onCursorChanged(oldCursor, value)
} }
} }
@SuppressLint("NotifyDataSetChanged")
protected open fun onCursorChanged(oldCursor: Cursor?, newCursor: Cursor?) {
val oldSize = oldCursor?.count ?: 0
val newSize = newCursor?.count ?: 0
if (oldCursor == null || newCursor == null) {
if (oldSize > 0) notifyItemRangeRemoved(0, oldSize)
if (newSize > 0) notifyItemRangeInserted(0, newSize)
return
}
// Further reduced threshold to 100 for DiffUtil to avoid any noticeable frame drops on the main thread.
// JSON parsing and DB access during diffing are slow.
if (oldSize > 100 || newSize > 100) {
notifyDataSetChanged()
return
}
val oldIdIndex = oldCursor.getColumnIndexOrThrow("_id")
val newIdIndex = newCursor.getColumnIndexOrThrow("_id")
try {
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldSize
override fun getNewListSize(): Int = newSize
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
if (!oldCursor.moveToPosition(oldItemPosition) || !newCursor.moveToPosition(newItemPosition)) return false
return oldCursor.getLong(oldIdIndex) == newCursor.getLong(newIdIndex)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
if (!oldCursor.moveToPosition(oldItemPosition) || !newCursor.moveToPosition(newItemPosition)) return false
return areContentsTheSame(oldCursor, newCursor)
}
})
diffResult.dispatchUpdatesTo(this)
} catch (_: Exception) {
// Fallback in case of cursor issues during diffing
notifyDataSetChanged()
}
}
protected open fun areContentsTheSame(oldCursor: Cursor, newCursor: Cursor): Boolean = false
final override fun setHasStableIds(hasStableIds: Boolean) { final override fun setHasStableIds(hasStableIds: Boolean) {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
@@ -1,6 +1,5 @@
package nya.kitsunyan.foxydroid.widget package nya.kitsunyan.foxydroid.widget
import android.annotation.SuppressLint
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.os.SystemClock import android.os.SystemClock
@@ -11,7 +10,6 @@ import androidx.recyclerview.widget.RecyclerView
import nya.kitsunyan.foxydroid.utility.extension.resources.* import nya.kitsunyan.foxydroid.utility.extension.resources.*
import kotlin.math.* import kotlin.math.*
@SuppressLint("ClickableViewAccessibility")
class RecyclerFastScroller(private val recyclerView: RecyclerView) { class RecyclerFastScroller(private val recyclerView: RecyclerView) {
companion object { companion object {
private const val TRANSITION_IN = 100L 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"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/primary_light" /> </adaptive-icon>
<foreground android:drawable="@drawable/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="versions">Versions</string>
<string name="waiting_to_start_download">Waiting to start download</string> <string name="waiting_to_start_download">Waiting to start download</string>
<string name="website">Website</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> </resources>