v0.6.5
This commit is contained in:
@@ -6,8 +6,9 @@ insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_code_style = android_studio
|
||||
indent_size = 4
|
||||
ij_kotlin_allow_trailing_comma = true
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||
ij_kotlin_name_count_to_use_star_import = 999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||
|
||||
|
||||
77
.github/workflows/build_debug.yml
vendored
77
.github/workflows/build_debug.yml
vendored
@@ -27,51 +27,48 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
- name: Setup Gradle
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
|
||||
- name: Grant execution permission to Gradle Wrapper
|
||||
run: chmod +x gradlew
|
||||
- name: Grant execution permission to Gradle Wrapper
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Format Code
|
||||
run: ./gradlew ktlintFormat
|
||||
- name: Build Debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Build Debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Sign Apk
|
||||
continue-on-error: true
|
||||
id: sign_apk
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDir: app/build/outputs/apk/debug
|
||||
signingKeyBase64: ${{ secrets.KEY_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
keyPassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
|
||||
- name: Sign Apk
|
||||
continue-on-error: true
|
||||
id: sign_apk
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDir: app/build/outputs/apk/debug
|
||||
signingKeyBase64: ${{ secrets.KEY_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
keyPassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
- name: Remove file that aren't signed
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ls | grep 'signed\.apk$' && find . -type f -name '*.apk' ! -name '*-signed.apk' -delete
|
||||
|
||||
- name: Remove file that aren't signed
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ls | grep 'signed\.apk$' && find . -type f -name '*.apk' ! -name '*-signed.apk' -delete
|
||||
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug
|
||||
path: app/build/outputs/apk/debug/app-debug*.apk
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug
|
||||
path: app/build/outputs/apk/debug/app-debug*.apk
|
||||
|
||||
24
.github/workflows/release_build.yml
vendored
24
.github/workflows/release_build.yml
vendored
@@ -56,12 +56,34 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
keyPassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "35.0.0"
|
||||
|
||||
- name: Extract Version Code
|
||||
id: extract_version
|
||||
run: |
|
||||
VERSION_CODE=$(grep -oP '(?<=versionCode=)\d+' app/build.gradle) # Adjust path to your build.gradle
|
||||
echo "::set-output name=version_code::$VERSION_CODE"
|
||||
echo "Version Code: $VERSION_CODE"
|
||||
|
||||
- name: Read Changelog
|
||||
id: read_changelog
|
||||
run: |
|
||||
CHANGELOG_PATH="metadata/en-US/changelogs/${{ steps.extract_version.outputs.version_code }}.txt"
|
||||
if [[ -f "$CHANGELOG_PATH" ]]; then
|
||||
CHANGELOG=$(cat "$CHANGELOG_PATH")
|
||||
echo "::set-output name=changelog::$CHANGELOG"
|
||||
else
|
||||
echo "::set-output name=changelog::No changelog found for this version."
|
||||
echo "No changelog found at: $CHANGELOG_PATH"
|
||||
fi
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
name: Create Release
|
||||
id: publish_release
|
||||
with:
|
||||
body: ${{ steps.read_changelog.outputs.changelog }}
|
||||
tag_name: ${{ github.ref }}
|
||||
name: Release ${{ github.ref }}
|
||||
files: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||
draft: true
|
||||
prerelease: false
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/Iamlooker/Droid-ify/releases/)
|
||||
[](https://github.com/Iamlooker/Droid-ify/releases/latest)
|
||||
[](https://f-droid.org/packages/com.looker.droidify)
|
||||
|
||||
</div>
|
||||
<div align="left">
|
||||
|
||||
## Features
|
||||
@@ -22,8 +22,10 @@
|
||||
<img src="metadata/en-US/images/phoneScreenshots/1.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/2.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/3.png" width="25%" /><img src="metadata/en-US/images/phoneScreenshots/4.png" width="25%" />
|
||||
|
||||
## Building and Installing
|
||||
|
||||
1. **Install Android Studio**:
|
||||
- Download and install [Android Studio](https://developer.android.com/studio) on your computer if you haven't already.
|
||||
- Download and install [Android Studio](https://developer.android.com/studio) on your computer
|
||||
if you haven't already.
|
||||
|
||||
2. **Clone the Repository**:
|
||||
- Open Android Studio and select "Project from Version Control."
|
||||
@@ -48,6 +50,7 @@
|
||||
- Your PR will undergo review
|
||||
|
||||
## Translations
|
||||
|
||||
[](https://hosted.weblate.org/engage/droidify/?utm_source=widget)
|
||||
|
||||
## License
|
||||
@@ -67,3 +70,5 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
||||
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.looker.android.application)
|
||||
alias(libs.plugins.looker.hilt.work)
|
||||
alias(libs.plugins.looker.lint)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
android {
|
||||
val latestVersionName = "0.6.5"
|
||||
namespace = "com.looker.droidify"
|
||||
buildToolsVersion = "35.0.0"
|
||||
compileSdk = 35
|
||||
defaultConfig {
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
minSdk = 23
|
||||
targetSdk = 35
|
||||
applicationId = "com.looker.droidify"
|
||||
versionCode = 650
|
||||
versionName = latestVersionName
|
||||
vectorDrawables.useSupportLibrary = false
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs = listOf(
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-Xcontext-receivers"
|
||||
)
|
||||
}
|
||||
|
||||
androidResources {
|
||||
@@ -23,11 +54,11 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
resValue("string", "application_name", "Droid-ify-Debug")
|
||||
}
|
||||
getByName("release") {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
resValue("string", "application_name", "Droid-ify")
|
||||
@@ -51,7 +82,7 @@ android {
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = "VERSION_NAME",
|
||||
value = "\"v${DefaultConfig.versionName}\""
|
||||
value = "\"v$latestVersionName\""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +95,8 @@ android {
|
||||
"/META-INF/**.kotlin_module",
|
||||
"/META-INF/**.pro",
|
||||
"/META-INF/**.version",
|
||||
"/META-INF/versions/9/previous-**.bin"
|
||||
"/META-INF/{AL2.0,LGPL2.1,LICENSE*}",
|
||||
"/META-INF/versions/9/previous-**.bin",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -73,33 +105,91 @@ android {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(true)
|
||||
debug.set(true)
|
||||
reporters {
|
||||
reporter(ReporterType.HTML)
|
||||
}
|
||||
filter {
|
||||
exclude("**/generated/**")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
|
||||
modules(
|
||||
Modules.coreDomain,
|
||||
// Modules.coreData,
|
||||
Modules.coreCommon,
|
||||
Modules.coreNetwork,
|
||||
Modules.coreDatastore,
|
||||
Modules.coreDI,
|
||||
Modules.installer,
|
||||
)
|
||||
implementation(libs.material)
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.activity)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.lifecycle.viewModel)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.sqlite.ktx)
|
||||
|
||||
implementation(libs.android.material)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewModel)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.sqlite.ktx)
|
||||
implementation(libs.coil.kt)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.jackson.core)
|
||||
implementation(libs.image.viewer)
|
||||
implementation(libs.bundles.coil)
|
||||
|
||||
implementation(libs.datastore.core)
|
||||
implementation(libs.datastore.proto)
|
||||
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.datetime)
|
||||
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(libs.coroutines.guava)
|
||||
|
||||
implementation(libs.libsu.core)
|
||||
implementation(libs.shizuku.api)
|
||||
api(libs.shizuku.provider)
|
||||
|
||||
implementation(libs.jackson.core)
|
||||
implementation(libs.serialization)
|
||||
|
||||
implementation(libs.ktor.core)
|
||||
implementation(libs.ktor.okhttp)
|
||||
|
||||
implementation(libs.work.ktx)
|
||||
|
||||
implementation(libs.hilt.core)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.hilt.ext.work)
|
||||
ksp(libs.hilt.compiler)
|
||||
ksp(libs.hilt.ext.compiler)
|
||||
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testImplementation(libs.bundles.test.unit)
|
||||
testRuntimeOnly(libs.junit.platform)
|
||||
androidTestImplementation(platform(libs.junit.bom))
|
||||
androidTestImplementation(libs.bundles.test.android)
|
||||
|
||||
// debugImplementation(libs.leakcanary)
|
||||
}
|
||||
|
||||
// using a task as a preBuild dependency instead of a function that takes some time insures that it runs
|
||||
task("detectAndroidLocals") {
|
||||
val langsList: MutableSet<String> = HashSet()
|
||||
|
||||
// in /res are (almost) all languages that have a translated string is saved. this is safer and saves some time
|
||||
fileTree("src/main/res").visit {
|
||||
if (this.file.path.endsWith("strings.xml") &&
|
||||
this.file.canonicalFile.readText().contains("<string")
|
||||
) {
|
||||
var languageCode = this.file.parentFile.name.replace("values-", "")
|
||||
languageCode = if (languageCode == "values") "en" else languageCode
|
||||
langsList.add(languageCode)
|
||||
}
|
||||
}
|
||||
val langsListString = "{${langsList.sorted().joinToString(",") { "\"${it}\"" }}}"
|
||||
android.defaultConfig.buildConfigField("String[]", "DETECTED_LOCALES", langsListString)
|
||||
}
|
||||
tasks.preBuild.dependsOn("detectAndroidLocals")
|
||||
|
||||
BIN
app/src/androidTest/assets/fdroid_index_v1.jar
Normal file
BIN
app/src/androidTest/assets/fdroid_index_v1.jar
Normal file
Binary file not shown.
1
app/src/androidTest/assets/fdroid_index_v1.json
Normal file
1
app/src/androidTest/assets/fdroid_index_v1.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/androidTest/assets/fdroid_index_v2.json
Normal file
1
app/src/androidTest/assets/fdroid_index_v2.json
Normal file
File diff suppressed because one or more lines are too long
BIN
app/src/androidTest/assets/index-v1.jar
Normal file
BIN
app/src/androidTest/assets/index-v1.jar
Normal file
Binary file not shown.
1
app/src/androidTest/assets/izzy_diff.json
Normal file
1
app/src/androidTest/assets/izzy_diff.json
Normal file
File diff suppressed because one or more lines are too long
BIN
app/src/androidTest/assets/izzy_entry.jar
Normal file
BIN
app/src/androidTest/assets/izzy_entry.jar
Normal file
Binary file not shown.
1
app/src/androidTest/assets/izzy_entry.json
Normal file
1
app/src/androidTest/assets/izzy_entry.json
Normal file
@@ -0,0 +1 @@
|
||||
{"timestamp": 1725903808000, "version": 20002, "index": {"name": "/index-v2.json", "sha256": "5c5d5b6495efd95c0e7b849df4f1411b6337272cdee2b28defc4eb0f1c4bae42", "size": 7134576, "numPackages": 1201}, "diffs": {"1725491992000": {"name": "/diff/1725491992000.json", "sha256": "58b2633fd72a8b517a69354f15ee88d028c55c92b4122158f0dd63cca82ff37b", "size": 220333, "numPackages": 55}, "1725492213000": {"name": "/diff/1725492213000.json", "sha256": "7aa22b070d9f6f77fd069cab7fdbb38aa9f734d01982d9b9fadb3560807367ff", "size": 218833, "numPackages": 55}, "1725558218000": {"name": "/diff/1725558218000.json", "sha256": "cfae51610c44ec8dd73f390f7af46f71ebf4b2233151ec2f40f8c403775d815b", "size": 208567, "numPackages": 54}, "1725581280000": {"name": "/diff/1725581280000.json", "sha256": "9c5e39cc363e2a98c35fab6ec389f6b1a272fb92298c8f7f8eb158ba5ad3f7b1", "size": 207136, "numPackages": 48}, "1725645028000": {"name": "/diff/1725645028000.json", "sha256": "996c8e982e21dbdbcf25424ea4d3f32a7b67f7eea249db2392ea31a2bef033f6", "size": 98678, "numPackages": 47}, "1725731263000": {"name": "/diff/1725731263000.json", "sha256": "5dd06ef6da469b3881933b076ca0d989372477300b1f43070ae6b041763539da", "size": 80050, "numPackages": 37}, "1725746579000": {"name": "/diff/1725746579000.json", "sha256": "8bb1c009b828a3cecaa8180c08ac7a81b51c2d8b036566ec08fb7159dc61127a", "size": 58098, "numPackages": 31}, "1725807608000": {"name": "/diff/1725807608000.json", "sha256": "95ffb733c6e1e839f18c90e1cc704e554b0ae0eb26b52f0cf6437ee7a91ec96e", "size": 53358, "numPackages": 30}, "1725817837000": {"name": "/diff/1725817837000.json", "sha256": "21fed03d9e1b89cc2c4753084bcf49b681b4e4219375d9fdb5db1dd48a9a2fd3", "size": 16366, "numPackages": 28}, "1725885326000": {"name": "/diff/1725885326000.json", "sha256": "f2f22d1a56262276e07c68d5d71a149a50ddfa8c47b82bb3b63e0077f58121b6", "size": 13324, "numPackages": 9}}}
|
||||
BIN
app/src/androidTest/assets/izzy_index_v1.jar
Normal file
BIN
app/src/androidTest/assets/izzy_index_v1.jar
Normal file
Binary file not shown.
1
app/src/androidTest/assets/izzy_index_v1.json
Normal file
1
app/src/androidTest/assets/izzy_index_v1.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/androidTest/assets/izzy_index_v2.json
Normal file
1
app/src/androidTest/assets/izzy_index_v2.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/androidTest/assets/izzy_index_v2_updated.json
Normal file
1
app/src/androidTest/assets/izzy_index_v2_updated.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,92 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.looker.droidify.model.Repository
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@SmallTest
|
||||
class RepositoryUpdaterTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var repository: Repository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
context = InstrumentationRegistry.getInstrumentation().context
|
||||
repository = Repository(
|
||||
id = 15,
|
||||
address = "https://apt.izzysoft.de/fdroid/repo",
|
||||
mirrors = emptyList(),
|
||||
name = "IzzyOnDroid F-Droid Repo",
|
||||
description = "",
|
||||
version = 20002,
|
||||
enabled = true,
|
||||
fingerprint = "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A",
|
||||
lastModified = "",
|
||||
entityTag = "",
|
||||
updated = 1735315749835,
|
||||
timestamp = 1725352450000,
|
||||
authentication = "",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processFile() {
|
||||
testRepetition(1) {
|
||||
val createFile = File.createTempFile("index", "entry")
|
||||
val mergerFile = File.createTempFile("index", "merger")
|
||||
val jarStream = context.resources.assets.open("index-v1.jar")
|
||||
jarStream.copyTo(createFile.outputStream())
|
||||
process(createFile, mergerFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(file: File, merger: File) = measureTimeMillis {
|
||||
RepositoryUpdater.processFile(
|
||||
context = context,
|
||||
repository = repository,
|
||||
indexType = RepositoryUpdater.IndexType.INDEX_V1,
|
||||
unstable = false,
|
||||
file = file,
|
||||
mergerFile = merger,
|
||||
lastModified = "",
|
||||
entityTag = "",
|
||||
callback = { stage, current, total ->
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun testRepetition(repetition: Int, block: () -> Long) {
|
||||
val times = (1..repetition).map {
|
||||
System.gc()
|
||||
System.runFinalization()
|
||||
block().toDouble()
|
||||
}
|
||||
val meanAndDeviation = times.culledMeanAndDeviation()
|
||||
println(times)
|
||||
println("${meanAndDeviation.first} ± ${meanAndDeviation.second}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Double>.culledMeanAndDeviation(): Pair<Double, Double> = when {
|
||||
isEmpty() -> Double.NaN to Double.NaN
|
||||
size == 1 || size == 2 -> this.meanAndDeviation()
|
||||
else -> sorted().subList(1, size - 1).meanAndDeviation()
|
||||
}
|
||||
|
||||
private fun List<Double>.meanAndDeviation(): Pair<Double, Double> {
|
||||
val mean = average()
|
||||
return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).squared() } / size)
|
||||
}
|
||||
|
||||
private fun Double.squared() = this * this
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.NetworkResponse
|
||||
import com.looker.droidify.network.ProgressListener
|
||||
import com.looker.droidify.network.header.HeadersBuilder
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import com.looker.droidify.sync.common.assets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.Proxy
|
||||
|
||||
val FakeDownloader = object : Downloader {
|
||||
override fun setProxy(proxy: Proxy) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit
|
||||
): NetworkResponse {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator?,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
block: ProgressListener?
|
||||
): NetworkResponse {
|
||||
return if (url.endsWith("fail")) NetworkResponse.Error.Unknown(Exception("You asked for it"))
|
||||
else {
|
||||
val index = when {
|
||||
url.endsWith("fdroid-index-v1.jar") -> assets("fdroid_index_v1.jar")
|
||||
url.endsWith("fdroid-index-v1.json") -> assets("fdroid_index_v1.json")
|
||||
url.endsWith("fdroid-index-v2.json") -> assets("fdroid_index_v2.json")
|
||||
url.endsWith("index-v1.jar") -> assets("izzy_index_v1.jar")
|
||||
url.endsWith("index-v2.json") -> assets("izzy_index_v2.json")
|
||||
url.endsWith("index-v2-updated.json") -> assets("izzy_index_v2_updated.json")
|
||||
url.endsWith("entry.jar") -> assets("izzy_entry.jar")
|
||||
url.endsWith("/diff/1725731263000.json") -> assets("izzy_diff.json")
|
||||
// Just in case we try these in future
|
||||
url.endsWith("index-v1.json") -> assets("izzy_index_v1.json")
|
||||
url.endsWith("entry.json") -> assets("izzy_entry.json")
|
||||
else -> error("Unknown URL: $url")
|
||||
}
|
||||
index.writeTo(target)
|
||||
NetworkResponse.Success(200, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend infix fun InputStream.writeTo(file: File) = withContext(Dispatchers.IO) {
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytesRead = read(buffer)
|
||||
file.outputStream().use { output ->
|
||||
while (bytesRead != -1) {
|
||||
ensureActive()
|
||||
output.write(buffer, 0, bytesRead)
|
||||
bytesRead = read(buffer)
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.common.IndexJarValidator
|
||||
import com.looker.droidify.sync.common.Izzy
|
||||
import com.looker.droidify.sync.common.JsonParser
|
||||
import com.looker.droidify.sync.common.assets
|
||||
import com.looker.droidify.sync.common.downloadIndex
|
||||
import com.looker.droidify.sync.common.benchmark
|
||||
import com.looker.droidify.sync.v2.EntryParser
|
||||
import com.looker.droidify.sync.v2.EntrySyncable
|
||||
import com.looker.droidify.sync.v2.model.Entry
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import org.junit.Before
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EntrySyncableTest {
|
||||
|
||||
private lateinit var dispatcher: CoroutineDispatcher
|
||||
private lateinit var context: Context
|
||||
private lateinit var syncable: Syncable<Entry>
|
||||
private lateinit var parser: Parser<Entry>
|
||||
private lateinit var validator: IndexValidator
|
||||
private lateinit var repo: Repo
|
||||
private lateinit var newIndex: IndexV2
|
||||
|
||||
/**
|
||||
* In this particular test 1 package is removed and 36 packages are updated
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Before
|
||||
fun before() {
|
||||
context = InstrumentationRegistry.getInstrumentation().context
|
||||
dispatcher = StandardTestDispatcher()
|
||||
validator = IndexJarValidator(dispatcher)
|
||||
parser = EntryParser(dispatcher, JsonParser, validator)
|
||||
syncable = EntrySyncable(context, FakeDownloader, dispatcher)
|
||||
newIndex = JsonParser.decodeFromStream<IndexV2>(assets("izzy_index_v2_updated.json"))
|
||||
repo = Izzy
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmark_sync_full() = runTest(dispatcher) {
|
||||
val output = benchmark(10) {
|
||||
measureTimeMillis { syncable.sync(repo) }
|
||||
}
|
||||
println(output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmark_entry_parser() = runTest(dispatcher) {
|
||||
val output = benchmark(10) {
|
||||
measureTimeMillis {
|
||||
parser.parse(
|
||||
file = FakeDownloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
fileName = "izzy",
|
||||
url = "entry.jar"
|
||||
),
|
||||
repo = repo
|
||||
)
|
||||
}
|
||||
}
|
||||
println(output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun check_if_patch_applies() = runTest(dispatcher) {
|
||||
// Downloads old index file as the index file does not exist
|
||||
val (fingerprint1, index1) = syncable.sync(repo)
|
||||
assert(index1 != null)
|
||||
// Downloads the diff as the index file exists and is older than entry version
|
||||
val (fingerprint2, index2) = syncable.sync(
|
||||
repo.copy(
|
||||
versionInfo = repo.versionInfo.copy(
|
||||
timestamp = index1!!.repo.timestamp
|
||||
)
|
||||
)
|
||||
)
|
||||
assert(index2 != null)
|
||||
// Does not download anything
|
||||
val (fingerprint3, index3) = syncable.sync(
|
||||
repo.copy(
|
||||
versionInfo = repo.versionInfo.copy(
|
||||
timestamp = index2!!.repo.timestamp
|
||||
)
|
||||
)
|
||||
)
|
||||
assert(index3 == null)
|
||||
|
||||
// Check if all the packages are same
|
||||
assertContentEquals(newIndex.packages.keys.sorted(), index2.packages.keys.sorted())
|
||||
// Check if all the version hashes are same
|
||||
assertContentEquals(
|
||||
newIndex.packages.values.flatMap { it.versions.keys }.sorted(),
|
||||
index2.packages.values.flatMap { it.versions.keys }.sorted(),
|
||||
)
|
||||
|
||||
// Check if repo antifeatures are same
|
||||
assertContentEquals(
|
||||
newIndex.repo.antiFeatures.keys.sorted(),
|
||||
index2.repo.antiFeatures.keys.sorted()
|
||||
)
|
||||
|
||||
// Check if repo categories are same
|
||||
assertContentEquals(
|
||||
newIndex.repo.categories.keys.sorted(),
|
||||
index2.repo.categories.keys.sorted()
|
||||
)
|
||||
|
||||
assertEquals(fingerprint1, fingerprint2)
|
||||
assertEquals(fingerprint2, fingerprint3)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import java.util.jar.JarEntry
|
||||
|
||||
val FakeIndexValidator = object : IndexValidator {
|
||||
override suspend fun validate(
|
||||
jarEntry: JarEntry,
|
||||
expectedFingerprint: Fingerprint?
|
||||
): Fingerprint {
|
||||
return expectedFingerprint ?: Fingerprint("0".repeat(64))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.common.IndexJarValidator
|
||||
import com.looker.droidify.sync.common.Izzy
|
||||
import com.looker.droidify.sync.common.JsonParser
|
||||
import com.looker.droidify.sync.common.downloadIndex
|
||||
import com.looker.droidify.sync.common.benchmark
|
||||
import com.looker.droidify.sync.common.toV2
|
||||
import com.looker.droidify.sync.v1.V1Parser
|
||||
import com.looker.droidify.sync.v1.V1Syncable
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import com.looker.droidify.sync.v2.V2Parser
|
||||
import com.looker.droidify.sync.v2.model.FileV2
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.sync.v2.model.MetadataV2
|
||||
import com.looker.droidify.sync.v2.model.VersionV2
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class V1SyncableTest {
|
||||
|
||||
private lateinit var dispatcher: CoroutineDispatcher
|
||||
private lateinit var context: Context
|
||||
private lateinit var syncable: Syncable<IndexV1>
|
||||
private lateinit var parser: Parser<IndexV1>
|
||||
private lateinit var v2Parser: Parser<IndexV2>
|
||||
private lateinit var validator: IndexValidator
|
||||
private lateinit var repo: Repo
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
context = InstrumentationRegistry.getInstrumentation().context
|
||||
dispatcher = StandardTestDispatcher()
|
||||
validator = IndexJarValidator(dispatcher)
|
||||
parser = V1Parser(dispatcher, JsonParser, validator)
|
||||
v2Parser = V2Parser(dispatcher, JsonParser)
|
||||
syncable = V1Syncable(context, FakeDownloader, dispatcher)
|
||||
repo = Izzy
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmark_sync_v1() = runTest(dispatcher) {
|
||||
val output = benchmark(10) {
|
||||
measureTimeMillis { syncable.sync(repo) }
|
||||
}
|
||||
println(output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmark_v1_parser() = runTest(dispatcher) {
|
||||
val file = FakeDownloader.downloadIndex(context, repo, "izzy", "index-v1.jar")
|
||||
val output = benchmark(10) {
|
||||
measureTimeMillis {
|
||||
parser.parse(
|
||||
file = file,
|
||||
repo = repo
|
||||
)
|
||||
}
|
||||
}
|
||||
println(output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmark_v1_vs_v2_parser() = runTest(dispatcher) {
|
||||
val v1File = FakeDownloader.downloadIndex(context, repo, "izzy-v1", "index-v1.jar")
|
||||
val v2File = FakeDownloader.downloadIndex(context, repo, "izzy-v2", "index-v2.json")
|
||||
val output1 = benchmark(10) {
|
||||
measureTimeMillis {
|
||||
parser.parse(
|
||||
file = v1File,
|
||||
repo = repo
|
||||
)
|
||||
}
|
||||
}
|
||||
val output2 = benchmark(10) {
|
||||
measureTimeMillis {
|
||||
parser.parse(
|
||||
file = v2File,
|
||||
repo = repo,
|
||||
)
|
||||
}
|
||||
}
|
||||
println(output1)
|
||||
println(output2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1tov2() = runTest(dispatcher) {
|
||||
testIndexConversion("index-v1.jar", "index-v2-updated.json")
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun v1tov2FDroidRepo() = runTest(dispatcher) {
|
||||
testIndexConversion("fdroid-index-v1.jar", "fdroid-index-v2.json")
|
||||
}
|
||||
|
||||
private suspend fun testIndexConversion(
|
||||
v1: String,
|
||||
v2: String,
|
||||
targeted: String? = null,
|
||||
) {
|
||||
val fileV1 = FakeDownloader.downloadIndex(context, repo, "data-v1", v1)
|
||||
val fileV2 = FakeDownloader.downloadIndex(context, repo, "data-v2", v2)
|
||||
val (fingerV1, foundIndexV1) = parser.parse(fileV1, repo)
|
||||
val (fingerV2, expectedIndex) = v2Parser.parse(fileV2, repo)
|
||||
val foundIndex = foundIndexV1.toV2()
|
||||
assertEquals(fingerV2, fingerV1)
|
||||
assertNotNull(foundIndex)
|
||||
assertNotNull(expectedIndex)
|
||||
assertEquals(expectedIndex.repo.timestamp, foundIndex.repo.timestamp)
|
||||
assertEquals(expectedIndex.packages.size, foundIndex.packages.size)
|
||||
assertContentEquals(
|
||||
expectedIndex.packages.keys.sorted(),
|
||||
foundIndex.packages.keys.sorted(),
|
||||
)
|
||||
if (targeted == null) {
|
||||
expectedIndex.packages.keys.forEach { key ->
|
||||
val expectedPackage = expectedIndex.packages[key]
|
||||
val foundPackage = foundIndex.packages[key]
|
||||
|
||||
println("**".repeat(25))
|
||||
println("Testing: ${expectedPackage?.metadata?.name?.get("en-US")} <$key>")
|
||||
|
||||
assertNotNull(expectedPackage)
|
||||
assertNotNull(foundPackage)
|
||||
assertMetadata(expectedPackage.metadata, foundPackage.metadata)
|
||||
assertVersion(expectedPackage.versions, foundPackage.versions)
|
||||
}
|
||||
} else {
|
||||
val expectedPackage = expectedIndex.packages[targeted]
|
||||
val foundPackage = foundIndex.packages[targeted]
|
||||
|
||||
println("**".repeat(25))
|
||||
println("Testing: ${expectedPackage?.metadata?.name?.get("en-US")} <$targeted>")
|
||||
|
||||
assertNotNull(expectedPackage)
|
||||
assertNotNull(foundPackage)
|
||||
assertMetadata(expectedPackage.metadata, foundPackage.metadata)
|
||||
assertVersion(expectedPackage.versions, foundPackage.versions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Cannot assert following:
|
||||
* - `name` => because fdroidserver behaves weirdly
|
||||
* */
|
||||
private fun assertMetadata(expectedMetaData: MetadataV2, foundMetadata: MetadataV2) {
|
||||
assertEquals(expectedMetaData.preferredSigner, foundMetadata.preferredSigner)
|
||||
// assertLocalizedString(expectedMetaData.name, foundMetadata.name)
|
||||
assertLocalizedString(expectedMetaData.summary, foundMetadata.summary)
|
||||
assertLocalizedString(expectedMetaData.description, foundMetadata.description)
|
||||
assertContentEquals(expectedMetaData.categories, foundMetadata.categories)
|
||||
// Update
|
||||
assertEquals(expectedMetaData.changelog, foundMetadata.changelog)
|
||||
assertEquals(expectedMetaData.added, foundMetadata.added)
|
||||
assertEquals(expectedMetaData.lastUpdated, foundMetadata.lastUpdated)
|
||||
// Author
|
||||
assertEquals(expectedMetaData.authorEmail, foundMetadata.authorEmail)
|
||||
assertEquals(expectedMetaData.authorName, foundMetadata.authorName)
|
||||
assertEquals(expectedMetaData.authorPhone, foundMetadata.authorPhone)
|
||||
assertEquals(expectedMetaData.authorWebSite, foundMetadata.authorWebSite)
|
||||
// Donate
|
||||
assertEquals(expectedMetaData.bitcoin, foundMetadata.bitcoin)
|
||||
assertEquals(expectedMetaData.liberapay, foundMetadata.liberapay)
|
||||
assertEquals(expectedMetaData.flattrID, foundMetadata.flattrID)
|
||||
assertEquals(expectedMetaData.openCollective, foundMetadata.openCollective)
|
||||
assertEquals(expectedMetaData.litecoin, foundMetadata.litecoin)
|
||||
assertContentEquals(expectedMetaData.donate, foundMetadata.donate)
|
||||
// Source
|
||||
assertEquals(expectedMetaData.translation, foundMetadata.translation)
|
||||
assertEquals(expectedMetaData.issueTracker, foundMetadata.issueTracker)
|
||||
assertEquals(expectedMetaData.license, foundMetadata.license)
|
||||
assertEquals(expectedMetaData.sourceCode, foundMetadata.sourceCode)
|
||||
// Graphics
|
||||
assertLocalizedString(expectedMetaData.video, foundMetadata.video)
|
||||
assertLocalized(expectedMetaData.icon, foundMetadata.icon) { expected, found ->
|
||||
assertEquals(expected.name, found.name)
|
||||
}
|
||||
assertLocalized(expectedMetaData.promoGraphic, foundMetadata.promoGraphic) { expected, found ->
|
||||
assertEquals(expected.name, found.name)
|
||||
}
|
||||
assertLocalized(expectedMetaData.tvBanner, foundMetadata.tvBanner) { expected, found ->
|
||||
assertEquals(expected.name, found.name)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.featureGraphic,
|
||||
foundMetadata.featureGraphic
|
||||
) { expected, found ->
|
||||
assertEquals(expected.name, found.name)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.screenshots?.phone,
|
||||
foundMetadata.screenshots?.phone
|
||||
) { expected, found ->
|
||||
assertFiles(expected, found)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.screenshots?.sevenInch,
|
||||
foundMetadata.screenshots?.sevenInch
|
||||
) { expected, found ->
|
||||
assertFiles(expected, found)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.screenshots?.tenInch,
|
||||
foundMetadata.screenshots?.tenInch
|
||||
) { expected, found ->
|
||||
assertFiles(expected, found)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.screenshots?.tv,
|
||||
foundMetadata.screenshots?.tv
|
||||
) { expected, found ->
|
||||
assertFiles(expected, found)
|
||||
}
|
||||
assertLocalized(
|
||||
expectedMetaData.screenshots?.wear,
|
||||
foundMetadata.screenshots?.wear
|
||||
) { expected, found ->
|
||||
assertFiles(expected, found)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Cannot assert following:
|
||||
* - `whatsNew` => we added same changelog to all versions
|
||||
* - `antiFeatures` => anti features are now version specific
|
||||
* */
|
||||
private fun assertVersion(
|
||||
expected: Map<String, VersionV2>,
|
||||
found: Map<String, VersionV2>,
|
||||
) {
|
||||
assertEquals(expected.keys.size, found.keys.size)
|
||||
assertContentEquals(expected.keys.sorted(), found.keys.sorted().asIterable())
|
||||
expected.keys.forEach { versionHash ->
|
||||
val expectedVersion = expected[versionHash]
|
||||
val foundVersion = found[versionHash]
|
||||
assertNotNull(expectedVersion)
|
||||
assertNotNull(foundVersion)
|
||||
|
||||
assertEquals(expectedVersion.added, foundVersion.added)
|
||||
assertEquals(expectedVersion.file.name, foundVersion.file.name)
|
||||
assertEquals(expectedVersion.src?.name, foundVersion.src?.name)
|
||||
|
||||
val expectedMan = expectedVersion.manifest
|
||||
val foundMan = foundVersion.manifest
|
||||
|
||||
assertEquals(expectedMan.versionCode, foundMan.versionCode)
|
||||
assertEquals(expectedMan.versionName, foundMan.versionName)
|
||||
assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion)
|
||||
assertEquals(expectedMan.usesSdk, foundMan.usesSdk)
|
||||
|
||||
assertContentEquals(
|
||||
expectedMan.features.sortedBy { it.name },
|
||||
foundMan.features.sortedBy { it.name },
|
||||
)
|
||||
assertContentEquals(expectedMan.usesPermission, foundMan.usesPermission)
|
||||
assertContentEquals(expectedMan.usesPermissionSdk23, foundMan.usesPermissionSdk23)
|
||||
assertContentEquals(expectedMan.signer?.sha256?.sorted(), foundMan.signer?.sha256?.sorted())
|
||||
assertContentEquals(expectedMan.nativecode.sorted(), foundMan.nativecode.sorted())
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertLocalizedString(
|
||||
expected: Map<String, String>?,
|
||||
found: Map<String, String>?,
|
||||
message: String? = null,
|
||||
) {
|
||||
assertLocalized(expected, found) { one, two ->
|
||||
assertEquals(one, two, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> assertLocalized(
|
||||
expected: Map<String, T>?,
|
||||
found: Map<String, T>?,
|
||||
block: (expected: T, found: T) -> Unit,
|
||||
) {
|
||||
if (expected == null || found == null) {
|
||||
assertEquals(expected, found)
|
||||
return
|
||||
}
|
||||
assertNotNull(expected)
|
||||
assertNotNull(found)
|
||||
assertEquals(expected.size, found.size)
|
||||
assertContentEquals(expected.keys.sorted(), found.keys.sorted().asIterable())
|
||||
expected.keys.forEach {
|
||||
if (expected[it] != null && found[it] != null) block(expected[it]!!, found[it]!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertFiles(expected: List<FileV2>, found: List<FileV2>, message: String? = null) {
|
||||
// Only check name, because we cannot add sha to old index
|
||||
assertContentEquals(expected.map { it.name }, found.map { it.name }.asIterable(), message)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal inline fun benchmark(
|
||||
repetition: Int,
|
||||
extraMessage: String? = null,
|
||||
block: () -> Long,
|
||||
): String {
|
||||
if (extraMessage != null) {
|
||||
println("=".repeat(50))
|
||||
println(extraMessage)
|
||||
println("=".repeat(50))
|
||||
}
|
||||
val times = DoubleArray(repetition)
|
||||
repeat(repetition) { iteration ->
|
||||
System.gc()
|
||||
System.runFinalization()
|
||||
times[iteration] = block().toDouble()
|
||||
}
|
||||
val meanAndDeviation = times.culledMeanAndDeviation()
|
||||
return buildString {
|
||||
append("=".repeat(50))
|
||||
append("\n")
|
||||
append(times.joinToString(" | "))
|
||||
append("\n")
|
||||
append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms")
|
||||
append("\n")
|
||||
append("=".repeat(50))
|
||||
append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun DoubleArray.culledMeanAndDeviation(): Pair<Double, Double> {
|
||||
sort()
|
||||
return meanAndDeviation()
|
||||
}
|
||||
|
||||
private fun DoubleArray.meanAndDeviation(): Pair<Double, Double> {
|
||||
val mean = average()
|
||||
return mean to sqrt(fold(0.0) { acc, value -> acc + (value - mean).pow(2) } / size)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import com.looker.droidify.domain.model.Authentication
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.domain.model.VersionInfo
|
||||
|
||||
val Izzy = Repo(
|
||||
id = 1L,
|
||||
enabled = true,
|
||||
address = "https://apt.izzysoft.de/fdroid/repo",
|
||||
name = "IzzyOnDroid F-Droid Repo",
|
||||
description = "This is a repository of apps to be used with F-Droid. Applications in this repository are official binaries built by the original application developers, taken from their resp. repositories (mostly Github, GitLab, Codeberg). Updates for the apps are usually fetched daily, and you can expect daily index updates.",
|
||||
fingerprint = Fingerprint("3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A"),
|
||||
authentication = Authentication("", ""),
|
||||
versionInfo = VersionInfo(0L, null),
|
||||
mirrors = emptyList(),
|
||||
antiFeatures = emptyList(),
|
||||
categories = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.InputStream
|
||||
|
||||
fun assets(name: String): InputStream {
|
||||
return InstrumentationRegistry.getInstrumentation().context.assets.open(name)
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:name=".Droidify"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
@@ -38,7 +38,7 @@
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<receiver
|
||||
android:name=".MainApplication$BootReceiver"
|
||||
android:name=".Droidify$BootReceiver"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
@@ -50,7 +50,6 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||
@@ -148,7 +147,7 @@
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name="com.looker.installer.installers.session.SessionInstallerReceiver"
|
||||
android:name=".installer.installers.session.SessionInstallerReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
@@ -174,7 +173,7 @@
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<provider
|
||||
android:name="com.looker.core.common.cache.Cache$Provider"
|
||||
android:name=".utility.common.cache.Cache$Provider"
|
||||
android:authorities="${applicationId}.provider.cache"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
|
||||
267
app/src/main/kotlin/com/looker/droidify/Droidify.kt
Normal file
267
app/src/main/kotlin/com/looker/droidify/Droidify.kt
Normal file
@@ -0,0 +1,267 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.NetworkType
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.crossfade
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.receivers.InstalledAppReceiver
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.sync.SyncPreference
|
||||
import com.looker.droidify.sync.toJobNetworkType
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.getDrawableCompat
|
||||
import com.looker.droidify.utility.common.extension.getInstalledPackagesCompat
|
||||
import com.looker.droidify.utility.common.extension.jobScheduler
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
import com.looker.droidify.work.CleanUpWorker
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.INFINITE
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@HiltAndroidApp
|
||||
class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Provider {
|
||||
|
||||
private val parentJob = SupervisorJob()
|
||||
private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
|
||||
|
||||
val databaseUpdated = Database.init(this)
|
||||
ProductPreferences.init(this, appScope)
|
||||
RepositoryUpdater.init(appScope, downloader)
|
||||
listenApplications()
|
||||
checkLanguage()
|
||||
updatePreference()
|
||||
appScope.launch { installer() }
|
||||
|
||||
if (databaseUpdated) forceSyncAll()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
appScope.cancel("Application Terminated")
|
||||
installer.close()
|
||||
}
|
||||
|
||||
private fun listenApplications() {
|
||||
appScope.launch(Dispatchers.Default) {
|
||||
registerReceiver(
|
||||
InstalledAppReceiver(packageManager),
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
)
|
||||
val installedItems =
|
||||
packageManager.getInstalledPackagesCompat()
|
||||
?.map { it.toInstalledItem() }
|
||||
?: return@launch
|
||||
Database.InstalledAdapter.putAll(installedItems)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkLanguage() {
|
||||
appScope.launch {
|
||||
val lastSetLanguage = settingsRepository.getInitial().language
|
||||
val systemSetLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags()
|
||||
if (systemSetLanguage != lastSetLanguage && lastSetLanguage != "system") {
|
||||
settingsRepository.setLanguage(systemSetLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreference() {
|
||||
appScope.launch {
|
||||
launch {
|
||||
settingsRepository.get { unstableUpdate }.drop(1).collect {
|
||||
forceSyncAll()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { autoSync }.collectIndexed { index, syncMode ->
|
||||
// Don't update sync job on initial collect
|
||||
updateSyncJob(index > 0, syncMode)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { cleanUpInterval }.drop(1).collect {
|
||||
if (it == INFINITE) {
|
||||
CleanUpWorker.removeAllSchedules(applicationContext)
|
||||
} else {
|
||||
CleanUpWorker.scheduleCleanup(applicationContext, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { proxy }.collect(::updateProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProxy(proxyPreference: ProxyPreference) {
|
||||
val type = proxyPreference.type
|
||||
val host = proxyPreference.host
|
||||
val port = proxyPreference.port
|
||||
val socketAddress = when (type) {
|
||||
ProxyType.DIRECT -> null
|
||||
ProxyType.HTTP, ProxyType.SOCKS -> {
|
||||
try {
|
||||
InetSocketAddress.createUnresolved(host, port)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
val androidProxyType = when (type) {
|
||||
ProxyType.DIRECT -> Proxy.Type.DIRECT
|
||||
ProxyType.HTTP -> Proxy.Type.HTTP
|
||||
ProxyType.SOCKS -> Proxy.Type.SOCKS
|
||||
}
|
||||
val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY
|
||||
downloader.setProxy(determinedProxy)
|
||||
}
|
||||
|
||||
private fun updateSyncJob(force: Boolean, autoSync: AutoSync) {
|
||||
if (autoSync == AutoSync.NEVER) {
|
||||
jobScheduler?.cancel(Constants.JOB_ID_SYNC)
|
||||
return
|
||||
}
|
||||
val jobScheduler = jobScheduler
|
||||
val syncConditions = when (autoSync) {
|
||||
AutoSync.ALWAYS -> SyncPreference(NetworkType.CONNECTED)
|
||||
AutoSync.WIFI_ONLY -> SyncPreference(NetworkType.UNMETERED)
|
||||
AutoSync.WIFI_PLUGGED_IN -> SyncPreference(NetworkType.UNMETERED, pluggedIn = true)
|
||||
else -> null
|
||||
}
|
||||
val isCompleted = jobScheduler?.allPendingJobs
|
||||
?.any { it.id == Constants.JOB_ID_SYNC } == false
|
||||
if ((force || isCompleted) && syncConditions != null) {
|
||||
val period = 12.hours.inWholeMilliseconds
|
||||
val job = SyncService.Job.create(
|
||||
context = this,
|
||||
periodMillis = period,
|
||||
networkType = syncConditions.toJobNetworkType(),
|
||||
isCharging = syncConditions.pluggedIn,
|
||||
isBatteryLow = syncConditions.batteryNotLow
|
||||
)
|
||||
jobScheduler?.schedule(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceSyncAll() {
|
||||
Database.RepositoryAdapter.getAll().forEach {
|
||||
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
|
||||
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
|
||||
}
|
||||
}
|
||||
Connection(SyncService::class.java, onBind = { connection, binder ->
|
||||
binder.sync(SyncService.SyncRequest.FORCE)
|
||||
connection.unbind(this)
|
||||
}).bind(this)
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver")
|
||||
override fun onReceive(context: Context, intent: Intent) = Unit
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||
val memoryCache = MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
|
||||
val diskCache = DiskCache.Builder()
|
||||
.directory(Cache.getImagesDir(this))
|
||||
.maxSizePercent(0.05)
|
||||
.build()
|
||||
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache(memoryCache)
|
||||
.diskCache(diskCache)
|
||||
.error(getDrawableCompat(R.drawable.ic_cannot_load).asImage())
|
||||
.crossfade(350)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun strictThreadPolicy() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectDiskReads()
|
||||
.detectDiskWrites()
|
||||
.detectNetwork()
|
||||
.detectUnbufferedIo()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,307 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.content.Intent
|
||||
import com.looker.core.common.getInstallPackageName
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.looker.droidify.utility.common.DeeplinkType
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.deeplinkType
|
||||
import com.looker.droidify.utility.common.extension.homeAsUp
|
||||
import com.looker.droidify.utility.common.extension.inputManager
|
||||
import com.looker.droidify.utility.common.getInstallPackageName
|
||||
import com.looker.droidify.utility.common.requestNotificationPermission
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.extension.getThemeRes
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.installFrom
|
||||
import com.looker.droidify.ui.appDetail.AppDetailFragment
|
||||
import com.looker.droidify.ui.favourites.FavouritesFragment
|
||||
import com.looker.droidify.ui.repository.EditRepositoryFragment
|
||||
import com.looker.droidify.ui.repository.RepositoriesFragment
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.ui.settings.SettingsFragment
|
||||
import com.looker.droidify.ui.tabsFragment.TabsFragment
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ScreenActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
|
||||
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
|
||||
const val EXTRA_CACHE_FILE_NAME =
|
||||
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
|
||||
}
|
||||
|
||||
override fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
|
||||
ACTION_INSTALL -> handleSpecialIntent(
|
||||
SpecialIntent.Install(
|
||||
intent.getInstallPackageName,
|
||||
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Parcelize
|
||||
private class FragmentStackItem(
|
||||
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
|
||||
) : Parcelable
|
||||
|
||||
lateinit var cursorOwner: CursorOwner
|
||||
private set
|
||||
|
||||
private var onBackPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private val fragmentStack = mutableListOf<FragmentStackItem>()
|
||||
|
||||
private val currentFragment: Fragment?
|
||||
get() {
|
||||
supportFragmentManager.executePendingTransactions()
|
||||
return supportFragmentManager.findFragmentById(R.id.main_content)
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface CustomUserRepositoryInjector {
|
||||
fun settingsRepository(): SettingsRepository
|
||||
}
|
||||
|
||||
private fun collectChange() {
|
||||
val hiltEntryPoint = EntryPointAccessors.fromApplication(
|
||||
this, CustomUserRepositoryInjector::class.java
|
||||
)
|
||||
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
|
||||
runBlocking {
|
||||
val theme = newSettings.first()
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = theme.first, dynamicTheme = theme.second
|
||||
)
|
||||
)
|
||||
|
||||
else -> super.handleIntent(intent)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
newSettings.drop(1).collect { themeAndDynamic ->
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
|
||||
)
|
||||
)
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
collectChange()
|
||||
super.onCreate(savedInstanceState)
|
||||
val rootView = FrameLayout(this).apply { id = R.id.main_content }
|
||||
addContentView(
|
||||
rootView, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
|
||||
requestNotificationPermission(request = notificationPermission::launch)
|
||||
|
||||
supportFragmentManager.addFragmentOnAttachListener { _, _ ->
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
cursorOwner = CursorOwner()
|
||||
supportFragmentManager.commit {
|
||||
add(cursorOwner, CursorOwner::class.java.name)
|
||||
}
|
||||
} else {
|
||||
cursorOwner =
|
||||
supportFragmentManager.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
|
||||
}
|
||||
|
||||
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
|
||||
?.let { fragmentStack += it }
|
||||
if (savedInstanceState == null) {
|
||||
replaceFragment(TabsFragment(), null)
|
||||
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
}
|
||||
if (SdkCheck.isR) {
|
||||
window.statusBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
window.navigationBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
}
|
||||
backHandler()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
onBackPressedCallback = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
|
||||
}
|
||||
|
||||
private fun backHandler() {
|
||||
if (onBackPressedCallback == null) {
|
||||
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
hideKeyboard()
|
||||
popFragment()
|
||||
}
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
onBackPressedCallback!!,
|
||||
)
|
||||
}
|
||||
onBackPressedCallback?.isEnabled = fragmentStack.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
|
||||
if (open != null) {
|
||||
currentFragment?.view?.translationZ =
|
||||
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
|
||||
}
|
||||
supportFragmentManager.commit {
|
||||
if (open != null) {
|
||||
setCustomAnimations(
|
||||
if (open) R.animator.slide_in else 0,
|
||||
if (open) R.animator.slide_in_keep else R.animator.slide_out
|
||||
)
|
||||
}
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.main_content, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushFragment(fragment: Fragment) {
|
||||
currentFragment?.let {
|
||||
fragmentStack.add(
|
||||
FragmentStackItem(
|
||||
it::class.java.name,
|
||||
it.arguments,
|
||||
supportFragmentManager.saveFragmentInstanceState(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
replaceFragment(fragment, true)
|
||||
backHandler()
|
||||
}
|
||||
|
||||
private fun popFragment(): Boolean {
|
||||
return fragmentStack.isNotEmpty() && run {
|
||||
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
|
||||
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
|
||||
stackItem.arguments?.let(fragment::setArguments)
|
||||
stackItem.savedState?.let(fragment::setInitialSavedState)
|
||||
replaceFragment(fragment, false)
|
||||
backHandler()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
|
||||
}
|
||||
|
||||
internal fun onToolbarCreated(toolbar: Toolbar) {
|
||||
if (fragmentStack.isNotEmpty()) {
|
||||
toolbar.navigationIcon = toolbar.context.homeAsUp
|
||||
toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_UPDATES -> {
|
||||
if (currentFragment !is TabsFragment) {
|
||||
fragmentStack.clear()
|
||||
replaceFragment(TabsFragment(), true)
|
||||
}
|
||||
val tabsFragment = currentFragment as TabsFragment
|
||||
tabsFragment.selectUpdates()
|
||||
backHandler()
|
||||
}
|
||||
|
||||
ACTION_INSTALL -> {
|
||||
val packageName = intent.getInstallPackageName
|
||||
if (!packageName.isNullOrEmpty()) {
|
||||
navigateProduct(packageName)
|
||||
val cacheFile = intent.getStringExtra(EXTRA_CACHE_FILE_NAME) ?: return
|
||||
val installItem = packageName installFrom cacheFile
|
||||
lifecycleScope.launch { installer install installItem }
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_VIEW -> {
|
||||
when (val deeplink = intent.deeplinkType) {
|
||||
is DeeplinkType.AppDetail -> {
|
||||
val fragment = currentFragment
|
||||
if (fragment !is AppDetailFragment) {
|
||||
navigateProduct(deeplink.packageName, deeplink.repoAddress)
|
||||
}
|
||||
}
|
||||
|
||||
is DeeplinkType.AddRepository -> {
|
||||
navigateAddRepository(repoAddress = deeplink.address)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_SHOW_APP_INFO -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val packageName = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
|
||||
|
||||
if (packageName != null && currentFragment !is AppDetailFragment) {
|
||||
navigateProduct(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateFavourites() = pushFragment(FavouritesFragment())
|
||||
fun navigateProduct(packageName: String, repoAddress: String? = null) =
|
||||
pushFragment(AppDetailFragment(packageName, repoAddress))
|
||||
|
||||
fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||
fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
|
||||
fun navigateAddRepository(repoAddress: String? = null) =
|
||||
pushFragment(EditRepositoryFragment(null, repoAddress))
|
||||
|
||||
fun navigateRepository(repositoryId: Long) =
|
||||
pushFragment(RepositoryFragment(repositoryId))
|
||||
|
||||
fun navigateEditRepository(repositoryId: Long) =
|
||||
pushFragment(EditRepositoryFragment(repositoryId, null))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.looker.droidify.content
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.serialization.productPreference
|
||||
|
||||
@@ -5,35 +5,36 @@ import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
|
||||
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
sealed class Request {
|
||||
internal abstract val id: Int
|
||||
|
||||
data class ProductsAvailable(
|
||||
class Available(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
val order: SortOrder,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 1
|
||||
}
|
||||
|
||||
data class ProductsInstalled(
|
||||
class Installed(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
val order: SortOrder,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 2
|
||||
}
|
||||
|
||||
data class ProductsUpdates(
|
||||
class Updates(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
val order: SortOrder,
|
||||
val skipSignatureCheck: Boolean,
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 3
|
||||
@@ -52,7 +53,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
private data class ActiveRequest(
|
||||
val request: Request,
|
||||
val callback: Callback?,
|
||||
val cursor: Cursor?
|
||||
val cursor: Cursor?,
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -93,7 +94,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
val request = activeRequests[id]!!.request
|
||||
return QueryLoader(requireContext()) {
|
||||
when (request) {
|
||||
is Request.ProductsAvailable ->
|
||||
is Request.Available ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = false,
|
||||
@@ -101,10 +102,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
signal = it,
|
||||
)
|
||||
|
||||
is Request.ProductsInstalled ->
|
||||
is Request.Installed ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
@@ -112,10 +113,10 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
signal = it,
|
||||
)
|
||||
|
||||
is Request.ProductsUpdates ->
|
||||
is Request.Updates ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
@@ -123,7 +124,8 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
signal = it,
|
||||
skipSignatureCheck = request.skipSignatureCheck,
|
||||
)
|
||||
|
||||
is Request.Repositories -> Database.RepositoryAdapter.query(it)
|
||||
|
||||
@@ -9,18 +9,18 @@ import android.os.CancellationSignal
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.firstOrNull
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.extension.firstOrNull
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.productItem
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
@@ -71,14 +71,20 @@ object Database {
|
||||
get() = "$databasePrefix$innerName"
|
||||
|
||||
fun formatCreateTable(name: String): String {
|
||||
return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
|
||||
return buildString(128) {
|
||||
append("CREATE TABLE ")
|
||||
append(name)
|
||||
append(" (")
|
||||
trimAndJoin(createTable)
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
|
||||
val createIndexPairFormatted: Pair<String, String>?
|
||||
get() = createIndex?.let {
|
||||
Pair(
|
||||
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)"
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +190,7 @@ object Database {
|
||||
}
|
||||
}
|
||||
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 4) {
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
|
||||
var created = false
|
||||
private set
|
||||
var updated = false
|
||||
@@ -214,7 +220,7 @@ object Database {
|
||||
Schema.Product,
|
||||
Schema.Category,
|
||||
Schema.Installed,
|
||||
Schema.Lock
|
||||
Schema.Lock,
|
||||
)
|
||||
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
|
||||
this.created = this.created || create
|
||||
@@ -227,7 +233,7 @@ object Database {
|
||||
val sql = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("sql"),
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName))
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
|
||||
).use { it.firstOrNull()?.getString(0) }.orEmpty()
|
||||
table.formatCreateTable(table.innerName) != sql
|
||||
}
|
||||
@@ -261,7 +267,7 @@ object Database {
|
||||
val sqls = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("name", "sql"),
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName))
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
|
||||
)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
@@ -289,7 +295,7 @@ object Database {
|
||||
val tables = db.query(
|
||||
"sqlite_master",
|
||||
columns = arrayOf("name"),
|
||||
selection = Pair("type = ?", arrayOf("table"))
|
||||
selection = Pair("type = ?", arrayOf("table")),
|
||||
)
|
||||
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
|
||||
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
|
||||
@@ -345,7 +351,7 @@ object Database {
|
||||
private fun SQLiteDatabase.insertOrReplace(
|
||||
replace: Boolean,
|
||||
table: String,
|
||||
contentValues: ContentValues
|
||||
contentValues: ContentValues,
|
||||
): Long {
|
||||
return if (replace) {
|
||||
replace(table, null, contentValues)
|
||||
@@ -353,7 +359,7 @@ object Database {
|
||||
insert(
|
||||
table,
|
||||
null,
|
||||
contentValues
|
||||
contentValues,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -363,7 +369,7 @@ object Database {
|
||||
columns: Array<String>? = null,
|
||||
selection: Pair<String, Array<String>>? = null,
|
||||
orderBy: String? = null,
|
||||
signal: CancellationSignal? = null
|
||||
signal: CancellationSignal? = null,
|
||||
): Cursor {
|
||||
return query(
|
||||
false,
|
||||
@@ -375,7 +381,7 @@ object Database {
|
||||
null,
|
||||
orderBy,
|
||||
null,
|
||||
signal
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -397,7 +403,7 @@ object Database {
|
||||
internal fun putWithoutNotification(
|
||||
repository: Repository,
|
||||
shouldReplace: Boolean,
|
||||
database: SQLiteDatabase
|
||||
database: SQLiteDatabase,
|
||||
): Long {
|
||||
return database.insertOrReplace(
|
||||
shouldReplace,
|
||||
@@ -409,7 +415,7 @@ object Database {
|
||||
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
|
||||
put(Schema.Repository.ROW_DELETED, 0)
|
||||
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -442,8 +448,8 @@ object Database {
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
arrayOf(id.toString()),
|
||||
),
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
@@ -463,9 +469,9 @@ object Database {
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
|
||||
"${Schema.Repository.ROW_DELETED} == 0",
|
||||
emptyArray()
|
||||
emptyArray(),
|
||||
),
|
||||
signal = null
|
||||
signal = null,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -473,7 +479,7 @@ object Database {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
signal = null
|
||||
signal = null,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -489,9 +495,9 @@ object Database {
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
|
||||
"${Schema.Repository.ROW_DELETED} != 0",
|
||||
emptyArray()
|
||||
emptyArray(),
|
||||
),
|
||||
signal = null
|
||||
signal = null,
|
||||
).use { parentCursor ->
|
||||
parentCursor.asSequence().associate {
|
||||
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
|
||||
@@ -508,7 +514,7 @@ object Database {
|
||||
put(Schema.Repository.ROW_DELETED, 1)
|
||||
},
|
||||
"${Schema.Repository.ROW_ID} = ?",
|
||||
arrayOf(id.toString())
|
||||
arrayOf(id.toString()),
|
||||
)
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
}
|
||||
@@ -519,18 +525,18 @@ object Database {
|
||||
val productsCount = db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null
|
||||
null,
|
||||
)
|
||||
val categoriesCount = db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null
|
||||
null,
|
||||
)
|
||||
if (isDeleted) {
|
||||
db.delete(
|
||||
Schema.Repository.name,
|
||||
"${Schema.Repository.ROW_ID} IN ($id)",
|
||||
null
|
||||
null,
|
||||
)
|
||||
}
|
||||
productsCount != 0 || categoriesCount != 0
|
||||
@@ -555,7 +561,7 @@ object Database {
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
|
||||
signal = signal
|
||||
signal = signal,
|
||||
).observable(Subject.Repositories)
|
||||
}
|
||||
|
||||
@@ -577,26 +583,28 @@ object Database {
|
||||
.map { get(packageName, null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun getUpdates(): List<ProductItem> = withContext(Dispatchers.IO) {
|
||||
query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = "",
|
||||
section = ProductItem.Section.All,
|
||||
order = SortOrder.NAME,
|
||||
signal = null
|
||||
).use {
|
||||
it.asSequence()
|
||||
.map(ProductAdapter::transformItem)
|
||||
.toList()
|
||||
suspend fun getUpdates(skipSignatureCheck: Boolean): List<ProductItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = "",
|
||||
skipSignatureCheck = skipSignatureCheck,
|
||||
section = ProductItem.Section.All,
|
||||
order = SortOrder.NAME,
|
||||
signal = null,
|
||||
).use {
|
||||
it.asSequence()
|
||||
.map(ProductAdapter::transformItem)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUpdatesStream(): Flow<List<ProductItem>> = flowOf(Unit)
|
||||
fun getUpdatesStream(skipSignatureCheck: Boolean): Flow<List<ProductItem>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
// Crashes due to immediate retrieval of data?
|
||||
.onEach { delay(50) }
|
||||
.map { getUpdates() }
|
||||
.map { getUpdates(skipSignatureCheck) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
|
||||
@@ -605,10 +613,10 @@ object Database {
|
||||
columns = arrayOf(
|
||||
Schema.Product.ROW_REPOSITORY_ID,
|
||||
Schema.Product.ROW_DESCRIPTION,
|
||||
Schema.Product.ROW_DATA
|
||||
Schema.Product.ROW_DATA,
|
||||
),
|
||||
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal
|
||||
signal = signal,
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
@@ -623,24 +631,26 @@ object Database {
|
||||
columns = arrayOf("COUNT (*)"),
|
||||
selection = Pair(
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repositoryId.toString())
|
||||
)
|
||||
arrayOf(repositoryId.toString()),
|
||||
),
|
||||
).use { it.firstOrNull()?.getInt(0) ?: 0 }
|
||||
}
|
||||
|
||||
fun query(
|
||||
installed: Boolean,
|
||||
updates: Boolean,
|
||||
skipSignatureCheck: Boolean = false,
|
||||
searchQuery: String,
|
||||
section: ProductItem.Section,
|
||||
order: SortOrder,
|
||||
signal: CancellationSignal?
|
||||
signal: CancellationSignal?,
|
||||
): Cursor {
|
||||
val builder = QueryBuilder()
|
||||
|
||||
val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} != ''"""
|
||||
val signatureMatches = if (skipSignatureCheck) "1"
|
||||
else """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} != ''"""
|
||||
|
||||
builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID},
|
||||
product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME},
|
||||
@@ -728,6 +738,10 @@ object Database {
|
||||
}
|
||||
}
|
||||
|
||||
fun transformPackageName(cursor: Cursor): String {
|
||||
return cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
||||
}
|
||||
|
||||
fun transformItem(cursor: Cursor): ProductItem {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
|
||||
.jsonParse {
|
||||
@@ -793,10 +807,10 @@ object Database {
|
||||
Schema.Installed.ROW_PACKAGE_NAME,
|
||||
Schema.Installed.ROW_VERSION,
|
||||
Schema.Installed.ROW_VERSION_CODE,
|
||||
Schema.Installed.ROW_SIGNATURE
|
||||
Schema.Installed.ROW_SIGNATURE,
|
||||
),
|
||||
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal
|
||||
signal = signal,
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
@@ -809,7 +823,7 @@ object Database {
|
||||
put(Schema.Installed.ROW_VERSION, installedItem.version)
|
||||
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
|
||||
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
@@ -829,7 +843,7 @@ object Database {
|
||||
val count = db.delete(
|
||||
Schema.Installed.name,
|
||||
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
|
||||
arrayOf(packageName)
|
||||
arrayOf(packageName),
|
||||
)
|
||||
if (count > 0) {
|
||||
notifyChanged(Subject.Products)
|
||||
@@ -841,7 +855,7 @@ object Database {
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE))
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -854,7 +868,7 @@ object Database {
|
||||
ContentValues().apply {
|
||||
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
|
||||
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
@@ -910,9 +924,9 @@ object Database {
|
||||
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
|
||||
put(
|
||||
Schema.Product.ROW_DATA_ITEM,
|
||||
jsonGenerate(product.item()::serialize)
|
||||
jsonGenerate(product.item()::serialize),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
for (category in product.categories) {
|
||||
db.insertOrReplace(
|
||||
@@ -922,7 +936,7 @@ object Database {
|
||||
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Category.ROW_NAME, category)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -935,20 +949,20 @@ object Database {
|
||||
db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString())
|
||||
arrayOf(repository.id.toString()),
|
||||
)
|
||||
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}"
|
||||
"FROM ${Schema.Product.temporaryName}",
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Category.name} SELECT * " +
|
||||
"FROM ${Schema.Category.temporaryName}"
|
||||
"FROM ${Schema.Category.temporaryName}",
|
||||
)
|
||||
RepositoryAdapter.putWithoutNotification(repository, true, db)
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
@@ -957,7 +971,7 @@ object Database {
|
||||
notifyChanged(
|
||||
Subject.Repositories,
|
||||
Subject.Repository(repository.id),
|
||||
Subject.Products
|
||||
Subject.Products,
|
||||
)
|
||||
} else {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
|
||||
@@ -3,26 +3,20 @@ package com.looker.droidify.database
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.CancellationSignal
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.log
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.log
|
||||
|
||||
class QueryBuilder {
|
||||
companion object {
|
||||
fun trimQuery(query: String): String {
|
||||
return query.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
.joinToString(separator = " ")
|
||||
}
|
||||
}
|
||||
|
||||
private val builder = StringBuilder()
|
||||
private val builder = StringBuilder(256)
|
||||
private val arguments = mutableListOf<String>()
|
||||
|
||||
operator fun plusAssign(query: String) {
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.append(" ")
|
||||
}
|
||||
builder.append(trimQuery(query))
|
||||
builder.trimAndJoin(query)
|
||||
}
|
||||
|
||||
operator fun remAssign(argument: String) {
|
||||
@@ -48,3 +42,53 @@ class QueryBuilder {
|
||||
return db.rawQuery(query, arguments, signal)
|
||||
}
|
||||
}
|
||||
|
||||
fun StringBuilder.trimAndJoin(
|
||||
input: String,
|
||||
) {
|
||||
var isFirstLine = true
|
||||
var startOfLine = 0
|
||||
for (i in input.indices) {
|
||||
val char = input[i]
|
||||
when {
|
||||
char == '\n' -> {
|
||||
trimAndAppendLine(input, startOfLine, i, this, isFirstLine)
|
||||
isFirstLine = false
|
||||
startOfLine = i + 1
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (i == input.lastIndex) {
|
||||
trimAndAppendLine(input, startOfLine, i + 1, this, isFirstLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimAndAppendLine(
|
||||
input: String,
|
||||
start: Int,
|
||||
end: Int,
|
||||
builder: StringBuilder,
|
||||
isFirstLine: Boolean,
|
||||
) {
|
||||
var lineStart = start
|
||||
var lineEnd = end - 1
|
||||
|
||||
while (lineStart <= lineEnd && input[lineStart].isWhitespace()) {
|
||||
lineStart++
|
||||
}
|
||||
|
||||
while (lineEnd >= lineStart && input[lineEnd].isWhitespace()) {
|
||||
lineEnd--
|
||||
}
|
||||
|
||||
if (lineStart <= lineEnd) {
|
||||
if (!isFirstLine) {
|
||||
builder.append(' ')
|
||||
}
|
||||
builder.append(input, lineStart, lineEnd + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ package com.looker.droidify.database
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.Exporter
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.di.ApplicationScope
|
||||
import com.looker.core.di.IoDispatcher
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.forEach
|
||||
import com.looker.droidify.utility.common.extension.forEachKey
|
||||
import com.looker.droidify.utility.common.extension.parseDictionary
|
||||
import com.looker.droidify.utility.common.extension.writeArray
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.di.ApplicationScope
|
||||
import com.looker.droidify.di.IoDispatcher
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.IOException
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
class PreferenceSettingsRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val exporter: Exporter<Settings>,
|
||||
) : SettingsRepository {
|
||||
override val data: Flow<Settings> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Log.e("TAG", "Error reading preferences.", exception)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}.map(::mapSettings)
|
||||
|
||||
override suspend fun getInitial(): Settings {
|
||||
return data.first()
|
||||
}
|
||||
|
||||
override suspend fun export(target: Uri) {
|
||||
val currentSettings = getInitial()
|
||||
exporter.export(currentSettings, target)
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri) {
|
||||
val importedSettings = exporter.import(target)
|
||||
val updatedFavorites = importedSettings.favouriteApps +
|
||||
getInitial().favouriteApps
|
||||
val updatedSettings = importedSettings.copy(favouriteApps = updatedFavorites)
|
||||
dataStore.edit {
|
||||
it.setting(updatedSettings)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setLanguage(language: String) =
|
||||
LANGUAGE.update(language)
|
||||
|
||||
override suspend fun enableIncompatibleVersion(enable: Boolean) =
|
||||
INCOMPATIBLE_VERSIONS.update(enable)
|
||||
|
||||
override suspend fun enableNotifyUpdates(enable: Boolean) =
|
||||
NOTIFY_UPDATES.update(enable)
|
||||
|
||||
override suspend fun enableUnstableUpdates(enable: Boolean) =
|
||||
UNSTABLE_UPDATES.update(enable)
|
||||
|
||||
override suspend fun setIgnoreSignature(enable: Boolean) =
|
||||
IGNORE_SIGNATURE.update(enable)
|
||||
|
||||
override suspend fun setTheme(theme: Theme) =
|
||||
THEME.update(theme.name)
|
||||
|
||||
override suspend fun setDynamicTheme(enable: Boolean) =
|
||||
DYNAMIC_THEME.update(enable)
|
||||
|
||||
override suspend fun setInstallerType(installerType: InstallerType) =
|
||||
INSTALLER_TYPE.update(installerType.name)
|
||||
|
||||
override suspend fun setAutoUpdate(allow: Boolean) =
|
||||
AUTO_UPDATE.update(allow)
|
||||
|
||||
override suspend fun setAutoSync(autoSync: AutoSync) =
|
||||
AUTO_SYNC.update(autoSync.name)
|
||||
|
||||
override suspend fun setSortOrder(sortOrder: SortOrder) =
|
||||
SORT_ORDER.update(sortOrder.name)
|
||||
|
||||
override suspend fun setProxyType(proxyType: ProxyType) =
|
||||
PROXY_TYPE.update(proxyType.name)
|
||||
|
||||
override suspend fun setProxyHost(proxyHost: String) =
|
||||
PROXY_HOST.update(proxyHost)
|
||||
|
||||
override suspend fun setProxyPort(proxyPort: Int) =
|
||||
PROXY_PORT.update(proxyPort)
|
||||
|
||||
override suspend fun setCleanUpInterval(interval: Duration) =
|
||||
CLEAN_UP_INTERVAL.update(interval.inWholeHours)
|
||||
|
||||
override suspend fun setCleanupInstant() =
|
||||
LAST_CLEAN_UP.update(Clock.System.now().toEpochMilliseconds())
|
||||
|
||||
override suspend fun setHomeScreenSwiping(value: Boolean) =
|
||||
HOME_SCREEN_SWIPING.update(value)
|
||||
|
||||
override suspend fun toggleFavourites(packageName: String) {
|
||||
dataStore.edit { preference ->
|
||||
val currentSet = preference[FAVOURITE_APPS] ?: emptySet()
|
||||
val newSet = currentSet.updateAsMutable {
|
||||
if (!add(packageName)) remove(packageName)
|
||||
}
|
||||
preference[FAVOURITE_APPS] = newSet
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapSettings(preferences: Preferences): Settings {
|
||||
val installerType =
|
||||
InstallerType.valueOf(preferences[INSTALLER_TYPE] ?: InstallerType.Default.name)
|
||||
|
||||
val language = preferences[LANGUAGE] ?: "system"
|
||||
val incompatibleVersions = preferences[INCOMPATIBLE_VERSIONS] ?: false
|
||||
val notifyUpdate = preferences[NOTIFY_UPDATES] ?: true
|
||||
val unstableUpdate = preferences[UNSTABLE_UPDATES] ?: false
|
||||
val ignoreSignature = preferences[IGNORE_SIGNATURE] ?: false
|
||||
val theme = Theme.valueOf(preferences[THEME] ?: Theme.SYSTEM.name)
|
||||
val dynamicTheme = preferences[DYNAMIC_THEME] ?: false
|
||||
val autoUpdate = preferences[AUTO_UPDATE] ?: false
|
||||
val autoSync = AutoSync.valueOf(preferences[AUTO_SYNC] ?: AutoSync.WIFI_ONLY.name)
|
||||
val sortOrder = SortOrder.valueOf(preferences[SORT_ORDER] ?: SortOrder.UPDATED.name)
|
||||
val type = ProxyType.valueOf(preferences[PROXY_TYPE] ?: ProxyType.DIRECT.name)
|
||||
val host = preferences[PROXY_HOST] ?: "localhost"
|
||||
val port = preferences[PROXY_PORT] ?: 9050
|
||||
val proxy = ProxyPreference(type = type, host = host, port = port)
|
||||
val cleanUpInterval = preferences[CLEAN_UP_INTERVAL]?.hours ?: 12L.hours
|
||||
val lastCleanup = preferences[LAST_CLEAN_UP]?.let { Instant.fromEpochMilliseconds(it) }
|
||||
val favouriteApps = preferences[FAVOURITE_APPS] ?: emptySet()
|
||||
val homeScreenSwiping = preferences[HOME_SCREEN_SWIPING] ?: true
|
||||
|
||||
return Settings(
|
||||
language = language,
|
||||
incompatibleVersions = incompatibleVersions,
|
||||
notifyUpdate = notifyUpdate,
|
||||
unstableUpdate = unstableUpdate,
|
||||
ignoreSignature = ignoreSignature,
|
||||
theme = theme,
|
||||
dynamicTheme = dynamicTheme,
|
||||
installerType = installerType,
|
||||
autoUpdate = autoUpdate,
|
||||
autoSync = autoSync,
|
||||
sortOrder = sortOrder,
|
||||
proxy = proxy,
|
||||
cleanUpInterval = cleanUpInterval,
|
||||
lastCleanup = lastCleanup,
|
||||
favouriteApps = favouriteApps,
|
||||
homeScreenSwiping = homeScreenSwiping,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Preferences.Key<T>.update(newValue: T) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[this] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
companion object PreferencesKeys {
|
||||
val LANGUAGE = stringPreferencesKey("key_language")
|
||||
val INCOMPATIBLE_VERSIONS = booleanPreferencesKey("key_incompatible_versions")
|
||||
val NOTIFY_UPDATES = booleanPreferencesKey("key_notify_updates")
|
||||
val UNSTABLE_UPDATES = booleanPreferencesKey("key_unstable_updates")
|
||||
val IGNORE_SIGNATURE = booleanPreferencesKey("key_ignore_signature")
|
||||
val DYNAMIC_THEME = booleanPreferencesKey("key_dynamic_theme")
|
||||
val AUTO_UPDATE = booleanPreferencesKey("key_auto_updates")
|
||||
val PROXY_HOST = stringPreferencesKey("key_proxy_host")
|
||||
val PROXY_PORT = intPreferencesKey("key_proxy_port")
|
||||
val CLEAN_UP_INTERVAL = longPreferencesKey("key_clean_up_interval")
|
||||
val LAST_CLEAN_UP = longPreferencesKey("key_last_clean_up_time")
|
||||
val FAVOURITE_APPS = stringSetPreferencesKey("key_favourite_apps")
|
||||
val HOME_SCREEN_SWIPING = booleanPreferencesKey("key_home_swiping")
|
||||
|
||||
// Enums
|
||||
val THEME = stringPreferencesKey("key_theme")
|
||||
val INSTALLER_TYPE = stringPreferencesKey("key_installer_type")
|
||||
val AUTO_SYNC = stringPreferencesKey("key_auto_sync")
|
||||
val SORT_ORDER = stringPreferencesKey("key_sort_order")
|
||||
val PROXY_TYPE = stringPreferencesKey("key_proxy_type")
|
||||
|
||||
fun MutablePreferences.setting(settings: Settings): Preferences {
|
||||
set(LANGUAGE, settings.language)
|
||||
set(INCOMPATIBLE_VERSIONS, settings.incompatibleVersions)
|
||||
set(NOTIFY_UPDATES, settings.notifyUpdate)
|
||||
set(UNSTABLE_UPDATES, settings.unstableUpdate)
|
||||
set(THEME, settings.theme.name)
|
||||
set(DYNAMIC_THEME, settings.dynamicTheme)
|
||||
set(INSTALLER_TYPE, settings.installerType.name)
|
||||
set(AUTO_UPDATE, settings.autoUpdate)
|
||||
set(AUTO_SYNC, settings.autoSync.name)
|
||||
set(SORT_ORDER, settings.sortOrder.name)
|
||||
set(PROXY_TYPE, settings.proxy.type.name)
|
||||
set(PROXY_HOST, settings.proxy.host)
|
||||
set(PROXY_PORT, settings.proxy.port)
|
||||
set(CLEAN_UP_INTERVAL, settings.cleanUpInterval.inWholeHours)
|
||||
set(LAST_CLEAN_UP, settings.lastCleanup?.toEpochMilliseconds() ?: 0L)
|
||||
set(FAVOURITE_APPS, settings.favouriteApps)
|
||||
set(HOME_SCREEN_SWIPING, settings.homeScreenSwiping)
|
||||
return this.toPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import androidx.datastore.core.Serializer
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyPreference
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
val language: String = "system",
|
||||
val incompatibleVersions: Boolean = false,
|
||||
val notifyUpdate: Boolean = true,
|
||||
val unstableUpdate: Boolean = false,
|
||||
val ignoreSignature: Boolean = false,
|
||||
val theme: Theme = Theme.SYSTEM,
|
||||
val dynamicTheme: Boolean = false,
|
||||
val installerType: InstallerType = InstallerType.Default,
|
||||
val autoUpdate: Boolean = false,
|
||||
val autoSync: AutoSync = AutoSync.WIFI_ONLY,
|
||||
val sortOrder: SortOrder = SortOrder.UPDATED,
|
||||
val proxy: ProxyPreference = ProxyPreference(),
|
||||
val cleanUpInterval: Duration = 12.hours,
|
||||
val lastCleanup: Instant? = null,
|
||||
val favouriteApps: Set<String> = emptySet(),
|
||||
val homeScreenSwiping: Boolean = true,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
object SettingsSerializer : Serializer<Settings> {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
override val defaultValue: Settings = Settings()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Settings {
|
||||
return try {
|
||||
json.decodeFromStream(input)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Settings, output: OutputStream) {
|
||||
try {
|
||||
json.encodeToStream(t, output)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.looker.droidify.datastore
|
||||
|
||||
import android.net.Uri
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface SettingsRepository {
|
||||
|
||||
val data: Flow<Settings>
|
||||
|
||||
suspend fun getInitial(): Settings
|
||||
|
||||
suspend fun export(target: Uri)
|
||||
|
||||
suspend fun import(target: Uri)
|
||||
|
||||
suspend fun setLanguage(language: String)
|
||||
|
||||
suspend fun enableIncompatibleVersion(enable: Boolean)
|
||||
|
||||
suspend fun enableNotifyUpdates(enable: Boolean)
|
||||
|
||||
suspend fun enableUnstableUpdates(enable: Boolean)
|
||||
|
||||
suspend fun setIgnoreSignature(enable: Boolean)
|
||||
|
||||
suspend fun setTheme(theme: Theme)
|
||||
|
||||
suspend fun setDynamicTheme(enable: Boolean)
|
||||
|
||||
suspend fun setInstallerType(installerType: InstallerType)
|
||||
|
||||
suspend fun setAutoUpdate(allow: Boolean)
|
||||
|
||||
suspend fun setAutoSync(autoSync: AutoSync)
|
||||
|
||||
suspend fun setSortOrder(sortOrder: SortOrder)
|
||||
|
||||
suspend fun setProxyType(proxyType: ProxyType)
|
||||
|
||||
suspend fun setProxyHost(proxyHost: String)
|
||||
|
||||
suspend fun setProxyPort(proxyPort: Int)
|
||||
|
||||
suspend fun setCleanUpInterval(interval: Duration)
|
||||
|
||||
suspend fun setCleanupInstant()
|
||||
|
||||
suspend fun setHomeScreenSwiping(value: Boolean)
|
||||
|
||||
suspend fun toggleFavourites(packageName: String)
|
||||
}
|
||||
|
||||
inline fun <T> SettingsRepository.get(crossinline block: suspend Settings.() -> T): Flow<T> {
|
||||
return data.map(block).distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.looker.droidify.datastore.exporter
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class SettingsExporter(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val json: Json
|
||||
) : Exporter<Settings> {
|
||||
|
||||
override suspend fun export(item: Settings, target: Uri) {
|
||||
scope.launch(ioDispatcher) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(target).use {
|
||||
if (it != null) json.encodeToStream(item, it)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
cancel()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri): Settings = withContext(ioDispatcher) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(target).use {
|
||||
checkNotNull(it) { "Null input stream for import file" }
|
||||
json.decodeFromStream(it)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTrace()
|
||||
throw IllegalStateException(e.message)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
throw IllegalStateException(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.looker.droidify.datastore.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
import com.looker.droidify.R.style as styleRes
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.datastore.model.AutoSync
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.datastore.model.ProxyType
|
||||
import com.looker.droidify.datastore.model.SortOrder
|
||||
import com.looker.droidify.datastore.model.Theme
|
||||
import kotlin.time.Duration
|
||||
|
||||
fun Configuration.getThemeRes(theme: Theme, dynamicTheme: Boolean) = when (theme) {
|
||||
Theme.SYSTEM -> {
|
||||
if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicDark
|
||||
} else {
|
||||
styleRes.Theme_Main_Dark
|
||||
}
|
||||
} else {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Theme.SYSTEM_BLACK -> {
|
||||
if ((uiMode and Configuration.UI_MODE_NIGHT_YES) != 0) {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicAmoled
|
||||
} else {
|
||||
styleRes.Theme_Main_Amoled
|
||||
}
|
||||
} else {
|
||||
if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Theme.LIGHT -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicLight
|
||||
} else {
|
||||
styleRes.Theme_Main_Light
|
||||
}
|
||||
Theme.DARK -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicDark
|
||||
} else {
|
||||
styleRes.Theme_Main_Dark
|
||||
}
|
||||
Theme.AMOLED -> if (SdkCheck.isSnowCake && dynamicTheme) {
|
||||
styleRes.Theme_Main_DynamicAmoled
|
||||
} else {
|
||||
styleRes.Theme_Main_Amoled
|
||||
}
|
||||
}
|
||||
|
||||
fun Context?.toTime(duration: Duration): String {
|
||||
val time = duration.inWholeHours.toInt()
|
||||
val days = duration.inWholeDays.toInt()
|
||||
if (duration == Duration.INFINITE) return this?.getString(stringRes.never) ?: ""
|
||||
return if (time >= 24) {
|
||||
"$days " + this?.resources?.getQuantityString(
|
||||
R.plurals.days,
|
||||
days
|
||||
)
|
||||
} else {
|
||||
"$time " + this?.resources?.getQuantityString(R.plurals.hours, time)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context?.themeName(theme: Theme) = this?.let {
|
||||
when (theme) {
|
||||
Theme.SYSTEM -> getString(stringRes.system)
|
||||
Theme.SYSTEM_BLACK -> getString(stringRes.system) + " " + getString(stringRes.amoled)
|
||||
Theme.LIGHT -> getString(stringRes.light)
|
||||
Theme.DARK -> getString(stringRes.dark)
|
||||
Theme.AMOLED -> getString(stringRes.amoled)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.sortOrderName(sortOrder: SortOrder) = this?.let {
|
||||
when (sortOrder) {
|
||||
SortOrder.UPDATED -> getString(stringRes.recently_updated)
|
||||
SortOrder.ADDED -> getString(stringRes.whats_new)
|
||||
SortOrder.NAME -> getString(stringRes.name)
|
||||
// SortOrder.SIZE -> getString(stringRes.size)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.autoSyncName(autoSync: AutoSync) = this?.let {
|
||||
when (autoSync) {
|
||||
AutoSync.NEVER -> getString(stringRes.never)
|
||||
AutoSync.WIFI_ONLY -> getString(stringRes.only_on_wifi)
|
||||
AutoSync.WIFI_PLUGGED_IN -> getString(stringRes.only_on_wifi_with_charging)
|
||||
AutoSync.ALWAYS -> getString(stringRes.always)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.proxyName(proxyType: ProxyType) = this?.let {
|
||||
when (proxyType) {
|
||||
ProxyType.DIRECT -> getString(stringRes.no_proxy)
|
||||
ProxyType.HTTP -> getString(stringRes.http_proxy)
|
||||
ProxyType.SOCKS -> getString(stringRes.socks_proxy)
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
fun Context?.installerName(installerType: InstallerType) = this?.let {
|
||||
when (installerType) {
|
||||
InstallerType.LEGACY -> getString(stringRes.legacy_installer)
|
||||
InstallerType.SESSION -> getString(stringRes.session_installer)
|
||||
InstallerType.SHIZUKU -> getString(stringRes.shizuku_installer)
|
||||
InstallerType.ROOT -> getString(stringRes.root_installer)
|
||||
}
|
||||
} ?: ""
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.datastore.migration
|
||||
|
||||
import com.looker.droidify.datastore.PreferenceSettingsRepository.PreferencesKeys.setting
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class ProtoToPreferenceMigration(
|
||||
private val oldDataStore: androidx.datastore.core.DataStore<Settings>
|
||||
) : androidx.datastore.core.DataMigration<androidx.datastore.preferences.core.Preferences> {
|
||||
override suspend fun cleanUp() {
|
||||
}
|
||||
|
||||
override suspend fun shouldMigrate(currentData: androidx.datastore.preferences.core.Preferences): Boolean {
|
||||
return currentData.asMap().isEmpty()
|
||||
}
|
||||
|
||||
override suspend fun migrate(currentData: androidx.datastore.preferences.core.Preferences): androidx.datastore.preferences.core.Preferences {
|
||||
val settings = oldDataStore.data.first()
|
||||
val preferences = currentData.toMutablePreferences()
|
||||
preferences.setting(settings)
|
||||
return preferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class AutoSync {
|
||||
ALWAYS,
|
||||
WIFI_ONLY,
|
||||
WIFI_PLUGGED_IN,
|
||||
NEVER
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
import com.looker.droidify.utility.common.device.Miui
|
||||
|
||||
enum class InstallerType {
|
||||
LEGACY,
|
||||
SESSION,
|
||||
SHIZUKU,
|
||||
ROOT;
|
||||
|
||||
companion object {
|
||||
val Default: InstallerType
|
||||
get() = if (Miui.isMiui) {
|
||||
if (Miui.isMiuiOptimizationDisabled()) SESSION else LEGACY
|
||||
} else {
|
||||
SESSION
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProxyPreference(
|
||||
val type: ProxyType = ProxyType.DIRECT,
|
||||
val host: String = "localhost",
|
||||
val port: Int = 9050
|
||||
) {
|
||||
fun update(
|
||||
newType: ProxyType? = null,
|
||||
newHost: String? = null,
|
||||
newPort: Int? = null
|
||||
): ProxyPreference = copy(
|
||||
type = newType ?: type,
|
||||
host = newHost ?: host,
|
||||
port = newPort ?: port
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class ProxyType {
|
||||
DIRECT,
|
||||
HTTP,
|
||||
SOCKS
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
// todo: Add Support for sorting by size
|
||||
enum class SortOrder {
|
||||
UPDATED,
|
||||
ADDED,
|
||||
NAME
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.looker.droidify.datastore.model
|
||||
|
||||
enum class Theme {
|
||||
SYSTEM,
|
||||
SYSTEM_BLACK,
|
||||
LIGHT,
|
||||
DARK,
|
||||
AMOLED
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Qualifier
|
||||
annotation class ApplicationScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CoroutinesModule {
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@DefaultDispatcher
|
||||
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun providesCoroutineScope(
|
||||
@DefaultDispatcher dispatcher: CoroutineDispatcher
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import com.looker.droidify.utility.common.Exporter
|
||||
import com.looker.droidify.datastore.PreferenceSettingsRepository
|
||||
import com.looker.droidify.datastore.Settings
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.SettingsSerializer
|
||||
import com.looker.droidify.datastore.exporter.SettingsExporter
|
||||
import com.looker.droidify.datastore.migration.ProtoToPreferenceMigration
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val PREFERENCES = "settings_file"
|
||||
|
||||
private const val SETTINGS = "settings"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatastoreModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideProtoDatastore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Settings> = DataStoreFactory.create(
|
||||
serializer = SettingsSerializer,
|
||||
) {
|
||||
context.dataStoreFile(PREFERENCES)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providePreferenceDatastore(
|
||||
@ApplicationContext context: Context,
|
||||
oldDatastore: DataStore<Settings>,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(
|
||||
ProtoToPreferenceMigration(oldDatastore)
|
||||
)
|
||||
) {
|
||||
context.preferencesDataStoreFile(SETTINGS)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideSettingsExporter(
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
@IoDispatcher dispatcher: CoroutineDispatcher
|
||||
): Exporter<Settings> = SettingsExporter(
|
||||
context = context,
|
||||
scope = scope,
|
||||
ioDispatcher = dispatcher,
|
||||
json = Json {
|
||||
encodeDefaults = true
|
||||
prettyPrint = true
|
||||
}
|
||||
)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideSettingsRepository(
|
||||
dataStore: DataStore<Preferences>,
|
||||
exporter: Exporter<Settings>
|
||||
): SettingsRepository = PreferenceSettingsRepository(dataStore, exporter)
|
||||
}
|
||||
23
app/src/main/kotlin/com/looker/droidify/di/InstallModule.kt
Normal file
23
app/src/main/kotlin/com/looker/droidify/di/InstallModule.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object InstallModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesInstaller(
|
||||
@ApplicationContext context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
): InstallManager = InstallManager(context, settingsRepository)
|
||||
}
|
||||
27
app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt
Normal file
27
app/src/main/kotlin/com/looker/droidify/di/NetworkModule.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.looker.droidify.di
|
||||
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.KtorDownloader
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideDownloader(
|
||||
@IoDispatcher
|
||||
dispatcher: CoroutineDispatcher
|
||||
): Downloader = KtorDownloader(
|
||||
httpClientEngine = OkHttp.create(),
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.domain
|
||||
|
||||
import com.looker.droidify.domain.model.App
|
||||
import com.looker.droidify.domain.model.AppMinimal
|
||||
import com.looker.droidify.domain.model.Author
|
||||
import com.looker.droidify.domain.model.Package
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AppRepository {
|
||||
|
||||
fun getApps(): Flow<List<AppMinimal>>
|
||||
|
||||
fun getApp(packageName: PackageName): Flow<List<App>>
|
||||
|
||||
fun getAppFromAuthor(author: Author): Flow<List<App>>
|
||||
|
||||
fun getPackages(packageName: PackageName): Flow<List<Package>>
|
||||
|
||||
/**
|
||||
* returns true is the app is added successfully
|
||||
* returns false if the app was already in the favourites and so it is removed
|
||||
*/
|
||||
suspend fun addToFavourite(packageName: PackageName): Boolean
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.domain
|
||||
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface RepoRepository {
|
||||
|
||||
suspend fun getRepo(id: Long): Repo?
|
||||
|
||||
fun getRepos(): Flow<List<Repo>>
|
||||
|
||||
suspend fun updateRepo(repo: Repo)
|
||||
|
||||
suspend fun enableRepository(repo: Repo, enable: Boolean)
|
||||
|
||||
suspend fun sync(repo: Repo): Boolean
|
||||
|
||||
suspend fun syncAll(): Boolean
|
||||
}
|
||||
83
app/src/main/kotlin/com/looker/droidify/domain/model/App.kt
Normal file
83
app/src/main/kotlin/com/looker/droidify/domain/model/App.kt
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class App(
|
||||
val repoId: Long,
|
||||
val appId: Long,
|
||||
val categories: List<String>,
|
||||
val links: Links,
|
||||
val metadata: Metadata,
|
||||
val author: Author,
|
||||
val screenshots: Screenshots,
|
||||
val graphics: Graphics,
|
||||
val donation: Donation,
|
||||
val preferredSigner: String = "",
|
||||
val packages: List<Package>
|
||||
)
|
||||
|
||||
data class Author(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val web: String
|
||||
)
|
||||
|
||||
data class Donation(
|
||||
val regularUrl: String? = null,
|
||||
val bitcoinAddress: String? = null,
|
||||
val flattrId: String? = null,
|
||||
val liteCoinAddress: String? = null,
|
||||
val openCollectiveId: String? = null,
|
||||
val librePayId: String? = null,
|
||||
)
|
||||
|
||||
data class Graphics(
|
||||
val featureGraphic: String = "",
|
||||
val promoGraphic: String = "",
|
||||
val tvBanner: String = "",
|
||||
val video: String = ""
|
||||
)
|
||||
|
||||
data class Links(
|
||||
val changelog: String = "",
|
||||
val issueTracker: String = "",
|
||||
val sourceCode: String = "",
|
||||
val translation: String = "",
|
||||
val webSite: String = ""
|
||||
)
|
||||
|
||||
data class Metadata(
|
||||
val name: String,
|
||||
val packageName: PackageName,
|
||||
val added: Long,
|
||||
val description: String,
|
||||
val icon: String,
|
||||
val lastUpdated: Long,
|
||||
val license: String,
|
||||
val suggestedVersionCode: Long,
|
||||
val suggestedVersionName: String,
|
||||
val summary: String
|
||||
)
|
||||
|
||||
data class Screenshots(
|
||||
val phone: List<String> = emptyList(),
|
||||
val sevenInch: List<String> = emptyList(),
|
||||
val tenInch: List<String> = emptyList(),
|
||||
val tv: List<String> = emptyList(),
|
||||
val wear: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class AppMinimal(
|
||||
val appId: Long,
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val icon: String,
|
||||
val suggestedVersion: String,
|
||||
)
|
||||
|
||||
fun App.minimal() = AppMinimal(
|
||||
appId = appId,
|
||||
name = metadata.name,
|
||||
summary = metadata.summary,
|
||||
icon = metadata.icon,
|
||||
suggestedVersion = metadata.suggestedVersionName,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
interface DataFile {
|
||||
val name: String
|
||||
val hash: String
|
||||
val size: Long
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.Certificate
|
||||
import java.util.Locale
|
||||
|
||||
@JvmInline
|
||||
value class Fingerprint(val value: String) {
|
||||
init {
|
||||
require(value.isNotBlank() && value.length == FINGERPRINT_LENGTH) { "Invalid Fingerprint: $value" }
|
||||
}
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun Fingerprint.check(found: Fingerprint): Boolean {
|
||||
return found.value.equals(value, ignoreCase = true)
|
||||
}
|
||||
|
||||
private const val FINGERPRINT_LENGTH = 64
|
||||
|
||||
fun ByteArray.hex(): String = joinToString(separator = "") { byte ->
|
||||
"%02x".format(Locale.US, byte.toInt() and 0xff)
|
||||
}
|
||||
|
||||
fun Fingerprint.formattedString(): String = value.windowed(2, 2, false)
|
||||
.take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) }
|
||||
|
||||
fun Certificate.fingerprint(): Fingerprint {
|
||||
val bytes = encoded
|
||||
return if (bytes.size >= 256) {
|
||||
try {
|
||||
val fingerprint = MessageDigest.getInstance("sha256").digest(bytes)
|
||||
Fingerprint(fingerprint.hex().uppercase())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Fingerprint("")
|
||||
}
|
||||
} else {
|
||||
Fingerprint("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class Package(
|
||||
val id: Long,
|
||||
val installed: Boolean,
|
||||
val added: Long,
|
||||
val apk: ApkFile,
|
||||
val platforms: Platforms,
|
||||
val features: List<String>,
|
||||
val antiFeatures: List<String>,
|
||||
val manifest: Manifest,
|
||||
val whatsNew: String
|
||||
)
|
||||
|
||||
data class ApkFile(
|
||||
override val name: String,
|
||||
override val hash: String,
|
||||
override val size: Long
|
||||
) : DataFile
|
||||
|
||||
data class Manifest(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val usesSDKs: SDKs,
|
||||
val signer: Set<String>,
|
||||
val permissions: List<Permission>
|
||||
)
|
||||
|
||||
@JvmInline
|
||||
value class Platforms(val value: List<String>)
|
||||
|
||||
data class SDKs(
|
||||
val min: Int = -1,
|
||||
val max: Int = -1,
|
||||
val target: Int = -1
|
||||
)
|
||||
|
||||
// means the max sdk here and any sdk value as -1 means not valid
|
||||
data class Permission(
|
||||
val name: String,
|
||||
val sdKs: SDKs
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
@JvmInline
|
||||
value class PackageName(val name: String)
|
||||
|
||||
fun String.toPackageName() = PackageName(this)
|
||||
49
app/src/main/kotlin/com/looker/droidify/domain/model/Repo.kt
Normal file
49
app/src/main/kotlin/com/looker/droidify/domain/model/Repo.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.looker.droidify.domain.model
|
||||
|
||||
data class Repo(
|
||||
val id: Long,
|
||||
val enabled: Boolean,
|
||||
val address: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val fingerprint: Fingerprint?,
|
||||
val authentication: Authentication,
|
||||
val versionInfo: VersionInfo,
|
||||
val mirrors: List<String>,
|
||||
val antiFeatures: List<AntiFeature>,
|
||||
val categories: List<Category>
|
||||
) {
|
||||
val shouldAuthenticate =
|
||||
authentication.username.isNotEmpty() && authentication.password.isNotEmpty()
|
||||
|
||||
fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo {
|
||||
return copy(
|
||||
fingerprint = fingerprint,
|
||||
versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AntiFeature(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val icon: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
data class Category(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val icon: String = "",
|
||||
val description: String = ""
|
||||
)
|
||||
|
||||
data class Authentication(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class VersionInfo(
|
||||
val timestamp: Long,
|
||||
val etag: String?
|
||||
)
|
||||
@@ -51,7 +51,6 @@ open class DrawableWrapper(val drawable: Drawable) : Drawable() {
|
||||
drawable.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOpacity(): Int = drawable.opacity
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ package com.looker.droidify.index
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.execWithResult
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.asSequence
|
||||
import com.looker.droidify.utility.common.extension.collectNotNull
|
||||
import com.looker.droidify.utility.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
@@ -83,9 +82,9 @@ class IndexMerger(file: File) : Closeable {
|
||||
closeTransaction()
|
||||
db.rawQuery(
|
||||
"""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
|
||||
LEFT JOIN releases ON product.package_name = releases.package_name""",
|
||||
LEFT JOIN releases ON product.package_name = releases.package_name""",
|
||||
null
|
||||
)?.use { cursor ->
|
||||
).use { cursor ->
|
||||
cursor.asSequence().map { currentCursor ->
|
||||
val description = currentCursor.getString(0)
|
||||
val product = Json.factory.createParser(currentCursor.getBlob(1)).use {
|
||||
@@ -112,4 +111,8 @@ class IndexMerger(file: File) : Closeable {
|
||||
override fun close() {
|
||||
db.use { closeTransaction() }
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.execWithResult(sql: String) {
|
||||
rawQuery(sql, null).use { it.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,27 @@ import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.collectDistinctNotEmptyStrings
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.illegal
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.utility.common.extension.Json
|
||||
import com.looker.droidify.utility.common.extension.collectDistinctNotEmptyStrings
|
||||
import com.looker.droidify.utility.common.extension.collectNotNull
|
||||
import com.looker.droidify.utility.common.extension.forEach
|
||||
import com.looker.droidify.utility.common.extension.forEachKey
|
||||
import com.looker.droidify.utility.common.extension.illegal
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Product.Donate.Bitcoin
|
||||
import com.looker.droidify.model.Product.Donate.Liberapay
|
||||
import com.looker.droidify.model.Product.Donate.Litecoin
|
||||
import com.looker.droidify.model.Product.Donate.OpenCollective
|
||||
import com.looker.droidify.model.Product.Donate.Regular
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.LARGE_TABLET
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.PHONE
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.SMALL_TABLET
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.TV
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.VIDEO
|
||||
import com.looker.droidify.model.Product.Screenshot.Type.WEAR
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.nullIfEmpty
|
||||
import java.io.InputStream
|
||||
|
||||
object IndexV1Parser {
|
||||
@@ -32,9 +43,12 @@ object IndexV1Parser {
|
||||
}
|
||||
|
||||
private class Screenshots(
|
||||
val video: List<String>,
|
||||
val phone: List<String>,
|
||||
val smallTablet: List<String>,
|
||||
val largeTablet: List<String>
|
||||
val largeTablet: List<String>,
|
||||
val wear: List<String>,
|
||||
val tv: List<String>,
|
||||
)
|
||||
|
||||
private class Localized(
|
||||
@@ -90,10 +104,9 @@ object IndexV1Parser {
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
|
||||
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
|
||||
"en",
|
||||
callback
|
||||
)
|
||||
return getAndCall("en-US", callback)
|
||||
?: getAndCall("en_US", callback)
|
||||
?: getAndCall("en", callback)
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.findLocalized(callback: (Localized) -> T?): T? {
|
||||
@@ -122,12 +135,11 @@ object IndexV1Parser {
|
||||
|
||||
internal object DonateComparator : Comparator<Product.Donate> {
|
||||
private val classes = listOf(
|
||||
Product.Donate.Regular::class,
|
||||
Product.Donate.Bitcoin::class,
|
||||
Product.Donate.Litecoin::class,
|
||||
Product.Donate.Flattr::class,
|
||||
Product.Donate.Liberapay::class,
|
||||
Product.Donate.OpenCollective::class
|
||||
Regular::class,
|
||||
Bitcoin::class,
|
||||
Litecoin::class,
|
||||
Liberapay::class,
|
||||
OpenCollective::class
|
||||
)
|
||||
|
||||
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
|
||||
@@ -141,14 +153,25 @@ object IndexV1Parser {
|
||||
}
|
||||
}
|
||||
|
||||
private const val DICT_REPO = "repo"
|
||||
private const val DICT_PRODUCT = "apps"
|
||||
private const val DICT_RELEASE = "packages"
|
||||
|
||||
private const val KEY_REPO_ADDRESS = "address"
|
||||
private const val KEY_REPO_MIRRORS = "mirrors"
|
||||
private const val KEY_REPO_NAME = "name"
|
||||
private const val KEY_REPO_DESC = "description"
|
||||
private const val KEY_REPO_VER = "version"
|
||||
private const val KEY_REPO_TIME = "timestamp"
|
||||
|
||||
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
|
||||
val jsonParser = Json.factory.createParser(inputStream)
|
||||
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
|
||||
jsonParser.illegal()
|
||||
} else {
|
||||
jsonParser.forEachKey { it ->
|
||||
jsonParser.forEachKey { key ->
|
||||
when {
|
||||
it.dictionary("repo") -> {
|
||||
key.dictionary(DICT_REPO) -> {
|
||||
var address = ""
|
||||
var mirrors = emptyList<String>()
|
||||
var name = ""
|
||||
@@ -157,12 +180,14 @@ object IndexV1Parser {
|
||||
var timestamp = 0L
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("address") -> address = valueAsString
|
||||
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.number("version") -> version = valueAsInt
|
||||
it.number("timestamp") -> timestamp = valueAsLong
|
||||
it.string(KEY_REPO_ADDRESS) -> address = valueAsString
|
||||
it.array(KEY_REPO_MIRRORS) -> mirrors =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
it.string(KEY_REPO_NAME) -> name = valueAsString
|
||||
it.string(KEY_REPO_DESC) -> description = valueAsString
|
||||
it.number(KEY_REPO_VER) -> version = valueAsInt
|
||||
it.number(KEY_REPO_TIME) -> timestamp = valueAsLong
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
@@ -182,12 +207,12 @@ object IndexV1Parser {
|
||||
)
|
||||
}
|
||||
|
||||
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
|
||||
key.array(DICT_PRODUCT) -> forEach(JsonToken.START_OBJECT) {
|
||||
val product = parseProduct(repositoryId)
|
||||
callback.onProduct(product)
|
||||
}
|
||||
|
||||
it.dictionary("packages") -> forEachKey {
|
||||
key.dictionary(DICT_RELEASE) -> forEachKey {
|
||||
if (it.token == JsonToken.START_ARRAY) {
|
||||
val packageName = it.key
|
||||
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
|
||||
@@ -203,6 +228,38 @@ object IndexV1Parser {
|
||||
}
|
||||
}
|
||||
|
||||
private const val KEY_PRODUCT_PACKAGENAME = "packageName"
|
||||
private const val KEY_PRODUCT_NAME = "name"
|
||||
private const val KEY_PRODUCT_SUMMARY = "summary"
|
||||
private const val KEY_PRODUCT_DESCRIPTION = "description"
|
||||
private const val KEY_PRODUCT_ICON = "icon"
|
||||
private const val KEY_PRODUCT_AUTHORNAME = "authorName"
|
||||
private const val KEY_PRODUCT_AUTHOREMAIL = "authorEmail"
|
||||
private const val KEY_PRODUCT_AUTHORWEBSITE = "authorWebSite"
|
||||
private const val KEY_PRODUCT_SOURCECODE = "sourceCode"
|
||||
private const val KEY_PRODUCT_CHANGELOG = "changelog"
|
||||
private const val KEY_PRODUCT_WEBSITE = "webSite"
|
||||
private const val KEY_PRODUCT_ISSUETRACKER = "issueTracker"
|
||||
private const val KEY_PRODUCT_ADDED = "added"
|
||||
private const val KEY_PRODUCT_LASTUPDATED = "lastUpdated"
|
||||
private const val KEY_PRODUCT_SUGGESTEDVERSIONCODE = "suggestedVersionCode"
|
||||
private const val KEY_PRODUCT_CATEGORIES = "categories"
|
||||
private const val KEY_PRODUCT_ANTIFEATURES = "antiFeatures"
|
||||
private const val KEY_PRODUCT_LICENSE = "license"
|
||||
private const val KEY_PRODUCT_DONATE = "donate"
|
||||
private const val KEY_PRODUCT_BITCOIN = "bitcoin"
|
||||
private const val KEY_PRODUCT_LIBERAPAYID = "liberapay"
|
||||
private const val KEY_PRODUCT_LITECOIN = "litecoin"
|
||||
private const val KEY_PRODUCT_OPENCOLLECTIVE = "openCollective"
|
||||
private const val KEY_PRODUCT_LOCALIZED = "localized"
|
||||
private const val KEY_PRODUCT_WHATSNEW = "whatsNew"
|
||||
private const val KEY_PRODUCT_PHONE_SCREENSHOTS = "phoneScreenshots"
|
||||
private const val KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS = "sevenInchScreenshots"
|
||||
private const val KEY_PRODUCT_TEN_INCH_SCREENSHOTS = "tenInchScreenshots"
|
||||
private const val KEY_PRODUCT_WEAR_SCREENSHOTS = "wearScreenshots"
|
||||
private const val KEY_PRODUCT_TV_SCREENSHOTS = "tvScreenshots"
|
||||
private const val KEY_PRODUCT_VIDEO = "video"
|
||||
|
||||
private fun JsonParser.parseProduct(repositoryId: Long): Product {
|
||||
var packageName = ""
|
||||
var nameFallback = ""
|
||||
@@ -224,42 +281,42 @@ object IndexV1Parser {
|
||||
val licenses = mutableListOf<String>()
|
||||
val donates = mutableListOf<Product.Donate>()
|
||||
val localizedMap = mutableMapOf<String, Localized>()
|
||||
forEachKey { it ->
|
||||
forEachKey { key ->
|
||||
when {
|
||||
it.string("packageName") -> packageName = valueAsString
|
||||
it.string("name") -> nameFallback = valueAsString
|
||||
it.string("summary") -> summaryFallback = valueAsString
|
||||
it.string("description") -> descriptionFallback = valueAsString
|
||||
it.string("icon") -> icon = validateIcon(valueAsString)
|
||||
it.string("authorName") -> authorName = valueAsString
|
||||
it.string("authorEmail") -> authorEmail = valueAsString
|
||||
it.string("authorWebSite") -> authorWeb = valueAsString
|
||||
it.string("sourceCode") -> source = valueAsString
|
||||
it.string("changelog") -> changelog = valueAsString
|
||||
it.string("webSite") -> web = valueAsString
|
||||
it.string("issueTracker") -> tracker = valueAsString
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("lastUpdated") -> updated = valueAsLong
|
||||
it.string("suggestedVersionCode") ->
|
||||
key.string(KEY_PRODUCT_PACKAGENAME) -> packageName = valueAsString
|
||||
key.string(KEY_PRODUCT_NAME) -> nameFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_SUMMARY) -> summaryFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_DESCRIPTION) -> descriptionFallback = valueAsString
|
||||
key.string(KEY_PRODUCT_ICON) -> icon = validateIcon(valueAsString)
|
||||
key.string(KEY_PRODUCT_AUTHORNAME) -> authorName = valueAsString
|
||||
key.string(KEY_PRODUCT_AUTHOREMAIL) -> authorEmail = valueAsString
|
||||
key.string(KEY_PRODUCT_AUTHORWEBSITE) -> authorWeb = valueAsString
|
||||
key.string(KEY_PRODUCT_SOURCECODE) -> source = valueAsString
|
||||
key.string(KEY_PRODUCT_CHANGELOG) -> changelog = valueAsString
|
||||
key.string(KEY_PRODUCT_WEBSITE) -> web = valueAsString
|
||||
key.string(KEY_PRODUCT_ISSUETRACKER) -> tracker = valueAsString
|
||||
key.number(KEY_PRODUCT_ADDED) -> added = valueAsLong
|
||||
key.number(KEY_PRODUCT_LASTUPDATED) -> updated = valueAsLong
|
||||
key.string(KEY_PRODUCT_SUGGESTEDVERSIONCODE) ->
|
||||
suggestedVersionCode =
|
||||
valueAsString.toLongOrNull() ?: 0L
|
||||
|
||||
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
|
||||
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
|
||||
it.string("license") -> licenses += valueAsString.split(',')
|
||||
key.array(KEY_PRODUCT_CATEGORIES) -> categories = collectDistinctNotEmptyStrings()
|
||||
key.array(KEY_PRODUCT_ANTIFEATURES) -> antiFeatures =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
key.string(KEY_PRODUCT_LICENSE) -> licenses += valueAsString.split(',')
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
|
||||
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
|
||||
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
|
||||
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
|
||||
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
|
||||
valueAsString
|
||||
)
|
||||
key.string(KEY_PRODUCT_DONATE) -> donates += Regular(valueAsString)
|
||||
key.string(KEY_PRODUCT_BITCOIN) -> donates += Bitcoin(valueAsString)
|
||||
key.string(KEY_PRODUCT_LIBERAPAYID) -> donates += Liberapay(valueAsString)
|
||||
key.string(KEY_PRODUCT_LITECOIN) -> donates += Litecoin(valueAsString)
|
||||
key.string(KEY_PRODUCT_OPENCOLLECTIVE) -> donates += OpenCollective(valueAsString)
|
||||
|
||||
it.dictionary("localized") -> forEachKey { it ->
|
||||
if (it.token == JsonToken.START_OBJECT) {
|
||||
val locale = it.key
|
||||
key.dictionary(KEY_PRODUCT_LOCALIZED) -> forEachKey { localizedKey ->
|
||||
if (localizedKey.token == JsonToken.START_OBJECT) {
|
||||
val locale = localizedKey.key
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var description = ""
|
||||
@@ -268,46 +325,52 @@ object IndexV1Parser {
|
||||
var phone = emptyList<String>()
|
||||
var smallTablet = emptyList<String>()
|
||||
var largeTablet = emptyList<String>()
|
||||
var wear = emptyList<String>()
|
||||
var tv = emptyList<String>()
|
||||
var video = emptyList<String>()
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("summary") -> summary = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.string("whatsNew") -> whatsNew = valueAsString
|
||||
it.string("icon") -> metadataIcon = valueAsString
|
||||
it.array("phoneScreenshots") ->
|
||||
phone =
|
||||
collectDistinctNotEmptyStrings()
|
||||
it.string(KEY_PRODUCT_NAME) -> name = valueAsString
|
||||
it.string(KEY_PRODUCT_SUMMARY) -> summary = valueAsString
|
||||
it.string(KEY_PRODUCT_DESCRIPTION) -> description = valueAsString
|
||||
it.string(KEY_PRODUCT_WHATSNEW) -> whatsNew = valueAsString
|
||||
it.string(KEY_PRODUCT_ICON) -> metadataIcon = valueAsString
|
||||
it.string(KEY_PRODUCT_VIDEO) -> video = listOf(valueAsString)
|
||||
it.array(KEY_PRODUCT_PHONE_SCREENSHOTS) ->
|
||||
phone = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array("sevenInchScreenshots") ->
|
||||
smallTablet =
|
||||
collectDistinctNotEmptyStrings()
|
||||
it.array(KEY_PRODUCT_SEVEN_INCH_SCREENSHOTS) ->
|
||||
smallTablet = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array("tenInchScreenshots") ->
|
||||
largeTablet =
|
||||
collectDistinctNotEmptyStrings()
|
||||
it.array(KEY_PRODUCT_TEN_INCH_SCREENSHOTS) ->
|
||||
largeTablet = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_WEAR_SCREENSHOTS) ->
|
||||
wear = collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array(KEY_PRODUCT_TV_SCREENSHOTS) ->
|
||||
tv = collectDistinctNotEmptyStrings()
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val isScreenshotEmpty =
|
||||
arrayOf(video, phone, smallTablet, largeTablet, wear, tv)
|
||||
.any { it.isNotEmpty() }
|
||||
val screenshots =
|
||||
if (sequenceOf(
|
||||
phone,
|
||||
smallTablet,
|
||||
largeTablet
|
||||
).any { it.isNotEmpty() }
|
||||
) {
|
||||
Screenshots(phone, smallTablet, largeTablet)
|
||||
if (isScreenshotEmpty) {
|
||||
Screenshots(video, phone, smallTablet, largeTablet, wear, tv)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
localizedMap[locale] = Localized(
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(),
|
||||
screenshots
|
||||
name = name,
|
||||
summary = summary,
|
||||
description = description,
|
||||
whatsNew = whatsNew,
|
||||
metadataIcon = metadataIcon.nullIfEmpty()?.let { "$locale/$it" }
|
||||
.orEmpty(),
|
||||
screenshots = screenshots,
|
||||
)
|
||||
} else {
|
||||
skipChildren()
|
||||
@@ -330,54 +393,61 @@ object IndexV1Parser {
|
||||
}
|
||||
val screenshotPairs =
|
||||
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
|
||||
val screenshots = screenshotPairs
|
||||
?.let { (key, screenshots) ->
|
||||
screenshots.phone.asSequence()
|
||||
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
|
||||
screenshots.smallTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.SMALL_TABLET,
|
||||
it
|
||||
)
|
||||
} +
|
||||
screenshots.largeTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.LARGE_TABLET,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
.orEmpty().toList()
|
||||
val screenshots = screenshotPairs?.let { (key, screenshots) ->
|
||||
screenshots.video.map { Product.Screenshot(key, VIDEO, it) } +
|
||||
screenshots.phone.map { Product.Screenshot(key, PHONE, it) } +
|
||||
screenshots.smallTablet.map { Product.Screenshot(key, SMALL_TABLET, it) } +
|
||||
screenshots.largeTablet.map { Product.Screenshot(key, LARGE_TABLET, it) } +
|
||||
screenshots.wear.map { Product.Screenshot(key, WEAR, it) } +
|
||||
screenshots.tv.map { Product.Screenshot(key, TV, it) }
|
||||
}.orEmpty()
|
||||
return Product(
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
icon,
|
||||
metadataIcon,
|
||||
Product.Author(authorName, authorEmail, authorWeb),
|
||||
source,
|
||||
changelog,
|
||||
web,
|
||||
tracker,
|
||||
added,
|
||||
updated,
|
||||
suggestedVersionCode,
|
||||
categories,
|
||||
antiFeatures,
|
||||
licenses,
|
||||
donates.sortedWith(DonateComparator),
|
||||
screenshots,
|
||||
emptyList()
|
||||
repositoryId = repositoryId,
|
||||
packageName = packageName,
|
||||
name = name,
|
||||
summary = summary,
|
||||
description = description,
|
||||
whatsNew = whatsNew,
|
||||
icon = icon,
|
||||
metadataIcon = metadataIcon,
|
||||
author = Product.Author(authorName, authorEmail, authorWeb),
|
||||
source = source,
|
||||
changelog = changelog,
|
||||
web = web,
|
||||
tracker = tracker,
|
||||
added = added,
|
||||
updated = updated,
|
||||
suggestedVersionCode = suggestedVersionCode,
|
||||
categories = categories,
|
||||
antiFeatures = antiFeatures,
|
||||
licenses = licenses,
|
||||
donates = donates.sortedWith(DonateComparator),
|
||||
screenshots = screenshots,
|
||||
releases = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private const val KEY_RELEASE_VERSIONNAME = "versionName"
|
||||
private const val KEY_RELEASE_VERSIONCODE = "versionCode"
|
||||
private const val KEY_RELEASE_ADDED = "added"
|
||||
private const val KEY_RELEASE_SIZE = "size"
|
||||
private const val KEY_RELEASE_MINSDKVERSION = "minSdkVersion"
|
||||
private const val KEY_RELEASE_TARGETSDKVERSION = "targetSdkVersion"
|
||||
private const val KEY_RELEASE_MAXSDKVERSION = "maxSdkVersion"
|
||||
private const val KEY_RELEASE_SRCNAME = "srcname"
|
||||
private const val KEY_RELEASE_APKNAME = "apkName"
|
||||
private const val KEY_RELEASE_HASH = "hash"
|
||||
private const val KEY_RELEASE_HASHTYPE = "hashType"
|
||||
private const val KEY_RELEASE_SIG = "sig"
|
||||
private const val KEY_RELEASE_OBBMAINFILE = "obbMainFile"
|
||||
private const val KEY_RELEASE_OBBMAINFILESHA256 = "obbMainFileSha256"
|
||||
private const val KEY_RELEASE_OBBPATCHFILE = "obbPatchFile"
|
||||
private const val KEY_RELEASE_OBBPATCHFILESHA256 = "obbPatchFileSha256"
|
||||
private const val KEY_RELEASE_USESPERMISSION = "uses-permission"
|
||||
private const val KEY_RELEASE_USESPERMISSIONSDK23 = "uses-permission-sdk-23"
|
||||
private const val KEY_RELEASE_FEATURES = "features"
|
||||
private const val KEY_RELEASE_NATIVECODE = "nativecode"
|
||||
|
||||
private fun JsonParser.parseRelease(): Release {
|
||||
var version = ""
|
||||
var versionCode = 0L
|
||||
@@ -398,28 +468,28 @@ object IndexV1Parser {
|
||||
val permissions = linkedSetOf<String>()
|
||||
var features = emptyList<String>()
|
||||
var platforms = emptyList<String>()
|
||||
forEachKey {
|
||||
forEachKey { key ->
|
||||
when {
|
||||
it.string("versionName") -> version = valueAsString
|
||||
it.number("versionCode") -> versionCode = valueAsLong
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("size") -> size = valueAsLong
|
||||
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
|
||||
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
|
||||
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
|
||||
it.string("srcname") -> source = valueAsString
|
||||
it.string("apkName") -> release = valueAsString
|
||||
it.string("hash") -> hash = valueAsString
|
||||
it.string("hashType") -> hashTypeCandidate = valueAsString
|
||||
it.string("sig") -> signature = valueAsString
|
||||
it.string("obbMainFile") -> obbMain = valueAsString
|
||||
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
|
||||
it.string("obbPatchFile") -> obbPatch = valueAsString
|
||||
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
|
||||
it.array("uses-permission") -> collectPermissions(permissions, 0)
|
||||
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
|
||||
it.array("features") -> features = collectDistinctNotEmptyStrings()
|
||||
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
|
||||
key.string(KEY_RELEASE_VERSIONNAME) -> version = valueAsString
|
||||
key.number(KEY_RELEASE_VERSIONCODE) -> versionCode = valueAsLong
|
||||
key.number(KEY_RELEASE_ADDED) -> added = valueAsLong
|
||||
key.number(KEY_RELEASE_SIZE) -> size = valueAsLong
|
||||
key.number(KEY_RELEASE_MINSDKVERSION) -> minSdkVersion = valueAsInt
|
||||
key.number(KEY_RELEASE_TARGETSDKVERSION) -> targetSdkVersion = valueAsInt
|
||||
key.number(KEY_RELEASE_MAXSDKVERSION) -> maxSdkVersion = valueAsInt
|
||||
key.string(KEY_RELEASE_SRCNAME) -> source = valueAsString
|
||||
key.string(KEY_RELEASE_APKNAME) -> release = valueAsString
|
||||
key.string(KEY_RELEASE_HASH) -> hash = valueAsString
|
||||
key.string(KEY_RELEASE_HASHTYPE) -> hashTypeCandidate = valueAsString
|
||||
key.string(KEY_RELEASE_SIG) -> signature = valueAsString
|
||||
key.string(KEY_RELEASE_OBBMAINFILE) -> obbMain = valueAsString
|
||||
key.string(KEY_RELEASE_OBBMAINFILESHA256) -> obbMainHash = valueAsString
|
||||
key.string(KEY_RELEASE_OBBPATCHFILE) -> obbPatch = valueAsString
|
||||
key.string(KEY_RELEASE_OBBPATCHFILESHA256) -> obbPatchHash = valueAsString
|
||||
key.array(KEY_RELEASE_USESPERMISSION) -> collectPermissions(permissions, 0)
|
||||
key.array(KEY_RELEASE_USESPERMISSIONSDK23) -> collectPermissions(permissions, 23)
|
||||
key.array(KEY_RELEASE_FEATURES) -> features = collectDistinctNotEmptyStrings()
|
||||
key.array(KEY_RELEASE_NATIVECODE) -> platforms = collectDistinctNotEmptyStrings()
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
@@ -428,29 +498,29 @@ object IndexV1Parser {
|
||||
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
|
||||
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
|
||||
return Release(
|
||||
false,
|
||||
version,
|
||||
versionCode,
|
||||
added,
|
||||
size,
|
||||
minSdkVersion,
|
||||
targetSdkVersion,
|
||||
maxSdkVersion,
|
||||
source,
|
||||
release,
|
||||
hash,
|
||||
hashType,
|
||||
signature,
|
||||
obbMain,
|
||||
obbMainHash,
|
||||
obbMainHashType,
|
||||
obbPatch,
|
||||
obbPatchHash,
|
||||
obbPatchHashType,
|
||||
permissions.toList(),
|
||||
features,
|
||||
platforms,
|
||||
emptyList()
|
||||
selected = false,
|
||||
version = version,
|
||||
versionCode = versionCode,
|
||||
added = added,
|
||||
size = size,
|
||||
minSdkVersion = minSdkVersion,
|
||||
targetSdkVersion = targetSdkVersion,
|
||||
maxSdkVersion = maxSdkVersion,
|
||||
source = source,
|
||||
release = release,
|
||||
hash = hash,
|
||||
hashType = hashType,
|
||||
signature = signature,
|
||||
obbMain = obbMain,
|
||||
obbMainHash = obbMainHash,
|
||||
obbMainHashType = obbMainHashType,
|
||||
obbPatch = obbPatch,
|
||||
obbPatchHash = obbPatchHash,
|
||||
obbPatchHashType = obbPatchHashType,
|
||||
permissions = permissions.toList(),
|
||||
features = features,
|
||||
platforms = platforms,
|
||||
incompatibilities = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,28 @@ package com.looker.droidify.index
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.fingerprint
|
||||
import com.looker.core.common.extension.toFormattedString
|
||||
import com.looker.core.common.result.Result
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.domain.model.fingerprint
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.NetworkResponse
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.toFormattedString
|
||||
import com.looker.droidify.utility.common.result.Result
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import com.looker.droidify.utility.getProgress
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
object RepositoryUpdater {
|
||||
enum class Stage {
|
||||
@@ -31,7 +31,7 @@ object RepositoryUpdater {
|
||||
}
|
||||
|
||||
// TODO Add support for Index-V2 and also cleanup everything here
|
||||
private enum class IndexType(
|
||||
enum class IndexType(
|
||||
val jarName: String,
|
||||
val contentName: String
|
||||
) {
|
||||
@@ -219,12 +219,13 @@ object RepositoryUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
private fun processFile(
|
||||
fun processFile(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
indexType: IndexType,
|
||||
unstable: Boolean,
|
||||
file: File,
|
||||
mergerFile: File = Cache.getTemporaryFile(context),
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
@@ -241,7 +242,6 @@ object RepositoryUpdater {
|
||||
|
||||
var changedRepository: Repository? = null
|
||||
|
||||
val mergerFile = Cache.getTemporaryFile(context)
|
||||
try {
|
||||
val unmergedProducts = mutableListOf<Product>()
|
||||
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
|
||||
@@ -344,6 +344,7 @@ object RepositoryUpdater {
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
.toString()
|
||||
.uppercase()
|
||||
|
||||
val commitRepository = if (!workRepository.fingerprint.equals(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.looker.droidify.installer
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.extension.addAndCompute
|
||||
import com.looker.droidify.utility.common.extension.filter
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.LegacyInstaller
|
||||
import com.looker.droidify.installer.installers.root.RootInstaller
|
||||
import com.looker.droidify.installer.installers.session.SessionInstaller
|
||||
import com.looker.droidify.installer.installers.shizuku.ShizukuInstaller
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.installer.notification.removeInstallNotification
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class InstallManager(
|
||||
private val context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
) {
|
||||
|
||||
private val installItems = Channel<InstallItem>()
|
||||
private val uninstallItems = Channel<PackageName>()
|
||||
|
||||
val state = MutableStateFlow<Map<PackageName, InstallState>>(emptyMap())
|
||||
|
||||
private var _installer: Installer? = null
|
||||
set(value) {
|
||||
field?.close()
|
||||
field = value
|
||||
}
|
||||
private val installer: Installer get() = _installer!!
|
||||
|
||||
private val lock = Mutex()
|
||||
private val installerPreference = settingsRepository.get { installerType }
|
||||
|
||||
suspend operator fun invoke() = coroutineScope {
|
||||
setupInstaller()
|
||||
installer()
|
||||
uninstaller()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_installer = null
|
||||
uninstallItems.close()
|
||||
installItems.close()
|
||||
}
|
||||
|
||||
suspend infix fun install(installItem: InstallItem) {
|
||||
installItems.send(installItem)
|
||||
}
|
||||
|
||||
suspend infix fun uninstall(packageName: PackageName) {
|
||||
uninstallItems.send(packageName)
|
||||
}
|
||||
|
||||
infix fun remove(packageName: PackageName) {
|
||||
updateState { remove(packageName) }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setupInstaller() = launch {
|
||||
installerPreference.collectLatest(::setInstaller)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.installer() = launch {
|
||||
val currentQueue = mutableSetOf<String>()
|
||||
installItems.filter { item ->
|
||||
currentQueue.addAndCompute(item.packageName.name) { isAdded ->
|
||||
if (isAdded) {
|
||||
updateState { put(item.packageName, InstallState.Pending) }
|
||||
}
|
||||
}
|
||||
}.consumeEach { item ->
|
||||
if (state.value.containsKey(item.packageName)) {
|
||||
updateState { put(item.packageName, InstallState.Installing) }
|
||||
context.notificationManager?.installNotification(
|
||||
packageName = item.packageName.name,
|
||||
notification = context.createInstallNotification(
|
||||
appName = item.packageName.name,
|
||||
state = InstallState.Installing,
|
||||
)
|
||||
)
|
||||
val success = installer.use {
|
||||
it.install(item)
|
||||
}
|
||||
context.notificationManager?.removeInstallNotification(item.packageName.name)
|
||||
updateState { put(item.packageName, success) }
|
||||
currentQueue.remove(item.packageName.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.uninstaller() = launch {
|
||||
uninstallItems.consumeEach {
|
||||
installer.uninstall(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setInstaller(installerType: InstallerType) {
|
||||
lock.withLock {
|
||||
_installer = when (installerType) {
|
||||
InstallerType.LEGACY -> LegacyInstaller(context)
|
||||
InstallerType.SESSION -> SessionInstaller(context)
|
||||
InstallerType.SHIZUKU -> ShizukuInstaller(context)
|
||||
InstallerType.ROOT -> RootInstaller(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(block: MutableMap<PackageName, InstallState>.() -> Unit) {
|
||||
state.update { it.updateAsMutable(block) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
|
||||
interface Installer : AutoCloseable {
|
||||
|
||||
suspend fun install(installItem: InstallItem): InstallState
|
||||
|
||||
suspend fun uninstall(packageName: PackageName)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.droidify.utility.common.extension.getLauncherActivities
|
||||
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.utility.common.extension.intent
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rikka.shizuku.ShizukuProvider
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
|
||||
|
||||
fun launchShizuku(context: Context) {
|
||||
val activities =
|
||||
context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID)
|
||||
val intent = intent(Intent.ACTION_MAIN) {
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
setComponent(
|
||||
ComponentName(
|
||||
ShizukuProvider.MANAGER_APPLICATION_ID,
|
||||
activities.first().first
|
||||
)
|
||||
)
|
||||
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun isShizukuInstalled(context: Context) =
|
||||
context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null
|
||||
|
||||
fun isShizukuAlive() = rikka.shizuku.Shizuku.pingBinder()
|
||||
|
||||
fun isShizukuGranted() = rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
suspend fun requestPermissionListener() = suspendCancellableCoroutine {
|
||||
val listener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
|
||||
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
|
||||
it.resume(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
}
|
||||
rikka.shizuku.Shizuku.addRequestPermissionResultListener(listener)
|
||||
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
it.invokeOnCancellation {
|
||||
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestShizuku() {
|
||||
rikka.shizuku.Shizuku.shouldShowRequestPermissionRationale()
|
||||
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
fun isMagiskGranted(): Boolean {
|
||||
com.topjohnwu.superuser.Shell.getCachedShell() ?: com.topjohnwu.superuser.Shell.getShell()
|
||||
return com.topjohnwu.superuser.Shell.isAppGrantedRoot() == true
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.looker.droidify.installer.installers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AndroidRuntimeException
|
||||
import androidx.core.net.toUri
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.intent
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class LegacyInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem,
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0
|
||||
val fileUri = if (SdkCheck.isNougat) {
|
||||
Cache.getReleaseUri(
|
||||
context,
|
||||
installItem.installFileName
|
||||
)
|
||||
} else {
|
||||
Cache.getReleaseFile(context, installItem.installFileName).toUri()
|
||||
}
|
||||
val installIntent = intent(Intent.ACTION_INSTALL_PACKAGE) {
|
||||
setDataAndType(fileUri, APK_MIME)
|
||||
flags = installFlag
|
||||
}
|
||||
try {
|
||||
context.startActivity(installIntent)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(installIntent)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: Exception) {
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
suspend fun Context.uninstallPackage(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
startActivity(
|
||||
intent(Intent.ACTION_UNINSTALL_PACKAGE) {
|
||||
data = "package:${packageName.name}".toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
)
|
||||
cont.resume(Unit)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.looker.droidify.installer.installers.root
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.uninstallPackage
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RootInstaller(private val context: Context) : Installer {
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem,
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val releaseFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val installCommand = INSTALL_COMMAND.format(
|
||||
releaseFile.absolutePath,
|
||||
currentUser(),
|
||||
releaseFile.length(),
|
||||
)
|
||||
Shell.cmd(installCommand).submit { shellResult ->
|
||||
val result = if (shellResult.isSuccess) InstallState.Installed
|
||||
else InstallState.Failed
|
||||
cont.resume(result)
|
||||
val deleteCommand = DELETE_COMMAND.format(utilBox(), releaseFile.absolutePath)
|
||||
Shell.cmd(deleteCommand).submit()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
|
||||
private const val INSTALL_COMMAND = "cat %s | pm install --user %s -t -r -S %s"
|
||||
private const val DELETE_COMMAND = "%s rm %s"
|
||||
|
||||
/** Returns the path of either toybox or busybox, or empty string if not found. */
|
||||
private fun utilBox(): String {
|
||||
listOf("toybox", "busybox").forEach {
|
||||
// Returns the path of the requested [command], or empty string if not found
|
||||
val out = Shell.cmd("which $it").exec().out
|
||||
if (out.isEmpty()) return ""
|
||||
if (out.first().contains("not found")) return ""
|
||||
return out.first()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Returns the current user of the device. */
|
||||
private fun currentUser() = if (SdkCheck.isOreo) {
|
||||
Shell.cmd("am get-current-user")
|
||||
.exec()
|
||||
.out[0]
|
||||
} else {
|
||||
Shell.cmd("dumpsys activity | grep -E \"mUserLru\"")
|
||||
.exec()
|
||||
.out[0]
|
||||
.trim()
|
||||
.removePrefix("mUserLru: [")
|
||||
.removeSuffix("]")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.looker.droidify.installer.installers.session
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.log
|
||||
import com.looker.droidify.utility.common.sdkAbove
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class SessionInstaller(private val context: Context) : Installer {
|
||||
|
||||
private val installer = context.packageManager.packageInstaller
|
||||
private val intent = Intent(context, SessionInstallerReceiver::class.java)
|
||||
|
||||
companion object {
|
||||
private var installerCallbacks: PackageInstaller.SessionCallback? = null
|
||||
private val flags = if (SdkCheck.isSnowCake) PendingIntent.FLAG_MUTABLE else 0
|
||||
private val sessionParams =
|
||||
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
sdkAbove(sdk = Build.VERSION_CODES.S) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
sdkAbove(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setRequestUpdateOwnership(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val cacheFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val id = installer.createSession(sessionParams)
|
||||
val installerCallback = object : PackageInstaller.SessionCallback() {
|
||||
override fun onCreated(sessionId: Int) {}
|
||||
override fun onBadgingChanged(sessionId: Int) {}
|
||||
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
|
||||
override fun onProgressChanged(sessionId: Int, progress: Float) {}
|
||||
override fun onFinished(sessionId: Int, success: Boolean) {
|
||||
if (sessionId == id) cont.resume(InstallState.Installed)
|
||||
}
|
||||
}
|
||||
installerCallbacks = installerCallback
|
||||
|
||||
installer.registerSessionCallback(
|
||||
installerCallbacks!!,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
|
||||
val session = installer.openSession(id)
|
||||
|
||||
session.use { activeSession ->
|
||||
val sizeBytes = cacheFile.length()
|
||||
cacheFile.inputStream().use { fileStream ->
|
||||
activeSession.openWrite(cacheFile.name, 0, sizeBytes).use { outputStream ->
|
||||
if (cont.isActive) {
|
||||
fileStream.copyTo(outputStream)
|
||||
activeSession.fsync(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, flags)
|
||||
|
||||
if (cont.isActive) activeSession.commit(pendingIntent.intentSender)
|
||||
}
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
try {
|
||||
installer.abandonSession(id)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
intent.putExtra(SessionInstallerReceiver.ACTION_UNINSTALL, true)
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, -1, intent, flags)
|
||||
|
||||
installer.uninstall(packageName.name, pendingIntent.intentSender)
|
||||
cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
installerCallbacks?.let {
|
||||
installer.unregisterSessionCallback(it)
|
||||
installerCallbacks = null
|
||||
}
|
||||
try {
|
||||
installer.mySessions.forEach { installer.abandonSession(it.sessionId) }
|
||||
} catch (e: SecurityException) {
|
||||
log(e.message, type = Log.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.looker.droidify.installer.installers.session
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.getPackageName
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.domain.model.toPackageName
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.installer.notification.removeInstallNotification
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SessionInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
// This is a cyclic dependency injection, I know but this is the best option for now
|
||||
@Inject
|
||||
lateinit var installManager: InstallManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
// prompts user to enable unknown source
|
||||
val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
|
||||
promptIntent?.let {
|
||||
it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(it)
|
||||
}
|
||||
} else {
|
||||
notifyStatus(intent, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStatus(intent: Intent, context: Context) {
|
||||
val packageManager = context.packageManager
|
||||
val notificationManager = context.notificationManager
|
||||
|
||||
context.createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = context.getString(R.string.install)
|
||||
)
|
||||
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val isUninstall = intent.getBooleanExtra(ACTION_UNINSTALL, false)
|
||||
|
||||
val appName = packageManager.getPackageName(packageName)
|
||||
|
||||
if (packageName != null) {
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
notificationManager?.removeInstallNotification(packageName)
|
||||
val notification = context.createInstallNotification(
|
||||
appName = (appName ?: packageName.substringAfterLast('.')).toString(),
|
||||
state = InstallState.Installed,
|
||||
isUninstall = isUninstall,
|
||||
) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName.toString(),
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
}
|
||||
|
||||
else -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
val notification = context.createInstallNotification(
|
||||
appName = appName.toString(),
|
||||
state = InstallState.Failed,
|
||||
) {
|
||||
setContentText(message)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName,
|
||||
notification = notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_UNINSTALL = "action_uninstall"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.looker.droidify.installer.installers.shizuku
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.extension.size
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.installer.installers.Installer
|
||||
import com.looker.droidify.installer.installers.uninstallPackage
|
||||
import com.looker.droidify.installer.model.InstallItem
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ShizukuInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
var sessionId: String? = null
|
||||
val file = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val packageName = installItem.packageName.name
|
||||
try {
|
||||
val fileSize = file.size ?: run {
|
||||
cont.cancel()
|
||||
error("File is not valid: Size ${file.size}")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
file.inputStream().use {
|
||||
val createCommand =
|
||||
if (SdkCheck.isNougat) {
|
||||
"pm install-create --user current -i $packageName -S $fileSize"
|
||||
} else {
|
||||
"pm install-create -i $packageName -S $fileSize"
|
||||
}
|
||||
val createResult = exec(createCommand)
|
||||
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
|
||||
?: run {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to create install session")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it)
|
||||
if (writeResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to write APK to session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val commitResult = exec("pm install-commit $sessionId")
|
||||
if (commitResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to commit install session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
cont.resume(InstallState.Installed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (sessionId != null) exec("pm install-abandon $sessionId")
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
|
||||
private data class ShellResult(val resultCode: Int, val out: String)
|
||||
|
||||
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
|
||||
val process = rikka.shizuku.Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
|
||||
if (stdin != null) {
|
||||
process.outputStream.use { stdin.copyTo(it) }
|
||||
}
|
||||
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val resultCode = process.waitFor()
|
||||
return ShellResult(resultCode, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.looker.droidify.installer.model
|
||||
|
||||
import com.looker.droidify.domain.model.PackageName
|
||||
import com.looker.droidify.domain.model.toPackageName
|
||||
|
||||
class InstallItem(
|
||||
val packageName: PackageName,
|
||||
val installFileName: String
|
||||
)
|
||||
|
||||
infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.installer.model
|
||||
|
||||
enum class InstallState { Failed, Pending, Installing, Installed }
|
||||
|
||||
inline val InstallState.isCancellable: Boolean
|
||||
get() = this == InstallState.Pending
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.looker.droidify.installer.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_ID_INSTALL
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.R
|
||||
|
||||
fun NotificationManager.installNotification(
|
||||
packageName: String,
|
||||
notification: Notification,
|
||||
) {
|
||||
notify(
|
||||
installTag(packageName),
|
||||
NOTIFICATION_ID_INSTALL,
|
||||
notification
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.removeInstallNotification(
|
||||
packageName: String,
|
||||
) {
|
||||
cancel(installTag(packageName), NOTIFICATION_ID_INSTALL)
|
||||
}
|
||||
|
||||
fun installTag(name: String): String = "install-${name.trim().replace(' ', '_')}"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
|
||||
fun Context.createInstallNotification(
|
||||
appName: String,
|
||||
state: InstallState,
|
||||
isUninstall: Boolean = false,
|
||||
autoCancel: Boolean = true,
|
||||
block: NotificationCompat.Builder.() -> Unit = {},
|
||||
): Notification {
|
||||
return NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_INSTALL)
|
||||
.apply {
|
||||
setAutoCancel(autoCancel)
|
||||
setOngoing(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setColor(Color.GREEN)
|
||||
val (title, text) = if (isUninstall) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(R.drawable.ic_delete)
|
||||
getString(R.string.uninstalled_application) to
|
||||
getString(R.string.uninstalled_application_DESC, appName)
|
||||
} else {
|
||||
when (state) {
|
||||
InstallState.Failed -> {
|
||||
setSmallIcon(R.drawable.ic_bug_report)
|
||||
getString(R.string.installation_failed) to
|
||||
getString(R.string.installation_failed_DESC, appName)
|
||||
}
|
||||
|
||||
InstallState.Pending -> {
|
||||
setSmallIcon(R.drawable.ic_download)
|
||||
getString(R.string.downloaded_FORMAT, appName) to
|
||||
getString(R.string.tap_to_install_DESC)
|
||||
}
|
||||
|
||||
InstallState.Installing -> {
|
||||
setSmallIcon(R.drawable.ic_download)
|
||||
setProgress(-1, -1, true)
|
||||
getString(R.string.installing) to
|
||||
appName
|
||||
}
|
||||
|
||||
InstallState.Installed -> {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(R.drawable.ic_check)
|
||||
getString(R.string.installed) to
|
||||
appName
|
||||
}
|
||||
}
|
||||
}
|
||||
setContentTitle(title)
|
||||
setContentText(text)
|
||||
block()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.videoPlaceHolder
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
data class Product(
|
||||
var repositoryId: Long,
|
||||
val packageName: String,
|
||||
@@ -30,20 +35,41 @@ data class Product(
|
||||
data class Regular(val url: String) : Donate()
|
||||
data class Bitcoin(val address: String) : Donate()
|
||||
data class Litecoin(val address: String) : Donate()
|
||||
data class Flattr(val id: String) : Donate()
|
||||
data class Liberapay(val id: String) : Donate()
|
||||
data class OpenCollective(val id: String) : Donate()
|
||||
}
|
||||
|
||||
class Screenshot(val locale: String, val type: Type, val path: String) {
|
||||
enum class Type(val jsonName: String) {
|
||||
VIDEO("video"),
|
||||
PHONE("phone"),
|
||||
SMALL_TABLET("smallTablet"),
|
||||
LARGE_TABLET("largeTablet")
|
||||
LARGE_TABLET("largeTablet"),
|
||||
WEAR("wear"),
|
||||
TV("tv")
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
get() = "$locale.${type.name}.$path"
|
||||
|
||||
fun url(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
packageName: String
|
||||
): Any {
|
||||
if (type == Type.VIDEO) return context.videoPlaceHolder.apply {
|
||||
setTintList(context.getColorFromAttr(MaterialR.attr.colorOnSurfaceInverse))
|
||||
}
|
||||
val phoneType = when (type) {
|
||||
Type.PHONE -> "phoneScreenshots"
|
||||
Type.SMALL_TABLET -> "sevenInchScreenshots"
|
||||
Type.LARGE_TABLET -> "tenInchScreenshots"
|
||||
Type.WEAR -> "wearScreenshots"
|
||||
Type.TV -> "tvScreenshots"
|
||||
else -> error("Should not be here, video url already returned")
|
||||
}
|
||||
return "${repository.address}/$packageName/$locale/$phoneType/$path"
|
||||
}
|
||||
}
|
||||
|
||||
// Same releases with different signatures
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import com.looker.droidify.utility.common.extension.dpi
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class ProductItem(
|
||||
@@ -16,15 +18,39 @@ data class ProductItem(
|
||||
var canUpdate: Boolean,
|
||||
var matchRank: Int
|
||||
) {
|
||||
sealed class Section : Parcelable {
|
||||
sealed interface Section : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
data object All : Section()
|
||||
object All : Section
|
||||
|
||||
@Parcelize
|
||||
data class Category(val name: String) : Section()
|
||||
class Category(val name: String) : Section
|
||||
|
||||
@Parcelize
|
||||
data class Repository(val id: Long, val name: String) : Section()
|
||||
class Repository(val id: Long, val name: String) : Section
|
||||
}
|
||||
|
||||
private val supportedDpi = intArrayOf(120, 160, 240, 320, 480, 640)
|
||||
private var deviceDpi: Int = -1
|
||||
|
||||
fun icon(
|
||||
view: View,
|
||||
repository: Repository
|
||||
): String? {
|
||||
if (packageName.isBlank()) return null
|
||||
if (icon.isBlank() && metadataIcon.isBlank()) return null
|
||||
if (repository.version < 11 && icon.isNotBlank()) {
|
||||
return "${repository.address}/icons/$icon"
|
||||
}
|
||||
if (icon.isNotBlank()) {
|
||||
if (deviceDpi == -1) {
|
||||
deviceDpi = supportedDpi.find { it >= view.dpi } ?: supportedDpi.last()
|
||||
}
|
||||
return "${repository.address}/icons-$deviceDpi/$icon"
|
||||
}
|
||||
if (metadataIcon.isNotBlank()) {
|
||||
return "${repository.address}/$packageName/$metadataIcon"
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ data class Release(
|
||||
object MinSdk : Incompatibility()
|
||||
object MaxSdk : Incompatibility()
|
||||
object Platform : Incompatibility()
|
||||
data class Feature(val feature: String) : Incompatibility()
|
||||
class Feature(val feature: String) : Incompatibility()
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import com.looker.core.common.extension.isOnion
|
||||
import java.net.URL
|
||||
|
||||
data class Repository(
|
||||
@@ -16,19 +15,9 @@ data class Repository(
|
||||
val entityTag: String,
|
||||
val updated: Long,
|
||||
val timestamp: Long,
|
||||
val authentication: String
|
||||
val authentication: String,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Remove all onion addresses and supply it as random address
|
||||
*
|
||||
* If the list only contains onion urls we will provide the default address
|
||||
*/
|
||||
val randomAddress: String
|
||||
get() = (mirrors + address)
|
||||
.filter { !it.isOnion }
|
||||
.randomOrNull() ?: address
|
||||
|
||||
fun edit(address: String, fingerprint: String, authentication: String): Repository {
|
||||
val isAddressChanged = this.address != address
|
||||
val isFingerprintChanged = this.fingerprint != fingerprint
|
||||
@@ -49,7 +38,7 @@ data class Repository(
|
||||
version: Int,
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
timestamp: Long
|
||||
timestamp: Long,
|
||||
): Repository {
|
||||
return copy(
|
||||
mirrors = mirrors,
|
||||
@@ -73,7 +62,7 @@ data class Repository(
|
||||
fun newRepository(
|
||||
address: String,
|
||||
fingerprint: String,
|
||||
authentication: String
|
||||
authentication: String,
|
||||
): Repository {
|
||||
val name = try {
|
||||
URL(address).let { "${it.host}${it.path}" }
|
||||
@@ -90,7 +79,7 @@ data class Repository(
|
||||
version: Int = 21,
|
||||
enabled: Boolean = false,
|
||||
fingerprint: String,
|
||||
authentication: String = ""
|
||||
authentication: String = "",
|
||||
): Repository {
|
||||
return Repository(
|
||||
-1, address, emptyList(), name, description, version, enabled,
|
||||
@@ -161,14 +150,6 @@ data class Repository(
|
||||
" by Netsyms Technologies.",
|
||||
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.bromite.org/fdroid/repo",
|
||||
name = "Bromite",
|
||||
description = "The official repository for Bromite. " +
|
||||
"Bromite is a Chromium with ad blocking and enhanced p" +
|
||||
"rivacy.",
|
||||
fingerprint = "E1EE5CD076D7B0DC84CB2B45FB78B86DF2EB39A3B6C56BA3DC292A5E0C3B9504"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://molly.im/fdroid/foss/fdroid/repo",
|
||||
name = "Molly",
|
||||
@@ -358,10 +339,7 @@ data class Repository(
|
||||
name = "SimpleX Chat F-Droid",
|
||||
description = "SimpleX Chat official F-Droid repository.",
|
||||
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
|
||||
)
|
||||
)
|
||||
|
||||
val newlyAdded = listOf<Repository>(
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f-droid.monerujo.io/fdroid/repo",
|
||||
name = "Monerujo Wallet",
|
||||
@@ -411,5 +389,29 @@ data class Repository(
|
||||
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
|
||||
),
|
||||
)
|
||||
|
||||
val newlyAdded: List<Repository> = listOf(
|
||||
defaultRepository(
|
||||
address = "https://fdroid.ironfoxoss.org/fdroid/repo",
|
||||
name = "IronFox",
|
||||
description = "The official repository for IronFox:" +
|
||||
" A privacy and security-oriented Firefox-based browser for Android.",
|
||||
fingerprint = "C5E291B5A571F9C8CD9A9799C2C94E02EC9703948893F2CA756D67B94204F904"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://raw.githubusercontent.com/chrisgch/tca/master/fdroid/repo",
|
||||
name = "Total Commander",
|
||||
description = "The official repository for Total Commander",
|
||||
fingerprint = "3576596CECDD70488D61CFD90799A49B7FFD26A81A8FEF1BADEC88D069FA72C1"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.cromite.org/fdroid/repo",
|
||||
name = "Cromite",
|
||||
description = "The official repository for Cromite. " +
|
||||
"Cromite is a Chromium fork based on Bromite with " +
|
||||
"built-in support for ad blocking and an eye for privacy.",
|
||||
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
app/src/main/kotlin/com/looker/droidify/network/DataSize.kt
Normal file
30
app/src/main/kotlin/com/looker/droidify/network/DataSize.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@JvmInline
|
||||
value class DataSize(val value: Long) {
|
||||
|
||||
companion object {
|
||||
private const val BYTE_SIZE = 1024L
|
||||
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val (size, index) = generateSequence(Pair(value.toFloat(), 0)) { (size, index) ->
|
||||
if (size >= BYTE_SIZE) {
|
||||
Pair(size / BYTE_SIZE, index + 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.take(sizeFormats.size).last()
|
||||
return sizeFormats[index].format(Locale.US, size)
|
||||
}
|
||||
}
|
||||
|
||||
infix fun DataSize.percentBy(denominator: DataSize?): Int = value percentBy denominator?.value
|
||||
|
||||
infix fun Long.percentBy(denominator: Long?): Int {
|
||||
if (denominator == null || denominator < 1) return -1
|
||||
return (this * 100 / denominator).toInt()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.network.header.HeadersBuilder
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
|
||||
interface Downloader {
|
||||
|
||||
fun setProxy(proxy: Proxy)
|
||||
|
||||
suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit = {}
|
||||
): NetworkResponse
|
||||
|
||||
suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator? = null,
|
||||
headers: HeadersBuilder.() -> Unit = {},
|
||||
block: ProgressListener? = null
|
||||
): NetworkResponse
|
||||
}
|
||||
|
||||
typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.network.header.HeadersBuilder
|
||||
import com.looker.droidify.network.header.KtorHeadersBuilder
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import com.looker.droidify.utility.common.extension.size
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.etag
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.http.lastModified
|
||||
import io.ktor.utils.io.jvm.javaio.copyTo
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.Proxy
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
internal class KtorDownloader(
|
||||
httpClientEngine: HttpClientEngine,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Downloader {
|
||||
|
||||
private var client = client(httpClientEngine)
|
||||
set(newClient) {
|
||||
field.close()
|
||||
field = newClient
|
||||
}
|
||||
|
||||
override fun setProxy(proxy: Proxy) {
|
||||
client = client(OkHttp.create { this.proxy = proxy })
|
||||
}
|
||||
|
||||
override suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit
|
||||
): NetworkResponse {
|
||||
val headRequest = createRequest(
|
||||
url = url,
|
||||
headers = headers
|
||||
)
|
||||
return client.head(headRequest).asNetworkResponse()
|
||||
}
|
||||
|
||||
override suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator?,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
block: ProgressListener?
|
||||
): NetworkResponse = withContext(dispatcher) {
|
||||
try {
|
||||
val request = createRequest(
|
||||
url = url,
|
||||
headers = {
|
||||
inRange(target.size)
|
||||
headers()
|
||||
},
|
||||
fileSize = target.size,
|
||||
block = block
|
||||
)
|
||||
client.prepareGet(request).execute { response ->
|
||||
val networkResponse = response.asNetworkResponse()
|
||||
if (networkResponse !is NetworkResponse.Success) {
|
||||
return@execute networkResponse
|
||||
}
|
||||
response.bodyAsChannel().copyTo(target.outputStream())
|
||||
validator?.validate(target)
|
||||
networkResponse
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
NetworkResponse.Error.SocketTimeout(e)
|
||||
} catch (e: ConnectTimeoutException) {
|
||||
NetworkResponse.Error.ConnectionTimeout(e)
|
||||
} catch (e: IOException) {
|
||||
NetworkResponse.Error.IO(e)
|
||||
} catch (e: ValidationException) {
|
||||
target.delete()
|
||||
NetworkResponse.Error.Validation(e)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
NetworkResponse.Error.Unknown(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(
|
||||
engine: HttpClientEngine = OkHttp.create()
|
||||
): HttpClient {
|
||||
return HttpClient(engine) {
|
||||
userAgentConfig()
|
||||
timeoutConfig()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun createRequest(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
fileSize: Long? = null,
|
||||
block: ProgressListener? = null
|
||||
) = request {
|
||||
url(url)
|
||||
this.headers {
|
||||
KtorHeadersBuilder(this).headers()
|
||||
}
|
||||
onDownload { read, total ->
|
||||
if (block != null) {
|
||||
block(
|
||||
DataSize(read + (fileSize ?: 0L)),
|
||||
DataSize((total ?: 0L) + (fileSize ?: 0L))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CONNECTION_TIMEOUT = 30_000L
|
||||
private const val SOCKET_TIMEOUT = 15_000L
|
||||
private const val USER_AGENT = "Droid-ify/${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}"
|
||||
|
||||
private fun HttpClientConfig<*>.userAgentConfig() = install(UserAgent) {
|
||||
agent = USER_AGENT
|
||||
}
|
||||
|
||||
private fun HttpClientConfig<*>.timeoutConfig() = install(HttpTimeout) {
|
||||
connectTimeoutMillis = CONNECTION_TIMEOUT
|
||||
socketTimeoutMillis = SOCKET_TIMEOUT
|
||||
}
|
||||
|
||||
private fun HttpResponse.asNetworkResponse(): NetworkResponse =
|
||||
if (status.isSuccess() || status == HttpStatusCode.NotModified) {
|
||||
NetworkResponse.Success(status.value, lastModified(), etag())
|
||||
} else {
|
||||
NetworkResponse.Error.Http(status.value)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.looker.droidify.network
|
||||
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import java.util.Date
|
||||
|
||||
sealed interface NetworkResponse {
|
||||
|
||||
sealed interface Error : NetworkResponse {
|
||||
|
||||
data class ConnectionTimeout(val exception: Exception) : Error
|
||||
|
||||
data class SocketTimeout(val exception: Exception) : Error
|
||||
|
||||
data class IO(val exception: Exception) : Error
|
||||
|
||||
data class Validation(val exception: ValidationException) : Error
|
||||
|
||||
data class Unknown(val exception: Exception) : Error
|
||||
|
||||
data class Http(val statusCode: Int) : Error
|
||||
}
|
||||
|
||||
data class Success(
|
||||
val statusCode: Int,
|
||||
val lastModified: Date?,
|
||||
val etag: String?
|
||||
) : NetworkResponse
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.network.header
|
||||
|
||||
import java.util.Date
|
||||
|
||||
interface HeadersBuilder {
|
||||
|
||||
infix fun String.headsWith(value: Any?)
|
||||
|
||||
fun etag(etagString: String)
|
||||
|
||||
fun ifModifiedSince(date: Date)
|
||||
|
||||
fun ifModifiedSince(date: String)
|
||||
|
||||
fun authentication(username: String, password: String)
|
||||
|
||||
fun authentication(base64: String)
|
||||
|
||||
fun inRange(start: Number?, end: Number? = null)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.looker.droidify.network.header
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.util.encodeBase64
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal class KtorHeadersBuilder(
|
||||
private val builder: io.ktor.http.HeadersBuilder
|
||||
) : HeadersBuilder {
|
||||
|
||||
override fun String.headsWith(value: Any?) {
|
||||
if (value == null) return
|
||||
with(builder) {
|
||||
append(this@headsWith, value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun etag(etagString: String) {
|
||||
HttpHeaders.ETag headsWith etagString
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: Date) {
|
||||
HttpHeaders.IfModifiedSince headsWith date.toFormattedString()
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: String) {
|
||||
HttpHeaders.IfModifiedSince headsWith date
|
||||
}
|
||||
|
||||
override fun authentication(username: String, password: String) {
|
||||
HttpHeaders.Authorization headsWith "Basic ${"$username:$password".encodeBase64()}"
|
||||
}
|
||||
|
||||
override fun authentication(base64: String) {
|
||||
HttpHeaders.Authorization headsWith base64
|
||||
}
|
||||
|
||||
override fun inRange(start: Number?, end: Number?) {
|
||||
if (start == null) return
|
||||
val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-"
|
||||
HttpHeaders.Range headsWith valueString
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val HTTP_DATE_FORMAT: SimpleDateFormat
|
||||
get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
|
||||
fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.looker.droidify.network.validation
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface FileValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(file: File)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.droidify.network.validation
|
||||
|
||||
class ValidationException(override val message: String) : Exception(message)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun invalid(message: String): Nothing = throw ValidationException(message)
|
||||
@@ -4,7 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.core.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
|
||||
|
||||
@@ -6,35 +6,34 @@ import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.core.common.R
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.percentBy
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.extension.toPendingIntent
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.datastore.get
|
||||
import com.looker.droidify.datastore.model.InstallerType
|
||||
import com.looker.droidify.installer.InstallManager
|
||||
import com.looker.droidify.installer.model.InstallState
|
||||
import com.looker.droidify.installer.model.installFrom
|
||||
import com.looker.droidify.installer.notification.createInstallNotification
|
||||
import com.looker.droidify.installer.notification.installNotification
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.installFrom
|
||||
import com.looker.installer.notification.createInstallNotification
|
||||
import com.looker.installer.notification.installNotification
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import com.looker.network.validation.ValidationException
|
||||
import com.looker.droidify.network.DataSize
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.network.NetworkResponse
|
||||
import com.looker.droidify.network.percentBy
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.startServiceCompat
|
||||
import com.looker.droidify.utility.common.extension.stopForegroundCompat
|
||||
import com.looker.droidify.utility.common.extension.toPendingIntent
|
||||
import com.looker.droidify.utility.common.extension.updateAsMutable
|
||||
import com.looker.droidify.utility.common.log
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -51,7 +50,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
@@ -175,7 +174,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = getString(R.string.install)
|
||||
name = getString(stringRes.install)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
@@ -379,7 +378,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
}
|
||||
if (!started) {
|
||||
started = true
|
||||
startSelf()
|
||||
startServiceCompat()
|
||||
}
|
||||
val task = tasks.removeFirstOrNull() ?: return
|
||||
with(stateNotificationBuilder) {
|
||||
|
||||
@@ -2,17 +2,17 @@ package com.looker.droidify.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.looker.core.common.extension.calculateHash
|
||||
import com.looker.core.common.extension.getPackageArchiveInfoCompat
|
||||
import com.looker.core.common.extension.singleSignature
|
||||
import com.looker.core.common.extension.versionCodeCompat
|
||||
import com.looker.network.validation.FileValidator
|
||||
import com.looker.core.common.signature.Hash
|
||||
import com.looker.network.validation.invalid
|
||||
import com.looker.core.common.signature.verifyHash
|
||||
import com.looker.droidify.utility.common.extension.calculateHash
|
||||
import com.looker.droidify.utility.common.extension.getPackageArchiveInfoCompat
|
||||
import com.looker.droidify.utility.common.extension.singleSignature
|
||||
import com.looker.droidify.utility.common.extension.versionCodeCompat
|
||||
import com.looker.droidify.network.validation.FileValidator
|
||||
import com.looker.droidify.utility.common.signature.Hash
|
||||
import com.looker.droidify.network.validation.invalid
|
||||
import com.looker.droidify.utility.common.signature.verifyHash
|
||||
import com.looker.droidify.model.Release
|
||||
import java.io.File
|
||||
import com.looker.core.common.R.string as strings
|
||||
import com.looker.droidify.R.string as strings
|
||||
|
||||
class ReleaseFileValidator(
|
||||
private val context: Context,
|
||||
|
||||
@@ -15,17 +15,16 @@ import android.text.style.ForegroundColorSpan
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.common.result.Result
|
||||
import com.looker.core.common.sdkAbove
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.droidify.utility.common.Constants
|
||||
import com.looker.droidify.utility.common.SdkCheck
|
||||
import com.looker.droidify.utility.common.createNotificationChannel
|
||||
import com.looker.droidify.utility.common.extension.getColorFromAttr
|
||||
import com.looker.droidify.utility.common.extension.notificationManager
|
||||
import com.looker.droidify.utility.common.extension.startServiceCompat
|
||||
import com.looker.droidify.utility.common.extension.stopForegroundCompat
|
||||
import com.looker.droidify.utility.common.result.Result
|
||||
import com.looker.droidify.utility.common.sdkAbove
|
||||
import com.looker.droidify.datastore.SettingsRepository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.database.Database
|
||||
@@ -33,8 +32,8 @@ import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.network.percentBy
|
||||
import com.looker.droidify.network.DataSize
|
||||
import com.looker.droidify.network.percentBy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -52,9 +51,12 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.core.common.R.style as styleRes
|
||||
import com.looker.droidify.R
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.math.roundToInt
|
||||
import android.R as AndroidR
|
||||
import com.looker.droidify.R.string as stringRes
|
||||
import com.looker.droidify.R.style as styleRes
|
||||
import kotlinx.coroutines.Job as CoroutinesJob
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -68,15 +70,16 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private const val MAX_UPDATE_NOTIFICATION = 5
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
|
||||
private val syncState = MutableSharedFlow<State>()
|
||||
val syncState = MutableSharedFlow<State>()
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
sealed class State(val name: String) {
|
||||
data class Connecting(val appName: String) : State(appName)
|
||||
data class Syncing(
|
||||
class Connecting(appName: String) : State(appName)
|
||||
|
||||
class Syncing(
|
||||
val appName: String,
|
||||
val stage: RepositoryUpdater.Stage,
|
||||
val read: DataSize,
|
||||
@@ -84,6 +87,18 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
) : State(appName)
|
||||
|
||||
data object Finish : State("")
|
||||
|
||||
val progress: Int
|
||||
get() = when (this) {
|
||||
is Connecting -> Int.MIN_VALUE
|
||||
Finish -> Int.MAX_VALUE
|
||||
is Syncing -> when(stage) {
|
||||
RepositoryUpdater.Stage.DOWNLOAD -> ((read percentBy total) * 0.4F).roundToInt()
|
||||
RepositoryUpdater.Stage.PROCESS -> 50
|
||||
RepositoryUpdater.Stage.MERGE -> 75
|
||||
RepositoryUpdater.Stage.COMMIT -> 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Task(val repositoryId: Long, val manual: Boolean)
|
||||
@@ -125,7 +140,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
if (request != SyncRequest.AUTO && started == Started.AUTO) {
|
||||
started = Started.MANUAL
|
||||
startSelf()
|
||||
startServiceCompat()
|
||||
handleSetStarted()
|
||||
currentTask?.lastState?.let { publishForegroundState(true, it) }
|
||||
}
|
||||
@@ -144,7 +159,8 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
suspend fun updateAllApps() {
|
||||
updateAllAppsInternal()
|
||||
val skipSignature = settingsRepository.getInitial().ignoreSignature
|
||||
updateAllAppsInternal(skipSignature)
|
||||
}
|
||||
|
||||
fun setUpdateNotificationBlocker(fragment: Fragment?) {
|
||||
@@ -197,6 +213,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -276,10 +293,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setSmallIcon(AndroidR.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name))
|
||||
.setContentText(description)
|
||||
@@ -290,10 +307,10 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(CommonR.drawable.ic_sync)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.addAction(
|
||||
0,
|
||||
@@ -368,7 +385,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
is State.Finish -> {}
|
||||
}::class
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
@@ -388,7 +405,8 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
handleUpdates(
|
||||
hasUpdates = hasUpdates,
|
||||
notifyUpdates = setting.notifyUpdate,
|
||||
autoUpdate = setting.autoUpdate
|
||||
autoUpdate = setting.autoUpdate,
|
||||
skipSignature = setting.ignoreSignature,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -405,7 +423,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
started = newStarted
|
||||
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
|
||||
startSelf()
|
||||
startServiceCompat()
|
||||
handleSetStarted()
|
||||
}
|
||||
val initialState = State.Connecting(repository!!.name)
|
||||
@@ -469,7 +487,8 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
private suspend fun handleUpdates(
|
||||
hasUpdates: Boolean,
|
||||
notifyUpdates: Boolean,
|
||||
autoUpdate: Boolean
|
||||
autoUpdate: Boolean,
|
||||
skipSignature: Boolean,
|
||||
) {
|
||||
try {
|
||||
if (!hasUpdates) {
|
||||
@@ -480,15 +499,16 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
return
|
||||
}
|
||||
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
|
||||
val updates = Database.ProductAdapter.getUpdates()
|
||||
val updates = Database.ProductAdapter.getUpdates(skipSignature)
|
||||
if (!blocked && updates.isNotEmpty()) {
|
||||
if (notifyUpdates) displayUpdatesNotification(updates)
|
||||
if (autoUpdate) updateAllAppsInternal()
|
||||
if (autoUpdate) updateAllAppsInternal(skipSignature)
|
||||
}
|
||||
handleUpdates(
|
||||
hasUpdates = false,
|
||||
notifyUpdates = notifyUpdates,
|
||||
autoUpdate = autoUpdate
|
||||
autoUpdate = autoUpdate,
|
||||
skipSignature = skipSignature,
|
||||
)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
@@ -498,10 +518,9 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAllAppsInternal() {
|
||||
log("Check Running", "Syncing")
|
||||
private suspend fun updateAllAppsInternal(skipSignature: Boolean) {
|
||||
Database.ProductAdapter
|
||||
.getUpdates()
|
||||
.getUpdates(skipSignature)
|
||||
// Update Droid-ify the last
|
||||
.sortedBy { if (it.packageName == packageName) 1 else -1 }
|
||||
.map {
|
||||
@@ -522,23 +541,22 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
}
|
||||
|
||||
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
|
||||
notificationManager?.notify(
|
||||
Constants.NOTIFICATION_ID_UPDATES,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(CommonR.drawable.ic_new_releases)
|
||||
.setSmallIcon(R.drawable.ic_new_releases)
|
||||
.setContentTitle(getString(stringRes.new_updates_available))
|
||||
.setContentText(
|
||||
resources.getQuantityString(
|
||||
CommonR.plurals.new_updates_DESC_FORMAT,
|
||||
R.plurals.new_updates_DESC_FORMAT,
|
||||
productItems.size,
|
||||
productItems.size
|
||||
)
|
||||
)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
.getColorFromAttr(AndroidR.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
@@ -550,7 +568,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
)
|
||||
)
|
||||
.setStyle(
|
||||
NotificationCompat.InboxStyle().applyHack {
|
||||
NotificationCompat.InboxStyle().also {
|
||||
for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) {
|
||||
val builder = SpannableStringBuilder(productItem.name)
|
||||
builder.setSpan(
|
||||
@@ -560,7 +578,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
builder.append(' ').append(productItem.version)
|
||||
addLine(builder)
|
||||
it.addLine(builder)
|
||||
}
|
||||
if (productItems.size > MAX_UPDATE_NOTIFICATION) {
|
||||
val summary =
|
||||
@@ -568,7 +586,11 @@ class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
stringRes.plus_more_FORMAT,
|
||||
productItems.size - MAX_UPDATE_NOTIFICATION
|
||||
)
|
||||
if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary)
|
||||
if (SdkCheck.isNougat) {
|
||||
it.addLine(summary)
|
||||
} else {
|
||||
it.setSummaryText(summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.network.validation.ValidationException
|
||||
import java.util.jar.JarEntry
|
||||
|
||||
interface IndexValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(
|
||||
jarEntry: JarEntry,
|
||||
expectedFingerprint: Fingerprint?,
|
||||
): Fingerprint
|
||||
|
||||
}
|
||||
14
app/src/main/kotlin/com/looker/droidify/sync/Parser.kt
Normal file
14
app/src/main/kotlin/com/looker/droidify/sync/Parser.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import java.io.File
|
||||
|
||||
interface Parser<out T> {
|
||||
|
||||
suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, T>
|
||||
|
||||
}
|
||||
20
app/src/main/kotlin/com/looker/droidify/sync/Syncable.kt
Normal file
20
app/src/main/kotlin/com/looker/droidify/sync/Syncable.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
|
||||
/**
|
||||
* Expected Architecture: [https://excalidraw.com/#json=JqpGunWTJONjq-ecDNiPg,j9t0X4coeNvIG7B33GTq6A]
|
||||
*
|
||||
* Current Issue: When downloading entry.jar we need to re-call the synchronizer,
|
||||
* which this arch doesn't allow.
|
||||
*/
|
||||
interface Syncable<T> {
|
||||
|
||||
val parser: Parser<T>
|
||||
|
||||
suspend fun sync(
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, com.looker.droidify.sync.v2.model.IndexV2?>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import com.looker.droidify.sync.v1.model.AppV1
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import com.looker.droidify.sync.v1.model.Localized
|
||||
import com.looker.droidify.sync.v1.model.PackageV1
|
||||
import com.looker.droidify.sync.v1.model.RepoV1
|
||||
import com.looker.droidify.sync.v1.model.maxSdk
|
||||
import com.looker.droidify.sync.v1.model.name
|
||||
import com.looker.droidify.sync.v2.model.AntiFeatureV2
|
||||
import com.looker.droidify.sync.v2.model.CategoryV2
|
||||
import com.looker.droidify.sync.v2.model.FeatureV2
|
||||
import com.looker.droidify.sync.v2.model.FileV2
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.sync.v2.model.LocalizedFiles
|
||||
import com.looker.droidify.sync.v2.model.LocalizedIcon
|
||||
import com.looker.droidify.sync.v2.model.LocalizedString
|
||||
import com.looker.droidify.sync.v2.model.ManifestV2
|
||||
import com.looker.droidify.sync.v2.model.MetadataV2
|
||||
import com.looker.droidify.sync.v2.model.MirrorV2
|
||||
import com.looker.droidify.sync.v2.model.PackageV2
|
||||
import com.looker.droidify.sync.v2.model.PermissionV2
|
||||
import com.looker.droidify.sync.v2.model.RepoV2
|
||||
import com.looker.droidify.sync.v2.model.ScreenshotsV2
|
||||
import com.looker.droidify.sync.v2.model.SignerV2
|
||||
import com.looker.droidify.sync.v2.model.UsesSdkV2
|
||||
import com.looker.droidify.sync.v2.model.VersionV2
|
||||
|
||||
private const val V1_LOCALE = "en-US"
|
||||
|
||||
internal fun IndexV1.toV2(): IndexV2 {
|
||||
val antiFeatures: MutableList<String> = mutableListOf()
|
||||
val categories: MutableList<String> = mutableListOf()
|
||||
|
||||
val packagesV2: HashMap<String, PackageV2> = hashMapOf()
|
||||
|
||||
apps.forEach { app ->
|
||||
antiFeatures.addAll(app.antiFeatures)
|
||||
categories.addAll(app.categories)
|
||||
val versions = packages[app.packageName]
|
||||
val preferredSigner = versions?.firstOrNull()?.signer
|
||||
val whatsNew: LocalizedString? = app.localized
|
||||
?.localizedString(null) { it.whatsNew }
|
||||
val packageV2 = PackageV2(
|
||||
versions = versions?.associate { packageV1 ->
|
||||
packageV1.hash to packageV1.toVersionV2(
|
||||
whatsNew = whatsNew,
|
||||
packageAntiFeatures = app.antiFeatures + (packageV1.antiFeatures ?: emptyList())
|
||||
)
|
||||
} ?: emptyMap(),
|
||||
metadata = app.toV2(preferredSigner)
|
||||
)
|
||||
packagesV2.putIfAbsent(app.packageName, packageV2)
|
||||
}
|
||||
|
||||
return IndexV2(
|
||||
repo = repo.toRepoV2(
|
||||
categories = categories,
|
||||
antiFeatures = antiFeatures
|
||||
),
|
||||
packages = packagesV2,
|
||||
)
|
||||
}
|
||||
|
||||
private fun RepoV1.toRepoV2(
|
||||
categories: List<String>,
|
||||
antiFeatures: List<String>,
|
||||
): RepoV2 = RepoV2(
|
||||
address = address,
|
||||
timestamp = timestamp,
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/$icon")),
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
description = mapOf(V1_LOCALE to description),
|
||||
mirrors = mirrors.toMutableList()
|
||||
.apply { add(0, address) }
|
||||
.map { MirrorV2(url = it, isPrimary = (it == address).takeIf { it }) },
|
||||
antiFeatures = antiFeatures.associateWith { name ->
|
||||
AntiFeatureV2(
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/ic_antifeature_${name.normalizeName()}.png")),
|
||||
)
|
||||
},
|
||||
categories = categories.associateWith { name ->
|
||||
CategoryV2(
|
||||
name = mapOf(V1_LOCALE to name),
|
||||
icon = mapOf(V1_LOCALE to FileV2("/icons/category_${name.normalizeName()}.png")),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun String.normalizeName(): String = lowercase().replace(" & ", "_")
|
||||
|
||||
private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
|
||||
added = added ?: 0L,
|
||||
lastUpdated = lastUpdated ?: 0L,
|
||||
icon = localized?.localizedIcon(packageName, icon) { it.icon },
|
||||
name = localized?.localizedString(name) { it.name },
|
||||
description = localized?.localizedString(description) { it.description },
|
||||
summary = localized?.localizedString(summary) { it.summary },
|
||||
authorEmail = authorEmail,
|
||||
authorName = authorName,
|
||||
authorPhone = authorPhone,
|
||||
authorWebSite = authorWebSite,
|
||||
bitcoin = bitcoin,
|
||||
categories = categories,
|
||||
changelog = changelog,
|
||||
donate = if (donate != null) listOf(donate) else emptyList(),
|
||||
featureGraphic = localized?.localizedIcon(packageName) { it.featureGraphic },
|
||||
flattrID = flattrID,
|
||||
issueTracker = issueTracker,
|
||||
liberapay = liberapay,
|
||||
license = license,
|
||||
litecoin = litecoin,
|
||||
openCollective = openCollective,
|
||||
preferredSigner = preferredSigner,
|
||||
promoGraphic = localized?.localizedIcon(packageName) { it.promoGraphic },
|
||||
screenshots = localized?.screenshotV2(packageName),
|
||||
sourceCode = sourceCode,
|
||||
translation = translation,
|
||||
tvBanner = localized?.localizedIcon(packageName) { it.tvBanner },
|
||||
video = localized?.localizedString(null) { it.video },
|
||||
webSite = webSite,
|
||||
)
|
||||
|
||||
private fun Map<String, Localized>.screenshotV2(
|
||||
packageName: String,
|
||||
): ScreenshotsV2? = ScreenshotsV2(
|
||||
phone = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.phoneScreenshots?.map {
|
||||
"/$packageName/$locale/phoneScreenshots/$it"
|
||||
}
|
||||
},
|
||||
sevenInch = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.sevenInchScreenshots?.map {
|
||||
"/$packageName/$locale/sevenInchScreenshots/$it"
|
||||
}
|
||||
},
|
||||
tenInch = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.tenInchScreenshots?.map {
|
||||
"/$packageName/$locale/tenInchScreenshots/$it"
|
||||
}
|
||||
},
|
||||
tv = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.tvScreenshots?.map {
|
||||
"/$packageName/$locale/tvScreenshots/$it"
|
||||
}
|
||||
},
|
||||
wear = localizedScreenshots { locale, screenshot ->
|
||||
screenshot.wearScreenshots?.map {
|
||||
"/$packageName/$locale/wearScreenshots/$it"
|
||||
}
|
||||
},
|
||||
).takeIf { !it.isNull }
|
||||
|
||||
private fun PackageV1.toVersionV2(
|
||||
whatsNew: LocalizedString?,
|
||||
packageAntiFeatures: List<String>,
|
||||
): VersionV2 = VersionV2(
|
||||
added = added ?: 0L,
|
||||
file = FileV2(
|
||||
name = "/$apkName",
|
||||
sha256 = hash,
|
||||
size = size,
|
||||
),
|
||||
src = srcName?.let { FileV2("/$it") },
|
||||
whatsNew = whatsNew ?: emptyMap(),
|
||||
antiFeatures = packageAntiFeatures.associateWith { mapOf(V1_LOCALE to it) },
|
||||
manifest = ManifestV2(
|
||||
versionName = versionName,
|
||||
versionCode = versionCode ?: 0L,
|
||||
signer = signer?.let { SignerV2(listOf(it)) },
|
||||
usesSdk = sdkV2(),
|
||||
maxSdkVersion = maxSdkVersion,
|
||||
usesPermission = usesPermission.map { PermissionV2(it.name, it.maxSdk) },
|
||||
usesPermissionSdk23 = usesPermission23.map { PermissionV2(it.name, it.maxSdk) },
|
||||
features = features?.map { FeatureV2(it) } ?: emptyList(),
|
||||
nativecode = nativeCode ?: emptyList()
|
||||
),
|
||||
)
|
||||
|
||||
private fun PackageV1.sdkV2(): UsesSdkV2? {
|
||||
return if (minSdkVersion == null && targetSdkVersion == null) {
|
||||
null
|
||||
} else {
|
||||
UsesSdkV2(
|
||||
minSdkVersion = minSdkVersion ?: 1,
|
||||
targetSdkVersion = targetSdkVersion ?: 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun Map<String, Localized>.localizedString(
|
||||
default: String?,
|
||||
crossinline block: (Localized) -> String?,
|
||||
): LocalizedString? {
|
||||
// Because top level fields are null if there are localized fields underneath
|
||||
// Turns out no
|
||||
if (isEmpty() && default != null) {
|
||||
return mapOf(V1_LOCALE to default)
|
||||
}
|
||||
val checkDefault = get(V1_LOCALE)?.let { block(it) }
|
||||
if (checkDefault == null && default != null) {
|
||||
return mapOf(V1_LOCALE to default)
|
||||
}
|
||||
return mapValuesNotNull { (_, localized) ->
|
||||
block(localized)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
|
||||
private inline fun Map<String, Localized>.localizedIcon(
|
||||
packageName: String,
|
||||
default: String? = null,
|
||||
crossinline block: (Localized) -> String?,
|
||||
): LocalizedIcon? {
|
||||
if (isEmpty() && default != null) {
|
||||
return mapOf(V1_LOCALE to FileV2("/icons/$default"))
|
||||
}
|
||||
val checkDefault = get(V1_LOCALE)?.let { block(it) }
|
||||
if (checkDefault == null && default != null) {
|
||||
return mapOf(V1_LOCALE to FileV2("/icons/$default"))
|
||||
}
|
||||
return mapValuesNotNull { (locale, localized) ->
|
||||
block(localized)?.let {
|
||||
FileV2("/$packageName/$locale/$it")
|
||||
}
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inline fun Map<String, Localized>.localizedScreenshots(
|
||||
crossinline block: (String, Localized) -> List<String>?,
|
||||
): LocalizedFiles? {
|
||||
return mapValuesNotNull { (locale, localized) ->
|
||||
val files = block(locale, localized)
|
||||
if (files.isNullOrEmpty()) null
|
||||
else files.map(::FileV2)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inline fun <K, V, M> Map<K, V>.mapValuesNotNull(
|
||||
block: (Map.Entry<K, V>) -> M?
|
||||
): Map<K, M> {
|
||||
val map = HashMap<K, M>()
|
||||
forEach { entry ->
|
||||
block(entry)?.let { map[entry.key] = it }
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.utility.common.cache.Cache
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.network.Downloader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
|
||||
suspend fun Downloader.downloadIndex(
|
||||
context: Context,
|
||||
repo: Repo,
|
||||
fileName: String,
|
||||
url: String,
|
||||
diff: Boolean = false,
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName")
|
||||
downloadToFile(
|
||||
url = url,
|
||||
target = tempFile,
|
||||
headers = {
|
||||
if (repo.shouldAuthenticate) {
|
||||
authentication(
|
||||
repo.authentication.username,
|
||||
repo.authentication.password
|
||||
)
|
||||
}
|
||||
if (repo.versionInfo.timestamp > 0L && !diff) {
|
||||
ifModifiedSince(Date(repo.versionInfo.timestamp))
|
||||
}
|
||||
}
|
||||
)
|
||||
tempFile
|
||||
}
|
||||
|
||||
const val INDEX_V1_NAME = "index-v1.jar"
|
||||
const val ENTRY_V2_NAME = "entry.jar"
|
||||
const val INDEX_V2_NAME = "index-v2.json"
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.check
|
||||
import com.looker.droidify.domain.model.fingerprint
|
||||
import com.looker.droidify.network.validation.invalid
|
||||
import com.looker.droidify.sync.utils.certificate
|
||||
import com.looker.droidify.sync.utils.codeSigner
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.jar.JarEntry
|
||||
|
||||
class IndexJarValidator(
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) : com.looker.droidify.sync.IndexValidator {
|
||||
override suspend fun validate(
|
||||
jarEntry: JarEntry,
|
||||
expectedFingerprint: Fingerprint?
|
||||
): Fingerprint = withContext(dispatcher) {
|
||||
val fingerprint = try {
|
||||
jarEntry
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
} catch (e: IllegalStateException) {
|
||||
invalid(e.message ?: "Unknown Exception")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
invalid(e.message ?: "Error creating Fingerprint object")
|
||||
}
|
||||
if (expectedFingerprint == null) {
|
||||
fingerprint
|
||||
} else {
|
||||
if (expectedFingerprint.check(fingerprint)) {
|
||||
expectedFingerprint
|
||||
} else {
|
||||
invalid(
|
||||
"Expected Fingerprint: ${expectedFingerprint}, " +
|
||||
"Acquired Fingerprint: $fingerprint"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.looker.droidify.sync.common
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val JsonParser = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.looker.droidify.sync.utils
|
||||
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
|
||||
fun File.toJarFile(verify: Boolean = true): JarFile = JarFile(this, verify)
|
||||
|
||||
@get:Throws(IllegalStateException::class)
|
||||
val JarEntry.codeSigner: CodeSigner
|
||||
get() = codeSigners?.singleOrNull()
|
||||
?: error("index.jar must be signed by a single code signer, Current: $codeSigners")
|
||||
|
||||
@get:Throws(IllegalStateException::class)
|
||||
val CodeSigner.certificate: Certificate
|
||||
get() = signerCertPath?.certificates?.singleOrNull()
|
||||
?: error("index.jar code signer should have only one certificate")
|
||||
31
app/src/main/kotlin/com/looker/droidify/sync/v1/V1Parser.kt
Normal file
31
app/src/main/kotlin/com/looker/droidify/sync/v1/V1Parser.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.looker.droidify.sync.v1
|
||||
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.IndexValidator
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.utils.toJarFile
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
class V1Parser(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val json: Json,
|
||||
private val validator: IndexValidator,
|
||||
) : Parser<IndexV1> {
|
||||
|
||||
override suspend fun parse(
|
||||
file: File,
|
||||
repo: Repo,
|
||||
): Pair<Fingerprint, IndexV1> = withContext(dispatcher) {
|
||||
val jar = file.toJarFile()
|
||||
val entry = jar.getJarEntry("index-v1.json")
|
||||
val indexString = jar.getInputStream(entry).use {
|
||||
it.readBytes().decodeToString()
|
||||
}
|
||||
validator.validate(entry, repo.fingerprint) to json.decodeFromString(indexString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.sync.v1
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.droidify.domain.model.Fingerprint
|
||||
import com.looker.droidify.domain.model.Repo
|
||||
import com.looker.droidify.sync.Parser
|
||||
import com.looker.droidify.sync.Syncable
|
||||
import com.looker.droidify.sync.common.INDEX_V1_NAME
|
||||
import com.looker.droidify.sync.common.IndexJarValidator
|
||||
import com.looker.droidify.sync.common.JsonParser
|
||||
import com.looker.droidify.sync.common.downloadIndex
|
||||
import com.looker.droidify.sync.common.toV2
|
||||
import com.looker.droidify.sync.v1.model.IndexV1
|
||||
import com.looker.droidify.sync.v2.model.IndexV2
|
||||
import com.looker.droidify.network.Downloader
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class V1Syncable(
|
||||
private val context: Context,
|
||||
private val downloader: Downloader,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Syncable<IndexV1> {
|
||||
override val parser: Parser<IndexV1>
|
||||
get() = V1Parser(
|
||||
dispatcher = dispatcher,
|
||||
json = JsonParser,
|
||||
validator = IndexJarValidator(dispatcher),
|
||||
)
|
||||
|
||||
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2> =
|
||||
withContext(dispatcher) {
|
||||
val jar = downloader.downloadIndex(
|
||||
context = context,
|
||||
repo = repo,
|
||||
url = repo.address.removeSuffix("/") + "/$INDEX_V1_NAME",
|
||||
fileName = INDEX_V1_NAME,
|
||||
)
|
||||
val (fingerprint, indexV1) = parser.parse(jar, repo)
|
||||
jar.delete()
|
||||
fingerprint to indexV1.toV2()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* AppV1 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AppV1(
|
||||
val packageName: String,
|
||||
val icon: String? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val summary: String? = null,
|
||||
val added: Long? = null,
|
||||
val antiFeatures: List<String> = emptyList(),
|
||||
val authorEmail: String? = null,
|
||||
val authorName: String? = null,
|
||||
val authorPhone: String? = null,
|
||||
val authorWebSite: String? = null,
|
||||
val binaries: String? = null,
|
||||
val bitcoin: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val changelog: String? = null,
|
||||
val donate: String? = null,
|
||||
val flattrID: String? = null,
|
||||
val issueTracker: String? = null,
|
||||
val lastUpdated: Long? = null,
|
||||
val liberapay: String? = null,
|
||||
val liberapayID: String? = null,
|
||||
val license: String,
|
||||
val litecoin: String? = null,
|
||||
val localized: Map<String, Localized>? = null,
|
||||
val openCollective: String? = null,
|
||||
val sourceCode: String? = null,
|
||||
val suggestedVersionCode: String? = null,
|
||||
val translation: String? = null,
|
||||
val webSite: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* IndexV1 is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class IndexV1(
|
||||
val repo: RepoV1,
|
||||
val apps: List<AppV1> = emptyList(),
|
||||
val packages: Map<String, List<PackageV1>> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.looker.droidify.sync.v1.model
|
||||
|
||||
/*
|
||||
* Localized is licensed under the GPL 3.0 to FDroid Organization.
|
||||
* */
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Localized(
|
||||
val icon: String? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val summary: String? = null,
|
||||
val featureGraphic: String? = null,
|
||||
val phoneScreenshots: List<String>? = null,
|
||||
val promoGraphic: String? = null,
|
||||
val sevenInchScreenshots: List<String>? = null,
|
||||
val tenInchScreenshots: List<String>? = null,
|
||||
val tvBanner: String? = null,
|
||||
val tvScreenshots: List<String>? = null,
|
||||
val video: String? = null,
|
||||
val wearScreenshots: List<String>? = null,
|
||||
val whatsNew: String? = null,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user