v0.6.6
Some checks failed
Build Debug APK / build (push) Has been cancelled

This commit is contained in:
Felitendo
2025-09-11 16:22:58 +02:00
parent 22d5a09491
commit 52fc500ed6
143 changed files with 6018 additions and 1837 deletions

View File

@@ -7,8 +7,8 @@ trim_trailing_whitespace = true
[*.{kt,kts}] [*.{kt,kts}]
indent_size = 4 indent_size = 4
ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site = 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 = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999

View File

@@ -61,7 +61,7 @@ jobs:
- name: Extract Version Code - name: Extract Version Code
id: extract_version id: extract_version
run: | run: |
VERSION_CODE=$(grep -oP '(?<=versionCode=)\d+' app/build.gradle) # Adjust path to your build.gradle VERSION_CODE=$(grep -oP '(?<=versionCode=)\d+' app/build.gradle.kts) # Adjust path to your build.gradle
echo "::set-output name=version_code::$VERSION_CODE" echo "::set-output name=version_code::$VERSION_CODE"
echo "Version Code: $VERSION_CODE" echo "Version Code: $VERSION_CODE"

View File

@@ -2,17 +2,17 @@
<img width="" src="metadata/en-US/images/featureGraphic.png" alt="Droid-ify" align="center"> <img width="" src="metadata/en-US/images/featureGraphic.png" alt="Droid-ify" align="center">
[![Github Stars](https://img.shields.io/github/stars/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/stargazers) [![GitHub stars](https://img.shields.io/github/stars/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/stargazers)
[![Github License](https://img.shields.io/github/license/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/blob/master/COPYING) [![GitHub license](https://img.shields.io/github/license/Iamlooker/Droid-ify?color=%2364f573&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/blob/master/COPYING)
[![Github Downloads](https://img.shields.io/github/downloads/Iamlooker/Droid-ify/total.svg?color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/) [![GitHub downloads](https://img.shields.io/github/downloads/Iamlooker/Droid-ify/total.svg?color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/)
[![Github Latest](https://img.shields.io/github/v/release/Iamlooker/Droid-ify?display_name=tag&color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/latest) [![GitHub latest release](https://img.shields.io/github/v/release/Iamlooker/Droid-ify?display_name=tag&color=%23f5ad64&style=for-the-badge)](https://github.com/Iamlooker/Droid-ify/releases/latest)
[![FDroid Latest](https://img.shields.io/f-droid/v/com.looker.droidify?color=%23f5ad64&style=for-the-badge)](https://f-droid.org/packages/com.looker.droidify) [![F-Droid latest release](https://img.shields.io/f-droid/v/com.looker.droidify?color=%23f5ad64&style=for-the-badge)](https://f-droid.org/packages/com.looker.droidify)
</div> </div>
<div align="left"> <div align="left">
## Features ## Features
* Material & Clean design * Clean Material 3 design
* Fast repository syncing * Fast repository syncing
* Smooth user experience * Smooth user experience
* Feature-rich * Feature-rich
@@ -21,13 +21,13 @@
<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%" /> <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 ## Building and installing
1. **Install Android Studio**: 1. **Install Android Studio**:
- Download and install [Android Studio](https://developer.android.com/studio) on your computer - Download and install [Android Studio](https://developer.android.com/studio) on your computer
if you haven't already. if you haven't already.
2. **Clone the Repository**: 2. **Clone the repository**:
- Open Android Studio and select "Project from Version Control." - Open Android Studio and select "Project from Version Control."
- Paste the link to this repository to clone it to your local machine. - Paste the link to this repository to clone it to your local machine.
@@ -39,15 +39,14 @@
## TODO ## TODO
- [ ] Add support for `index-v2` - [ ] Add support for `index-v2`
- [ ] Add detekt code-analysis - [ ] Add detekt code analysis
- [ ] Add GitHub Repo feature
## Contribution ## Contributing
- Pick any issue you would like to resolve - Pick any issue you would like to resolve
- Fork the project - Fork the project
- Open a Pull Request - Open a pull request
- Your PR will undergo review - Your pull request will undergo review
## Translations ## Translations

View File

@@ -1,10 +1,10 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.internal.tasks.factory.dependsOn
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
@@ -12,7 +12,7 @@ plugins {
} }
android { android {
val latestVersionName = "0.6.5" val latestVersionName = "0.6.6"
namespace = "com.looker.droidify" namespace = "com.looker.droidify"
buildToolsVersion = "35.0.0" buildToolsVersion = "35.0.0"
compileSdk = 35 compileSdk = 35
@@ -20,37 +20,28 @@ android {
minSdk = 23 minSdk = 23
targetSdk = 35 targetSdk = 35
applicationId = "com.looker.droidify" applicationId = "com.looker.droidify"
versionCode = 650 versionCode = 660
versionName = latestVersionName versionName = latestVersionName
vectorDrawables.useSupportLibrary = false vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "com.looker.droidify.TestRunner"
} }
compileOptions { compileOptions.isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 kotlinOptions.freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-parameters")
targetCompatibility = JavaVersion.VERSION_17 androidResources.generateLocaleConfig = true
isCoreLibraryDesugaringEnabled = true
kotlin {
jvmToolchain(17)
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_2_2)
apiVersion.set(KotlinVersion.KOTLIN_2_2)
jvmTarget.set(JvmTarget.JVM_17)
}
} }
kotlinOptions { ksp {
jvmTarget = "17" arg("room.schemaLocation", "$projectDir/schemas")
freeCompilerArgs = listOf( arg("room.generateKotlin", "true")
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xcontext-receivers"
)
}
androidResources {
generateLocaleConfig = true
}
sourceSets.forEach { source ->
val javaDir = source.java.srcDirs.find { it.name == "java" }
source.java {
srcDir(File(javaDir?.parentFile, "kotlin"))
}
} }
buildTypes { buildTypes {
@@ -64,7 +55,7 @@ android {
resValue("string", "application_name", "Droid-ify") resValue("string", "application_name", "Droid-ify")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard.pro" "proguard.pro",
) )
} }
create("alpha") { create("alpha") {
@@ -73,7 +64,7 @@ android {
resValue("string", "application_name", "Droid-ify Alpha") resValue("string", "application_name", "Droid-ify Alpha")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard.pro" "proguard.pro",
) )
isDebuggable = true isDebuggable = true
isMinifyEnabled = true isMinifyEnabled = true
@@ -82,7 +73,7 @@ android {
buildConfigField( buildConfigField(
type = "String", type = "String",
name = "VERSION_NAME", name = "VERSION_NAME",
value = "\"v$latestVersionName\"" value = "\"v$latestVersionName\"",
) )
} }
} }
@@ -111,18 +102,6 @@ android {
} }
} }
ktlint {
android.set(true)
ignoreFailures.set(true)
debug.set(true)
reporters {
reporter(ReporterType.HTML)
}
filter {
exclude("**/generated/**")
}
}
dependencies { dependencies {
coreLibraryDesugaring(libs.desugaring) coreLibraryDesugaring(libs.desugaring)
@@ -144,19 +123,17 @@ dependencies {
implementation(libs.kotlin.stdlib) implementation(libs.kotlin.stdlib)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.coroutines.core) implementation(libs.bundles.coroutines)
implementation(libs.coroutines.android)
implementation(libs.coroutines.guava)
implementation(libs.libsu.core) implementation(libs.libsu.core)
implementation(libs.shizuku.api) implementation(libs.bundles.shizuku)
api(libs.shizuku.provider)
implementation(libs.jackson.core) implementation(libs.jackson.core)
implementation(libs.serialization) implementation(libs.serialization)
implementation(libs.ktor.core) implementation(libs.bundles.ktor)
implementation(libs.ktor.okhttp) implementation(libs.bundles.room)
ksp(libs.room.compiler)
implementation(libs.work.ktx) implementation(libs.work.ktx)
@@ -169,13 +146,16 @@ dependencies {
testImplementation(platform(libs.junit.bom)) testImplementation(platform(libs.junit.bom))
testImplementation(libs.bundles.test.unit) testImplementation(libs.bundles.test.unit)
testRuntimeOnly(libs.junit.platform) testRuntimeOnly(libs.junit.platform)
androidTestImplementation(platform(libs.junit.bom)) androidTestImplementation(libs.hilt.test)
androidTestImplementation(libs.room.test)
androidTestImplementation(libs.bundles.test.android) androidTestImplementation(libs.bundles.test.android)
kspAndroidTest(libs.hilt.compiler)
// debugImplementation(libs.leakcanary) // debugImplementation(libs.leakcanary)
} }
// using a task as a preBuild dependency instead of a function that takes some time insures that it runs // using a task as a preBuild dependency instead of a function that takes some time insures that it runs
// in /res are (almost) all languages that have a translated string is saved. this is safer and saves some time
task("detectAndroidLocals") { task("detectAndroidLocals") {
val langsList: MutableSet<String> = HashSet() val langsList: MutableSet<String> = HashSet()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="default_repos">
<!-- SHIFT -->
<!-- name -->
<item>SHIFT</item>
<!-- address -->
<item>https://fdroid.shiftphones.com/fdroid/repo</item>
<!-- description -->
<item>Provides updates and optional apps for your SHIFT device.</item>
<!-- version -->
<item>20002</item>
<!-- enabled -->
<item>1</item>
<!-- priority (disabled for additional_repos.xml) -->
<!--<item>1</item>-->
<!-- push requests -->
<item>ignore</item>
<!-- pubkey -->
<item>308205ea308203d2a003020102020900e74081628660407c300d06092a864886f70d01010b05003081a13125302306092a864886f70d01090116166664726f696440736869667470686f6e65732e636f6d311f301d060355040313166664726f69642e736869667470686f6e65732e636f6d31143012060355040b130b534849465450484f4e4553310e300c060355040a13055348494654311330110603550407130a46616c6b656e62657267310f300d0603550408130648657373656e310b30090603550406130244453020170d3235303630353039353133345a180f32303532313032313039353133345a3081a13125302306092a864886f70d01090116166664726f696440736869667470686f6e65732e636f6d311f301d060355040313166664726f69642e736869667470686f6e65732e636f6d31143012060355040b130b534849465450484f4e4553310e300c060355040a13055348494654311330110603550407130a46616c6b656e62657267310f300d0603550408130648657373656e310b300906035504061302444530820222300d06092a864886f70d01010105000382020f003082020a0282020100b5042d416fde7b9eb1b4f399030da3bb7f903e29823aca5d193ef4e2bea848375e2ee1e1a599aa7d157292ba09759938d40b38c5fccaa12f6877a8335c3a1646788a4282dddf197f8e264bf1241efb93f12dfdaed175c4df2a4f68701db5518a6a76c7f51815154d6909598a8ba8af731197ee03d79fc3b5bbbb5fb0d0435e1a33caa7a8c9a4cd9d69c625663741b889efb505d634c8b11765bc703831b8db1cec78c7c2ccec83e211565f13a253a2b320a88621c68978cf7530b01a61796cfd56109ffa09525c1dc20dbabc9b77a0b3e547af052c31a7c137500ee82269cef267fd2c87f3c58036f69561eac39e9ba03ab7e0627880212285f165bbfa6ed12997921891387317c9185695e7ee4694955ff76ef17f9ad8dadd60d5fd0f9b43ce6cb6c8484dfea300fdec8c4dc59d07697694590e28f0f4f01e2dd72afdcb1ec64e95ee2712ba0cf0120807bcd307090af5a7423ace98c990d9b3ae132fbc61414334af2d9ceb81aa460a79529fcab2fd5565c4b7027e6697164d07b800a23b202fc23140055fb36090f78dc24a87483e16dd40e7270a47f020035e352d67fc5ddfcfee82de243f5688e268d1450bca1cb6dcbfc0c5f85e675293d8907b96951cf913fef33177248c3442c59e0dfb75803dd55ef750c0ff80997fde5bf36bf9c4cc6d0dc562e5ed91ccd9139f7885cb7984d61ee6e71b5616fb5e8bf89162c98f0203010001a321301f301d0603551d0e041604142e046a0536dcc228ae2c82770be90c751d4f6fc7300d06092a864886f70d01010b050003820201006cd87b00983dad8f298f9bb880ab982bf25cc6c12002cfa6494b76d08fea86a19f035d42b5f3551896531c67df5618e3153e822c218f220e875cac9e89fab080f0a1410a776f745417110275766c494bc598323481595d75dd019e15dcef8a94814155fc76531feaada8f53d364b75f8bb59ad3493b2bb483d037fcbfa136030ee842a6f3babe851224cd5f8e9ada88ad0eb2f894989310f4bc5751bdf39becccb2a261b76c46ebd4a6df533c990b88e65d21d1ac566d8f801b93a97b9b316f6c3bc40442018429290e06056dbe07c49fca4ef614212421040971d65182acc2c49c0cb8fdd533590e61b7889c39464478a4936f914c276114ae4478bcfad8d0ec67b8a05c7ee83e442ba66775a2376c9939ff54e56e6485abefd31ec89c5d3dcf66df26f590c4a369a04fd4400518eae5e7312d5960da573d18d26dc8717e2913b6f2ccd4dc07958b6d40856ba0720727524127e91656b42cd969161c8e07f66896c2f5bf40268b3c6a84a600492b607f09f5bee013532ead0c1d4773aaab361141c0b44ac31fd40368c92828597758575a27d3b079ce98a41b5837f8007d8890354f5549b150a5bf0bce792bda1981daf0bcf90f2754d74100d85e1110fa6749bf43ba36afe33f828a4341ad5c161878da07a6c87479bb183ebbfa83bbe646da17a26b9947ceb2ee3c94114c18b1d76b7236f585031a0d21dd6359493a8d043</item>
<!-- microG -->
<!-- name -->
<item>microG F-Droid repo</item>
<!-- address -->
<item>https://microg.org/fdroid/repo</item>
<!-- description -->
<item>This is a repository of microG apps to be used with F-Droid. Applications in this repository are signed official binaries built by the microG Team from the corresponding source code.</item>
<!-- version -->
<item>20002</item>
<!-- enabled -->
<item>1</item>
<!-- priority (disabled for additional_repos.xml) -->
<!--<item>1</item>-->
<!-- push requests -->
<item>ignore</item>
<!-- pubkey -->
<item>308202ed308201d5a003020102020426ffa009300d06092a864886f70d01010b05003027310b300906035504061302444531183016060355040a130f4e4f47415050532050726f6a656374301e170d3132313030363132303533325a170d3337303933303132303533325a3027310b300906035504061302444531183016060355040a130f4e4f47415050532050726f6a65637430820122300d06092a864886f70d01010105000382010f003082010a02820101009a8d2a5336b0eaaad89ce447828c7753b157459b79e3215dc962ca48f58c2cd7650df67d2dd7bda0880c682791f32b35c504e43e77b43c3e4e541f86e35a8293a54fb46e6b16af54d3a4eda458f1a7c8bc1b7479861ca7043337180e40079d9cdccb7e051ada9b6c88c9ec635541e2ebf0842521c3024c826f6fd6db6fd117c74e859d5af4db04448965ab5469b71ce719939a06ef30580f50febf96c474a7d265bb63f86a822ff7b643de6b76e966a18553c2858416cf3309dd24278374bdd82b4404ef6f7f122cec93859351fc6e5ea947e3ceb9d67374fe970e593e5cd05c905e1d24f5a5484f4aadef766e498adf64f7cf04bddd602ae8137b6eea40722d0203010001a321301f301d0603551d0e04160414110b7aa9ebc840b20399f69a431f4dba6ac42a64300d06092a864886f70d01010b0500038201010007c32ad893349cf86952fb5a49cfdc9b13f5e3c800aece77b2e7e0e9c83e34052f140f357ec7e6f4b432dc1ed542218a14835acd2df2deea7efd3fd5e8f1c34e1fb39ec6a427c6e6f4178b609b369040ac1f8844b789f3694dc640de06e44b247afed11637173f36f5886170fafd74954049858c6096308fc93c1bc4dd5685fa7a1f982a422f2a3b36baa8c9500474cf2af91c39cbec1bc898d10194d368aa5e91f1137ec115087c31962d8f76cd120d28c249cf76f4c70f5baa08c70a7234ce4123be080cee789477401965cfe537b924ef36747e8caca62dfefdd1a6288dcb1c4fd2aaa6131a7ad254e9742022cfd597d2ca5c660ce9e41ff537e5a4041e37</item>
<!-- IzzyOnDroid -->
<!-- name -->
<item>IzzyOnDroid F-Droid Repo</item>
<!-- address -->
<item>https://apt.izzysoft.de/fdroid/repo</item>
<!-- description -->
<item>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.</item>
<!-- version -->
<item>20002</item>
<!-- enabled -->
<item>1</item>
<!-- priority (disabled for additional_repos.xml) -->
<!--<item>1</item>-->
<!-- push requests -->
<item>ignore</item>
<!-- pubkey -->
<item>308204e1308202c9a003020102020454c60934300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b060355040313046e65626f301e170d3136303331303230313634325a170d3433303732373230313634325a30213110300e060355040b1307462d44726f6964310d300b060355040313046e65626f30820222300d06092a864886f70d01010105000382020f003082020a0282020100ac59258ca2e9c216af14d58cb53adb13658480aed5ebc1f59bfc474f0f67c0efe9d58304d0cbda2897bd3283e7afe15512f32743ee243f4b9bba5a017806bc5c3441c905df37d00d3cf77b012af33ee4033b7e8d686277043bcb28241a3fe9f6ebfd72f305a928e300edf554ffaa139d85b5c9282aa8f1a82ff74caea2c13006dbeae8aac9ff44fa4c9122808b90c304db8b9e6ddecdbfbf5ce4ed0115cf1ba2bc6a4d6211765553df9b650db69155448aec4b0aaf59d19712aca3010a0d96eb02ed84e90c16162272af32fe909a5acde37d78fba500994f50c1ec5afa528945a7567567560a9fbafbabd68190c5c13f9a53f39a72734bd8de43c06b21a5cecf2747e6a1879352c49ee29fa092c26ca495baac69eddb614941e27b6a27fb3fb74cbdfe5822bfc266130c1f723a7ab91ed3d6c5261d31fc80ab82b7caa2727120522e65863af436a438c05039e1e099faae4d6170baa10fc9bb7bf101e2b4c9769e693eb7e4e3eebd47bfbfe0069c24a8b1ef72d8fe6549202490cff7b0f36c458b8192fe58f984839290d69639abb15fe1ef2925eb491627f2eefbd13225b925a7bbfc0fb4d95a3fb43599c172037e599639b4f86c4eabc173013776a854e146dfacf424cbae4254f9806ecd79d092f5e67a2f00c98ad64c0bfbeaff117fe4c62685e2e75e2ef507325d05f866510c20006a6c01e8e25d75bd42a0d5397b73eb0203010001a321301f301d0603551d0e0416041417f4fd41b0aa3f4fa981423a123f6f6016e3ce80300d06092a864886f70d01010b050003820201008d5d93cbb48fde9df566d75c54a8da2f29e9ae1bac2ed2436a0f165730244ac9e471b473674bc68717c34e30c29ce5ffa027fa12a7eb2f45b036db0cca79238262ba84f6ec8ffddcfe2b398c0a6aa33d117f83996b3bece96b1ea6f8066c395e5021c2b5fe1638c7ac146cda6ef2e4a836bd9c968ed76c51cc0b09caa4b1a79d5d10b3829804db992a70feb9a76535bc04631193abee9c9d7ebfb07ad464542f65744e76d92c5aeb3beb96dbb0b3d746845cbfa2b12c6da31863ea4a0d664dc5974d5b808c1be52a5e595ed181d86feeff4dc82bc8ee3c11ff807a811322931e804df1d90b5b813dd9ce81f3d8dd7d1bb2994901fe1c1004673f53c7b60cdbc2f914ce0718fbfc8e89b443091f71ecb9f169d558c3818bb1db714a47025154eb974600ca54e29933a87a4080910eee05dcc34de7048fa95b1128d8910b18b5957f2e745de00decd2434af455b24aa3e53de889e37919212a6adb3f4088baec6cc9f3e21b812593605fba0394355bd994f21ceaba861aae29244f5113d4291fdddedbef091e63885ebf318c6e12d338fa9555783643a19181c2cc935307fcee5e6dabf8dd6e19a92b29dbc529d3ef170916fb7b2d9dbf95a358ac7c0204b6e6a416b59441c49c41d6f78b1de63eb8b10c516a5952a20eb0c595cfa21530350c5adde74d815918deb870a9e7750fcb4dc50538fd591006434cbbb001cc2ae1fe11</item>
</string-array>
</resources>

View File

@@ -0,0 +1,34 @@
package com.looker.droidify
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.index.OemRepositoryParser
import com.looker.droidify.sync.common.assets
import org.junit.Before
import org.junit.runner.RunWith
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
@SmallTest
class OemRepositoryParserTest {
private lateinit var context: Context
@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().targetContext
}
@Test
fun parseFile() {
val stream = assets("additional_repos.xml")
val list = OemRepositoryParser.parse(stream)
assertEquals(3, list.size)
val listOfNames = list.map { it.name }
assertContentEquals(listOfNames, listOf("SHIFT", "microG F-Droid repo", "IzzyOnDroid F-Droid Repo"))
}
}

View File

@@ -0,0 +1,116 @@
package com.looker.droidify
import android.content.Context
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo
import com.looker.droidify.model.Repository
import com.looker.droidify.sync.FakeDownloader
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.v2.model.IndexV2
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
import kotlin.test.Test
import kotlin.test.assertTrue
@HiltAndroidTest
class RoomTesting {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var indexDao: IndexDao
@Inject
lateinit var appDao: AppDao
@Inject
@ApplicationContext
lateinit var context: Context
private val defaults = Repository.defaultRepositories
private val izzyLegacy = defaults[4]
private val fdroidLegacy = defaults[0]
@Before
fun before() = runTest {
hiltRule.inject()
launch {
val izzy = izzyLegacy.toRepo(1)
val izzyFile = FakeDownloader.downloadIndex(context, izzy, "i2", "index-v2.json")
val izzyIndex =
JsonParser.decodeFromString<IndexV2>(izzyFile.readBytes().decodeToString())
indexDao.insertIndex(
fingerprint = izzy.fingerprint!!,
index = izzyIndex,
expectedRepoId = izzy.id,
)
}
// launch {
// val fdroid = fdroidLegacy.toRepo(2)
// val fdroidFile =
// FakeDownloader.downloadIndex(context, fdroid, "f2", "fdroid-index-v2.json")
// val fdroidIndex =
// JsonParser.decodeFromString<IndexV2>(fdroidFile.readBytes().decodeToString())
// indexDao.insertIndex(
// fingerprint = fdroid.fingerprint!!,
// index = fdroidIndex,
// expectedRepoId = fdroid.id,
// )
// }
}
@Test
fun sortOrderTest() = runTest {
val lastUpdatedQuery = appDao.query(sortOrder = SortOrder.UPDATED)
var previousUpdated = Long.MAX_VALUE
lastUpdatedQuery.forEach {
println("Previous: $previousUpdated, Current: ${it.lastUpdated}")
assertTrue(it.lastUpdated <= previousUpdated)
previousUpdated = it.lastUpdated
}
val addedQuery = appDao.query(sortOrder = SortOrder.ADDED)
var previousAdded = Long.MAX_VALUE
addedQuery.forEach {
println("Previous: $previousAdded, Current: ${it.added}")
assertTrue(it.added <= previousAdded)
previousAdded = it.added
}
}
@Test
fun categoryTest() = runTest {
val categoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToInclude = listOf("Games", "Food"),
)
val nonCategoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToExclude = listOf("Games", "Food"),
)
}
}
private fun Repository.toRepo(id: Int) = Repo(
id = id,
enabled = enabled,
address = address,
name = name,
description = description,
fingerprint = Fingerprint(fingerprint),
authentication = null,
versionInfo = VersionInfo(timestamp, entityTag),
mirrors = emptyList(),
)

View File

@@ -0,0 +1,16 @@
package com.looker.droidify
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class TestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader,
appName: String,
context: Context,
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.getName(), context)
}
}

View File

@@ -4,12 +4,18 @@ import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.looker.droidify.database.Database
import com.looker.droidify.index.RepositoryUpdater.IndexType
import com.looker.droidify.model.Repository import com.looker.droidify.model.Repository
import com.looker.droidify.sync.FakeDownloader
import com.looker.droidify.sync.common.assets
import com.looker.droidify.sync.common.benchmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
import kotlin.math.sqrt
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -21,7 +27,9 @@ class RepositoryUpdaterTest {
@Before @Before
fun setup() { fun setup() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
Database.init(context)
RepositoryUpdater.init(CoroutineScope(Dispatchers.Default), FakeDownloader)
repository = Repository( repository = Repository(
id = 15, id = 15,
address = "https://apt.izzysoft.de/fdroid/repo", address = "https://apt.izzysoft.de/fdroid/repo",
@@ -41,13 +49,14 @@ class RepositoryUpdaterTest {
@Test @Test
fun processFile() { fun processFile() {
testRepetition(1) { val output = benchmark(1) {
val createFile = File.createTempFile("index", "entry") val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger") val mergerFile = File.createTempFile("index", "merger")
val jarStream = context.resources.assets.open("index-v1.jar") val jarStream = context.resources.assets.open("index-v1.jar")
jarStream.copyTo(createFile.outputStream()) jarStream.copyTo(createFile.outputStream())
process(createFile, mergerFile) process(createFile, mergerFile)
} }
println(output)
} }
private fun process(file: File, merger: File) = measureTimeMillis { private fun process(file: File, merger: File) = measureTimeMillis {
@@ -65,28 +74,4 @@ class RepositoryUpdaterTest {
}, },
) )
} }
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

View File

@@ -44,7 +44,7 @@ class EntrySyncableTest {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Before @Before
fun before() { fun before() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher() dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher) validator = IndexJarValidator(dispatcher)
parser = EntryParser(dispatcher, JsonParser, validator) parser = EntryParser(dispatcher, JsonParser, validator)

View File

@@ -7,8 +7,8 @@ import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.common.IndexJarValidator import com.looker.droidify.sync.common.IndexJarValidator
import com.looker.droidify.sync.common.Izzy import com.looker.droidify.sync.common.Izzy
import com.looker.droidify.sync.common.JsonParser 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.benchmark
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.toV2 import com.looker.droidify.sync.common.toV2
import com.looker.droidify.sync.v1.V1Parser import com.looker.droidify.sync.v1.V1Parser
import com.looker.droidify.sync.v1.V1Syncable import com.looker.droidify.sync.v1.V1Syncable
@@ -17,6 +17,7 @@ import com.looker.droidify.sync.v2.V2Parser
import com.looker.droidify.sync.v2.model.FileV2 import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.IndexV2 import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.MetadataV2 import com.looker.droidify.sync.v2.model.MetadataV2
import com.looker.droidify.sync.v2.model.PackageV2
import com.looker.droidify.sync.v2.model.VersionV2 import com.looker.droidify.sync.v2.model.VersionV2
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -28,6 +29,8 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class V1SyncableTest { class V1SyncableTest {
@@ -42,7 +45,7 @@ class V1SyncableTest {
@Before @Before
fun before() { fun before() {
context = InstrumentationRegistry.getInstrumentation().context context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher() dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher) validator = IndexJarValidator(dispatcher)
parser = V1Parser(dispatcher, JsonParser, validator) parser = V1Parser(dispatcher, JsonParser, validator)
@@ -102,9 +105,38 @@ class V1SyncableTest {
testIndexConversion("index-v1.jar", "index-v2-updated.json") testIndexConversion("index-v1.jar", "index-v2-updated.json")
} }
// @Test @Test
fun v1tov2FDroidRepo() = runTest(dispatcher) { fun targetPropertyTest() = runTest(dispatcher) {
testIndexConversion("fdroid-index-v1.jar", "fdroid-index-v2.json") val v2IzzyFile =
FakeDownloader.downloadIndex(context, repo, "izzy-v2", "index-v2-updated.json")
val v2FdroidFile =
FakeDownloader.downloadIndex(context, repo, "fdroid-v2", "fdroid-index-v2.json")
val (_, v2Izzy) = v2Parser.parse(v2IzzyFile, repo)
val (_, v2Fdroid) = v2Parser.parse(v2FdroidFile, repo)
val performTest: (PackageV2) -> Unit = { data ->
print("lib: ")
println(data.metadata.liberapay)
print("donate: ")
println(data.metadata.donate)
print("bit: ")
println(data.metadata.bitcoin)
print("flattr: ")
println(data.metadata.flattrID)
print("Open: ")
println(data.metadata.openCollective)
print("LiteCoin: ")
println(data.metadata.litecoin)
}
v2Izzy.packages.forEach { (packageName, data) ->
println("Testing on Izzy $packageName")
performTest(data)
}
v2Fdroid.packages.forEach { (packageName, data) ->
println("Testing on FDroid $packageName")
performTest(data)
}
} }
private suspend fun testIndexConversion( private suspend fun testIndexConversion(
@@ -252,6 +284,8 @@ private fun assertVersion(
assertNotNull(foundVersion) assertNotNull(foundVersion)
assertEquals(expectedVersion.added, foundVersion.added) assertEquals(expectedVersion.added, foundVersion.added)
assertEquals(expectedVersion.file.sha256, foundVersion.file.sha256)
assertEquals(expectedVersion.file.size, foundVersion.file.size)
assertEquals(expectedVersion.file.name, foundVersion.file.name) assertEquals(expectedVersion.file.name, foundVersion.file.name)
assertEquals(expectedVersion.src?.name, foundVersion.src?.name) assertEquals(expectedVersion.src?.name, foundVersion.src?.name)
@@ -261,7 +295,13 @@ private fun assertVersion(
assertEquals(expectedMan.versionCode, foundMan.versionCode) assertEquals(expectedMan.versionCode, foundMan.versionCode)
assertEquals(expectedMan.versionName, foundMan.versionName) assertEquals(expectedMan.versionName, foundMan.versionName)
assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion) assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion)
assertNotNull(expectedMan.usesSdk)
assertNotNull(foundMan.usesSdk)
assertEquals(expectedMan.usesSdk, foundMan.usesSdk) assertEquals(expectedMan.usesSdk, foundMan.usesSdk)
assertTrue(expectedMan.usesSdk.minSdkVersion >= 1)
assertTrue(expectedMan.usesSdk.targetSdkVersion >= 1)
assertTrue(foundMan.usesSdk.minSdkVersion >= 1)
assertTrue(foundMan.usesSdk.targetSdkVersion >= 1)
assertContentEquals( assertContentEquals(
expectedMan.features.sortedBy { it.name }, expectedMan.features.sortedBy { it.name },

View File

@@ -8,11 +8,6 @@ internal inline fun benchmark(
extraMessage: String? = null, extraMessage: String? = null,
block: () -> Long, block: () -> Long,
): String { ): String {
if (extraMessage != null) {
println("=".repeat(50))
println(extraMessage)
println("=".repeat(50))
}
val times = DoubleArray(repetition) val times = DoubleArray(repetition)
repeat(repetition) { iteration -> repeat(repetition) { iteration ->
System.gc() System.gc()
@@ -20,11 +15,19 @@ internal inline fun benchmark(
times[iteration] = block().toDouble() times[iteration] = block().toDouble()
} }
val meanAndDeviation = times.culledMeanAndDeviation() val meanAndDeviation = times.culledMeanAndDeviation()
return buildString { return buildString(200) {
append("=".repeat(50)) append("=".repeat(50))
append("\n") append("\n")
if (extraMessage != null) {
append(extraMessage)
append("\n")
append("=".repeat(50))
append("\n")
}
if (times.size > 1) {
append(times.joinToString(" | ")) append(times.joinToString(" | "))
append("\n") append("\n")
}
append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms") append("${meanAndDeviation.first} ms ± ${meanAndDeviation.second.toFloat()} ms")
append("\n") append("\n")
append("=".repeat(50)) append("=".repeat(50))

View File

@@ -6,7 +6,7 @@ import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo import com.looker.droidify.domain.model.VersionInfo
val Izzy = Repo( val Izzy = Repo(
id = 1L, id = 1,
enabled = true, enabled = true,
address = "https://apt.izzysoft.de/fdroid/repo", address = "https://apt.izzysoft.de/fdroid/repo",
name = "IzzyOnDroid F-Droid Repo", name = "IzzyOnDroid F-Droid Repo",
@@ -15,6 +15,4 @@ val Izzy = Repo(
authentication = Authentication("", ""), authentication = Authentication("", ""),
versionInfo = VersionInfo(0L, null), versionInfo = VersionInfo(0L, null),
mirrors = emptyList(), mirrors = emptyList(),
antiFeatures = emptyList(),
categories = emptyList(),
) )

File diff suppressed because one or more lines are too long

View File

@@ -97,7 +97,7 @@
<data android:pathPattern="/.*/packages/.*" /> <data android:pathPattern="/.*/packages/.*" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />

View File

@@ -80,7 +80,7 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy() // if (BuildConfig.DEBUG && SdkCheck.isOreo) strictThreadPolicy()
val databaseUpdated = Database.init(this) val databaseUpdated = Database.init(this)
ProductPreferences.init(this, appScope) ProductPreferences.init(this, appScope)
@@ -107,7 +107,7 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package") addDataScheme("package")
} },
) )
val installedItems = val installedItems =
packageManager.getInstalledPackagesCompat() packageManager.getInstalledPackagesCompat()
@@ -200,7 +200,7 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
periodMillis = period, periodMillis = period,
networkType = syncConditions.toJobNetworkType(), networkType = syncConditions.toJobNetworkType(),
isCharging = syncConditions.pluggedIn, isCharging = syncConditions.pluggedIn,
isBatteryLow = syncConditions.batteryNotLow isBatteryLow = syncConditions.batteryNotLow,
) )
jobScheduler?.schedule(job) jobScheduler?.schedule(job)
} }
@@ -212,10 +212,13 @@ class Droidify : Application(), SingletonImageLoader.Factory, Configuration.Prov
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = "")) Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
} }
} }
Connection(SyncService::class.java, onBind = { connection, binder -> Connection(
SyncService::class.java,
onBind = { connection, binder ->
binder.sync(SyncService.SyncRequest.FORCE) binder.sync(SyncService.SyncRequest.FORCE)
connection.unbind(this) connection.unbind(this)
}).bind(this) },
).bind(this)
} }
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@@ -256,12 +259,12 @@ fun strictThreadPolicy() {
.detectNetwork() .detectNetwork()
.detectUnbufferedIo() .detectUnbufferedIo()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder() StrictMode.VmPolicy.Builder()
.detectAll() .detectAll()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
} }

View File

@@ -14,13 +14,6 @@ import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope 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.database.CursorOwner
import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.extension.getThemeRes import com.looker.droidify.datastore.extension.getThemeRes
@@ -34,6 +27,13 @@ import com.looker.droidify.ui.repository.RepositoriesFragment
import com.looker.droidify.ui.repository.RepositoryFragment import com.looker.droidify.ui.repository.RepositoryFragment
import com.looker.droidify.ui.settings.SettingsFragment import com.looker.droidify.ui.settings.SettingsFragment
import com.looker.droidify.ui.tabsFragment.TabsFragment import com.looker.droidify.ui.tabsFragment.TabsFragment
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 dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -64,7 +64,7 @@ class MainActivity : AppCompatActivity() {
@Parcelize @Parcelize
private class FragmentStackItem( private class FragmentStackItem(
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState? val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?,
) : Parcelable ) : Parcelable
lateinit var cursorOwner: CursorOwner lateinit var cursorOwner: CursorOwner
@@ -87,24 +87,25 @@ class MainActivity : AppCompatActivity() {
} }
private fun collectChange() { private fun collectChange() {
val hiltEntryPoint = EntryPointAccessors.fromApplication( val hiltEntryPoint =
this, CustomUserRepositoryInjector::class.java EntryPointAccessors.fromApplication(this, CustomUserRepositoryInjector::class.java)
)
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme } val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
runBlocking { runBlocking {
val theme = newSettings.first() val theme = newSettings.first()
setTheme( setTheme(
resources.configuration.getThemeRes( resources.configuration.getThemeRes(
theme = theme.first, dynamicTheme = theme.second theme = theme.first,
) dynamicTheme = theme.second,
),
) )
} }
lifecycleScope.launch { lifecycleScope.launch {
newSettings.drop(1).collect { themeAndDynamic -> newSettings.drop(1).collect { themeAndDynamic ->
setTheme( setTheme(
resources.configuration.getThemeRes( resources.configuration.getThemeRes(
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second theme = themeAndDynamic.first,
) dynamicTheme = themeAndDynamic.second,
),
) )
recreate() recreate()
} }
@@ -116,9 +117,11 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val rootView = FrameLayout(this).apply { id = R.id.main_content } val rootView = FrameLayout(this).apply { id = R.id.main_content }
addContentView( addContentView(
rootView, ViewGroup.LayoutParams( rootView,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams(
) ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
) )
requestNotificationPermission(request = notificationPermission::launch) requestNotificationPermission(request = notificationPermission::launch)
@@ -188,7 +191,7 @@ class MainActivity : AppCompatActivity() {
if (open != null) { if (open != null) {
setCustomAnimations( setCustomAnimations(
if (open) R.animator.slide_in else 0, if (open) R.animator.slide_in else 0,
if (open) R.animator.slide_in_keep else R.animator.slide_out if (open) R.animator.slide_in_keep else R.animator.slide_out,
) )
} }
setReorderingAllowed(true) setReorderingAllowed(true)
@@ -202,8 +205,8 @@ class MainActivity : AppCompatActivity() {
FragmentStackItem( FragmentStackItem(
it::class.java.name, it::class.java.name,
it.arguments, it.arguments,
supportFragmentManager.saveFragmentInstanceState(it) supportFragmentManager.saveFragmentInstanceState(it),
) ),
) )
} }
replaceFragment(fragment, true) replaceFragment(fragment, true)

View File

@@ -0,0 +1,61 @@
@file:OptIn(ExperimentalEncodingApi::class)
package com.looker.droidify.data.encryption
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
private const val KEY_SIZE = 256
private const val IV_SIZE = 16
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/CBC/PKCS5Padding"
@JvmInline
value class Key(val secretKey: ByteArray) {
val spec: SecretKeySpec
get() = SecretKeySpec(secretKey, ALGORITHM)
fun encrypt(input: String): Pair<Encrypted, ByteArray> {
val iv = generateIV()
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, spec, ivSpec)
val encrypted = cipher.doFinal(input.toByteArray())
return Encrypted(Base64.encode(encrypted)) to iv
}
}
/**
* Before encrypting we convert it to a base64 string
* */
@JvmInline
value class Encrypted(val value: String) {
fun decrypt(key: Key, iv: ByteArray): String {
val iv = IvParameterSpec(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key.spec, iv)
val decrypted = cipher.doFinal(Base64.decode(value))
return String(decrypted)
}
}
fun generateSecretKey(): ByteArray {
return with(KeyGenerator.getInstance(ALGORITHM)) {
init(KEY_SIZE)
generateKey().encoded
}
}
private fun generateIV(): ByteArray {
val iv = ByteArray(IV_SIZE)
val secureRandom = SecureRandom()
secureRandom.nextBytes(iv)
return iv
}

View File

@@ -0,0 +1,83 @@
package com.looker.droidify.data.local
import android.content.Context
import androidx.room.BuiltInTypeConverters
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import com.looker.droidify.data.local.converters.Converters
import com.looker.droidify.data.local.converters.PermissionConverter
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.AuthDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.data.local.dao.RepoDao
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AntiFeatureEntity
import com.looker.droidify.data.local.model.AntiFeatureRepoRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AuthenticationEntity
import com.looker.droidify.data.local.model.AuthorEntity
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.CategoryEntity
import com.looker.droidify.data.local.model.CategoryRepoRelation
import com.looker.droidify.data.local.model.DonateEntity
import com.looker.droidify.data.local.model.GraphicEntity
import com.looker.droidify.data.local.model.InstalledEntity
import com.looker.droidify.data.local.model.LinksEntity
import com.looker.droidify.data.local.model.MirrorEntity
import com.looker.droidify.data.local.model.RepoEntity
import com.looker.droidify.data.local.model.ScreenshotEntity
import com.looker.droidify.data.local.model.VersionEntity
@Database(
entities = [
AntiFeatureEntity::class,
AntiFeatureAppRelation::class,
AntiFeatureRepoRelation::class,
AuthenticationEntity::class,
AuthorEntity::class,
AppEntity::class,
CategoryEntity::class,
CategoryAppRelation::class,
CategoryRepoRelation::class,
DonateEntity::class,
GraphicEntity::class,
InstalledEntity::class,
LinksEntity::class,
MirrorEntity::class,
RepoEntity::class,
ScreenshotEntity::class,
VersionEntity::class,
],
version = 1,
)
@TypeConverters(
PermissionConverter::class,
Converters::class,
builtInTypeConverters = BuiltInTypeConverters(),
)
abstract class DroidifyDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
abstract fun repoDao(): RepoDao
abstract fun authDao(): AuthDao
abstract fun indexDao(): IndexDao
}
fun droidifyDatabase(context: Context): DroidifyDatabase = Room
.databaseBuilder(
context = context,
klass = DroidifyDatabase::class.java,
name = "droidify_room",
)
.addCallback(
object : RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
db.query("PRAGMA synchronous = OFF")
db.query("PRAGMA journal_mode = WAL")
}
},
)
.build()

View File

@@ -0,0 +1,61 @@
package com.looker.droidify.data.local.converters
import androidx.room.TypeConverter
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
private val localizedStringSerializer =
MapSerializer(String.serializer(), String.serializer())
private val stringListSerializer = ListSerializer(String.serializer())
private val localizedIconSerializer =
MapSerializer(String.serializer(), FileV2.serializer())
private val mapOfLocalizedStringsSerializer =
MapSerializer(String.serializer(), localizedStringSerializer)
object Converters {
@TypeConverter
fun fromLocalizedString(value: LocalizedString): String {
return JsonParser.encodeToString(localizedStringSerializer, value)
}
@TypeConverter
fun toLocalizedString(value: String): LocalizedString {
return JsonParser.decodeFromString(localizedStringSerializer, value)
}
@TypeConverter
fun fromLocalizedIcon(value: LocalizedIcon?): String? {
return value?.let { JsonParser.encodeToString(localizedIconSerializer, it) }
}
@TypeConverter
fun toLocalizedIcon(value: String?): LocalizedIcon? {
return value?.let { JsonParser.decodeFromString(localizedIconSerializer, it) }
}
@TypeConverter
fun fromLocalizedList(value: Map<String, LocalizedString>): String {
return JsonParser.encodeToString(mapOfLocalizedStringsSerializer, value)
}
@TypeConverter
fun toLocalizedList(value: String): Map<String, LocalizedString> {
return JsonParser.decodeFromString(mapOfLocalizedStringsSerializer, value)
}
@TypeConverter
fun fromStringList(value: List<String>): String {
return JsonParser.encodeToString(stringListSerializer, value)
}
@TypeConverter
fun toStringList(value: String): List<String> {
return JsonParser.decodeFromString(stringListSerializer, value)
}
}

View File

@@ -0,0 +1,21 @@
package com.looker.droidify.data.local.converters
import androidx.room.TypeConverter
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.model.PermissionV2
import kotlinx.serialization.builtins.ListSerializer
private val permissionListSerializer = ListSerializer(PermissionV2.serializer())
object PermissionConverter {
@TypeConverter
fun fromPermissionV2List(value: List<PermissionV2>): String {
return JsonParser.encodeToString(permissionListSerializer, value)
}
@TypeConverter
fun toPermissionV2List(value: String): List<PermissionV2> {
return JsonParser.decodeFromString(permissionListSerializer, value)
}
}

View File

@@ -0,0 +1,189 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SimpleSQLiteQuery
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AppEntityRelations
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.VersionEntity
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.sync.v2.model.DefaultName
import com.looker.droidify.sync.v2.model.Tag
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@RawQuery(
observedEntities = [
AppEntity::class,
VersionEntity::class,
CategoryAppRelation::class,
AntiFeatureAppRelation::class,
],
)
fun _rawStreamAppEntities(query: SimpleSQLiteQuery): Flow<List<AppEntity>>
@RawQuery
suspend fun _rawQueryAppEntities(query: SimpleSQLiteQuery): List<AppEntity>
fun stream(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): Flow<List<AppEntity>> = _rawStreamAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
suspend fun query(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): List<AppEntity> = _rawQueryAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
private fun searchQuery(
sortOrder: SortOrder,
searchQuery: String?,
repoId: Int?,
categoriesToInclude: List<DefaultName>?,
categoriesToExclude: List<DefaultName>?,
antiFeaturesToInclude: List<Tag>?,
antiFeaturesToExclude: List<Tag>?,
): SimpleSQLiteQuery {
val args = arrayListOf<Any?>()
val query = buildString(1024) {
append("SELECT DISTINCT app.* FROM app")
append(" LEFT JOIN version ON app.id = version.appId")
append(" LEFT JOIN category_app_relation ON app.id = category_app_relation.id")
append(" LEFT JOIN anti_features_app_relation ON app.id = anti_features_app_relation.appId")
append(" WHERE 1")
if (repoId != null) {
append(" AND app.repoId = ?")
args.add(repoId)
}
if (categoriesToInclude != null) {
append(" AND category_app_relation.defaultName IN (")
append(categoriesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToInclude)
}
if (categoriesToExclude != null) {
append(" AND category_app_relation.defaultName NOT IN (")
append(categoriesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToExclude)
}
if (antiFeaturesToInclude != null) {
append(" AND anti_features_app_relation.tag IN (")
append(antiFeaturesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToInclude)
}
if (antiFeaturesToExclude != null) {
append(" AND anti_features_app_relation.tag NOT IN (")
append(antiFeaturesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToExclude)
}
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append(
"""
AND (
app.name LIKE ?
OR app.summary LIKE ?
OR app.packageName LIKE ?
OR app.description LIKE ?
)""",
)
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
append(" ORDER BY ")
// Weighting: name > summary > packageName > description
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append("(CASE WHEN app.name LIKE ? THEN 4 ELSE 0 END) + ")
append("(CASE WHEN app.summary LIKE ? THEN 3 ELSE 0 END) + ")
append("(CASE WHEN app.packageName LIKE ? THEN 2 ELSE 0 END) + ")
append("(CASE WHEN app.description LIKE ? THEN 1 ELSE 0 END) DESC, ")
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
when (sortOrder) {
SortOrder.UPDATED -> append("app.lastUpdated DESC, ")
SortOrder.ADDED -> append("app.added DESC, ")
SortOrder.SIZE -> append("version.apk_size DESC, ")
SortOrder.NAME -> Unit
}
append("app.name COLLATE LOCALIZED ASC")
}
return SimpleSQLiteQuery(query, args.toTypedArray())
}
@Query(
"""
SELECT app.*
FROM app
LEFT JOIN installed
ON app.packageName = installed.packageName
LEFT JOIN version
ON version.appId = app.id
WHERE installed.packageName IS NOT NULL
ORDER BY
CASE WHEN version.versionCode > installed.versionCode THEN 1 ELSE 2 END,
app.lastUpdated DESC,
app.name COLLATE LOCALIZED ASC
""",
)
fun installedStream(): Flow<List<AppEntity>>
@Transaction
@Query("SELECT * FROM app WHERE packageName = :packageName")
fun queryAppEntity(packageName: String): Flow<List<AppEntityRelations>>
@Query("SELECT COUNT(*) FROM app")
suspend fun count(): Int
@Query("DELETE FROM app WHERE id = :id")
suspend fun delete(id: Int)
}

View File

@@ -0,0 +1,16 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.looker.droidify.data.local.model.AuthenticationEntity
@Dao
interface AuthDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(authentication: AuthenticationEntity)
@Query("SELECT * FROM authentication WHERE repoId = :repoId")
suspend fun getAuthentication(repoId: Int): AuthenticationEntity?
}

View File

@@ -0,0 +1,181 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.looker.droidify.data.local.model.AntiFeatureAppRelation
import com.looker.droidify.data.local.model.AntiFeatureEntity
import com.looker.droidify.data.local.model.AntiFeatureRepoRelation
import com.looker.droidify.data.local.model.AppEntity
import com.looker.droidify.data.local.model.AuthorEntity
import com.looker.droidify.data.local.model.CategoryAppRelation
import com.looker.droidify.data.local.model.CategoryEntity
import com.looker.droidify.data.local.model.CategoryRepoRelation
import com.looker.droidify.data.local.model.DonateEntity
import com.looker.droidify.data.local.model.GraphicEntity
import com.looker.droidify.data.local.model.LinksEntity
import com.looker.droidify.data.local.model.MirrorEntity
import com.looker.droidify.data.local.model.RepoEntity
import com.looker.droidify.data.local.model.ScreenshotEntity
import com.looker.droidify.data.local.model.VersionEntity
import com.looker.droidify.data.local.model.antiFeatureEntity
import com.looker.droidify.data.local.model.appEntity
import com.looker.droidify.data.local.model.authorEntity
import com.looker.droidify.data.local.model.categoryEntity
import com.looker.droidify.data.local.model.donateEntity
import com.looker.droidify.data.local.model.linkEntity
import com.looker.droidify.data.local.model.localizedGraphics
import com.looker.droidify.data.local.model.localizedScreenshots
import com.looker.droidify.data.local.model.mirrorEntity
import com.looker.droidify.data.local.model.repoEntity
import com.looker.droidify.data.local.model.versionEntities
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.sync.v2.model.IndexV2
@Dao
interface IndexDao {
@Transaction
suspend fun insertIndex(
fingerprint: Fingerprint,
index: IndexV2,
expectedRepoId: Int = 0,
) {
val repoId = upsertRepo(
index.repo.repoEntity(
id = expectedRepoId,
fingerprint = fingerprint,
),
)
val antiFeatures = index.repo.antiFeatures.flatMap { (tag, feature) ->
feature.antiFeatureEntity(tag)
}
val categories = index.repo.categories.flatMap { (defaultName, category) ->
category.categoryEntity(defaultName)
}
val antiFeatureRepoRelations = antiFeatures.map { AntiFeatureRepoRelation(repoId, it.tag) }
val categoryRepoRelations = categories.map { CategoryRepoRelation(repoId, it.defaultName) }
val mirrors = index.repo.mirrors.map { it.mirrorEntity(repoId) }
insertAntiFeatures(antiFeatures)
insertAntiFeatureRepoRelation(antiFeatureRepoRelations)
insertCategories(categories)
insertCategoryRepoRelation(categoryRepoRelations)
insertMirror(mirrors)
index.packages.forEach { (packageName, packages) ->
val metadata = packages.metadata
val author = metadata.authorEntity()
val authorId = upsertAuthor(author)
val appId = appIdByPackageName(repoId, packageName) ?: insertApp(
appEntity = metadata.appEntity(
packageName = packageName,
repoId = repoId,
authorId = authorId,
),
).toInt()
val versions = packages.versionEntities(appId)
insertVersions(versions.keys.toList())
insertAntiFeatureAppRelation(versions.values.flatten())
val appCategories = packages.metadata.categories.map { CategoryAppRelation(appId, it) }
insertCategoryAppRelation(appCategories)
metadata.linkEntity(appId)?.let { insertLink(it) }
metadata.screenshots?.localizedScreenshots(appId)?.let { insertScreenshots(it) }
metadata.localizedGraphics(appId)?.let { insertGraphics(it) }
metadata.donateEntity(appId)?.let { insertDonate(it) }
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertRepo(repoEntity: RepoEntity): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateRepo(repoEntity: RepoEntity): Int
@Transaction
suspend fun upsertRepo(repoEntity: RepoEntity): Int {
val id = insertRepo(repoEntity)
return if (id == -1L) {
updateRepo(repoEntity)
} else {
id.toInt()
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMirror(mirrors: List<MirrorEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAntiFeatures(antiFeatures: List<AntiFeatureEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCategories(categories: List<CategoryEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAntiFeatureRepoRelation(crossRef: List<AntiFeatureRepoRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryRepoRelation(crossRef: List<CategoryRepoRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertApp(appEntity: AppEntity): Long
@Query("SELECT id FROM app WHERE packageName = :packageName AND repoId = :repoId LIMIT 1")
suspend fun appIdByPackageName(repoId: Int, packageName: String): Int?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAuthor(authorEntity: AuthorEntity): Long
@Query(
"""
SELECT id FROM author
WHERE
(:email IS NULL AND email IS NULL OR email = :email) AND
(:name IS NULL AND name IS NULL OR name = :name COLLATE NOCASE) AND
(:website IS NULL AND website IS NULL OR website = :website COLLATE NOCASE)
LIMIT 1
""",
)
suspend fun authorId(
email: String?,
name: String?,
website: String?,
): Int?
@Transaction
suspend fun upsertAuthor(authorEntity: AuthorEntity): Int {
val id = insertAuthor(authorEntity)
return if (id == -1L) {
authorId(
email = authorEntity.email,
name = authorEntity.name,
website = authorEntity.website,
)!!.toInt()
} else {
id.toInt()
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertScreenshots(screenshotEntity: List<ScreenshotEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLink(linksEntity: LinksEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGraphics(graphicEntity: List<GraphicEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDonate(donateEntity: List<DonateEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertVersions(versions: List<VersionEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryAppRelation(crossRef: List<CategoryAppRelation>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAntiFeatureAppRelation(crossRef: List<AntiFeatureAppRelation>)
}

View File

@@ -0,0 +1,20 @@
package com.looker.droidify.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import com.looker.droidify.data.local.model.RepoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RepoDao {
@Query("SELECT * FROM repository")
fun stream(): Flow<List<RepoEntity>>
@Query("SELECT * FROM repository WHERE id = :repoId")
fun repo(repoId: Int): Flow<RepoEntity>
@Query("DELETE FROM repository WHERE id = :id")
suspend fun delete(id: Int)
}

View File

@@ -0,0 +1,71 @@
package com.looker.droidify.data.local.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import com.looker.droidify.sync.v2.model.AntiFeatureReason
import com.looker.droidify.sync.v2.model.AntiFeatureV2
import com.looker.droidify.sync.v2.model.Tag
@Entity(
tableName = "anti_feature",
primaryKeys = ["tag", "locale"],
)
data class AntiFeatureEntity(
val icon: String?,
val name: String,
val description: String?,
val locale: String,
val tag: Tag,
)
@Entity(
tableName = "anti_feature_repo_relation",
primaryKeys = ["id", "tag"],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AntiFeatureRepoRelation(
@ColumnInfo("id")
val repoId: Int,
val tag: Tag,
)
@Entity(
tableName = "anti_features_app_relation",
primaryKeys = ["tag", "appId", "versionCode"],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AntiFeatureAppRelation(
val tag: Tag,
val reason: AntiFeatureReason,
val appId: Int,
val versionCode: Long,
)
fun AntiFeatureV2.antiFeatureEntity(
tag: Tag,
): List<AntiFeatureEntity> {
return name.map { (locale, localizedName) ->
AntiFeatureEntity(
icon = icon[locale]?.name,
name = localizedName,
description = description[locale],
tag = tag,
locale = locale,
)
}
}

View File

@@ -0,0 +1,110 @@
package com.looker.droidify.data.local.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.Junction
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "app",
indices = [
Index("authorId"),
Index("repoId"),
Index("packageName"),
Index("packageName", "repoId", unique = true),
],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
ForeignKey(
entity = AuthorEntity::class,
childColumns = ["authorId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class AppEntity(
val added: Long,
val lastUpdated: Long,
val license: String?,
val name: LocalizedString,
val icon: LocalizedIcon?,
val preferredSigner: String?,
val summary: LocalizedString?,
val description: LocalizedString?,
val packageName: String,
val authorId: Int,
val repoId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
data class AppEntityRelations(
@Embedded val app: AppEntity,
@Relation(
parentColumn = "authorId",
entityColumn = "id",
)
val author: AuthorEntity,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val links: LinksEntity?,
@Relation(
parentColumn = "id",
entityColumn = "defaultName",
associateBy = Junction(CategoryAppRelation::class),
)
val categories: List<CategoryEntity>,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val graphics: List<GraphicEntity>?,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val screenshots: List<ScreenshotEntity>?,
@Relation(
parentColumn = "id",
entityColumn = "appId",
)
val versions: List<VersionEntity>?,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
val installed: InstalledEntity?,
)
fun MetadataV2.appEntity(
packageName: String,
repoId: Int,
authorId: Int,
) = AppEntity(
added = added,
lastUpdated = lastUpdated,
license = license,
name = name,
icon = icon,
preferredSigner = preferredSigner,
summary = summary,
description = description,
packageName = packageName,
authorId = authorId,
repoId = repoId,
)

View File

@@ -0,0 +1,26 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.PrimaryKey
import com.looker.droidify.data.encryption.Encrypted
@Entity(
tableName = "authentication",
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class AuthenticationEntity(
val password: Encrypted,
val username: String,
val initializationVector: String,
@PrimaryKey
val repoId: Int,
)

View File

@@ -0,0 +1,33 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.domain.model.Author
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "author",
indices = [Index("email", "name", "website", unique = true)],
)
data class AuthorEntity(
val email: String?,
val name: String?,
val website: String?,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun MetadataV2.authorEntity() = AuthorEntity(
email = authorEmail,
name = authorName,
website = authorWebSite,
)
fun AuthorEntity.toAuthor() = Author(
email = email,
name = name,
phone = null,
web = website,
id = id,
)

View File

@@ -0,0 +1,71 @@
package com.looker.droidify.data.local.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.looker.droidify.sync.v2.model.CategoryV2
import com.looker.droidify.sync.v2.model.DefaultName
@Entity(
tableName = "category",
primaryKeys = ["defaultName", "locale"],
indices = [Index("defaultName")]
)
data class CategoryEntity(
val icon: String?,
val name: String,
val description: String?,
val locale: String,
val defaultName: DefaultName,
)
@Entity(
tableName = "category_repo_relation",
primaryKeys = ["id", "defaultName"],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CategoryRepoRelation(
@ColumnInfo("id")
val repoId: Int,
val defaultName: DefaultName,
)
@Entity(
tableName = "category_app_relation",
primaryKeys = ["id", "defaultName"],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class CategoryAppRelation(
@ColumnInfo("id")
val appId: Int,
val defaultName: DefaultName,
)
fun CategoryV2.categoryEntity(
defaultName: DefaultName,
): List<CategoryEntity> {
return name.map { (locale, localizedName) ->
CategoryEntity(
icon = icon[locale]?.name,
name = localizedName,
description = description[locale],
defaultName = defaultName,
locale = locale,
)
}
}

View File

@@ -0,0 +1,100 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Donation
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "donate",
primaryKeys = ["type", "appId"],
indices = [Index("appId")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class DonateEntity(
@param:DonationType
val type: Int,
val value: String,
val appId: Int,
)
fun MetadataV2.donateEntity(appId: Int): List<DonateEntity>? {
return buildList {
if (bitcoin != null) {
add(DonateEntity(BITCOIN_ADD, bitcoin, appId))
}
if (litecoin != null) {
add(DonateEntity(LITECOIN_ADD, litecoin, appId))
}
if (liberapay != null) {
add(DonateEntity(LIBERAPAY_ID, liberapay, appId))
}
if (openCollective != null) {
add(DonateEntity(OPEN_COLLECTIVE_ID, openCollective, appId))
}
if (flattrID != null) {
add(DonateEntity(FLATTR_ID, flattrID, appId))
}
if (!donate.isNullOrEmpty()) {
add(DonateEntity(REGULAR, donate.joinToString(STRING_LIST_SEPARATOR), appId))
}
}.ifEmpty { null }
}
fun List<DonateEntity>.toDonation(): Donation {
var bitcoinAddress: String? = null
var litecoinAddress: String? = null
var liberapayId: String? = null
var openCollectiveId: String? = null
var flattrId: String? = null
var regular: List<String>? = null
for (entity in this) {
when (entity.type) {
BITCOIN_ADD -> bitcoinAddress = entity.value
FLATTR_ID -> flattrId = entity.value
LIBERAPAY_ID -> liberapayId = entity.value
LITECOIN_ADD -> litecoinAddress = entity.value
OPEN_COLLECTIVE_ID -> openCollectiveId = entity.value
REGULAR -> regular = entity.value.split(STRING_LIST_SEPARATOR)
}
}
return Donation(
bitcoinAddress = bitcoinAddress,
litecoinAddress = litecoinAddress,
liberapayId = liberapayId,
openCollectiveId = openCollectiveId,
flattrId = flattrId,
regularUrl = regular,
)
}
private const val STRING_LIST_SEPARATOR = "&^%#@!"
@Retention(AnnotationRetention.BINARY)
@IntDef(
BITCOIN_ADD,
LITECOIN_ADD,
LIBERAPAY_ID,
OPEN_COLLECTIVE_ID,
FLATTR_ID,
REGULAR,
)
private annotation class DonationType
private const val BITCOIN_ADD = 0
private const val LITECOIN_ADD = 1
private const val LIBERAPAY_ID = 2
private const val OPEN_COLLECTIVE_ID = 3
private const val FLATTR_ID = 4
private const val REGULAR = 5

View File

@@ -0,0 +1,83 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Graphics
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "graphic",
primaryKeys = ["type", "locale", "appId"],
indices = [Index("appId", "locale")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class GraphicEntity(
@param:GraphicType
val type: Int,
val url: String,
val locale: String,
val appId: Int,
)
fun MetadataV2.localizedGraphics(appId: Int): List<GraphicEntity>? {
return buildList {
promoGraphic?.forEach { (locale, value) ->
add(GraphicEntity(PROMO_GRAPHIC, value.name, locale, appId))
}
featureGraphic?.forEach { (locale, value) ->
add(GraphicEntity(FEATURE_GRAPHIC, value.name, locale, appId))
}
tvBanner?.forEach { (locale, value) ->
add(GraphicEntity(TV_BANNER, value.name, locale, appId))
}
video?.forEach { (locale, value) ->
add(GraphicEntity(VIDEO, value, locale, appId))
}
}.ifEmpty { null }
}
fun List<GraphicEntity>.toGraphics(): Graphics {
var featureGraphic: String? = null
var promoGraphic: String? = null
var tvBanner: String? = null
var video: String? = null
for (entity in this) {
when (entity.type) {
FEATURE_GRAPHIC -> featureGraphic = entity.url
PROMO_GRAPHIC -> promoGraphic = entity.url
TV_BANNER -> tvBanner = entity.url
VIDEO -> video = entity.url
}
}
return Graphics(
featureGraphic = featureGraphic,
promoGraphic = promoGraphic,
tvBanner = tvBanner,
video = video,
)
}
@Retention(AnnotationRetention.BINARY)
@IntDef(
VIDEO,
TV_BANNER,
PROMO_GRAPHIC,
FEATURE_GRAPHIC,
)
annotation class GraphicType
private const val VIDEO = 0
private const val TV_BANNER = 1
private const val PROMO_GRAPHIC = 2
private const val FEATURE_GRAPHIC = 3

View File

@@ -0,0 +1,13 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity("installed")
data class InstalledEntity(
val versionCode: String,
val versionName: String,
val signature: String,
@PrimaryKey
val packageName: String,
)

View File

@@ -0,0 +1,56 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.PrimaryKey
import com.looker.droidify.domain.model.Links
import com.looker.droidify.sync.v2.model.MetadataV2
@Entity(
tableName = "link",
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class LinksEntity(
val changelog: String?,
val issueTracker: String?,
val translation: String?,
val sourceCode: String?,
val webSite: String?,
@PrimaryKey
val appId: Int,
)
private fun MetadataV2.isLinkNull(): Boolean {
return changelog == null &&
issueTracker == null &&
translation == null &&
sourceCode == null &&
webSite == null
}
fun MetadataV2.linkEntity(appId: Int) = if (!isLinkNull()) {
LinksEntity(
appId = appId,
changelog = changelog,
issueTracker = issueTracker,
translation = translation,
sourceCode = sourceCode,
webSite = webSite,
)
} else null
fun LinksEntity.toLinks() = Links(
changelog = changelog,
issueTracker = issueTracker,
translation = translation,
sourceCode = sourceCode,
webSite = webSite,
)

View File

@@ -0,0 +1,36 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.sync.v2.model.MirrorV2
@Entity(
tableName = "mirror",
indices = [Index("repoId")],
foreignKeys = [
ForeignKey(
entity = RepoEntity::class,
childColumns = ["repoId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
]
)
data class MirrorEntity(
val url: String,
val countryCode: String?,
val isPrimary: Boolean,
val repoId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun MirrorV2.mirrorEntity(repoId: Int) = MirrorEntity(
url = url,
countryCode = countryCode,
isPrimary = isPrimary == true,
repoId = repoId,
)

View File

@@ -0,0 +1,54 @@
package com.looker.droidify.data.local.model
import androidx.room.Entity
import androidx.room.PrimaryKey
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
import com.looker.droidify.sync.v2.model.LocalizedIcon
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.RepoV2
import com.looker.droidify.sync.v2.model.localizedValue
@Entity(tableName = "repository")
data class RepoEntity(
val icon: LocalizedIcon?,
val address: String,
val name: LocalizedString,
val description: LocalizedString,
val fingerprint: Fingerprint,
val timestamp: Long,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun RepoV2.repoEntity(
id: Int,
fingerprint: Fingerprint,
) = RepoEntity(
id = id,
icon = icon,
address = address,
name = name,
description = description,
timestamp = timestamp,
fingerprint = fingerprint,
)
fun RepoEntity.toRepo(
locale: String,
mirrors: List<String>,
enabled: Boolean,
authentication: Authentication? = null,
) = Repo(
name = name.localizedValue(locale) ?: "Unknown",
description = description.localizedValue(locale) ?: "Unknown",
fingerprint = fingerprint,
authentication = authentication,
enabled = enabled,
address = address,
versionInfo = VersionInfo(timestamp = timestamp, etag = null),
mirrors = mirrors,
id = id,
)

View File

@@ -0,0 +1,99 @@
package com.looker.droidify.data.local.model
import androidx.annotation.IntDef
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import com.looker.droidify.domain.model.Screenshots
import com.looker.droidify.sync.v2.model.LocalizedFiles
import com.looker.droidify.sync.v2.model.ScreenshotsV2
@Entity(
tableName = "screenshot",
primaryKeys = ["path", "type", "locale", "appId"],
indices = [Index("appId", "locale")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class ScreenshotEntity(
val path: String,
@param:ScreenshotType
val type: Int,
val locale: String,
val appId: Int,
)
fun ScreenshotsV2.localizedScreenshots(appId: Int): List<ScreenshotEntity> {
if (isNull) return emptyList()
val screenshots = mutableListOf<ScreenshotEntity>()
val screenshotIterator: (Int, LocalizedFiles?) -> Unit = { type, localizedFiles ->
localizedFiles?.forEach { (locale, files) ->
for ((path, _, _) in files) {
screenshots.add(
ScreenshotEntity(
locale = locale,
appId = appId,
type = type,
path = path,
)
)
}
}
}
screenshotIterator(PHONE, phone)
screenshotIterator(SEVEN_INCH, sevenInch)
screenshotIterator(TEN_INCH, tenInch)
screenshotIterator(WEAR, wear)
screenshotIterator(TV, tv)
return screenshots
}
fun List<ScreenshotEntity>.toScreenshots(): Screenshots {
val phone = mutableListOf<String>()
val sevenInch = mutableListOf<String>()
val tenInch = mutableListOf<String>()
val wear = mutableListOf<String>()
val tv = mutableListOf<String>()
for (index in this.indices) {
val entity = get(index)
when (entity.type) {
PHONE -> phone.add(entity.path)
SEVEN_INCH -> sevenInch.add(entity.path)
TEN_INCH -> tenInch.add(entity.path)
TV -> tv.add(entity.path)
WEAR -> wear.add(entity.path)
}
}
return Screenshots(
phone = phone,
sevenInch = sevenInch,
tenInch = tenInch,
wear = wear,
tv = tv,
)
}
@Retention(AnnotationRetention.BINARY)
@IntDef(
PHONE,
SEVEN_INCH,
TEN_INCH,
WEAR,
TV,
)
private annotation class ScreenshotType
private const val PHONE = 0
private const val SEVEN_INCH = 1
private const val TEN_INCH = 2
private const val WEAR = 3
private const val TV = 4

View File

@@ -0,0 +1,74 @@
package com.looker.droidify.data.local.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import androidx.room.PrimaryKey
import com.looker.droidify.sync.v2.model.ApkFileV2
import com.looker.droidify.sync.v2.model.FileV2
import com.looker.droidify.sync.v2.model.LocalizedString
import com.looker.droidify.sync.v2.model.PackageV2
import com.looker.droidify.sync.v2.model.PermissionV2
@Entity(
tableName = "version",
indices = [Index("appId")],
foreignKeys = [
ForeignKey(
entity = AppEntity::class,
childColumns = ["appId"],
parentColumns = ["id"],
onDelete = CASCADE,
),
],
)
data class VersionEntity(
val added: Long,
val whatsNew: LocalizedString,
val versionName: String,
val versionCode: Long,
val maxSdkVersion: Int?,
val minSdkVersion: Int,
val targetSdkVersion: Int,
@Embedded("apk_")
val apk: ApkFileV2,
@Embedded("src_")
val src: FileV2?,
val features: List<String>,
val nativeCode: List<String>,
val permissions: List<PermissionV2>,
val permissionsSdk23: List<PermissionV2>,
val appId: Int,
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
)
fun PackageV2.versionEntities(appId: Int): Map<VersionEntity, List<AntiFeatureAppRelation>> {
return versions.map { (_, version) ->
VersionEntity(
added = version.added,
whatsNew = version.whatsNew,
versionName = version.manifest.versionName,
versionCode = version.manifest.versionCode,
maxSdkVersion = version.manifest.maxSdkVersion,
minSdkVersion = version.manifest.usesSdk?.minSdkVersion ?: -1,
targetSdkVersion = version.manifest.usesSdk?.targetSdkVersion ?: -1,
apk = version.file,
src = version.src,
features = version.manifest.features.map { it.name },
nativeCode = version.manifest.nativecode,
permissions = version.manifest.usesPermission,
permissionsSdk23 = version.manifest.usesPermissionSdk23,
appId = appId,
) to version.antiFeatures.map { (tag, reason) ->
AntiFeatureAppRelation(
tag = tag,
reason = reason,
appId = appId,
versionCode = version.manifest.versionCode,
)
}
}.toMap()
}

View File

@@ -5,44 +5,45 @@ import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@AndroidEntryPoint
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> { class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request {
internal abstract val id: Int @Inject
lateinit var settingsRepository: SettingsRepository
sealed interface Request {
val id: Int
class Available( class Available(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder,
) : Request() { override val id: Int = 1,
override val id: Int ) : Request
get() = 1
}
class Installed( class Installed(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder,
) : Request() { override val id: Int = 2,
override val id: Int ) : Request
get() = 2
}
class Updates( class Updates(
val searchQuery: String, val searchQuery: String,
val section: ProductItem.Section, val section: ProductItem.Section,
val order: SortOrder, val order: SortOrder,
val skipSignatureCheck: Boolean, override val id: Int = 3,
) : Request() { ) : Request
override val id: Int
get() = 3
}
object Repositories : Request() { object Repositories : Request {
override val id: Int override val id = 4
get() = 4
} }
} }
@@ -56,10 +57,6 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
val cursor: Cursor?, val cursor: Cursor?,
) )
init {
retainInstance = true
}
private val activeRequests = mutableMapOf<Int, ActiveRequest>() private val activeRequests = mutableMapOf<Int, ActiveRequest>()
fun attach(callback: Callback, request: Request) { fun attach(callback: Callback, request: Request) {
@@ -93,6 +90,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> { override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = activeRequests[id]!!.request val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) { return QueryLoader(requireContext()) {
val settings = runBlocking { settingsRepository.getInitial() }
when (request) { when (request) {
is Request.Available -> is Request.Available ->
Database.ProductAdapter Database.ProductAdapter
@@ -103,6 +101,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it,
skipSignatureCheck = settings.ignoreSignature,
) )
is Request.Installed -> is Request.Installed ->
@@ -114,6 +113,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it,
skipSignatureCheck = settings.ignoreSignature,
) )
is Request.Updates -> is Request.Updates ->
@@ -125,7 +125,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section, section = request.section,
order = request.order, order = request.order,
signal = it, signal = it,
skipSignatureCheck = request.skipSignatureCheck, skipSignatureCheck = settings.ignoreSignature,
) )
is Request.Repositories -> Database.RepositoryAdapter.query(it) is Request.Repositories -> Database.RepositoryAdapter.query(it)

View File

@@ -9,7 +9,8 @@ import android.os.CancellationSignal
import androidx.core.database.sqlite.transaction import androidx.core.database.sqlite.transaction
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.BuildConfig import com.looker.droidify.database.table.DatabaseHelper
import com.looker.droidify.database.table.Table
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.model.InstalledItem import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
@@ -20,7 +21,6 @@ import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.firstOrNull import com.looker.droidify.utility.common.extension.firstOrNull
import com.looker.droidify.utility.common.extension.parseDictionary import com.looker.droidify.utility.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary 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.product
import com.looker.droidify.utility.serialization.productItem import com.looker.droidify.utility.serialization.productItem
import com.looker.droidify.utility.serialization.repository import com.looker.droidify.utility.serialization.repository
@@ -44,52 +44,15 @@ import kotlin.collections.set
object Database { object Database {
fun init(context: Context): Boolean { fun init(context: Context): Boolean {
val helper = Helper(context) val helper = DatabaseHelper(context)
db = helper.writableDatabase db = helper.writableDatabase
if (helper.created) {
for (repository in Repository.defaultRepositories.sortedBy { it.name }) {
RepositoryAdapter.put(repository)
}
}
RepositoryAdapter.removeDuplicates() RepositoryAdapter.removeDuplicates()
return helper.created || helper.updated return helper.created || helper.updated
} }
private lateinit var db: SQLiteDatabase private lateinit var db: SQLiteDatabase
private interface Table { object Schema {
val memory: Boolean
val innerName: String
val createTable: String
val createIndex: String?
get() = null
val databasePrefix: String
get() = if (memory) "memory." else ""
val name: String
get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String {
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)",
)
}
}
private object Schema {
object Repository : Table { object Repository : Table {
const val ROW_ID = "_id" const val ROW_ID = "_id"
const val ROW_ENABLED = "enabled" const val ROW_ENABLED = "enabled"
@@ -190,126 +153,6 @@ object Database {
} }
} }
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
var created = false
private set
var updated = false
private set
override fun onCreate(db: SQLiteDatabase) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
onVersionChange(db)
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
onVersionChange(db)
private fun onVersionChange(db: SQLiteDatabase) {
handleTables(db, true, Schema.Product, Schema.Category)
addRepos(db, Repository.newlyAdded)
this.updated = true
}
override fun onOpen(db: SQLiteDatabase) {
val create = handleTables(db, false, Schema.Repository)
val updated = handleTables(db, create, Schema.Product, Schema.Category)
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
handleTables(db, false, Schema.Installed, Schema.Lock)
handleIndexes(
db,
Schema.Repository,
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock,
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
this.created = this.created || create
this.updated = this.updated || create || updated
}
}
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
val shouldRecreate = recreate || tables.any { table ->
val sql = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql
}
return shouldRecreate && run {
val shouldVacuum = tables.map {
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
db.execSQL(it.formatCreateTable(it.name))
!it.memory
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
true
}
}
private fun addRepos(db: SQLiteDatabase, repos: List<Repository>) {
if (BuildConfig.DEBUG) {
log("Add Repos: $repos", "RepositoryAdapter")
}
if (repos.isEmpty()) return
db.transaction {
repos.forEach {
RepositoryAdapter.put(it, database = this)
}
}
}
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { table ->
val sqls = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
)
.use { cursor ->
cursor.asSequence()
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
.toList()
}
.filter { !it.first.startsWith("sqlite_") }
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run {
for (name in sqls.map { it.first }) {
db.execSQL("DROP INDEX IF EXISTS $name")
}
for (createIndexPair in createIndexes) {
db.execSQL(createIndexPair.second)
}
!table.memory
}
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
}
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query(
"sqlite_master",
columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")),
)
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
if (tables.isNotEmpty()) {
for (table in tables) {
db.execSQL("DROP TABLE IF EXISTS $table")
}
if (!db.inTransaction()) {
db.execSQL("VACUUM")
}
}
}
sealed class Subject { sealed class Subject {
data object Repositories : Subject() data object Repositories : Subject()
data class Repository(val id: Long) : Subject() data class Repository(val id: Long) : Subject()
@@ -364,7 +207,7 @@ object Database {
} }
} }
private fun SQLiteDatabase.query( fun SQLiteDatabase.query(
table: String, table: String,
columns: Array<String>? = null, columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null, selection: Pair<String, Array<String>>? = null,
@@ -607,6 +450,19 @@ object Database {
.map { getUpdates(skipSignatureCheck) } .map { getUpdates(skipSignatureCheck) }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
fun getAll(): List<Product> {
return db.query(
Schema.Product.name,
columns = arrayOf(
Schema.Product.ROW_REPOSITORY_ID,
Schema.Product.ROW_DESCRIPTION,
Schema.Product.ROW_DATA,
),
selection = null,
signal = null,
).use { it.asSequence().map(::transform).toList() }
}
fun get(packageName: String, signal: CancellationSignal?): List<Product> { fun get(packageName: String, signal: CancellationSignal?): List<Product> {
return db.query( return db.query(
Schema.Product.name, Schema.Product.name,
@@ -719,7 +575,7 @@ object Database {
when (order) { when (order) {
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC," SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC," SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
SortOrder.NAME -> Unit else -> Unit
}::class }::class
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC" builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"

View File

@@ -0,0 +1,236 @@
package com.looker.droidify.database.table
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction
import com.looker.droidify.database.Database.RepositoryAdapter
import com.looker.droidify.database.Database.Schema
import com.looker.droidify.database.Database.jsonParse
import com.looker.droidify.database.Database.query
import com.looker.droidify.index.OemRepositoryParser
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.firstOrNull
import com.looker.droidify.utility.serialization.repository
private const val DB_LEGACY_NAME = "droidify"
private const val DB_LEGACY_VERSION = 6
class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DB_LEGACY_NAME, null, DB_LEGACY_VERSION) {
var created = false
private set
var updated = false
private set
override fun onCreate(db: SQLiteDatabase) {
// Create all tables
db.execSQL(Schema.Repository.formatCreateTable(Schema.Repository.name))
db.execSQL(Schema.Product.formatCreateTable(Schema.Product.name))
db.execSQL(Schema.Category.formatCreateTable(Schema.Category.name))
// Add default repositories for new database
db.addDefaultRepositories()
db.addOemRepositories()
this.created = true
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.removeRepositories()
db.addNewlyAddedRepositories()
db.addOemRepositories()
this.updated = true
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// Handle database downgrades if needed
onUpgrade(db, oldVersion, newVersion)
}
override fun onOpen(db: SQLiteDatabase) {
// Handle memory tables and indexes
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
handleTables(db, Schema.Installed, Schema.Lock)
handleIndexes(
db,
Schema.Repository,
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock,
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
}
private fun SQLiteDatabase.addOemRepositories() {
transaction {
OemRepositoryParser
.getSystemDefaultRepos()
?.forEach { repo -> RepositoryAdapter.put(repo, database = this) }
}
}
private fun SQLiteDatabase.addDefaultRepositories() {
// Add all default repositories for new database
transaction {
(Repository.defaultRepositories + Repository.newlyAdded)
.sortedBy { it.name }
.forEach { repo -> RepositoryAdapter.put(repo, database = this) }
}
}
private fun SQLiteDatabase.addNewlyAddedRepositories() {
// Add only newly added repositories, checking for existing ones
val existingRepos = query(
Schema.Repository.name,
columns = arrayOf(Schema.Repository.ROW_DATA),
selection = null,
signal = null,
).use { cursor ->
cursor.asSequence().mapNotNull {
val dataIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)
val data = it.getBlob(dataIndex)
try {
data.jsonParse { json -> json.repository() }.address
} catch (_: Exception) {
null
}
}.toSet()
}
// Only add repositories that don't already exist
val reposToAdd = Repository.newlyAdded.filter { repo ->
repo.address !in existingRepos
}
if (reposToAdd.isNotEmpty()) {
transaction {
reposToAdd.forEach { repo ->
RepositoryAdapter.put(repo, database = this)
}
}
}
}
private fun SQLiteDatabase.removeRepositories() {
// Remove repositories that are in the toRemove list
val reposToRemove = Repository.toRemove
if (reposToRemove.isEmpty()) return
// Get all repositories with their IDs and addresses
val existingRepos = query(
Schema.Repository.name,
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DATA),
selection = null,
signal = null,
).use { cursor ->
cursor.asSequence().mapNotNull {
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
val dataIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)
val id = it.getLong(idIndex)
val data = it.getBlob(dataIndex)
try {
val repo = data.jsonParse { json -> json.repository() }
id to repo.address
} catch (_: Exception) {
null
}
}.toMap()
}
// Find repositories to remove
val reposToRemoveIds = existingRepos.filter { (_, address) ->
address in reposToRemove
}.keys
if (reposToRemoveIds.isNotEmpty()) {
transaction {
reposToRemoveIds.forEach { repoId ->
// Directly update the database to mark repository as deleted
update(
Schema.Repository.name,
android.content.ContentValues().apply {
put(Schema.Repository.ROW_DELETED, 1)
},
"${Schema.Repository.ROW_ID} = ?",
arrayOf(repoId.toString()),
)
}
}
}
}
private fun handleTables(db: SQLiteDatabase, vararg tables: Table): Boolean {
val shouldRecreate = tables.any { table ->
val sql = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql
}
return shouldRecreate && run {
val shouldVacuum = tables.map {
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
db.execSQL(it.formatCreateTable(it.name))
!it.memory
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
true
}
}
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { table ->
val sqls = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
)
.use { cursor ->
cursor.asSequence()
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
.toList()
}
.filter { !it.first.startsWith("sqlite_") }
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run {
for (name in sqls.map { it.first }) {
db.execSQL("DROP INDEX IF EXISTS $name")
}
for (createIndexPair in createIndexes) {
db.execSQL(createIndexPair.second)
}
!table.memory
}
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
}
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query(
"sqlite_master",
columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")),
)
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
if (tables.isNotEmpty()) {
for (table in tables) {
db.execSQL("DROP TABLE IF EXISTS $table")
}
if (!db.inTransaction()) {
db.execSQL("VACUUM")
}
}
}
}

View File

@@ -0,0 +1,35 @@
package com.looker.droidify.database.table
import com.looker.droidify.database.trimAndJoin
interface Table {
val memory: Boolean
val innerName: String
val createTable: String
val createIndex: String?
get() = null
val databasePrefix: String
get() = if (memory) "memory." else ""
val name: String
get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String {
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)",
)
}
}

View File

@@ -14,6 +14,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.looker.droidify.datastore.model.AutoSync import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyPreference import com.looker.droidify.datastore.model.ProxyPreference
import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
@@ -24,11 +25,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock import kotlin.time.Clock
import kotlinx.datetime.Instant
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalTime::class)
class PreferenceSettingsRepository( class PreferenceSettingsRepository(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
private val exporter: Exporter<Settings>, private val exporter: Exporter<Settings>,
@@ -36,7 +39,7 @@ class PreferenceSettingsRepository(
override val data: Flow<Settings> = dataStore.data override val data: Flow<Settings> = dataStore.data
.catch { exception -> .catch { exception ->
if (exception is IOException) { if (exception is IOException) {
Log.e("TAG", "Error reading preferences.", exception) Log.e("PreferencesSettingsRepository", "Error reading preferences.", exception)
} else { } else {
throw exception throw exception
} }
@@ -85,6 +88,31 @@ class PreferenceSettingsRepository(
override suspend fun setInstallerType(installerType: InstallerType) = override suspend fun setInstallerType(installerType: InstallerType) =
INSTALLER_TYPE.update(installerType.name) INSTALLER_TYPE.update(installerType.name)
override suspend fun setLegacyInstallerComponent(component: LegacyInstallerComponent?) {
when (component) {
null -> {
LEGACY_INSTALLER_COMPONENT_TYPE.update("")
LEGACY_INSTALLER_COMPONENT_CLASS.update("")
LEGACY_INSTALLER_COMPONENT_ACTIVITY.update("")
}
is LegacyInstallerComponent.Component -> {
LEGACY_INSTALLER_COMPONENT_TYPE.update("component")
LEGACY_INSTALLER_COMPONENT_CLASS.update(component.clazz)
LEGACY_INSTALLER_COMPONENT_ACTIVITY.update(component.activity)
}
LegacyInstallerComponent.Unspecified -> {
LEGACY_INSTALLER_COMPONENT_TYPE.update("unspecified")
LEGACY_INSTALLER_COMPONENT_CLASS.update("")
LEGACY_INSTALLER_COMPONENT_ACTIVITY.update("")
}
LegacyInstallerComponent.AlwaysChoose -> {
LEGACY_INSTALLER_COMPONENT_TYPE.update("always_choose")
LEGACY_INSTALLER_COMPONENT_CLASS.update("")
LEGACY_INSTALLER_COMPONENT_ACTIVITY.update("")
}
}
}
override suspend fun setAutoUpdate(allow: Boolean) = override suspend fun setAutoUpdate(allow: Boolean) =
AUTO_UPDATE.update(allow) AUTO_UPDATE.update(allow)
@@ -125,6 +153,18 @@ class PreferenceSettingsRepository(
private fun mapSettings(preferences: Preferences): Settings { private fun mapSettings(preferences: Preferences): Settings {
val installerType = val installerType =
InstallerType.valueOf(preferences[INSTALLER_TYPE] ?: InstallerType.Default.name) InstallerType.valueOf(preferences[INSTALLER_TYPE] ?: InstallerType.Default.name)
val legacyInstallerComponent = when (preferences[LEGACY_INSTALLER_COMPONENT_TYPE]) {
"component" -> {
preferences[LEGACY_INSTALLER_COMPONENT_CLASS]?.takeIf { it.isNotBlank() }?.let { cls ->
preferences[LEGACY_INSTALLER_COMPONENT_ACTIVITY]?.takeIf { it.isNotBlank() }?.let { act ->
LegacyInstallerComponent.Component(cls, act)
}
}
}
"unspecified" -> LegacyInstallerComponent.Unspecified
"always_choose" -> LegacyInstallerComponent.AlwaysChoose
else -> null
}
val language = preferences[LANGUAGE] ?: "system" val language = preferences[LANGUAGE] ?: "system"
val incompatibleVersions = preferences[INCOMPATIBLE_VERSIONS] ?: false val incompatibleVersions = preferences[INCOMPATIBLE_VERSIONS] ?: false
@@ -154,6 +194,7 @@ class PreferenceSettingsRepository(
theme = theme, theme = theme,
dynamicTheme = dynamicTheme, dynamicTheme = dynamicTheme,
installerType = installerType, installerType = installerType,
legacyInstallerComponent = legacyInstallerComponent,
autoUpdate = autoUpdate, autoUpdate = autoUpdate,
autoSync = autoSync, autoSync = autoSync,
sortOrder = sortOrder, sortOrder = sortOrder,
@@ -185,6 +226,9 @@ class PreferenceSettingsRepository(
val LAST_CLEAN_UP = longPreferencesKey("key_last_clean_up_time") val LAST_CLEAN_UP = longPreferencesKey("key_last_clean_up_time")
val FAVOURITE_APPS = stringSetPreferencesKey("key_favourite_apps") val FAVOURITE_APPS = stringSetPreferencesKey("key_favourite_apps")
val HOME_SCREEN_SWIPING = booleanPreferencesKey("key_home_swiping") val HOME_SCREEN_SWIPING = booleanPreferencesKey("key_home_swiping")
val LEGACY_INSTALLER_COMPONENT_CLASS = stringPreferencesKey("key_legacy_installer_component_class")
val LEGACY_INSTALLER_COMPONENT_ACTIVITY = stringPreferencesKey("key_legacy_installer_component_activity")
val LEGACY_INSTALLER_COMPONENT_TYPE = stringPreferencesKey("key_legacy_installer_component_type")
// Enums // Enums
val THEME = stringPreferencesKey("key_theme") val THEME = stringPreferencesKey("key_theme")
@@ -200,6 +244,28 @@ class PreferenceSettingsRepository(
set(UNSTABLE_UPDATES, settings.unstableUpdate) set(UNSTABLE_UPDATES, settings.unstableUpdate)
set(THEME, settings.theme.name) set(THEME, settings.theme.name)
set(DYNAMIC_THEME, settings.dynamicTheme) set(DYNAMIC_THEME, settings.dynamicTheme)
when (settings.legacyInstallerComponent) {
is LegacyInstallerComponent.Component -> {
set(LEGACY_INSTALLER_COMPONENT_TYPE, "component")
set(LEGACY_INSTALLER_COMPONENT_CLASS, settings.legacyInstallerComponent.clazz)
set(LEGACY_INSTALLER_COMPONENT_ACTIVITY, settings.legacyInstallerComponent.activity)
}
LegacyInstallerComponent.Unspecified -> {
set(LEGACY_INSTALLER_COMPONENT_TYPE, "unspecified")
set(LEGACY_INSTALLER_COMPONENT_CLASS, "")
set(LEGACY_INSTALLER_COMPONENT_ACTIVITY, "")
}
LegacyInstallerComponent.AlwaysChoose -> {
set(LEGACY_INSTALLER_COMPONENT_TYPE, "always_choose")
set(LEGACY_INSTALLER_COMPONENT_CLASS, "")
set(LEGACY_INSTALLER_COMPONENT_ACTIVITY, "")
}
null -> {
set(LEGACY_INSTALLER_COMPONENT_TYPE, "")
set(LEGACY_INSTALLER_COMPONENT_CLASS, "")
set(LEGACY_INSTALLER_COMPONENT_ACTIVITY, "")
}
}
set(INSTALLER_TYPE, settings.installerType.name) set(INSTALLER_TYPE, settings.installerType.name)
set(AUTO_UPDATE, settings.autoUpdate) set(AUTO_UPDATE, settings.autoUpdate)
set(AUTO_SYNC, settings.autoSync.name) set(AUTO_SYNC, settings.autoSync.name)

View File

@@ -3,23 +3,26 @@ package com.looker.droidify.datastore
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import com.looker.droidify.datastore.model.AutoSync import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyPreference import com.looker.droidify.datastore.model.ProxyPreference
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.datastore.model.Theme 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.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream import kotlinx.serialization.json.encodeToStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
data class Settings( data class Settings(
val language: String = "system", val language: String = "system",
val incompatibleVersions: Boolean = false, val incompatibleVersions: Boolean = false,
@@ -29,6 +32,7 @@ data class Settings(
val theme: Theme = Theme.SYSTEM, val theme: Theme = Theme.SYSTEM,
val dynamicTheme: Boolean = false, val dynamicTheme: Boolean = false,
val installerType: InstallerType = InstallerType.Default, val installerType: InstallerType = InstallerType.Default,
val legacyInstallerComponent: LegacyInstallerComponent? = null,
val autoUpdate: Boolean = false, val autoUpdate: Boolean = false,
val autoSync: AutoSync = AutoSync.WIFI_ONLY, val autoSync: AutoSync = AutoSync.WIFI_ONLY,
val sortOrder: SortOrder = SortOrder.UPDATED, val sortOrder: SortOrder = SortOrder.UPDATED,
@@ -44,6 +48,7 @@ object SettingsSerializer : Serializer<Settings> {
private val json = Json { encodeDefaults = true } private val json = Json { encodeDefaults = true }
@OptIn(ExperimentalTime::class)
override val defaultValue: Settings = Settings() override val defaultValue: Settings = Settings()
override suspend fun readFrom(input: InputStream): Settings { override suspend fun readFrom(input: InputStream): Settings {

View File

@@ -3,6 +3,7 @@ package com.looker.droidify.datastore
import android.net.Uri import android.net.Uri
import com.looker.droidify.datastore.model.AutoSync import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.datastore.model.Theme import com.looker.droidify.datastore.model.Theme
@@ -37,6 +38,8 @@ interface SettingsRepository {
suspend fun setInstallerType(installerType: InstallerType) suspend fun setInstallerType(installerType: InstallerType)
suspend fun setLegacyInstallerComponent(component: LegacyInstallerComponent?)
suspend fun setAutoUpdate(allow: Boolean) suspend fun setAutoUpdate(allow: Boolean)
suspend fun setAutoSync(autoSync: AutoSync) suspend fun setAutoSync(autoSync: AutoSync)

View File

@@ -92,7 +92,7 @@ fun Context?.sortOrderName(sortOrder: SortOrder) = this?.let {
SortOrder.UPDATED -> getString(stringRes.recently_updated) SortOrder.UPDATED -> getString(stringRes.recently_updated)
SortOrder.ADDED -> getString(stringRes.whats_new) SortOrder.ADDED -> getString(stringRes.whats_new)
SortOrder.NAME -> getString(stringRes.name) SortOrder.NAME -> getString(stringRes.name)
// SortOrder.SIZE -> getString(stringRes.size) SortOrder.SIZE -> getString(stringRes.size)
} }
} ?: "" } ?: ""

View File

@@ -0,0 +1,26 @@
package com.looker.droidify.datastore.model
import kotlinx.serialization.Serializable
@Serializable
sealed class LegacyInstallerComponent {
@Serializable
object Unspecified : LegacyInstallerComponent()
@Serializable
object AlwaysChoose : LegacyInstallerComponent()
@Serializable
data class Component(
val clazz: String,
val activity: String,
) : LegacyInstallerComponent() {
fun update(
newClazz: String? = null,
newActivity: String? = null,
): Component = copy(
clazz = newClazz ?: clazz,
activity = newActivity ?: activity
)
}
}

View File

@@ -4,5 +4,8 @@ package com.looker.droidify.datastore.model
enum class SortOrder { enum class SortOrder {
UPDATED, UPDATED,
ADDED, ADDED,
NAME NAME,
SIZE,
} }
fun supportedSortOrders(): List<SortOrder> = listOf(SortOrder.UPDATED, SortOrder.ADDED, SortOrder.NAME)

View File

@@ -0,0 +1,51 @@
package com.looker.droidify.di
import android.content.Context
import com.looker.droidify.data.local.DroidifyDatabase
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.AuthDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.data.local.dao.RepoDao
import com.looker.droidify.data.local.droidifyDatabase
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 DatabaseModule {
@Singleton
@Provides
fun provideDatabase(
@ApplicationContext
context: Context,
): DroidifyDatabase = droidifyDatabase(context)
@Singleton
@Provides
fun provideAppDao(
db: DroidifyDatabase,
): AppDao = db.appDao()
@Singleton
@Provides
fun provideRepoDao(
db: DroidifyDatabase,
): RepoDao = db.repoDao()
@Singleton
@Provides
fun provideAuthDao(
db: DroidifyDatabase,
): AuthDao = db.authDao()
@Singleton
@Provides
fun provideIndexDao(
db: DroidifyDatabase,
): IndexDao = db.indexDao()
}

View File

@@ -0,0 +1,23 @@
package com.looker.droidify.di
import android.content.Context
import com.looker.droidify.sync.LocalSyncable
import com.looker.droidify.sync.Syncable
import com.looker.droidify.sync.v2.model.IndexV2
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 SyncableModule {
@Singleton
@Provides
fun provideSyncable(
@ApplicationContext context: Context,
): Syncable<IndexV2> = LocalSyncable(context)
}

View File

@@ -6,7 +6,7 @@ data class App(
val categories: List<String>, val categories: List<String>,
val links: Links, val links: Links,
val metadata: Metadata, val metadata: Metadata,
val author: Author, val author: Author?,
val screenshots: Screenshots, val screenshots: Screenshots,
val graphics: Graphics, val graphics: Graphics,
val donation: Donation, val donation: Donation,
@@ -15,34 +15,35 @@ data class App(
) )
data class Author( data class Author(
val id: Long, val id: Int,
val name: String, val name: String?,
val email: String, val email: String?,
val web: String val phone: String?,
val web: String?,
) )
data class Donation( data class Donation(
val regularUrl: String? = null, val regularUrl: List<String>? = null,
val bitcoinAddress: String? = null, val bitcoinAddress: String? = null,
val flattrId: String? = null, val flattrId: String? = null,
val liteCoinAddress: String? = null, val litecoinAddress: String? = null,
val openCollectiveId: String? = null, val openCollectiveId: String? = null,
val librePayId: String? = null, val liberapayId: String? = null,
) )
data class Graphics( data class Graphics(
val featureGraphic: String = "", val featureGraphic: String? = null,
val promoGraphic: String = "", val promoGraphic: String? = null,
val tvBanner: String = "", val tvBanner: String? = null,
val video: String = "" val video: String? = null,
) )
data class Links( data class Links(
val changelog: String = "", val changelog: String? = null,
val issueTracker: String = "", val issueTracker: String? = null,
val sourceCode: String = "", val sourceCode: String? = null,
val translation: String = "", val translation: String? = null,
val webSite: String = "" val webSite: String? = null,
) )
data class Metadata( data class Metadata(

View File

@@ -27,6 +27,22 @@ fun ByteArray.hex(): String = joinToString(separator = "") { byte ->
fun Fingerprint.formattedString(): String = value.windowed(2, 2, false) fun Fingerprint.formattedString(): String = value.windowed(2, 2, false)
.take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) } .take(FINGERPRINT_LENGTH / 2).joinToString(separator = " ") { it.uppercase(Locale.US) }
fun String.fingerprint(): Fingerprint = Fingerprint(
MessageDigest.getInstance("SHA-256")
.digest(
this
.chunked(2)
.mapNotNull { byteStr ->
try {
byteStr.toInt(16).toByte()
} catch (_: NumberFormatException) {
null
}
}
.toByteArray(),
).hex(),
)
fun Certificate.fingerprint(): Fingerprint { fun Certificate.fingerprint(): Fingerprint {
val bytes = encoded val bytes = encoded
return if (bytes.size >= 256) { return if (bytes.size >= 256) {

View File

@@ -1,25 +1,23 @@
package com.looker.droidify.domain.model package com.looker.droidify.domain.model
data class Repo( data class Repo(
val id: Long, val id: Int,
val enabled: Boolean, val enabled: Boolean,
val address: String, val address: String,
val name: String, val name: String,
val description: String, val description: String,
val fingerprint: Fingerprint?, val fingerprint: Fingerprint?,
val authentication: Authentication, val authentication: Authentication?,
val versionInfo: VersionInfo, val versionInfo: VersionInfo,
val mirrors: List<String>, val mirrors: List<String>,
val antiFeatures: List<AntiFeature>,
val categories: List<Category>
) { ) {
val shouldAuthenticate = val shouldAuthenticate = authentication != null
authentication.username.isNotEmpty() && authentication.password.isNotEmpty()
fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo { fun update(fingerprint: Fingerprint, timestamp: Long? = null, etag: String? = null): Repo {
return copy( return copy(
fingerprint = fingerprint, fingerprint = fingerprint,
versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) } ?: versionInfo versionInfo = timestamp?.let { VersionInfo(timestamp = it, etag = etag) }
?: versionInfo,
) )
} }
} }
@@ -28,22 +26,22 @@ data class AntiFeature(
val id: Long, val id: Long,
val name: String, val name: String,
val icon: String = "", val icon: String = "",
val description: String = "" val description: String = "",
) )
data class Category( data class Category(
val id: Long, val id: Long,
val name: String, val name: String,
val icon: String = "", val icon: String = "",
val description: String = "" val description: String = "",
) )
data class Authentication( data class Authentication(
val username: String, val username: String,
val password: String val password: String,
) )
data class VersionInfo( data class VersionInfo(
val timestamp: Long, val timestamp: Long,
val etag: String? val etag: String?,
) )

View File

@@ -0,0 +1,57 @@
package com.looker.droidify.index
import android.util.Xml
import com.looker.droidify.domain.model.fingerprint
import com.looker.droidify.model.Repository
import com.looker.droidify.model.Repository.Companion.defaultRepository
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.InputStream
/**
* Direct copy of implementation from https://github.com/NeoApplications/Neo-Store/blob/master/src/main/kotlin/com/machiav3lli/fdroid/data/database/entity/Repository.kt
* */
object OemRepositoryParser {
private val rootDirs = arrayOf("/system", "/product", "/vendor", "/odm", "/oem")
private val supportedPackageNames = arrayOf("com.looker.droidify", "org.fdroid.fdroid")
private const val FILE_NAME = "additional_repos.xml"
fun getSystemDefaultRepos() = rootDirs.flatMap { rootDir ->
supportedPackageNames.map { packageName -> "$rootDir/etc/$packageName/$FILE_NAME" }
}.flatMap { path ->
val file = File(path)
if (file.exists()) parse(file.inputStream()) else emptyList()
}.takeIf { it.isNotEmpty() }
fun parse(inputStream: InputStream): List<Repository> = with(Xml.newPullParser()) {
val repoItems = mutableListOf<String>()
inputStream.use { input ->
setInput(input, null)
var isItem = false
while (next() != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> if (name == "item") isItem = true
XmlPullParser.TEXT -> if (isItem) repoItems.add(text)
XmlPullParser.END_TAG -> isItem = false
}
}
}
repoItems.chunked(7).mapNotNull { itemsSet -> fromXML(itemsSet) }
}
private fun fromXML(xml: List<String>) = runCatching {
defaultRepository(
name = xml[0],
address = xml[1],
description = xml[2].replace(Regex("\\s+"), " ").trim(),
version = xml[3].toInt(),
enabled = xml[4].toInt() > 0,
fingerprint = xml[6].let {
if (it.length > 32) it.fingerprint().value
else it
},
authentication = "",
)
}.getOrNull()
}

View File

@@ -1,7 +1,7 @@
package com.looker.droidify.index package com.looker.droidify.index
import android.content.Context import android.content.Context
import android.net.Uri import androidx.core.net.toUri
import com.looker.droidify.database.Database import com.looker.droidify.database.Database
import com.looker.droidify.domain.model.fingerprint import com.looker.droidify.domain.model.fingerprint
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
@@ -15,10 +15,13 @@ import com.looker.droidify.utility.common.extension.toFormattedString
import com.looker.droidify.utility.common.result.Result import com.looker.droidify.utility.common.result.Result
import com.looker.droidify.utility.extension.android.Android import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.getProgress import com.looker.droidify.utility.getProgress
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.security.CodeSigner import java.security.CodeSigner
import java.security.cert.Certificate import java.security.cert.Certificate
@@ -33,7 +36,7 @@ object RepositoryUpdater {
// TODO Add support for Index-V2 and also cleanup everything here // TODO Add support for Index-V2 and also cleanup everything here
enum class IndexType( enum class IndexType(
val jarName: String, val jarName: String,
val contentName: String val contentName: String,
) { ) {
INDEX_V1("index-v1.jar", "index-v1.json") INDEX_V1("index-v1.jar", "index-v1.json")
} }
@@ -51,7 +54,7 @@ object RepositoryUpdater {
constructor(errorType: ErrorType, message: String, cause: Exception) : super( constructor(errorType: ErrorType, message: String, cause: Exception) : super(
message, message,
cause cause,
) { ) {
this.errorType = errorType this.errorType = errorType
} }
@@ -89,13 +92,13 @@ object RepositoryUpdater {
context: Context, context: Context,
repository: Repository, repository: Repository,
unstable: Boolean, unstable: Boolean,
callback: (Stage, Long, Long?) -> Unit callback: (Stage, Long, Long?) -> Unit,
) = update( ) = update(
context = context, context = context,
repository = repository, repository = repository,
unstable = unstable, unstable = unstable,
indexTypes = listOf(IndexType.INDEX_V1), indexTypes = listOf(IndexType.INDEX_V1),
callback = callback callback = callback,
) )
private suspend fun update( private suspend fun update(
@@ -103,7 +106,7 @@ object RepositoryUpdater {
repository: Repository, repository: Repository,
unstable: Boolean, unstable: Boolean,
indexTypes: List<IndexType>, indexTypes: List<IndexType>,
callback: (Stage, Long, Long?) -> Unit callback: (Stage, Long, Long?) -> Unit,
): Result<Boolean> = withContext(Dispatchers.IO) { ): Result<Boolean> = withContext(Dispatchers.IO) {
val indexType = indexTypes[0] val indexType = indexTypes[0]
when (val request = downloadIndex(context, repository, indexType, callback)) { when (val request = downloadIndex(context, repository, indexType, callback)) {
@@ -120,14 +123,14 @@ object RepositoryUpdater {
repository = repository, repository = repository,
indexTypes = indexTypes.subList(1, indexTypes.size), indexTypes = indexTypes.subList(1, indexTypes.size),
unstable = unstable, unstable = unstable,
callback = callback callback = callback,
) )
} else { } else {
Result.Error( Result.Error(
UpdateException( UpdateException(
ErrorType.HTTP, ErrorType.HTTP,
"Invalid response: HTTP ${result.statusCode}" "Invalid response: HTTP ${result.statusCode}",
) ),
) )
} }
} }
@@ -146,7 +149,7 @@ object RepositoryUpdater {
file = request.data.file, file = request.data.file,
lastModified = request.data.lastModified, lastModified = request.data.lastModified,
entityTag = request.data.entityTag, entityTag = request.data.entityTag,
callback = callback callback = callback,
) )
Result.Success(isFileParsedSuccessfully) Result.Success(isFileParsedSuccessfully)
} catch (e: UpdateException) { } catch (e: UpdateException) {
@@ -161,20 +164,20 @@ object RepositoryUpdater {
context: Context, context: Context,
repository: Repository, repository: Repository,
indexType: IndexType, indexType: IndexType,
callback: (Stage, Long, Long?) -> Unit callback: (Stage, Long, Long?) -> Unit,
): Result<IndexFile> = withContext(Dispatchers.IO) { ): Result<IndexFile> = withContext(Dispatchers.IO) {
val file = Cache.getTemporaryFile(context) val file = Cache.getTemporaryFile(context)
val result = downloader.downloadToFile( val result = downloader.downloadToFile(
url = Uri.parse(repository.address).buildUpon() url = repository.address.toUri().buildUpon()
.appendPath(indexType.jarName).build().toString(), .appendPath(indexType.jarName).build().toString(),
target = file, target = file,
headers = { headers = {
ifModifiedSince(repository.lastModified) ifModifiedSince(repository.lastModified)
etag(repository.entityTag) etag(repository.entityTag)
authentication(repository.authentication) authentication(repository.authentication)
} },
) { read, total -> ) { read, total ->
callback(Stage.DOWNLOAD, read.value, total.value.takeIf { it != 0L }) callback(Stage.DOWNLOAD, read.value, total?.value)
} }
when (result) { when (result) {
@@ -185,8 +188,8 @@ object RepositoryUpdater {
lastModified = result.lastModified?.toFormattedString() ?: "", lastModified = result.lastModified?.toFormattedString() ?: "",
entityTag = result.etag ?: "", entityTag = result.etag ?: "",
statusCode = result.statusCode, statusCode = result.statusCode,
file = file file = file,
) ),
) )
} }
@@ -203,8 +206,8 @@ object RepositoryUpdater {
Result.Error( Result.Error(
UpdateException( UpdateException(
errorType = errorType, errorType = errorType,
message = "Failed with Status: ${result.statusCode}" message = "Failed with Status: ${result.statusCode}",
) ),
) )
} }
@@ -228,7 +231,7 @@ object RepositoryUpdater {
mergerFile: File = Cache.getTemporaryFile(context), mergerFile: File = Cache.getTemporaryFile(context),
lastModified: String, lastModified: String,
entityTag: String, entityTag: String,
callback: (Stage, Long, Long?) -> Unit callback: (Stage, Long, Long?) -> Unit,
): Boolean { ): Boolean {
var rollback = true var rollback = true
return synchronized(updaterLock) { return synchronized(updaterLock) {
@@ -258,7 +261,7 @@ object RepositoryUpdater {
name: String, name: String,
description: String, description: String,
version: Int, version: Int,
timestamp: Long timestamp: Long,
) { ) {
changedRepository = repository.update( changedRepository = repository.update(
mirrors, mirrors,
@@ -267,7 +270,7 @@ object RepositoryUpdater {
version, version,
lastModified, lastModified,
entityTag, entityTag,
timestamp timestamp,
) )
} }
@@ -284,7 +287,7 @@ object RepositoryUpdater {
override fun onReleases( override fun onReleases(
packageName: String, packageName: String,
releases: List<Release> releases: List<Release>,
) { ) {
if (Thread.interrupted()) { if (Thread.interrupted()) {
throw InterruptedException() throw InterruptedException()
@@ -295,7 +298,7 @@ object RepositoryUpdater {
unmergedReleases.clear() unmergedReleases.clear()
} }
} }
} },
) )
if (Thread.interrupted()) { if (Thread.interrupted()) {
@@ -318,11 +321,11 @@ object RepositoryUpdater {
callback( callback(
Stage.MERGE, Stage.MERGE,
progress.toLong(), progress.toLong(),
totalCount.toLong() totalCount.toLong(),
) )
Database.UpdaterAdapter.putTemporary( Database.UpdaterAdapter.putTemporary(
products products
.map { transformProduct(it, features, unstable) } .map { transformProduct(it, features, unstable) },
) )
} }
} }
@@ -336,7 +339,7 @@ object RepositoryUpdater {
throw UpdateException( throw UpdateException(
ErrorType.VALIDATION, ErrorType.VALIDATION,
"New index is older than current index:" + "New index is older than current index:" +
" ${workRepository.timestamp} < ${repository.timestamp}" " ${workRepository.timestamp} < ${repository.timestamp}",
) )
} }
@@ -349,13 +352,13 @@ object RepositoryUpdater {
val commitRepository = if (!workRepository.fingerprint.equals( val commitRepository = if (!workRepository.fingerprint.equals(
fingerprint, fingerprint,
ignoreCase = true ignoreCase = true,
) )
) { ) {
if (workRepository.fingerprint.isNotEmpty()) { if (workRepository.fingerprint.isNotEmpty()) {
throw UpdateException( throw UpdateException(
ErrorType.VALIDATION, ErrorType.VALIDATION,
"Certificate fingerprints do not match" "Certificate fingerprints do not match",
) )
} }
@@ -391,7 +394,7 @@ object RepositoryUpdater {
get() = codeSigners?.singleOrNull() get() = codeSigners?.singleOrNull()
?: throw UpdateException( ?: throw UpdateException(
ErrorType.VALIDATION, ErrorType.VALIDATION,
"index.jar must be signed by a single code signer" "index.jar must be signed by a single code signer",
) )
@get:Throws(UpdateException::class) @get:Throws(UpdateException::class)
@@ -399,13 +402,13 @@ object RepositoryUpdater {
get() = signerCertPath?.certificates?.singleOrNull() get() = signerCertPath?.certificates?.singleOrNull()
?: throw UpdateException( ?: throw UpdateException(
ErrorType.VALIDATION, ErrorType.VALIDATION,
"index.jar code signer should have only one certificate" "index.jar code signer should have only one certificate",
) )
private fun transformProduct( private fun transformProduct(
product: Product, product: Product,
features: Set<String>, features: Set<String>,
unstable: Boolean unstable: Boolean,
): Product { ): Product {
val releasePairs = product.releases val releasePairs = product.releases
.distinctBy { it.identifier } .distinctBy { it.identifier }
@@ -445,7 +448,7 @@ object RepositoryUpdater {
selected = firstSelected?.let { selected = firstSelected?.let {
it.first.versionCode == release.versionCode && it.first.versionCode == release.versionCode &&
it.second == incompatibilities it.second == incompatibilities
} ?: false } ?: false,
) )
} }
return product.copy(releases = releases) return product.copy(releases = releases)
@@ -457,5 +460,5 @@ data class IndexFile(
val lastModified: String, val lastModified: String,
val entityTag: String, val entityTag: String,
val statusCode: Int, val statusCode: Int,
val file: File val file: File,
) )

View File

@@ -32,7 +32,7 @@ import kotlinx.coroutines.sync.withLock
class InstallManager( class InstallManager(
private val context: Context, private val context: Context,
settingsRepository: SettingsRepository private val settingsRepository: SettingsRepository
) { ) {
private val installItems = Channel<InstallItem>() private val installItems = Channel<InstallItem>()
@@ -115,7 +115,7 @@ class InstallManager(
private suspend fun setInstaller(installerType: InstallerType) { private suspend fun setInstaller(installerType: InstallerType) {
lock.withLock { lock.withLock {
_installer = when (installerType) { _installer = when (installerType) {
InstallerType.LEGACY -> LegacyInstaller(context) InstallerType.LEGACY -> LegacyInstaller(context, settingsRepository)
InstallerType.SESSION -> SessionInstaller(context) InstallerType.SESSION -> SessionInstaller(context)
InstallerType.SHIZUKU -> ShizukuInstaller(context) InstallerType.SHIZUKU -> ShizukuInstaller(context)
InstallerType.ROOT -> RootInstaller(context) InstallerType.ROOT -> RootInstaller(context)

View File

@@ -8,7 +8,9 @@ import com.looker.droidify.utility.common.extension.getLauncherActivities
import com.looker.droidify.utility.common.extension.getPackageInfoCompat import com.looker.droidify.utility.common.extension.getPackageInfoCompat
import com.looker.droidify.utility.common.extension.intent import com.looker.droidify.utility.common.extension.intent
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuProvider import rikka.shizuku.ShizukuProvider
import rikka.sui.Sui
import kotlin.coroutines.resume import kotlin.coroutines.resume
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263 private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
@@ -16,42 +18,47 @@ private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
fun launchShizuku(context: Context) { fun launchShizuku(context: Context) {
val activities = val activities =
context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID) context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID)
if (activities.isEmpty()) return
val intent = intent(Intent.ACTION_MAIN) { val intent = intent(Intent.ACTION_MAIN) {
addCategory(Intent.CATEGORY_LAUNCHER) addCategory(Intent.CATEGORY_LAUNCHER)
setComponent( setComponent(
ComponentName( ComponentName(
ShizukuProvider.MANAGER_APPLICATION_ID, ShizukuProvider.MANAGER_APPLICATION_ID,
activities.first().first activities.first().first,
) ),
) )
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent) context.startActivity(intent)
} }
fun initSui(context: Context) = Sui.init(context.packageName)
fun isSuiAvailable() = Sui.isSui()
fun isShizukuInstalled(context: Context) = fun isShizukuInstalled(context: Context) =
context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null
fun isShizukuAlive() = rikka.shizuku.Shizuku.pingBinder() fun isShizukuAlive() = Shizuku.pingBinder()
fun isShizukuGranted() = rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED fun isShizukuGranted() = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
suspend fun requestPermissionListener() = suspendCancellableCoroutine { suspend fun requestPermissionListener() = suspendCancellableCoroutine {
val listener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) { if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
it.resume(grantResult == PackageManager.PERMISSION_GRANTED) it.resume(grantResult == PackageManager.PERMISSION_GRANTED)
} }
} }
rikka.shizuku.Shizuku.addRequestPermissionResultListener(listener) Shizuku.addRequestPermissionResultListener(listener)
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
it.invokeOnCancellation { it.invokeOnCancellation {
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(listener) Shizuku.removeRequestPermissionResultListener(listener)
} }
} }
fun requestShizuku() { fun requestShizuku() {
rikka.shizuku.Shizuku.shouldShowRequestPermissionRationale() Shizuku.shouldShowRequestPermissionRationale()
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
} }
fun isMagiskGranted(): Boolean { fun isMagiskGranted(): Boolean {

View File

@@ -1,20 +1,29 @@
package com.looker.droidify.installer.installers package com.looker.droidify.installer.installers
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.AndroidRuntimeException import android.util.AndroidRuntimeException
import androidx.core.net.toUri import androidx.core.net.toUri
import com.looker.droidify.R
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.get
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.domain.model.PackageName import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.installer.model.InstallItem import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.utility.common.SdkCheck import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.intent import com.looker.droidify.utility.common.extension.intent
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume import kotlin.coroutines.resume
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
class LegacyInstaller(private val context: Context) : Installer { class LegacyInstaller(
private val context: Context,
private val settingsRepository: SettingsRepository
) : Installer {
companion object { companion object {
private const val APK_MIME = "application/vnd.android.package-archive" private const val APK_MIME = "application/vnd.android.package-archive"
@@ -22,30 +31,51 @@ class LegacyInstaller(private val context: Context) : Installer {
override suspend fun install( override suspend fun install(
installItem: InstallItem, installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont -> ): InstallState {
val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0 val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0
val fileUri = if (SdkCheck.isNougat) { val fileUri = if (SdkCheck.isNougat) {
Cache.getReleaseUri( Cache.getReleaseUri(context, installItem.installFileName)
context,
installItem.installFileName
)
} else { } else {
Cache.getReleaseFile(context, installItem.installFileName).toUri() Cache.getReleaseFile(context, installItem.installFileName).toUri()
} }
val installIntent = intent(Intent.ACTION_INSTALL_PACKAGE) {
val comp = settingsRepository.get { legacyInstallerComponent }.firstOrNull()
return suspendCancellableCoroutine { cont ->
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(fileUri, APK_MIME) setDataAndType(fileUri, APK_MIME)
flags = installFlag flags = installFlag
when (comp) {
is LegacyInstallerComponent.Component -> {
component = ComponentName(comp.clazz, comp.activity)
} }
else -> {
// For Unspecified and AlwaysChoose, don't set component
}
}
}
val installIntent = when (comp) {
LegacyInstallerComponent.AlwaysChoose -> Intent.createChooser(intent, context.getString(
R.string.select_installer))
else -> intent
}
try { try {
context.startActivity(installIntent) context.startActivity(installIntent)
cont.resume(InstallState.Installed) cont.resume(InstallState.Installed)
} catch (e: AndroidRuntimeException) { } catch (e: AndroidRuntimeException) {
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
try {
context.startActivity(installIntent) context.startActivity(installIntent)
cont.resume(InstallState.Installed) cont.resume(InstallState.Installed)
} catch (e: Exception) { } catch (e: Exception) {
cont.resume(InstallState.Failed) cont.resume(InstallState.Failed)
} }
} catch (e: Exception) {
cont.resume(InstallState.Failed)
}
}
} }
override suspend fun uninstall(packageName: PackageName) = override suspend fun uninstall(packageName: PackageName) =

View File

@@ -1,14 +1,14 @@
package com.looker.droidify.installer.installers.shizuku package com.looker.droidify.installer.installers.shizuku
import android.content.Context 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.domain.model.PackageName
import com.looker.droidify.installer.installers.Installer import com.looker.droidify.installer.installers.Installer
import com.looker.droidify.installer.installers.uninstallPackage import com.looker.droidify.installer.installers.uninstallPackage
import com.looker.droidify.installer.model.InstallItem import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState 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.size
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStream import java.io.InputStream
@@ -21,13 +21,14 @@ class ShizukuInstaller(private val context: Context) : Installer {
} }
override suspend fun install( override suspend fun install(
installItem: InstallItem installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont -> ): InstallState = suspendCancellableCoroutine { cont ->
var sessionId: String? = null var sessionId: String? = null
val file = Cache.getReleaseFile(context, installItem.installFileName) val file = Cache.getReleaseFile(context, installItem.installFileName)
val packageName = installItem.packageName.name val packageName = installItem.packageName.name
try { try {
val fileSize = file.size ?: run { val fileSize = file.length()
if (fileSize == 0L) {
cont.cancel() cont.cancel()
error("File is not valid: Size ${file.size}") error("File is not valid: Size ${file.size}")
} }
@@ -43,26 +44,26 @@ class ShizukuInstaller(private val context: Context) : Installer {
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
?: run { ?: run {
cont.cancel() cont.cancel()
throw RuntimeException("Failed to create install session") error("Failed to create install session")
} }
if (cont.isCompleted) return@suspendCancellableCoroutine if (cont.isCompleted) return@suspendCancellableCoroutine
val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it) val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it)
if (writeResult.resultCode != 0) { if (writeResult.resultCode != 0) {
cont.cancel() cont.cancel()
throw RuntimeException("Failed to write APK to session $sessionId") error("Failed to write APK to session $sessionId")
} }
if (cont.isCompleted) return@suspendCancellableCoroutine if (cont.isCompleted) return@suspendCancellableCoroutine
val commitResult = exec("pm install-commit $sessionId") val commitResult = exec("pm install-commit $sessionId")
if (commitResult.resultCode != 0) { if (commitResult.resultCode != 0) {
cont.cancel() cont.cancel()
throw RuntimeException("Failed to commit install session $sessionId") error("Failed to commit install session $sessionId")
} }
if (cont.isCompleted) return@suspendCancellableCoroutine if (cont.isCompleted) return@suspendCancellableCoroutine
cont.resume(InstallState.Installed) cont.resume(InstallState.Installed)
} }
} catch (e: Exception) { } catch (_: Exception) {
if (sessionId != null) exec("pm install-abandon $sessionId") if (sessionId != null) exec("pm install-abandon $sessionId")
cont.resume(InstallState.Failed) cont.resume(InstallState.Failed)
} }
@@ -71,7 +72,7 @@ class ShizukuInstaller(private val context: Context) : Installer {
override suspend fun uninstall(packageName: PackageName) = override suspend fun uninstall(packageName: PackageName) =
context.uninstallPackage(packageName) context.uninstallPackage(packageName)
override fun close() {} override fun close() = Unit
private data class ShellResult(val resultCode: Int, val out: String) private data class ShellResult(val resultCode: Int, val out: String)

View File

@@ -72,7 +72,7 @@ data class Repository(
return defaultRepository(address, name, "", 0, true, fingerprint, authentication) return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
} }
private fun defaultRepository( fun defaultRepository(
address: String, address: String,
name: String, name: String,
description: String, description: String,
@@ -137,9 +137,9 @@ data class Repository(
), ),
defaultRepository( defaultRepository(
address = "https://microg.org/fdroid/repo", address = "https://microg.org/fdroid/repo",
name = "MicroG Project", name = "microG Project",
description = "The official repository for MicroG." + description = "The official repository for microG." +
" MicroG is a lightweight open-source implementation" + " microG is a lightweight open source implementation" +
" of Google Play Services.", " of Google Play Services.",
fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165" fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165"
), ),
@@ -171,13 +171,6 @@ data class Repository(
description = "Collabora Office is an office suite based on LibreOffice.", description = "Collabora Office is an office suite based on LibreOffice.",
fingerprint = "573258C84E149B5F4D9299E7434B2B69A8410372921D4AE586BA91EC767892CC" fingerprint = "573258C84E149B5F4D9299E7434B2B69A8410372921D4AE586BA91EC767892CC"
), ),
defaultRepository(
address = "https://fdroid.libretro.com/repo",
name = "LibRetro",
description = "The official canary repository for this great" +
" retro emulators hub.",
fingerprint = "3F05B24D497515F31FEAB421297C79B19552C5C81186B3750B7C131EF41D733D"
),
defaultRepository( defaultRepository(
address = "https://cdn.kde.org/android/fdroid/repo", address = "https://cdn.kde.org/android/fdroid/repo",
name = "KDE Android", name = "KDE Android",
@@ -200,7 +193,7 @@ data class Repository(
address = "https://fdroid.fedilab.app/repo", address = "https://fdroid.fedilab.app/repo",
name = "Fedilab", name = "Fedilab",
description = "The official repository for Fedilab. Fedilab is a " + description = "The official repository for Fedilab. Fedilab is a " +
"multi-accounts client for Mastodon, Peertube, and other free" + "multi-accounts client for Mastodon, PeerTube, and other free" +
" software social networks.", " software social networks.",
fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB" fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB"
), ),
@@ -209,14 +202,7 @@ data class Repository(
name = "Kali Nethunter", name = "Kali Nethunter",
description = "Kali Nethunter's official selection of original b" + description = "Kali Nethunter's official selection of original b" +
"inaries.", "inaries.",
fingerprint = "7E418D34C3AD4F3C37D7E6B0FACE13332364459C862134EB099A3BDA2CCF4494" fingerprint = "FE7A23DFC003A1CF2D2ADD2469B9C0C49B206BA5DC9EDD6563B3B7EB6A8F5FAB"
),
defaultRepository(
address = "https://secfirst.org/fdroid/repo",
name = "Umbrella",
description = "The official repository for Umbrella. Umbrella is" +
" a collection of security advices, tutorials, tools etc.",
fingerprint = "39EB57052F8D684514176819D1645F6A0A7BD943DBC31AB101949006AC0BC228"
), ),
defaultRepository( defaultRepository(
address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo", address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo",
@@ -257,14 +243,14 @@ data class Repository(
name = "Threema Libre", name = "Threema Libre",
description = "The official repository for Threema Libre. R" + description = "The official repository for Threema Libre. R" +
"equires Threema Shop license. Threema Libre is an open" + "equires Threema Shop license. Threema Libre is an open" +
"-source messanger focused on security and privacy.", "-source messenger focused on security and privacy.",
fingerprint = "5734E753899B25775D90FE85362A49866E05AC4F83C05BEF5A92880D2910639E" fingerprint = "5734E753899B25775D90FE85362A49866E05AC4F83C05BEF5A92880D2910639E"
), ),
defaultRepository( defaultRepository(
address = "https://fdroid.getsession.org/fdroid/repo", address = "https://fdroid.getsession.org/fdroid/repo",
name = "Session", name = "Session",
description = "The official repository for Session. Session" + description = "The official repository for Session. Session" +
" is an open-source messanger focused on security and privacy.", " is an open-source messenger focused on security and privacy.",
fingerprint = "DB0E5297EB65CC22D6BD93C869943BDCFCB6A07DC69A48A0DD8C7BA698EC04E6" fingerprint = "DB0E5297EB65CC22D6BD93C869943BDCFCB6A07DC69A48A0DD8C7BA698EC04E6"
), ),
defaultRepository( defaultRepository(
@@ -413,5 +399,12 @@ data class Repository(
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B" fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
) )
) )
val toRemove: List<String> = listOf(
// Add repository addresses that should be removed during database upgrades and remove them from the lists above
// Example: "https://example.com/fdroid/repo"
"https://secfirst.org/fdroid/repo",
"https://fdroid.libretro.com/repo"
)
} }
} }

View File

@@ -23,4 +23,4 @@ interface Downloader {
): NetworkResponse ): NetworkResponse
} }
typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize?) -> Unit

View File

@@ -5,7 +5,6 @@ import com.looker.droidify.network.header.HeadersBuilder
import com.looker.droidify.network.header.KtorHeadersBuilder import com.looker.droidify.network.header.KtorHeadersBuilder
import com.looker.droidify.network.validation.FileValidator import com.looker.droidify.network.validation.FileValidator
import com.looker.droidify.network.validation.ValidationException import com.looker.droidify.network.validation.ValidationException
import com.looker.droidify.utility.common.extension.size
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.HttpClientEngine
@@ -51,12 +50,9 @@ internal class KtorDownloader(
override suspend fun headCall( override suspend fun headCall(
url: String, url: String,
headers: HeadersBuilder.() -> Unit headers: HeadersBuilder.() -> Unit,
): NetworkResponse { ): NetworkResponse {
val headRequest = createRequest( val headRequest = request(url, headers = headers)
url = url,
headers = headers
)
return client.head(headRequest).asNetworkResponse() return client.head(headRequest).asNetworkResponse()
} }
@@ -65,24 +61,26 @@ internal class KtorDownloader(
target: File, target: File,
validator: FileValidator?, validator: FileValidator?,
headers: HeadersBuilder.() -> Unit, headers: HeadersBuilder.() -> Unit,
block: ProgressListener? block: ProgressListener?,
): NetworkResponse = withContext(dispatcher) { ): NetworkResponse = withContext(dispatcher) {
try { try {
val request = createRequest( val fileSize = target.length()
val request = request(
url = url, url = url,
headers = { fileSize = fileSize,
inRange(target.size) block = block,
) {
inRange(fileSize)
headers() headers()
}, }
fileSize = target.size,
block = block
)
client.prepareGet(request).execute { response -> client.prepareGet(request).execute { response ->
val networkResponse = response.asNetworkResponse() val networkResponse = response.asNetworkResponse()
if (networkResponse !is NetworkResponse.Success) { if (networkResponse !is NetworkResponse.Success) {
return@execute networkResponse return@execute networkResponse
} }
response.bodyAsChannel().copyTo(target.outputStream()) target.outputStream().use { output ->
response.bodyAsChannel().copyTo(output)
}
validator?.validate(target) validator?.validate(target)
networkResponse networkResponse
} }
@@ -95,37 +93,34 @@ internal class KtorDownloader(
} catch (e: ValidationException) { } catch (e: ValidationException) {
target.delete() target.delete()
NetworkResponse.Error.Validation(e) NetworkResponse.Error.Validation(e)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
NetworkResponse.Error.Unknown(e) NetworkResponse.Error.Unknown(e)
} }
} }
private fun client( private fun client(
engine: HttpClientEngine = OkHttp.create() engine: HttpClientEngine = OkHttp.create(),
): HttpClient { ) = HttpClient(engine) {
return HttpClient(engine) {
userAgentConfig() userAgentConfig()
timeoutConfig() timeoutConfig()
} }
}
private fun createRequest( private fun request(
url: String, url: String,
fileSize: Long = 0L,
block: ProgressListener? = null,
headers: HeadersBuilder.() -> Unit, headers: HeadersBuilder.() -> Unit,
fileSize: Long? = null,
block: ProgressListener? = null
) = request { ) = request {
url(url) url(url)
this.headers { headers { KtorHeadersBuilder(this).headers() }
KtorHeadersBuilder(this).headers()
}
onDownload { read, total ->
if (block != null) { if (block != null) {
onDownload { read, total ->
block( block(
DataSize(read + (fileSize ?: 0L)), DataSize(read + fileSize),
DataSize((total ?: 0L) + (fileSize ?: 0L)) total?.let { DataSize(total + fileSize) },
) )
} }
} }

View File

@@ -16,5 +16,5 @@ interface HeadersBuilder {
fun authentication(base64: String) fun authentication(base64: String)
fun inRange(start: Number?, end: Number? = null) fun inRange(start: Long, end: Long? = null)
} }

View File

@@ -8,7 +8,7 @@ import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
internal class KtorHeadersBuilder( internal class KtorHeadersBuilder(
private val builder: io.ktor.http.HeadersBuilder private val builder: io.ktor.http.HeadersBuilder,
) : HeadersBuilder { ) : HeadersBuilder {
override fun String.headsWith(value: Any?) { override fun String.headsWith(value: Any?) {
@@ -38,8 +38,7 @@ internal class KtorHeadersBuilder(
HttpHeaders.Authorization headsWith base64 HttpHeaders.Authorization headsWith base64
} }
override fun inRange(start: Number?, end: Number?) { override fun inRange(start: Long, end: Long?) {
if (start == null) return
val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-" val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-"
HttpHeaders.Range headsWith valueString HttpHeaders.Range headsWith valueString
} }

View File

@@ -74,7 +74,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
data object Idle : State("") data object Idle : State("")
data class Connecting(val name: String) : State(name) data class Connecting(val name: String) : State(name)
data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State( data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State(
name name,
) )
data class Error(val name: String) : State(name) data class Error(val name: String) : State(name)
@@ -84,7 +84,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
data class DownloadState( data class DownloadState(
val currentItem: State = State.Idle, val currentItem: State = State.Idle,
val queue: List<String> = emptyList() val queue: List<String> = emptyList(),
) { ) {
infix fun isDownloading(packageName: String): Boolean = infix fun isDownloading(packageName: String): Boolean =
currentItem.packageName == packageName && ( currentItem.packageName == packageName && (
@@ -108,7 +108,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
val release: Release, val release: Release,
val url: String, val url: String,
val authentication: String, val authentication: String,
val isUpdate: Boolean = false val isUpdate: Boolean = false,
) { ) {
val notificationTag: String val notificationTag: String
get() = "download-$packageName" get() = "download-$packageName"
@@ -129,7 +129,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
name: String, name: String,
repository: Repository, repository: Repository,
release: Release, release: Release,
isUpdate: Boolean = false isUpdate: Boolean = false,
) { ) {
val task = Task( val task = Task(
packageName = packageName, packageName = packageName,
@@ -137,7 +137,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
release = release, release = release,
url = release.getDownloadUrl(repository), url = release.getDownloadUrl(repository),
authentication = repository.authentication, authentication = repository.authentication,
isUpdate = isUpdate isUpdate = isUpdate,
) )
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) { if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
lifecycleScope.launch { publishSuccess(task) } lifecycleScope.launch { publishSuccess(task) }
@@ -147,7 +147,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
cancelCurrentTask(packageName) cancelCurrentTask(packageName)
notificationManager?.cancel( notificationManager?.cancel(
task.notificationTag, task.notificationTag,
Constants.NOTIFICATION_ID_DOWNLOADING Constants.NOTIFICATION_ID_DOWNLOADING,
) )
tasks += task tasks += task
if (currentTask == null) { if (currentTask == null) {
@@ -174,7 +174,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
) )
createNotificationChannel( createNotificationChannel(
id = NOTIFICATION_CHANNEL_INSTALL, id = NOTIFICATION_CHANNEL_INSTALL,
name = getString(stringRes.install) name = getString(stringRes.install),
) )
lifecycleScope.launch { lifecycleScope.launch {
@@ -250,13 +250,13 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(intent) .setContentIntent(intent)
.errorNotificationContent(task, errorType) .errorNotificationContent(task, errorType)
.build() .build(),
) )
} }
private fun NotificationCompat.Builder.errorNotificationContent( private fun NotificationCompat.Builder.errorNotificationContent(
task: Task, task: Task,
errorType: ErrorType errorType: ErrorType,
): NotificationCompat.Builder { ): NotificationCompat.Builder {
val title = if (errorType is ErrorType.Validation) { val title = if (errorType is ErrorType.Validation) {
stringRes.could_not_validate_FORMAT stringRes.could_not_validate_FORMAT
@@ -325,8 +325,8 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
this, this,
0, 0,
Intent(this, this::class.java).setAction(ACTION_CANCEL), Intent(this, this::class.java).setAction(ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) ),
) )
} }
@@ -337,7 +337,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
?.let { notification -> ?.let { notification ->
startForeground( startForeground(
Constants.NOTIFICATION_ID_DOWNLOADING, Constants.NOTIFICATION_ID_DOWNLOADING,
notification.build() notification.build(),
) )
} ?: run { } ?: run {
log("Invalid Download State: $state", "DownloadService", Log.ERROR) log("Invalid Download State: $state", "DownloadService", Log.ERROR)
@@ -345,7 +345,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
} }
private fun NotificationCompat.Builder.downloadingNotificationContent( private fun NotificationCompat.Builder.downloadingNotificationContent(
state: State state: State,
): NotificationCompat.Builder? { ): NotificationCompat.Builder? {
return when (state) { return when (state) {
is State.Connecting -> { is State.Connecting -> {
@@ -403,19 +403,19 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private fun CoroutineScope.downloadFile( private fun CoroutineScope.downloadFile(
task: Task, task: Task,
target: File target: File,
) = launch { ) = launch {
try { try {
val releaseValidator = ReleaseFileValidator( val releaseValidator = ReleaseFileValidator(
context = this@DownloadService, context = this@DownloadService,
packageName = task.packageName, packageName = task.packageName,
release = task.release release = task.release,
) )
val response = downloader.downloadToFile( val response = downloader.downloadToFile(
url = task.url, url = task.url,
target = target, target = target,
validator = releaseValidator, validator = releaseValidator,
headers = { authentication(task.authentication) } headers = { if (task.authentication.isNotEmpty()) authentication(task.authentication) },
) { read, total -> ) { read, total ->
yield() yield()
updateCurrentState(State.Downloading(task.packageName, read, total)) updateCurrentState(State.Downloading(task.packageName, read, total))
@@ -425,7 +425,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
is NetworkResponse.Success -> { is NetworkResponse.Success -> {
val releaseFile = Cache.getReleaseFile( val releaseFile = Cache.getReleaseFile(
this@DownloadService, this@DownloadService,
task.release.cacheFileName task.release.cacheFileName,
) )
target.renameTo(releaseFile) target.renameTo(releaseFile)
publishSuccess(task) publishSuccess(task)
@@ -438,7 +438,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
is NetworkResponse.Error.IO -> ErrorType.IO is NetworkResponse.Error.IO -> ErrorType.IO
is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout
is NetworkResponse.Error.Validation -> ErrorType.Validation( is NetworkResponse.Error.Validation -> ErrorType.Validation(
response.exception response.exception,
) )
else -> ErrorType.Http else -> ErrorType.Http

View File

@@ -0,0 +1,26 @@
package com.looker.droidify.sync
import android.content.Context
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.v2.V2Parser
import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.Dispatchers
class LocalSyncable(
private val context: Context,
) : Syncable<IndexV2> {
override val parser: Parser<IndexV2>
get() = V2Parser(Dispatchers.IO, JsonParser)
override suspend fun sync(repo: Repo): Pair<Fingerprint, IndexV2?> {
val file = Cache.getTemporaryFile(context).apply {
outputStream().use {
it.write(context.assets.open("izzy_index_v2.json").readBytes())
}
}
return parser.parse(file, repo)
}
}

View File

@@ -2,19 +2,14 @@ package com.looker.droidify.sync
import com.looker.droidify.domain.model.Fingerprint import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.sync.v2.model.IndexV2
/**
* 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> { interface Syncable<T> {
val parser: Parser<T> val parser: Parser<T>
suspend fun sync( suspend fun sync(
repo: Repo, repo: Repo,
): Pair<Fingerprint, com.looker.droidify.sync.v2.model.IndexV2?> ): Pair<Fingerprint, IndexV2?>
} }

View File

@@ -8,6 +8,7 @@ import com.looker.droidify.sync.v1.model.RepoV1
import com.looker.droidify.sync.v1.model.maxSdk import com.looker.droidify.sync.v1.model.maxSdk
import com.looker.droidify.sync.v1.model.name import com.looker.droidify.sync.v1.model.name
import com.looker.droidify.sync.v2.model.AntiFeatureV2 import com.looker.droidify.sync.v2.model.AntiFeatureV2
import com.looker.droidify.sync.v2.model.ApkFileV2
import com.looker.droidify.sync.v2.model.CategoryV2 import com.looker.droidify.sync.v2.model.CategoryV2
import com.looker.droidify.sync.v2.model.FeatureV2 import com.looker.droidify.sync.v2.model.FeatureV2
import com.looker.droidify.sync.v2.model.FileV2 import com.looker.droidify.sync.v2.model.FileV2
@@ -94,7 +95,7 @@ private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
added = added ?: 0L, added = added ?: 0L,
lastUpdated = lastUpdated ?: 0L, lastUpdated = lastUpdated ?: 0L,
icon = localized?.localizedIcon(packageName, icon) { it.icon }, icon = localized?.localizedIcon(packageName, icon) { it.icon },
name = localized?.localizedString(name) { it.name }, name = localized?.localizedString(name) { it.name } ?: emptyMap(),
description = localized?.localizedString(description) { it.description }, description = localized?.localizedString(description) { it.description },
summary = localized?.localizedString(summary) { it.summary }, summary = localized?.localizedString(summary) { it.summary },
authorEmail = authorEmail, authorEmail = authorEmail,
@@ -157,7 +158,7 @@ private fun PackageV1.toVersionV2(
packageAntiFeatures: List<String>, packageAntiFeatures: List<String>,
): VersionV2 = VersionV2( ): VersionV2 = VersionV2(
added = added ?: 0L, added = added ?: 0L,
file = FileV2( file = ApkFileV2(
name = "/$apkName", name = "/$apkName",
sha256 = hash, sha256 = hash,
size = size, size = size,

View File

@@ -1,9 +1,9 @@
package com.looker.droidify.sync.common package com.looker.droidify.sync.common
import android.content.Context import android.content.Context
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.network.Downloader import com.looker.droidify.network.Downloader
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -16,23 +16,25 @@ suspend fun Downloader.downloadIndex(
url: String, url: String,
diff: Boolean = false, diff: Boolean = false,
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val tempFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName") val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$fileName")
downloadToFile( downloadToFile(
url = url, url = url,
target = tempFile, target = indexFile,
headers = { headers = {
if (repo.shouldAuthenticate) { if (repo.shouldAuthenticate) {
with(requireNotNull(repo.authentication)) {
authentication( authentication(
repo.authentication.username, username = username,
repo.authentication.password password = password,
) )
} }
}
if (repo.versionInfo.timestamp > 0L && !diff) { if (repo.versionInfo.timestamp > 0L && !diff) {
ifModifiedSince(Date(repo.versionInfo.timestamp)) ifModifiedSince(Date(repo.versionInfo.timestamp))
} }
} },
) )
tempFile indexFile
} }
const val INDEX_V1_NAME = "index-v1.jar" const val INDEX_V1_NAME = "index-v1.jar"

View File

@@ -1,12 +1,11 @@
package com.looker.droidify.sync.v2 package com.looker.droidify.sync.v2
import android.content.Context import android.content.Context
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.domain.model.Fingerprint import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo import com.looker.droidify.domain.model.Repo
import com.looker.droidify.network.Downloader
import com.looker.droidify.sync.Parser import com.looker.droidify.sync.Parser
import com.looker.droidify.sync.Syncable import com.looker.droidify.sync.Syncable
import com.looker.droidify.network.Downloader
import com.looker.droidify.sync.common.ENTRY_V2_NAME import com.looker.droidify.sync.common.ENTRY_V2_NAME
import com.looker.droidify.sync.common.INDEX_V2_NAME import com.looker.droidify.sync.common.INDEX_V2_NAME
import com.looker.droidify.sync.common.IndexJarValidator import com.looker.droidify.sync.common.IndexJarValidator
@@ -15,7 +14,9 @@ import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.v2.model.Entry import com.looker.droidify.sync.v2.model.Entry
import com.looker.droidify.sync.v2.model.IndexV2 import com.looker.droidify.sync.v2.model.IndexV2
import com.looker.droidify.sync.v2.model.IndexV2Diff import com.looker.droidify.sync.v2.model.IndexV2Diff
import com.looker.droidify.utility.common.cache.Cache
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -51,7 +52,7 @@ class EntrySyncable(
context = context, context = context,
repo = repo, repo = repo,
url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME", url = repo.address.removeSuffix("/") + "/$ENTRY_V2_NAME",
fileName = ENTRY_V2_NAME fileName = ENTRY_V2_NAME,
) )
val (fingerprint, entry) = parser.parse(jar, repo) val (fingerprint, entry) = parser.parse(jar, repo)
jar.delete() jar.delete()
@@ -61,7 +62,6 @@ class EntrySyncable(
val indexPath = repo.address.removeSuffix("/") + index.name val indexPath = repo.address.removeSuffix("/") + index.name
val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME") val indexFile = Cache.getIndexFile(context, "repo_${repo.id}_$INDEX_V2_NAME")
val indexV2 = if (index != entry.index && indexFile.exists()) { val indexV2 = if (index != entry.index && indexFile.exists()) {
// example https://apt.izzysoft.de/fdroid/repo/diff/1725372028000.json
val diffFile = downloader.downloadIndex( val diffFile = downloader.downloadIndex(
context = context, context = context,
repo = repo, repo = repo,
@@ -69,17 +69,12 @@ class EntrySyncable(
fileName = "diff_${repo.versionInfo.timestamp}.json", fileName = "diff_${repo.versionInfo.timestamp}.json",
diff = true, diff = true,
) )
// TODO: Maybe parse in parallel val diff = async { diffParser.parse(diffFile, repo).second }
diffParser.parse(diffFile, repo).second.let { val oldIndex = async { indexParser.parse(indexFile, repo).second }
diff.await().patchInto(oldIndex.await()) { index ->
diffFile.delete() diffFile.delete()
it.patchInto(
indexParser.parse(
indexFile,
repo
).second) { index ->
Json.encodeToStream(index, indexFile.outputStream()) Json.encodeToStream(index, indexFile.outputStream())
} }
}
} else { } else {
// example https://apt.izzysoft.de/fdroid/repo/index-v2.json // example https://apt.izzysoft.de/fdroid/repo/index-v2.json
val newIndexFile = downloader.downloadIndex( val newIndexFile = downloader.downloadIndex(

View File

@@ -12,3 +12,10 @@ data class FileV2(
val sha256: String? = null, val sha256: String? = null,
val size: Long? = null, val size: Long? = null,
) )
@Serializable
data class ApkFileV2(
val name: String,
val sha256: String,
val size: Long,
)

View File

@@ -1,7 +1,44 @@
package com.looker.droidify.sync.v2.model package com.looker.droidify.sync.v2.model
import androidx.core.os.LocaleListCompat
typealias LocalizedString = Map<String, String> typealias LocalizedString = Map<String, String>
typealias NullableLocalizedString = Map<String, String?> typealias NullableLocalizedString = Map<String, String?>
typealias LocalizedIcon = Map<String, FileV2> typealias LocalizedIcon = Map<String, FileV2>
typealias LocalizedList = Map<String, List<String>> typealias LocalizedList = Map<String, List<String>>
typealias LocalizedFiles = Map<String, List<FileV2>> typealias LocalizedFiles = Map<String, List<FileV2>>
typealias DefaultName = String
typealias Tag = String
typealias AntiFeatureReason = LocalizedString
fun Map<String, Any>?.localesSize(): Int? = this?.keys?.size
fun Map<String, Any>?.locales(): List<String> = buildList {
if (!isNullOrEmpty()) {
for (locale in this@locales!!.keys) {
add(locale)
}
}
}
fun <T> Map<String, T>?.localizedValue(locale: String): T? {
if (isNullOrEmpty()) return null
val localeList = LocaleListCompat.forLanguageTags(locale)
val match = localeList.getFirstMatch(keys.toTypedArray()) ?: return null
return get(match.toLanguageTag()) ?: run {
val langCountryTag = "${match.language}-${match.country}"
getOrStartsWith(langCountryTag) ?: run {
val langTag = match.language
getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first()
}
}
}
private fun <T> Map<String, T>.getOrStartsWith(s: String): T? = get(s) ?: run {
entries.forEach { (key, value) ->
if (key.startsWith(s)) return value
}
return null
}

View File

@@ -23,7 +23,7 @@ data class PackageV2Diff(
added = metadata?.added ?: 0L, added = metadata?.added ?: 0L,
lastUpdated = metadata?.lastUpdated ?: 0L, lastUpdated = metadata?.lastUpdated ?: 0L,
name = metadata?.name name = metadata?.name
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap() ?: emptyMap(),
summary = metadata?.summary summary = metadata?.summary
?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(), ?.mapNotNull { (key, value) -> value?.let { key to value } }?.toMap(),
description = metadata?.description description = metadata?.description
@@ -116,7 +116,7 @@ data class PackageV2Diff(
@Serializable @Serializable
data class MetadataV2( data class MetadataV2(
val name: LocalizedString? = null, val name: LocalizedString,
val summary: LocalizedString? = null, val summary: LocalizedString? = null,
val description: LocalizedString? = null, val description: LocalizedString? = null,
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
@@ -129,7 +129,7 @@ data class MetadataV2(
val bitcoin: String? = null, val bitcoin: String? = null,
val categories: List<String> = emptyList(), val categories: List<String> = emptyList(),
val changelog: String? = null, val changelog: String? = null,
val donate: List<String> = emptyList(), val donate: List<String>? = null,
val featureGraphic: LocalizedIcon? = null, val featureGraphic: LocalizedIcon? = null,
val flattrID: String? = null, val flattrID: String? = null,
val issueTracker: String? = null, val issueTracker: String? = null,
@@ -183,25 +183,25 @@ data class MetadataV2Diff(
@Serializable @Serializable
data class VersionV2( data class VersionV2(
val added: Long, val added: Long,
val file: FileV2, val file: ApkFileV2,
val src: FileV2? = null, val src: FileV2? = null,
val whatsNew: LocalizedString = emptyMap(), val whatsNew: LocalizedString = emptyMap(),
val manifest: ManifestV2, val manifest: ManifestV2,
val antiFeatures: Map<String, LocalizedString> = emptyMap(), val antiFeatures: Map<Tag, AntiFeatureReason> = emptyMap(),
) )
@Serializable @Serializable
data class VersionV2Diff( data class VersionV2Diff(
val added: Long? = null, val added: Long? = null,
val file: FileV2? = null, val file: ApkFileV2? = null,
val src: FileV2? = null, val src: FileV2? = null,
val whatsNew: LocalizedString? = null, val whatsNew: LocalizedString? = null,
val manifest: ManifestV2? = null, val manifest: ManifestV2? = null,
val antiFeatures: Map<String, LocalizedString>? = null, val antiFeatures: Map<Tag, AntiFeatureReason>? = null,
) { ) {
fun toVersion() = VersionV2( fun toVersion() = VersionV2(
added = added ?: 0, added = added ?: 0,
file = file ?: FileV2(""), file = file ?: ApkFileV2("", "", -1L),
src = src ?: FileV2(""), src = src ?: FileV2(""),
whatsNew = whatsNew ?: emptyMap(), whatsNew = whatsNew ?: emptyMap(),
manifest = manifest ?: ManifestV2( manifest = manifest ?: ManifestV2(

View File

@@ -10,8 +10,8 @@ data class RepoV2(
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
val name: LocalizedString = emptyMap(), val name: LocalizedString = emptyMap(),
val description: LocalizedString = emptyMap(), val description: LocalizedString = emptyMap(),
val antiFeatures: Map<String, AntiFeatureV2> = emptyMap(), val antiFeatures: Map<Tag, AntiFeatureV2> = emptyMap(),
val categories: Map<String, CategoryV2> = emptyMap(), val categories: Map<DefaultName, CategoryV2> = emptyMap(),
val mirrors: List<MirrorV2> = emptyList(), val mirrors: List<MirrorV2> = emptyList(),
val timestamp: Long, val timestamp: Long,
) )
@@ -22,8 +22,8 @@ data class RepoV2Diff(
val icon: LocalizedIcon? = null, val icon: LocalizedIcon? = null,
val name: LocalizedString? = null, val name: LocalizedString? = null,
val description: LocalizedString? = null, val description: LocalizedString? = null,
val antiFeatures: Map<String, AntiFeatureV2?>? = null, val antiFeatures: Map<Tag, AntiFeatureV2?>? = null,
val categories: Map<String, CategoryV2?>? = null, val categories: Map<DefaultName, CategoryV2?>? = null,
val mirrors: List<MirrorV2>? = null, val mirrors: List<MirrorV2>? = null,
val timestamp: Long, val timestamp: Long,
) { ) {
@@ -69,7 +69,7 @@ data class RepoV2Diff(
data class MirrorV2( data class MirrorV2(
val url: String, val url: String,
val isPrimary: Boolean? = null, val isPrimary: Boolean? = null,
val location: String? = null val countryCode: String? = null
) )
@Serializable @Serializable

View File

@@ -75,7 +75,6 @@ import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.TypefaceExtra import com.looker.droidify.utility.extension.resources.TypefaceExtra
import com.looker.droidify.utility.extension.resources.sizeScaled import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.widget.StableRecyclerAdapter import com.looker.droidify.widget.StableRecyclerAdapter
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -87,10 +86,13 @@ import java.util.Locale
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sin import kotlin.math.sin
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import com.google.android.material.R as MaterialR import com.google.android.material.R as MaterialR
import com.looker.droidify.R.drawable as drawableRes import com.looker.droidify.R.drawable as drawableRes
import com.looker.droidify.R.string as stringRes import com.looker.droidify.R.string as stringRes
@OptIn(ExperimentalTime::class)
class AppDetailAdapter(private val callbacks: Callbacks) : class AppDetailAdapter(private val callbacks: Callbacks) :
StableRecyclerAdapter<AppDetailAdapter.ViewType, RecyclerView.ViewHolder>() { StableRecyclerAdapter<AppDetailAdapter.ViewType, RecyclerView.ViewHolder>() {
@@ -557,7 +559,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
val size = itemView.findViewById<TextView>(R.id.size)!! val size = itemView.findViewById<TextView>(R.id.size)!!
val signature = itemView.findViewById<TextView>(R.id.signature)!! val signature = itemView.findViewById<TextView>(R.id.signature)!!
val compatibility = itemView.findViewById<TextView>(R.id.compatibility)!! val compatibility = itemView.findViewById<TextView>(R.id.compatibility)!!
val targetSdk = itemView.findViewById<TextView>(R.id.target_sdk)!! val sdkVer = itemView.findViewById<TextView>(R.id.sdk_ver)!!
val statefulViews: Sequence<View> val statefulViews: Sequence<View>
get() = sequenceOf( get() = sequenceOf(
@@ -569,7 +571,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
size, size,
signature, signature,
compatibility, compatibility,
targetSdk, sdkVer,
) )
} }
@@ -1712,15 +1714,22 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
) )
} }
} }
with(holder.targetSdk) { with(holder.sdkVer) {
val sdkVersion = sdkName.getOrDefault( val targetSdkVersion = sdkName.getOrDefault(
item.release.targetSdkVersion, item.release.targetSdkVersion,
context.getString( context.getString(
stringRes.label_unknown_sdk, stringRes.label_unknown_sdk,
item.release.targetSdkVersion, item.release.targetSdkVersion,
), ),
) )
text = context.getString(stringRes.label_targets_sdk, sdkVersion) val minSdkVersion = sdkName.getOrDefault(
item.release.minSdkVersion,
context.getString(
stringRes.label_unknown_sdk,
item.release.minSdkVersion,
),
)
text = context.getString(stringRes.label_sdk_version, targetSdkVersion, minSdkVersion)
} }
val enabled = status == Status.Idle val enabled = status == Status.Idle
holder.statefulViews.forEach { it.isEnabled = enabled } holder.statefulViews.forEach { it.isEnabled = enabled }

View File

@@ -66,13 +66,13 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
constructor(packageName: String, repoAddress: String? = null) : this() { constructor(packageName: String, repoAddress: String? = null) : this() {
arguments = bundleOf( arguments = bundleOf(
ARG_PACKAGE_NAME to packageName, ARG_PACKAGE_NAME to packageName,
ARG_REPO_ADDRESS to repoAddress ARG_REPO_ADDRESS to repoAddress,
) )
} }
private enum class Action( private enum class Action(
val id: Int, val id: Int,
val adapterAction: AppDetailAdapter.Action val adapterAction: AppDetailAdapter.Action,
) { ) {
INSTALL(1, AppDetailAdapter.Action.INSTALL), INSTALL(1, AppDetailAdapter.Action.INSTALL),
UPDATE(2, AppDetailAdapter.Action.UPDATE), UPDATE(2, AppDetailAdapter.Action.UPDATE),
@@ -85,7 +85,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
private class Installed( private class Installed(
val installedItem: InstalledItem, val installedItem: InstalledItem,
val isSystem: Boolean, val isSystem: Boolean,
val launcherActivities: List<Pair<String, String>> val launcherActivities: List<Pair<String, String>>,
) )
private val viewModel: AppDetailViewModel by viewModels() private val viewModel: AppDetailViewModel by viewModels()
@@ -109,7 +109,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
lifecycleScope.launch { lifecycleScope.launch {
binder.downloadState.collect(::updateDownloadState) binder.downloadState.collect(::updateDownloadState)
} }
} },
) )
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@@ -138,7 +138,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
this.layoutManager = LinearLayoutManager( this.layoutManager = LinearLayoutManager(
context, context,
LinearLayoutManager.VERTICAL, LinearLayoutManager.VERTICAL,
false false,
) )
isMotionEventSplittingEnabled = false isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false isVerticalScrollBarEnabled = false
@@ -151,7 +151,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
recyclerView = this recyclerView = this
systemBarsPadding(includeFab = false) systemBarsPadding(includeFab = false)
} },
) )
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) { repeatOnLifecycle(Lifecycle.State.CREATED) {
@@ -188,7 +188,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
products = products, products = products,
installedItem = state.installedItem, installedItem = state.installedItem,
isFavourite = state.isFavourite, isFavourite = state.isFavourite,
allowIncompatibleVersion = state.allowIncompatibleVersions allowIncompatibleVersion = state.allowIncompatibleVersions,
) )
updateButtons() updateButtons()
} }
@@ -226,7 +226,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
} }
private fun updateButtons( private fun updateButtons(
preference: ProductPreference = ProductPreferences[viewModel.packageName] preference: ProductPreference = ProductPreferences[viewModel.packageName],
) { ) {
val installed = installed val installed = installed
val product = products.findSuggested(installed?.installedItem)?.first val product = products.findSuggested(installed?.installedItem)?.first
@@ -278,7 +278,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
private fun updateToolbarButtons( private fun updateToolbarButtons(
isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager) isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager)
.findFirstVisibleItemPosition() == 0 .findFirstVisibleItemPosition() == 0,
) { ) {
toolbar.title = if (isActionVisible) { toolbar.title = if (isActionVisible) {
getString(stringRes.application) getString(stringRes.application)
@@ -324,7 +324,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting
is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading( is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading(
state.currentItem.read, state.currentItem.read,
state.currentItem.total state.currentItem.total,
) )
else -> AppDetailAdapter.Status.Idle else -> AppDetailAdapter.Status.Idle
@@ -340,7 +340,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
if (state.currentItem is DownloadService.State.Success && isResumed) { if (state.currentItem is DownloadService.State.Success && isResumed) {
viewModel.installPackage( viewModel.installPackage(
state.currentItem.packageName, state.currentItem.packageName,
state.currentItem.release.cacheFileName state.currentItem.release.cacheFileName,
) )
} }
} }
@@ -348,7 +348,8 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
override fun onActionClick(action: AppDetailAdapter.Action) { override fun onActionClick(action: AppDetailAdapter.Action) {
when (action) { when (action) {
AppDetailAdapter.Action.INSTALL, AppDetailAdapter.Action.INSTALL,
AppDetailAdapter.Action.UPDATE -> { AppDetailAdapter.Action.UPDATE,
-> {
if (Cache.getEmptySpace(requireContext()) < products.first().first.releases.first().size) { if (Cache.getEmptySpace(requireContext()) < products.first().first.releases.first().size) {
MessageDialog(Message.InsufficientStorage).show(childFragmentManager) MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
return return
@@ -375,7 +376,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
if (launcherActivities.size >= 2) { if (launcherActivities.size >= 2) {
LaunchDialog(launcherActivities).show( LaunchDialog(launcherActivities).show(
childFragmentManager, childFragmentManager,
LaunchDialog::class.java.name LaunchDialog::class.java.name,
) )
} else { } else {
launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) } launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) }
@@ -386,8 +387,8 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
startActivity( startActivity(
Intent( Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package:${viewModel.packageName}".toUri() "package:${viewModel.packageName}".toUri(),
) ),
) )
} }
@@ -405,18 +406,17 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
AppDetailAdapter.Action.SHARE -> { AppDetailAdapter.Action.SHARE -> {
val repo = products[0].second val repo = products[0].second
val address = when { val address = when {
repo.name == "F-Droid" -> "https://f-droid.org/repo" in repo.mirrors ->
"https://f-droid.org/packages/" + "https://f-droid.org/packages/${viewModel.packageName}/"
"${viewModel.packageName}/"
"IzzyOnDroid" in repo.name -> { "https://f-droid.org/archive/repo" in repo.mirrors ->
"https://f-droid.org/packages/${viewModel.packageName}/"
"https://apt.izzysoft.de/fdroid/repo" in repo.mirrors ->
"https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}" "https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}"
}
else -> { else ->
"https://droidify.eu.org/app/?id=" + "https://droidify.eu.org/app/?id=${viewModel.packageName}&repo_address=${repo.address}"
"${viewModel.packageName}&repo_address=${repo.address}"
}
} }
val sendIntent = Intent(Intent.ACTION_SEND) val sendIntent = Intent(Intent.ACTION_SEND)
.putExtra(Intent.EXTRA_TEXT, address) .putExtra(Intent.EXTRA_TEXT, address)
@@ -436,7 +436,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
Intent(Intent.ACTION_MAIN) Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER) .addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(viewModel.packageName, name)) .setComponent(ComponentName(viewModel.packageName, name))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
) )
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -464,7 +464,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
val screenshotUrl = current.url( val screenshotUrl = current.url(
context = requireContext(), context = requireContext(),
repository = productRepository.second, repository = productRepository.second,
packageName = viewModel.packageName packageName = viewModel.packageName,
) )
view.load(screenshotUrl) { view.load(screenshotUrl) {
allowHardware(false) allowHardware(false)
@@ -484,8 +484,8 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
release.incompatibilities, release.incompatibilities,
release.platforms, release.platforms,
release.minSdkVersion, release.minSdkVersion,
release.maxSdkVersion release.maxSdkVersion,
) ),
).show(childFragmentManager) ).show(childFragmentManager)
} }
@@ -524,7 +524,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
productRepository.first.name, productRepository.first.name,
productRepository.second, productRepository.second,
release, release,
installedItem != null installedItem != null,
) )
} }
} }

View File

@@ -4,22 +4,23 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.BuildConfig
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.domain.model.toPackageName import com.looker.droidify.domain.model.toPackageName
import com.looker.droidify.BuildConfig
import com.looker.droidify.database.Database
import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository
import com.looker.droidify.installer.InstallManager import com.looker.droidify.installer.InstallManager
import com.looker.droidify.installer.installers.isShizukuAlive import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted import com.looker.droidify.installer.installers.isShizukuGranted
import com.looker.droidify.installer.installers.isShizukuInstalled import com.looker.droidify.installer.installers.isShizukuInstalled
import com.looker.droidify.installer.installers.isSuiAvailable
import com.looker.droidify.installer.installers.requestPermissionListener import com.looker.droidify.installer.installers.requestPermissionListener
import com.looker.droidify.installer.model.InstallState import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.installer.model.installFrom import com.looker.droidify.installer.model.installFrom
import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -33,7 +34,7 @@ import javax.inject.Inject
class AppDetailViewModel @Inject constructor( class AppDetailViewModel @Inject constructor(
private val installer: InstallManager, private val installer: InstallManager,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME]) val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME])
@@ -52,7 +53,7 @@ class AppDetailViewModel @Inject constructor(
Database.RepositoryAdapter.getAllStream(), Database.RepositoryAdapter.getAllStream(),
Database.InstalledAdapter.getStream(packageName), Database.InstalledAdapter.getStream(packageName),
repoAddress, repoAddress,
flow { emit(settingsRepository.getInitial()) } flow { emit(settingsRepository.getInitial()) },
) { products, repositories, installedItem, suggestedAddress, initialSettings -> ) { products, repositories, installedItem, suggestedAddress, initialSettings ->
val idAndRepos = repositories.associateBy { it.id } val idAndRepos = repositories.associateBy { it.id }
val filteredProducts = products.filter { product -> val filteredProducts = products.filter { product ->
@@ -65,7 +66,7 @@ class AppDetailViewModel @Inject constructor(
isFavourite = packageName in initialSettings.favouriteApps, isFavourite = packageName in initialSettings.favouriteApps,
allowIncompatibleVersions = initialSettings.incompatibleVersions, allowIncompatibleVersions = initialSettings.incompatibleVersions,
isSelf = packageName == BuildConfig.APPLICATION_ID, isSelf = packageName == BuildConfig.APPLICATION_ID,
addressIfUnavailable = suggestedAddress addressIfUnavailable = suggestedAddress,
) )
}.asStateFlow(AppDetailUiState()) }.asStateFlow(AppDetailUiState())
@@ -74,6 +75,9 @@ class AppDetailViewModel @Inject constructor(
runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU } runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU }
if (!isSelected) return null if (!isSelected) return null
val isAlive = isShizukuAlive() val isAlive = isShizukuAlive()
val isSuiAvailable = isSuiAvailable()
if (isSuiAvailable) return null
val isGranted = if (isAlive) { val isGranted = if (isAlive) {
if (isShizukuGranted()) { if (isShizukuGranted()) {
true true
@@ -144,5 +148,5 @@ data class AppDetailUiState(
val isSelf: Boolean = false, val isSelf: Boolean = false,
val isFavourite: Boolean = false, val isFavourite: Boolean = false,
val allowIncompatibleVersions: Boolean = false, val allowIncompatibleVersions: Boolean = false,
val addressIfUnavailable: String? = null val addressIfUnavailable: String? = null,
) )

View File

@@ -45,12 +45,11 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
enum class Source( enum class Source(
val titleResId: Int, val titleResId: Int,
val sections: Boolean, val sections: Boolean,
val order: Boolean,
val updateAll: Boolean, val updateAll: Boolean,
) { ) {
AVAILABLE(stringRes.available, true, true, false), AVAILABLE(stringRes.available, true, false),
INSTALLED(stringRes.installed, false, true, false), INSTALLED(stringRes.installed, false, false),
UPDATES(stringRes.updates, false, false, true) UPDATES(stringRes.updates, false, true)
} }
constructor(source: Source) : this() { constructor(source: Source) : this() {
@@ -134,7 +133,6 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER) layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
updateRequest()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch { launch {
@@ -143,7 +141,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
} }
} }
launch { launch {
viewModel.sortOrderFlow.collect { viewModel.state.collect {
updateRequest() updateRequest()
} }
} }
@@ -185,16 +183,12 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
} }
} }
internal fun setSearchQuery(searchQuery: String) { fun setSearchQuery(searchQuery: String) {
viewModel.setSearchQuery(searchQuery) { viewModel.setSearchQuery(searchQuery)
updateRequest()
}
} }
internal fun setSection(section: ProductItem.Section) { fun setSection(section: ProductItem.Section) {
viewModel.setSection(section) { viewModel.setSection(section)
updateRequest()
}
} }
private fun updateRequest() { private fun updateRequest() {

View File

@@ -16,10 +16,10 @@ import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -34,25 +34,37 @@ class AppListViewModel
.get { ignoreSignature } .get { ignoreSignature }
.asStateFlow(false) .asStateFlow(false)
val sortOrderFlow = settingsRepository private val sortOrderFlow = settingsRepository
.get { sortOrder } .get { sortOrder }
.asStateFlow(SortOrder.UPDATED) .asStateFlow(SortOrder.UPDATED)
private val sections = MutableStateFlow<ProductItem.Section>(All)
val searchQuery = MutableStateFlow("")
val state = combine(
sortOrderFlow,
sections,
searchQuery,
) { sortOrder, section, query ->
AppListState(
searchQuery = query,
sections = section,
sortOrder = sortOrder,
)
}.asStateFlow(AppListState())
val reposStream = Database.RepositoryAdapter val reposStream = Database.RepositoryAdapter
.getAllStream() .getAllStream()
.asStateFlow(emptyList()) .asStateFlow(emptyList())
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val showUpdateAllButton = skipSignatureStream.flatMapConcat { skip -> val showUpdateAllButton = skipSignatureStream.flatMapLatest { skip ->
Database.ProductAdapter Database.ProductAdapter
.getUpdatesStream(skip) .getUpdatesStream(skip)
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
}.asStateFlow(false) }.asStateFlow(false)
private val sections = MutableStateFlow<ProductItem.Section>(All)
val searchQuery = MutableStateFlow("")
val syncConnection = Connection(SyncService::class.java) val syncConnection = Connection(SyncService::class.java)
fun updateAll() { fun updateAll() {
@@ -79,26 +91,25 @@ class AppListViewModel
searchQuery = searchQuery.value, searchQuery = searchQuery.value,
section = sections.value, section = sections.value,
order = sortOrderFlow.value, order = sortOrderFlow.value,
skipSignatureCheck = skipSignatureStream.value,
) )
} }
} }
fun setSection(newSection: ProductItem.Section, perform: () -> Unit) { fun setSection(newSection: ProductItem.Section) {
viewModelScope.launch { viewModelScope.launch {
if (newSection != sections.value) {
sections.emit(newSection) sections.emit(newSection)
launch(Dispatchers.Main) { perform() }
}
} }
} }
fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) { fun setSearchQuery(newSearchQuery: String) {
viewModelScope.launch { viewModelScope.launch {
if (newSearchQuery != searchQuery.value) {
searchQuery.emit(newSearchQuery) searchQuery.emit(newSearchQuery)
launch(Dispatchers.Main) { perform() }
}
} }
} }
} }
data class AppListState(
val searchQuery: String = "",
val sections: ProductItem.Section = All,
val sortOrder: SortOrder = SortOrder.UPDATED,
)

View File

@@ -27,6 +27,7 @@ import com.looker.droidify.ui.Message
import com.looker.droidify.ui.MessageDialog import com.looker.droidify.ui.MessageDialog
import com.looker.droidify.ui.ScreenFragment import com.looker.droidify.ui.ScreenFragment
import com.looker.droidify.utility.common.extension.clipboardManager import com.looker.droidify.utility.common.extension.clipboardManager
import com.looker.droidify.utility.common.extension.exceptCancellation
import com.looker.droidify.utility.common.extension.get import com.looker.droidify.utility.common.extension.get
import com.looker.droidify.utility.common.extension.getMutatedIcon import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.nullIfEmpty import com.looker.droidify.utility.common.nullIfEmpty
@@ -136,7 +137,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Selection.setSelection( Selection.setSelection(
text, text,
realPosition(outputString, inputStart), realPosition(outputString, inputStart),
realPosition(outputString, inputEnd) realPosition(outputString, inputEnd),
) )
} }
} }
@@ -155,7 +156,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Pair( Pair(
uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null) uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null)
.build().toString(), .build().toString(),
fingerprintText fingerprintText,
) )
} catch (e: Exception) { } catch (e: Exception) {
Pair(null, null) Pair(null, null)
@@ -171,7 +172,7 @@ class EditRepositoryFragment() : ScreenFragment() {
setEndIconOnClickListener { setEndIconOnClickListener {
SelectMirrorDialog(mirrors).show( SelectMirrorDialog(mirrors).show(
childFragmentManager, childFragmentManager,
SelectMirrorDialog::class.java.name SelectMirrorDialog::class.java.name,
) )
} }
} }
@@ -189,7 +190,7 @@ class EditRepositoryFragment() : ScreenFragment() {
if (index >= 0) { if (index >= 0) {
Pair( Pair(
it.substring(0, index), it.substring(0, index),
it.substring(index + 1) it.substring(index + 1),
) )
} else { } else {
null null
@@ -319,7 +320,7 @@ class EditRepositoryFragment() : ScreenFragment() {
return if (endsWith != null) { return if (endsWith != null) {
cropped.substring( cropped.substring(
0, 0,
cropped.length - endsWith.length - 1 cropped.length - endsWith.length - 1,
) )
} else { } else {
cropped cropped
@@ -330,12 +331,12 @@ class EditRepositoryFragment() : ScreenFragment() {
val uri = try { val uri = try {
val uri = URI(address) val uri = URI(address)
if (uri.isAbsolute) uri.normalize() else null if (uri.isAbsolute) uri.normalize() else null
} catch (e: URISyntaxException) { } catch (_: URISyntaxException) {
return null return null
} }
return try { return try {
uri?.toURL()?.toURI()?.toString()?.removeSuffix("/") uri?.toURL()?.toURI()?.toString()?.removeSuffix("/")
} catch (e: URISyntaxException) { } catch (_: URISyntaxException) {
null null
} }
} }
@@ -346,7 +347,10 @@ class EditRepositoryFragment() : ScreenFragment() {
private fun onSaveRepositoryClick(check: Boolean) { private fun onSaveRepositoryClick(check: Boolean) {
if (!checkInProgress) { if (!checkInProgress) {
val address = normalizeAddress(binding.address.text.toString())!! val address = normalizeAddress(binding.address.text.toString()) ?: kotlin.run {
failedAddressCheck()
return
}
val fingerprint = binding.fingerprint.text.toString().replace(" ", "") val fingerprint = binding.fingerprint.text.toString().replace(" ", "")
val username = binding.username.text.toString().nullIfEmpty() val username = binding.username.text.toString().nullIfEmpty()
val password = binding.password.text.toString().nullIfEmpty() val password = binding.password.text.toString().nullIfEmpty()
@@ -354,7 +358,7 @@ class EditRepositoryFragment() : ScreenFragment() {
password?.let { p -> password?.let { p ->
Base64.encodeToString( Base64.encodeToString(
"$u:$p".toByteArray(Charset.defaultCharset()), "$u:$p".toByteArray(Charset.defaultCharset()),
Base64.NO_WRAP Base64.NO_WRAP,
) )
} }
}?.let { "Basic $it" }.orEmpty() }?.let { "Basic $it" }.orEmpty()
@@ -364,7 +368,7 @@ class EditRepositoryFragment() : ScreenFragment() {
val resultAddress = try { val resultAddress = try {
checkAddress(address, authentication) checkAddress(address, authentication)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.exceptCancellation()
failedAddressCheck() failedAddressCheck()
null null
} }
@@ -378,7 +382,7 @@ class EditRepositoryFragment() : ScreenFragment() {
onSaveRepositoryProceedInvalidate( onSaveRepositoryProceedInvalidate(
resultAddress, resultAddress,
fingerprint, fingerprint,
authentication authentication,
) )
} else { } else {
invalidateState() invalidateState()
@@ -393,7 +397,7 @@ class EditRepositoryFragment() : ScreenFragment() {
private suspend fun checkAddress( private suspend fun checkAddress(
rawAddress: String, rawAddress: String,
authentication: String authentication: String,
): String? = coroutineScope { ): String? = coroutineScope {
checkInProgress = true checkInProgress = true
invalidateState() invalidateState()
@@ -403,7 +407,7 @@ class EditRepositoryFragment() : ScreenFragment() {
.forEach { address -> .forEach { address ->
val response = downloader.headCall( val response = downloader.headCall(
url = "$address/index-v1.jar", url = "$address/index-v1.jar",
headers = { authentication(authentication) } headers = { authentication(authentication) },
) )
if (response is NetworkResponse.Success) return@coroutineScope address if (response is NetworkResponse.Success) return@coroutineScope address
} }
@@ -413,7 +417,7 @@ class EditRepositoryFragment() : ScreenFragment() {
private fun onSaveRepositoryProceedInvalidate( private fun onSaveRepositoryProceedInvalidate(
address: String, address: String,
fingerprint: String, fingerprint: String,
authentication: String authentication: String,
) { ) {
val binder = syncConnection.binder val binder = syncConnection.binder
if (binder != null) { if (binder != null) {
@@ -442,7 +446,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Snackbar.make( Snackbar.make(
requireView(), requireView(),
R.string.repository_unreachable, R.string.repository_unreachable,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }

View File

@@ -2,6 +2,7 @@ package com.looker.droidify.ui.settings
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -13,6 +14,7 @@ import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@@ -36,6 +38,7 @@ import com.looker.droidify.datastore.extension.themeName
import com.looker.droidify.datastore.extension.toTime import com.looker.droidify.datastore.extension.toTime
import com.looker.droidify.datastore.model.AutoSync import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.Theme import com.looker.droidify.datastore.model.Theme
import com.looker.droidify.utility.common.SdkCheck import com.looker.droidify.utility.common.SdkCheck
@@ -72,7 +75,7 @@ class SettingsFragment : Fragment() {
private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid" private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid"
private const val DROID_IFY_TITLE = "Droid-ify" private const val DROID_IFY_TITLE = "Droid-ify"
private const val DROID_IFY_URL = "https://github.com/Iamlooker/Droid-ify" private const val DROID_IFY_URL = "https://github.com/Droid-ify/client"
} }
private val viewModel: SettingsViewModel by viewModels() private val viewModel: SettingsViewModel by viewModels()
@@ -114,7 +117,7 @@ class SettingsFragment : Fragment() {
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
): View { ): View {
_binding = SettingsPageBinding.inflate(inflater, container, false) _binding = SettingsPageBinding.inflate(inflater, container, false)
binding.nestedScrollView.systemBarsPadding() binding.nestedScrollView.systemBarsPadding()
@@ -128,42 +131,42 @@ class SettingsFragment : Fragment() {
dynamicTheme.connect( dynamicTheme.connect(
titleText = getString(R.string.material_you), titleText = getString(R.string.material_you),
contentText = getString(R.string.material_you_desc), contentText = getString(R.string.material_you_desc),
setting = viewModel.getInitialSetting { dynamicTheme } setting = viewModel.getInitialSetting { dynamicTheme },
) )
homeScreenSwiping.connect( homeScreenSwiping.connect(
titleText = getString(R.string.home_screen_swiping), titleText = getString(R.string.home_screen_swiping),
contentText = getString(R.string.home_screen_swiping_DESC), contentText = getString(R.string.home_screen_swiping_DESC),
setting = viewModel.getInitialSetting { homeScreenSwiping } setting = viewModel.getInitialSetting { homeScreenSwiping },
) )
autoUpdate.connect( autoUpdate.connect(
titleText = getString(R.string.auto_update), titleText = getString(R.string.auto_update),
contentText = getString(R.string.auto_update_apps), contentText = getString(R.string.auto_update_apps),
setting = viewModel.getInitialSetting { autoUpdate } setting = viewModel.getInitialSetting { autoUpdate },
) )
notifyUpdates.connect( notifyUpdates.connect(
titleText = getString(R.string.notify_about_updates), titleText = getString(R.string.notify_about_updates),
contentText = getString(R.string.notify_about_updates_summary), contentText = getString(R.string.notify_about_updates_summary),
setting = viewModel.getInitialSetting { notifyUpdate } setting = viewModel.getInitialSetting { notifyUpdate },
) )
unstableUpdates.connect( unstableUpdates.connect(
titleText = getString(R.string.unstable_updates), titleText = getString(R.string.unstable_updates),
contentText = getString(R.string.unstable_updates_summary), contentText = getString(R.string.unstable_updates_summary),
setting = viewModel.getInitialSetting { unstableUpdate } setting = viewModel.getInitialSetting { unstableUpdate },
) )
ignoreSignature.connect( ignoreSignature.connect(
titleText = getString(R.string.ignore_signature), titleText = getString(R.string.ignore_signature),
contentText = getString(R.string.ignore_signature_summary), contentText = getString(R.string.ignore_signature_summary),
setting = viewModel.getInitialSetting { ignoreSignature } setting = viewModel.getInitialSetting { ignoreSignature },
) )
incompatibleUpdates.connect( incompatibleUpdates.connect(
titleText = getString(R.string.incompatible_versions), titleText = getString(R.string.incompatible_versions),
contentText = getString(R.string.incompatible_versions_summary), contentText = getString(R.string.incompatible_versions_summary),
setting = viewModel.getInitialSetting { incompatibleVersions } setting = viewModel.getInitialSetting { incompatibleVersions },
) )
language.connect( language.connect(
titleText = getString(R.string.prefs_language_title), titleText = getString(R.string.prefs_language_title),
map = { translateLocale(getLocaleOfCode(it)) }, map = { translateLocale(getLocaleOfCode(it)) },
setting = viewModel.getSetting { language } setting = viewModel.getSetting { language },
) { selectedLocale, valueToString -> ) { selectedLocale, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = selectedLocale, initialValue = selectedLocale,
@@ -171,13 +174,13 @@ class SettingsFragment : Fragment() {
title = R.string.prefs_language_title, title = R.string.prefs_language_title,
iconRes = R.drawable.ic_language, iconRes = R.drawable.ic_language,
valueToString = valueToString, valueToString = valueToString,
onClick = viewModel::setLanguage onClick = viewModel::setLanguage,
) )
} }
theme.connect( theme.connect(
titleText = getString(R.string.theme), titleText = getString(R.string.theme),
setting = viewModel.getSetting { theme }, setting = viewModel.getSetting { theme },
map = { themeName(it) } map = { themeName(it) },
) { theme, valueToString -> ) { theme, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = theme, initialValue = theme,
@@ -185,13 +188,13 @@ class SettingsFragment : Fragment() {
title = R.string.themes, title = R.string.themes,
iconRes = R.drawable.ic_themes, iconRes = R.drawable.ic_themes,
valueToString = valueToString, valueToString = valueToString,
onClick = viewModel::setTheme onClick = viewModel::setTheme,
) )
} }
cleanUp.connect( cleanUp.connect(
titleText = getString(R.string.cleanup_title), titleText = getString(R.string.cleanup_title),
setting = viewModel.getSetting { cleanUpInterval }, setting = viewModel.getSetting { cleanUpInterval },
map = { toTime(it) } map = { toTime(it) },
) { duration, valueToString -> ) { duration, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = duration, initialValue = duration,
@@ -199,13 +202,13 @@ class SettingsFragment : Fragment() {
title = R.string.cleanup_title, title = R.string.cleanup_title,
iconRes = R.drawable.ic_time, iconRes = R.drawable.ic_time,
valueToString = valueToString, valueToString = valueToString,
onClick = viewModel::setCleanUpInterval onClick = viewModel::setCleanUpInterval,
) )
} }
autoSync.connect( autoSync.connect(
titleText = getString(R.string.sync_repositories_automatically), titleText = getString(R.string.sync_repositories_automatically),
setting = viewModel.getSetting { autoSync }, setting = viewModel.getSetting { autoSync },
map = { autoSyncName(it) } map = { autoSyncName(it) },
) { autoSync, valueToString -> ) { autoSync, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = autoSync, initialValue = autoSync,
@@ -213,13 +216,13 @@ class SettingsFragment : Fragment() {
title = R.string.sync_repositories_automatically, title = R.string.sync_repositories_automatically,
iconRes = R.drawable.ic_sync_type, iconRes = R.drawable.ic_sync_type,
valueToString = valueToString, valueToString = valueToString,
onClick = viewModel::setAutoSync onClick = viewModel::setAutoSync,
) )
} }
installer.connect( installer.connect(
titleText = getString(R.string.installer), titleText = getString(R.string.installer),
setting = viewModel.getSetting { installerType }, setting = viewModel.getSetting { installerType },
map = { installerName(it) } map = { installerName(it) },
) { installerType, valueToString -> ) { installerType, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = installerType, initialValue = installerType,
@@ -227,13 +230,68 @@ class SettingsFragment : Fragment() {
title = R.string.installer, title = R.string.installer,
iconRes = R.drawable.ic_apk_install, iconRes = R.drawable.ic_apk_install,
valueToString = valueToString, valueToString = valueToString,
onClick = { viewModel.setInstaller(requireContext(), it) } onClick = { viewModel.setInstaller(requireContext(), it) },
) )
} }
val pm = requireContext().packageManager
legacyInstallerComponent.connect(
titleText = getString(R.string.legacyInstallerComponent),
setting = viewModel.getSetting { legacyInstallerComponent },
map = {
when (it) {
is LegacyInstallerComponent.Component -> {
val component = it
val appLabel = runCatching {
val info = pm.getApplicationInfo(component.clazz, 0)
pm.getApplicationLabel(info).toString()
}.getOrElse { component.clazz }
"$appLabel (${component.activity})"
}
LegacyInstallerComponent.Unspecified -> getString(R.string.unspecified)
LegacyInstallerComponent.AlwaysChoose -> getString(R.string.always_choose)
null -> getString(R.string.unspecified)
}
},
) { component, valueToString ->
val installerOptions = run {
var contentProtocol = "content://"
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(
contentProtocol.toUri(),
"application/vnd.android.package-archive",
)
}
val activities =
pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
listOf(
LegacyInstallerComponent.Unspecified,
LegacyInstallerComponent.AlwaysChoose,
) + activities.map {
LegacyInstallerComponent.Component(
clazz = it.activityInfo.packageName,
activity = it.activityInfo.name,
)
}
}
addSingleCorrectDialog(
initialValue = component ?: LegacyInstallerComponent.Unspecified,
values = installerOptions,
title = R.string.legacyInstallerComponent,
iconRes = R.drawable.ic_apk_install,
valueToString = valueToString,
onClick = { viewModel.setLegacyInstallerComponentComponent(it) },
)
}
incompatibleUpdates.connect(
titleText = getString(R.string.incompatible_versions),
contentText = getString(R.string.incompatible_versions_summary),
setting = viewModel.getInitialSetting { incompatibleVersions },
)
proxyType.connect( proxyType.connect(
titleText = getString(R.string.proxy_type), titleText = getString(R.string.proxy_type),
setting = viewModel.getSetting { proxy.type }, setting = viewModel.getSetting { proxy.type },
map = { proxyName(it) } map = { proxyName(it) },
) { proxyType, valueToString -> ) { proxyType, valueToString ->
addSingleCorrectDialog( addSingleCorrectDialog(
initialValue = proxyType, initialValue = proxyType,
@@ -241,29 +299,29 @@ class SettingsFragment : Fragment() {
title = R.string.proxy_type, title = R.string.proxy_type,
iconRes = R.drawable.ic_proxy, iconRes = R.drawable.ic_proxy,
valueToString = valueToString, valueToString = valueToString,
onClick = viewModel::setProxyType onClick = viewModel::setProxyType,
) )
} }
proxyHost.connect( proxyHost.connect(
titleText = getString(R.string.proxy_host), titleText = getString(R.string.proxy_host),
setting = viewModel.getSetting { proxy.host }, setting = viewModel.getSetting { proxy.host },
map = { it } map = { it },
) { host, _ -> ) { host, _ ->
addEditTextDialog( addEditTextDialog(
initialValue = host, initialValue = host,
title = R.string.proxy_host, title = R.string.proxy_host,
onFinish = viewModel::setProxyHost onFinish = viewModel::setProxyHost,
) )
} }
proxyPort.connect( proxyPort.connect(
titleText = getString(R.string.proxy_port), titleText = getString(R.string.proxy_port),
setting = viewModel.getSetting { proxy.port }, setting = viewModel.getSetting { proxy.port },
map = { it.toString() } map = { it.toString() },
) { port, _ -> ) { port, _ ->
addEditTextDialog( addEditTextDialog(
initialValue = port.toString(), initialValue = port.toString(),
title = R.string.proxy_port, title = R.string.proxy_port,
onFinish = viewModel::setProxyPort onFinish = viewModel::setProxyPort,
) )
} }
@@ -287,15 +345,15 @@ class SettingsFragment : Fragment() {
allowBackgroundWork.root.setBackgroundColor( allowBackgroundWork.root.setBackgroundColor(
requireContext() requireContext()
.getColorFromAttr(MaterialR.attr.colorErrorContainer) .getColorFromAttr(MaterialR.attr.colorErrorContainer)
.defaultColor .defaultColor,
) )
allowBackgroundWork.title.setTextColor( allowBackgroundWork.title.setTextColor(
requireContext() requireContext()
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer) .getColorFromAttr(MaterialR.attr.colorOnErrorContainer),
) )
allowBackgroundWork.content.setTextColor( allowBackgroundWork.content.setTextColor(
requireContext() requireContext()
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer) .getColorFromAttr(MaterialR.attr.colorOnErrorContainer),
) )
creditFoxy.title.text = getString(R.string.special_credits) creditFoxy.title.text = getString(R.string.special_credits)
creditFoxy.content.text = FOXY_DROID_TITLE creditFoxy.content.text = FOXY_DROID_TITLE
@@ -389,6 +447,9 @@ class SettingsFragment : Fragment() {
proxyHost.root.isVisible = allowProxies proxyHost.root.isVisible = allowProxies
proxyPort.root.isVisible = allowProxies proxyPort.root.isVisible = allowProxies
forceCleanUp.root.isVisible = settings.cleanUpInterval == Duration.INFINITE forceCleanUp.root.isVisible = settings.cleanUpInterval == Duration.INFINITE
val useLegacyInstaller = settings.installerType == InstallerType.LEGACY
legacyInstallerComponent.root.isVisible = useLegacyInstaller
} }
} }
@@ -404,7 +465,7 @@ class SettingsFragment : Fragment() {
( (
if (country?.isNotEmpty() == true && country.compareTo( if (country?.isNotEmpty() == true && country.compareTo(
language.toString(), language.toString(),
true true,
) != 0 ) != 0
) { ) {
"($country)" "($country)"
@@ -437,12 +498,12 @@ class SettingsFragment : Fragment() {
localeCode.contains("-r") -> Locale( localeCode.contains("-r") -> Locale(
localeCode.substring(0, 2), localeCode.substring(0, 2),
localeCode.substring(4) localeCode.substring(4),
) )
localeCode.contains("_") -> Locale( localeCode.contains("_") -> Locale(
localeCode.substring(0, 2), localeCode.substring(0, 2),
localeCode.substring(3) localeCode.substring(3),
) )
localeCode == "system" -> null localeCode == "system" -> null
@@ -453,7 +514,7 @@ class SettingsFragment : Fragment() {
titleText: String, titleText: String,
setting: Flow<T>, setting: Flow<T>,
map: Context.(T) -> String, map: Context.(T) -> String,
dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog,
) { ) {
title.text = titleText title.text = titleText
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -473,7 +534,7 @@ class SettingsFragment : Fragment() {
private fun SwitchTypeBinding.connect( private fun SwitchTypeBinding.connect(
titleText: String, titleText: String,
contentText: String, contentText: String,
setting: Flow<Boolean> setting: Flow<Boolean>,
) { ) {
title.text = titleText title.text = titleText
content.text = contentText content.text = contentText
@@ -495,13 +556,13 @@ class SettingsFragment : Fragment() {
@StringRes title: Int, @StringRes title: Int,
@DrawableRes iconRes: Int, @DrawableRes iconRes: Int,
onClick: (T) -> Unit, onClick: (T) -> Unit,
valueToString: Context.(T) -> String valueToString: Context.(T) -> String,
) = MaterialAlertDialogBuilder(context) ) = MaterialAlertDialogBuilder(context)
.setTitle(title) .setTitle(title)
.setIcon(iconRes) .setIcon(iconRes)
.setSingleChoiceItems( .setSingleChoiceItems(
values.map { context.valueToString(it) }.toTypedArray(), values.map { context.valueToString(it) }.toTypedArray(),
values.indexOf(initialValue) values.indexOf(initialValue),
) { dialog, newValue -> ) { dialog, newValue ->
dialog.dismiss() dialog.dismiss()
post { post {
@@ -514,7 +575,7 @@ class SettingsFragment : Fragment() {
private fun View.addEditTextDialog( private fun View.addEditTextDialog(
initialValue: String, initialValue: String,
@StringRes title: Int, @StringRes title: Int,
onFinish: (String) -> Unit onFinish: (String) -> Unit,
): AlertDialog { ): AlertDialog {
val scroll = NestedScrollView(context) val scroll = NestedScrollView(context)
val customEditText = TextInputEditText(context) val customEditText = TextInputEditText(context)
@@ -528,7 +589,7 @@ class SettingsFragment : Fragment() {
scroll.addView( scroll.addView(
customEditText, customEditText,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT,
) )
return MaterialAlertDialogBuilder(context) return MaterialAlertDialogBuilder(context)
.setTitle(title) .setTitle(title)
@@ -540,7 +601,7 @@ class SettingsFragment : Fragment() {
.create() .create()
.apply { .apply {
window!!.setSoftInputMode( window!!.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE,
) )
} }
} }

View File

@@ -17,8 +17,10 @@ import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.InstallerType.ROOT import com.looker.droidify.datastore.model.InstallerType.ROOT
import com.looker.droidify.datastore.model.InstallerType.SHIZUKU import com.looker.droidify.datastore.model.InstallerType.SHIZUKU
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.Theme import com.looker.droidify.datastore.model.Theme
import com.looker.droidify.installer.installers.initSui
import com.looker.droidify.installer.installers.isMagiskGranted import com.looker.droidify.installer.installers.isMagiskGranted
import com.looker.droidify.installer.installers.isShizukuAlive import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted import com.looker.droidify.installer.installers.isShizukuGranted
@@ -40,7 +42,7 @@ import kotlin.time.Duration
class SettingsViewModel class SettingsViewModel
@Inject constructor( @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val repositoryExporter: RepositoryExporter private val repositoryExporter: RepositoryExporter,
) : ViewModel() { ) : ViewModel() {
private val initialSetting = flow { private val initialSetting = flow {
@@ -162,7 +164,7 @@ class SettingsViewModel
viewModelScope.launch { viewModelScope.launch {
when (installerType) { when (installerType) {
SHIZUKU -> { SHIZUKU -> {
if (isShizukuInstalled(context)) { if (isShizukuInstalled(context) || initSui(context)) {
if (!isShizukuAlive()) { if (!isShizukuAlive()) {
createSnackbar(R.string.shizuku_not_alive) createSnackbar(R.string.shizuku_not_alive)
return@launch return@launch
@@ -191,6 +193,12 @@ class SettingsViewModel
} }
} }
fun setLegacyInstallerComponentComponent(component: LegacyInstallerComponent?) {
viewModelScope.launch {
settingsRepository.setLegacyInstallerComponent(component)
}
}
fun exportSettings(file: Uri) { fun exportSettings(file: Uri) {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.export(file) settingsRepository.export(file)
@@ -227,12 +235,12 @@ class SettingsViewModel
private fun String.toLocale(): Locale = when { private fun String.toLocale(): Locale = when {
contains("-r") -> Locale( contains("-r") -> Locale(
substring(0, 2), substring(0, 2),
substring(4) substring(4),
) )
contains("_") -> Locale( contains("_") -> Locale(
substring(0, 2), substring(0, 2),
substring(3) substring(3),
) )
else -> Locale(this) else -> Locale(this)

View File

@@ -33,6 +33,7 @@ import com.looker.droidify.R
import com.looker.droidify.databinding.TabsToolbarBinding import com.looker.droidify.databinding.TabsToolbarBinding
import com.looker.droidify.datastore.extension.sortOrderName import com.looker.droidify.datastore.extension.sortOrderName
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.datastore.model.supportedSortOrders
import com.looker.droidify.model.ProductItem import com.looker.droidify.model.ProductItem
import com.looker.droidify.service.Connection import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService import com.looker.droidify.service.SyncService
@@ -44,8 +45,8 @@ import com.looker.droidify.utility.common.extension.getMutatedIcon
import com.looker.droidify.utility.common.extension.selectableBackground import com.looker.droidify.utility.common.extension.selectableBackground
import com.looker.droidify.utility.common.extension.systemBarsPadding import com.looker.droidify.utility.common.extension.systemBarsPadding
import com.looker.droidify.utility.common.sdkAbove import com.looker.droidify.utility.common.sdkAbove
import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.utility.extension.mainActivity import com.looker.droidify.utility.extension.mainActivity
import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.widget.DividerConfiguration import com.looker.droidify.widget.DividerConfiguration
import com.looker.droidify.widget.FocusSearchView import com.looker.droidify.widget.FocusSearchView
import com.looker.droidify.widget.StableRecyclerAdapter import com.looker.droidify.widget.StableRecyclerAdapter
@@ -121,7 +122,7 @@ class TabsFragment : ScreenFragment() {
val source = AppListFragment.Source.entries[it.currentItem] val source = AppListFragment.Source.entries[it.currentItem]
updateUpdateNotificationBlocker(source) updateUpdateNotificationBlocker(source)
} }
} },
) )
private var sectionsAnimator: ValueAnimator? = null private var sectionsAnimator: ValueAnimator? = null
@@ -160,7 +161,8 @@ class TabsFragment : ScreenFragment() {
val searchView = FocusSearchView(toolbar.context).apply { val searchView = FocusSearchView(toolbar.context).apply {
maxWidth = Int.MAX_VALUE maxWidth = Int.MAX_VALUE
queryHint = getString(stringRes.search) queryHint = getString(stringRes.search)
setOnQueryTextListener(object : SearchView.OnQueryTextListener { setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
clearFocus() clearFocus()
return true return true
@@ -173,7 +175,8 @@ class TabsFragment : ScreenFragment() {
} }
return true return true
} }
}) },
)
} }
toolbar.menu.apply { toolbar.menu.apply {
@@ -187,9 +190,10 @@ class TabsFragment : ScreenFragment() {
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_search)) .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_search))
.setActionView(searchView) .setActionView(searchView)
.setShowAsActionFlags( .setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW,
) )
.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { .setOnActionExpandListener(
object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
viewModel.isSearchActionItemExpanded.value = true viewModel.isSearchActionItemExpanded.value = true
return true return true
@@ -199,7 +203,8 @@ class TabsFragment : ScreenFragment() {
viewModel.isSearchActionItemExpanded.value = false viewModel.isSearchActionItemExpanded.value = false
return true return true
} }
}) },
)
syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories) syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories)
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sync)) .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sync))
@@ -212,7 +217,7 @@ class TabsFragment : ScreenFragment() {
.setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sort)) .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_sort))
.let { menu -> .let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val menuItems = SortOrder.entries.map { sortOrder -> val menuItems = supportedSortOrders().map { sortOrder ->
menu.add(context.sortOrderName(sortOrder)) menu.add(context.sortOrderName(sortOrder))
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
viewModel.setSortOrder(sortOrder) viewModel.setSortOrder(sortOrder)
@@ -224,9 +229,7 @@ class TabsFragment : ScreenFragment() {
} }
favouritesItem = add(1, 0, 0, stringRes.favourites) favouritesItem = add(1, 0, 0, stringRes.favourites)
.setIcon( .setIcon(toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked))
toolbar.context.getMutatedIcon(R.drawable.ic_favourite_checked)
)
.setOnMenuItemClickListener { .setOnMenuItemClickListener {
view.post { mainActivity.navigateFavourites() } view.post { mainActivity.navigateFavourites() }
true true
@@ -262,7 +265,7 @@ class TabsFragment : ScreenFragment() {
adapter = object : FragmentStateAdapter(this@TabsFragment) { adapter = object : FragmentStateAdapter(this@TabsFragment) {
override fun getItemCount(): Int = AppListFragment.Source.entries.size override fun getItemCount(): Int = AppListFragment.Source.entries.size
override fun createFragment(position: Int): Fragment = AppListFragment( override fun createFragment(position: Int): Fragment = AppListFragment(
AppListFragment.Source.entries[position] AppListFragment.Source.entries[position],
) )
} }
content.addView(this) content.addView(this)
@@ -321,7 +324,7 @@ class TabsFragment : ScreenFragment() {
val backgroundPath = ShapeAppearanceModel.builder() val backgroundPath = ShapeAppearanceModel.builder()
.setAllCornerSizes( .setAllCornerSizes(
context?.resources?.getDimension(R.dimen.shape_large_corner) ?: 0F context?.resources?.getDimension(R.dimen.shape_large_corner) ?: 0F,
) )
.build() .build()
val sectionBackground = MaterialShapeDrawable(backgroundPath) val sectionBackground = MaterialShapeDrawable(backgroundPath)
@@ -449,7 +452,7 @@ class TabsFragment : ScreenFragment() {
val viewPager = viewPager val viewPager = viewPager
viewPager?.setCurrentItem( viewPager?.setCurrentItem(
AppListFragment.Source.UPDATES.ordinal, AppListFragment.Source.UPDATES.ordinal,
allowSmooth && viewPager.isLaidOut allowSmooth && viewPager.isLaidOut,
) )
} else { } else {
needSelectUpdates = true needSelectUpdates = true
@@ -461,7 +464,7 @@ class TabsFragment : ScreenFragment() {
} }
private fun updateSections( private fun updateSections(
sectionsList: List<ProductItem.Section> sectionsList: List<ProductItem.Section>,
) { ) {
sectionsAdapter?.sections = sectionsList sectionsAdapter?.sections = sectionsList
layout?.run { layout?.run {
@@ -518,7 +521,7 @@ class TabsFragment : ScreenFragment() {
override fun onPageScrolled( override fun onPageScrolled(
position: Int, position: Int,
positionOffset: Float, positionOffset: Float,
positionOffsetPixels: Int positionOffsetPixels: Int,
) { ) {
val layout = layout!! val layout = layout!!
val fromSections = AppListFragment.Source.entries[position].sections val fromSections = AppListFragment.Source.entries[position].sections
@@ -547,15 +550,9 @@ class TabsFragment : ScreenFragment() {
val source = AppListFragment.Source.entries[position] val source = AppListFragment.Source.entries[position]
updateUpdateNotificationBlocker(source) updateUpdateNotificationBlocker(source)
sortOrderMenu!!.first.apply { sortOrderMenu!!.first.apply {
isVisible = source.order
setShowAsActionFlags( setShowAsActionFlags(
if (!source.order || if (resources.configuration.screenWidthDp >= 300) MenuItem.SHOW_AS_ACTION_ALWAYS
resources.configuration.screenWidthDp >= 300 else 0,
) {
MenuItem.SHOW_AS_ACTION_ALWAYS
} else {
0
}
) )
} }
syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
@@ -576,7 +573,7 @@ class TabsFragment : ScreenFragment() {
} }
private class SectionsAdapter( private class SectionsAdapter(
private val onClick: (ProductItem.Section) -> Unit private val onClick: (ProductItem.Section) -> Unit,
) : StableRecyclerAdapter<SectionsAdapter.ViewType, RecyclerView.ViewHolder>() { ) : StableRecyclerAdapter<SectionsAdapter.ViewType, RecyclerView.ViewHolder>() {
enum class ViewType { SECTION } enum class ViewType { SECTION }
@@ -590,13 +587,13 @@ class TabsFragment : ScreenFragment() {
setPadding(16.dp, 0, 16.dp, 0) setPadding(16.dp, 0, 16.dp, 0)
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.MATCH_PARENT FrameLayout.LayoutParams.MATCH_PARENT,
) )
} }
with(itemView as FrameLayout) { with(itemView as FrameLayout) {
layoutParams = RecyclerView.LayoutParams( layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT,
48.dp 48.dp,
) )
background = context.selectableBackground background = context.selectableBackground
addView(title) addView(title)
@@ -613,7 +610,7 @@ class TabsFragment : ScreenFragment() {
fun configureDivider( fun configureDivider(
context: Context, context: Context,
position: Int, position: Int,
configuration: DividerConfiguration configuration: DividerConfiguration,
) { ) {
val currentSection = sections[position] val currentSection = sections[position]
val nextSection = sections.getOrNull(position + 1) val nextSection = sections.getOrNull(position + 1)
@@ -624,7 +621,7 @@ class TabsFragment : ScreenFragment() {
needDivider = true, needDivider = true,
toTop = false, toTop = false,
paddingStart = padding, paddingStart = padding,
paddingEnd = padding paddingEnd = padding,
) )
} }
@@ -633,7 +630,7 @@ class TabsFragment : ScreenFragment() {
needDivider = false, needDivider = false,
toTop = false, toTop = false,
paddingStart = 0, paddingStart = 0,
paddingEnd = 0 paddingEnd = 0,
) )
} }
} }
@@ -648,7 +645,7 @@ class TabsFragment : ScreenFragment() {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: ViewType viewType: ViewType,
): RecyclerView.ViewHolder { ): RecyclerView.ViewHolder {
return SectionViewHolder(parent.context).apply { return SectionViewHolder(parent.context).apply {
itemView.setOnClickListener { onClick(sections[absoluteAdapterPosition]) } itemView.setOnClickListener { onClick(sections[absoluteAdapterPosition]) }
@@ -678,7 +675,7 @@ class TabsFragment : ScreenFragment() {
} }
holder.title.text = when (section) { holder.title.text = when (section) {
is ProductItem.Section.All -> holder.itemView.resources.getString( is ProductItem.Section.All -> holder.itemView.resources.getString(
stringRes.all_applications stringRes.all_applications,
) )
is ProductItem.Section.Category -> section.name is ProductItem.Section.Category -> section.name

View File

@@ -20,7 +20,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TabsViewModel @Inject constructor( class TabsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val currentSection = val currentSection =
@@ -37,7 +37,7 @@ class TabsViewModel @Inject constructor(
val sections = val sections =
combine( combine(
Database.CategoryAdapter.getAllStream(), Database.CategoryAdapter.getAllStream(),
Database.RepositoryAdapter.getEnabledStream() Database.RepositoryAdapter.getEnabledStream(),
) { categories, repos -> ) { categories, repos ->
val productCategories = categories val productCategories = categories
.asSequence() .asSequence()
@@ -60,7 +60,7 @@ class TabsViewModel @Inject constructor(
val backAction = combine( val backAction = combine(
currentSection, currentSection,
isSearchActionItemExpanded, isSearchActionItemExpanded,
showSections showSections,
) { currentSection, isSearchActionItemExpanded, showSections -> ) { currentSection, isSearchActionItemExpanded, showSections ->
when { when {
currentSection != ProductItem.Section.All -> BackAction.ProductAll currentSection != ProductItem.Section.All -> BackAction.ProductAll
@@ -80,6 +80,30 @@ class TabsViewModel @Inject constructor(
} }
} }
private fun calcBackAction(
currentSection: ProductItem.Section,
isSearchActionItemExpanded: Boolean,
showSections: Boolean,
): BackAction {
return when {
currentSection != ProductItem.Section.All -> {
BackAction.ProductAll
}
isSearchActionItemExpanded -> {
BackAction.CollapseSearchView
}
showSections -> {
BackAction.HideSections
}
else -> {
BackAction.None
}
}
}
companion object { companion object {
private const val STATE_SECTION = "section" private const val STATE_SECTION = "section"
} }

View File

@@ -1,5 +1,18 @@
package com.looker.droidify.utility.common.extension package com.looker.droidify.utility.common.extension
inline fun <K, E> Map<K, E>.windowed(windowSize: Int, block: (Map<K, E>) -> Unit) {
var index = 0
val windowedPackages: HashMap<K, E> = HashMap(windowSize)
forEach {
index++
windowedPackages.put(it.key, it.value)
if (windowedPackages.size == windowSize || index == size) {
block(windowedPackages)
windowedPackages.clear()
}
}
}
inline fun <K, E> Map<K, E>.updateAsMutable(block: MutableMap<K, E>.() -> Unit): Map<K, E> { inline fun <K, E> Map<K, E>.updateAsMutable(block: MutableMap<K, E>.() -> Unit): Map<K, E> {
return toMutableMap().apply(block) return toMutableMap().apply(block)
} }

View File

@@ -13,22 +13,22 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
context(ViewModel) context(viewModel: ViewModel)
fun <T> Flow<T>.asStateFlow( fun <T> Flow<T>.asStateFlow(
initialValue: T, initialValue: T,
scope: CoroutineScope = viewModelScope, scope: CoroutineScope = viewModel.viewModelScope,
started: SharingStarted = SharingStarted.WhileSubscribed(5_000) started: SharingStarted = SharingStarted.WhileSubscribed(5_000),
): StateFlow<T> = stateIn( ): StateFlow<T> = stateIn(
scope = scope, scope = scope,
started = started, started = started,
initialValue = initialValue initialValue = initialValue,
) )
context(CoroutineScope) context(scope: CoroutineScope)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun <T> ReceiveChannel<T>.filter( fun <T> ReceiveChannel<T>.filter(
block: suspend (T) -> Boolean block: suspend (T) -> Boolean,
): ReceiveChannel<T> = produce(capacity = Channel.UNLIMITED) { ): ReceiveChannel<T> = scope.produce(capacity = Channel.UNLIMITED) {
consumeEach { item -> consumeEach { item ->
if (block(item)) send(item) if (block(item)) send(item)
} }

View File

@@ -12,6 +12,6 @@ val Number.dpToPx
Resources.getSystem().displayMetrics Resources.getSystem().displayMetrics
) )
context(View) context(view: View)
val Int.dp: Int val Int.dp: Int
get() = (this * resources.displayMetrics.density).roundToInt() get() = (this * view.resources.displayMetrics.density).roundToInt()

View File

@@ -97,7 +97,7 @@
android:textSize="14sp" /> android:textSize="14sp" />
<TextView <TextView
android:id="@+id/target_sdk" android:id="@+id/sdk_ver"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:singleLine="true" android:singleLine="true"

View File

@@ -145,6 +145,9 @@
<include <include
android:id="@+id/installer" android:id="@+id/installer"
layout="@layout/enum_type" /> layout="@layout/enum_type" />
<include
android:id="@+id/legacy_installer_component"
layout="@layout/enum_type" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -241,8 +241,13 @@
<string name="label_unknown_sdk">غير معروف (%d)</string> <string name="label_unknown_sdk">غير معروف (%d)</string>
<string name="error_shizuku_not_installed">Shizuku غير مثبت</string> <string name="error_shizuku_not_installed">Shizuku غير مثبت</string>
<string name="error_shizuku_not_running_DESC">خدمة Shizuku لا تعمل. يُرجى التحقق من تطبيق Shizuku</string> <string name="error_shizuku_not_running_DESC">خدمة Shizuku لا تعمل. يُرجى التحقق من تطبيق Shizuku</string>
<string name="label_targets_sdk">يستهدف: أندرويد %s</string>
<string name="label_open_video">فيديو</string> <string name="label_open_video">فيديو</string>
<string name="error_shizuku_service_unavailable">Shizuku لا يعمل</string> <string name="error_shizuku_service_unavailable">Shizuku لا يعمل</string>
<string name="switch_to_default_installer">بدّل إلى الافتراضي</string> <string name="switch_to_default_installer">بدّل إلى الافتراضي</string>
<string name="unspecified">غير محدد</string>
<string name="legacyInstallerComponent">مكون المثبت القديم</string>
<string name="shizuku_legacy_installer">أداة تثبيت شيزوكو العتيقة</string>
<string name="always_choose">اختر دائمًا</string>
<string name="select_installer">اختيار المثبت</string>
<string name="label_sdk_version">الأهداف: أندرويد %1$s |الحد الأدنى: أندرويد %2$s</string>
</resources> </resources>

View File

@@ -235,7 +235,6 @@
<string name="insufficient_storage">Не хапае месца</string> <string name="insufficient_storage">Не хапае месца</string>
<string name="insufficient_storage_DESC">На вашай прыладзе не хапае памяці для ўсталявання гэтай праграмы. Паспрабуйце вызваліць крыху месца</string> <string name="insufficient_storage_DESC">На вашай прыладзе не хапае памяці для ўсталявання гэтай праграмы. Паспрабуйце вызваліць крыху месца</string>
<string name="open_shizuku">Адкрыць Shizuku</string> <string name="open_shizuku">Адкрыць Shizuku</string>
<string name="label_targets_sdk">Цэль: Android %s</string>
<string name="switch_to_default_installer">Пераключыцца на стандартны</string> <string name="switch_to_default_installer">Пераключыцца на стандартны</string>
<string name="label_open_video">Відэа</string> <string name="label_open_video">Відэа</string>
</resources> </resources>

View File

@@ -3,13 +3,13 @@
<string name="action_failed">Неуспешна операция</string> <string name="action_failed">Неуспешна операция</string>
<string name="add_repository">Добави хранилище</string> <string name="add_repository">Добави хранилище</string>
<string name="address">Адрес</string> <string name="address">Адрес</string>
<string name="all_applications">Всички Приложения</string> <string name="all_applications">Всички приложения</string>
<string name="all_applications_up_to_date">Всички приложения са актуални</string> <string name="all_applications_up_to_date">Всички приложения са актуални</string>
<string name="already_exists">Вече съществува</string> <string name="already_exists">Вече съществува</string>
<string name="always">Винаги</string> <string name="always">Винаги</string>
<string name="amoled">Чернa</string> <string name="amoled">Чернa</string>
<string name="application">Приложение</string> <string name="application">Приложение</string>
<string name="application_not_found">Приложението не може да бъде намерено</string> <string name="application_not_found">Приложението не беше намерено</string>
<string name="author_email">Ел. поща на автора</string> <string name="author_email">Ел. поща на автора</string>
<string name="available">Налични</string> <string name="available">Налични</string>
<string name="bug_tracker">Тракер за грешки</string> <string name="bug_tracker">Тракер за грешки</string>
@@ -21,7 +21,7 @@
<string name="connecting">Свързване…</string> <string name="connecting">Свързване…</string>
<string name="contains_non_free_media">Съдържа несвободна медия</string> <string name="contains_non_free_media">Съдържа несвободна медия</string>
<string name="could_not_sync_FORMAT">Неуспешно синхронизиране на %s</string> <string name="could_not_sync_FORMAT">Неуспешно синхронизиране на %s</string>
<string name="could_not_validate_FORMAT">Неуспешна валидиция на %s</string> <string name="could_not_validate_FORMAT">Неуспешно потвърждаване на %s</string>
<string name="credits">Доброволци</string> <string name="credits">Доброволци</string>
<string name="delete">Изтрий</string> <string name="delete">Изтрий</string>
<string name="delete_repository_DESC">Изтриване на хранилището\?</string> <string name="delete_repository_DESC">Изтриване на хранилището\?</string>
@@ -31,25 +31,25 @@
<string name="downloaded_FORMAT">Изтегли се %s</string> <string name="downloaded_FORMAT">Изтегли се %s</string>
<string name="downloading">Изтегля се</string> <string name="downloading">Изтегля се</string>
<string name="downloading_FORMAT">Изтегля се %s…</string> <string name="downloading_FORMAT">Изтегля се %s…</string>
<string name="edit_repository">Редактирай</string> <string name="edit_repository">Редактиране</string>
<string name="has_advertising">Има реклами</string> <string name="has_advertising">Съдържа реклама</string>
<string name="has_security_vulnerabilities">Има уязвимости в сигурността</string> <string name="has_security_vulnerabilities">Има известни уязвимости в сигурността</string>
<string name="http_error_DESC">Невалиден отговор от сървъра.</string> <string name="http_error_DESC">Невалиден отговор от сървъра</string>
<string name="http_proxy">HTTP прокси</string> <string name="http_proxy">HTTP прокси</string>
<string name="ignore_all_updates">Игнорирай всички нови версии</string> <string name="ignore_all_updates">Игнориране на нови версии</string>
<string name="incompatible_api_max_DESC_FORMAT">Максималната АПИ версия е %d.</string> <string name="incompatible_api_max_DESC_FORMAT">Максималната версия на API е %d</string>
<string name="incompatible_api_min_DESC_FORMAT">Минималната АПИ версия е %d.</string> <string name="incompatible_api_min_DESC_FORMAT">Минималната версия на API е %d</string>
<string name="incompatible_features_DESC">Липсващи функции.</string> <string name="incompatible_features_DESC">Липсващи функции</string>
<string name="incompatible_platforms_DESC_FORMAT">Вашата %1$s платформа не се поддържа. Поддържани платформи: %2$s.</string> <string name="incompatible_platforms_DESC_FORMAT">Вашата %1$s архитектура не се поддържа. Поддържани архитектури: %2$s</string>
<string name="incompatible_version">Несъвместима версия</string> <string name="incompatible_version">Несъвместима версия</string>
<string name="incompatible_versions">Несъвместими версии</string> <string name="incompatible_versions">Несъвместими версии</string>
<string name="incompatible_with_FORMAT">Несъвместим с %s</string> <string name="incompatible_with_FORMAT">Несъвместим с %s</string>
<string name="install">Инсталирай</string> <string name="install">Инсталирай</string>
<string name="install_types">Начини на инсталиране</string> <string name="install_types">Инсталация</string>
<string name="installed">Инсталирани</string> <string name="installed">Инсталирани</string>
<string name="invalid_address">Невалиден адрес</string> <string name="invalid_address">Невалиден адрес</string>
<string name="invalid_permissions_error_DESC">Невалидни разрешения.</string> <string name="invalid_permissions_error_DESC">Невалидни разрешения</string>
<string name="invalid_signature_error_DESC">Невалиден подпис.</string> <string name="invalid_signature_error_DESC">Невалиден подпис</string>
<string name="launch">Стартирай</string> <string name="launch">Стартирай</string>
<string name="license">Лиценз</string> <string name="license">Лиценз</string>
<string name="license_FORMAT">%s лиценз</string> <string name="license_FORMAT">%s лиценз</string>
@@ -65,10 +65,10 @@
<string name="notify_about_updates">Уведомления за актуализации</string> <string name="notify_about_updates">Уведомления за актуализации</string>
<string name="number_of_applications">Брой приложения</string> <string name="number_of_applications">Брой приложения</string>
<string name="ok">Окей</string> <string name="ok">Окей</string>
<string name="only_on_wifi">Само на Wi-Fi</string> <string name="only_on_wifi">Само при Wi-Fi</string>
<string name="open_DESC_FORMAT">Отвори %s\?</string> <string name="open_DESC_FORMAT">Отвори %s\?</string>
<string name="other">Други</string> <string name="other">Други</string>
<string name="parsing_index_error_DESC">Не може да се прочете индекс файла.</string> <string name="parsing_index_error_DESC">Не можа да се анализира индексният файл</string>
<string name="password">Парола</string> <string name="password">Парола</string>
<string name="password_missing">Липсваща парола</string> <string name="password_missing">Липсваща парола</string>
<string name="proxy">Прокси</string> <string name="proxy">Прокси</string>
@@ -78,7 +78,7 @@
<string name="recently_updated">Наскоро обновени</string> <string name="recently_updated">Наскоро обновени</string>
<string name="repositories">Хранилища</string> <string name="repositories">Хранилища</string>
<string name="repository">Хранилище</string> <string name="repository">Хранилище</string>
<string name="repository_unsigned_DESC">Неподписано. Не може да провери списъка с неподписаните приложения. Внимавайте с тегленето на приложения от неподписани хранилища.</string> <string name="repository_unsigned_DESC">Не е намерен подпис. Не можа да се провери списъкът с приложения</string>
<string name="requires_FORMAT">Изисква %s</string> <string name="requires_FORMAT">Изисква %s</string>
<string name="save">Запази</string> <string name="save">Запази</string>
<string name="saving_details">Запазване на подробности…</string> <string name="saving_details">Запазване на подробности…</string>
@@ -105,46 +105,46 @@
<string name="version">Версия</string> <string name="version">Версия</string>
<string name="versions">Версии</string> <string name="versions">Версии</string>
<string name="waiting_to_start_download">В очакване да започне изтеглянето…</string> <string name="waiting_to_start_download">В очакване да започне изтеглянето…</string>
<string name="whats_new">Какво е новото</string> <string name="whats_new">Какво е ново</string>
<string name="show_less">Покажи по-малко</string> <string name="show_less">Покажи по-малко</string>
<string name="update_all">Инсталирай всички</string> <string name="update_all">Инсталирай всички</string>
<string name="anti_features">Антифункции</string> <string name="anti_features">Антифункции</string>
<string name="author_website">Уебстраница на автора</string> <string name="author_website">Уебстраница на автора</string>
<string name="cant_edit_sync_DESC">Не може да се редактират синхронизиращи се хранилища.</string> <string name="cant_edit_sync_DESC">Не може да се редактират синхронизиращи се хранилища</string>
<string name="could_not_download_FORMAT">Неуспешно изтегляне на %s</string> <string name="could_not_download_FORMAT">Неуспешно изтегляне на %s</string>
<string name="compiled_for_debugging">Компилирано за отстраняване на грешки</string> <string name="compiled_for_debugging">Компилирано за отстраняване на грешки</string>
<string name="dark">Тъмнa</string> <string name="dark">Тъмнa</string>
<string name="file_format_error_DESC">Невалиден файлов формат.</string> <string name="file_format_error_DESC">Невалиден файлов формат</string>
<string name="fingerprint">Отпечатък</string> <string name="fingerprint">Отпечатък</string>
<string name="invalid_fingerprint_format">Невалиден формат на отпечатъка</string> <string name="invalid_fingerprint_format">Невалиден формат на отпечатъка</string>
<string name="has_non_free_dependencies">Има несвободни зависимости</string> <string name="has_non_free_dependencies">Зависи от други несвободни приложения</string>
<string name="ignore_this_update">Игнорирай тази версия</string> <string name="ignore_this_update">Игнорирай тази версия</string>
<string name="incompatible_api_DESC_FORMAT">Вашата %1$s (АПИ версия %2$d) се поддържа. %3$s</string> <string name="incompatible_api_DESC_FORMAT">Вашата %1$s (АПИ версия %2$d) се поддържа. %3$s</string>
<string name="incompatible_older_DESC">Тази версия е по-стара от инсталираната на вашето устройство. Деинсталирайте първо нея.</string> <string name="incompatible_older_DESC">Тази версия е по-стара от инсталираната на вашето устройство</string>
<string name="invalid_username_format">Невалиден формат на потребителското име</string> <string name="invalid_username_format">Невалиден формат на потребителското име</string>
<string name="unstable_updates">Нестабилни актуализации</string> <string name="unstable_updates">Нестабилни актуализации</string>
<string name="incompatible_signature_DESC">Тази версия е подписана със сертификат, различен от този, инсталиран на вашето устройство. Деинсталирайте първо нея.</string> <string name="incompatible_signature_DESC">Тази версия е подписана с различен подпис от инсталирания на вашето устройство</string>
<string name="incompatible_versions_summary">Показване на версии, несъвместими с устройството</string> <string name="incompatible_versions_summary">Показване на версии, несъвместими с устройството</string>
<string name="integrity_check_error_DESC">Не може да се провери целостта.</string> <string name="integrity_check_error_DESC">Не може да се провери целостта</string>
<string name="invalid_metadata_error_DESC">Невалидни метаданни.</string> <string name="invalid_metadata_error_DESC">Невалидни метаданни</string>
<string name="plus_more_FORMAT">+%d повече</string> <string name="plus_more_FORMAT">+%d повече</string>
<string name="promotes_non_free_network_services">Насърчава несвободни мрежови услуги</string> <string name="promotes_non_free_network_services">Насърчава или зависи изцяло от несвободна мрежова услуга</string>
<string name="search">Търсене</string> <string name="search">Търсене</string>
<string name="link_copied_to_clipboard">Линкът е копиран</string> <string name="link_copied_to_clipboard">Линкът е копиран</string>
<string name="new_updates_available">Налични нови версии на приложението</string> <string name="new_updates_available">Налични са нови актуализации</string>
<string name="no_applications_available">Няма налични приложения</string> <string name="no_applications_available">Няма налични приложения</string>
<plurals name="new_updates_DESC_FORMAT"> <plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d приложение има нова версия.</item> <item quantity="one">%d приложението има налични актуализации</item>
<item quantity="other">%d приложения имат нова версия.</item> <item quantity="other">%d приложенията имат налични актуализации</item>
</plurals> </plurals>
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
<string name="no_matching_applications_found">Не могат да бъдат намерени такива приложения</string> <string name="no_matching_applications_found">Няма резултати</string>
<string name="notify_about_updates_summary">Покажи известие, при налични нови версии</string> <string name="notify_about_updates_summary">Показване на известие, когато е налична нова версия на приложение</string>
<string name="only_compatible_with_FORMAT">Съвместим само с %s</string> <string name="only_compatible_with_FORMAT">Съвместим само с %s</string>
<string name="permissions">Разрешения</string> <string name="permissions">Разрешения</string>
<string name="processing_FORMAT">Обработка на %1$s…</string> <string name="processing_FORMAT">Обработка на %1$s…</string>
<string name="project_website">Уебстраница на проекта</string> <string name="project_website">Уеб страница</string>
<string name="promotes_non_free_software">Насърчава несвободен софтуер</string> <string name="promotes_non_free_software">Насърчава несвободни добавки</string>
<string name="provided_by_FORMAT">Предоставено от %s</string> <string name="provided_by_FORMAT">Предоставено от %s</string>
<string name="themes">Теми</string> <string name="themes">Теми</string>
<string name="uninstall">Деинсталиране</string> <string name="uninstall">Деинсталиране</string>
@@ -154,72 +154,88 @@
<string name="version_FORMAT">Версия %s</string> <string name="version_FORMAT">Версия %s</string>
<string name="sync_repositories">Синхронизирай хранилищата</string> <string name="sync_repositories">Синхронизирай хранилищата</string>
<string name="system">Системна</string> <string name="system">Системна</string>
<string name="tap_to_install_DESC">Докосни за инсталиране.</string> <string name="tap_to_install_DESC">Докоснете за инсталиране</string>
<string name="unknown_error_DESC">Неизвестна грешка.</string> <string name="unknown_error_DESC">Неизвестна грешка</string>
<string name="unstable_updates_summary">Предложи инсталирането на нестабилни версии</string> <string name="unstable_updates_summary">Предложи инсталиране на нестабилни версии на приложения</string>
<string name="upstream_source_code_is_not_free">Актуалният програмен код вече не е със свободен лиценз</string> <string name="upstream_source_code_is_not_free">Изходният код нагоре по веригата е несвободен</string>
<string name="username_missing">Потребителско име липсва</string> <string name="username_missing">Потребителско име липсва</string>
<string name="validation_index_error_DESC">Не може да валидира индексът.</string> <string name="validation_index_error_DESC">Индексът не може да бъде потвърден</string>
<string name="website">Уебстраница</string> <string name="website">Уеб страница на проекта</string>
<string name="prefs_language_title">Език</string> <string name="prefs_language_title">Език</string>
<string name="prefs_personalization">Персонализация</string> <string name="prefs_personalization">Персонализация</string>
<string name="installer">Инсталатор</string> <string name="installer">Инсталатор</string>
<string name="legacy_installer">Стар Инсталатор</string> <string name="legacy_installer">Наследен инсталатор</string>
<string name="session_installer">Session Инсталатор</string> <string name="session_installer">Session инсталатор</string>
<string name="root_installer">Root Инсталатор</string> <string name="root_installer">Root инсталатор</string>
<string name="shizuku_installer">Shizuku Инсталатор</string> <string name="shizuku_installer">Shizuku/Sui инсталатор</string>
<plurals name="days"> <plurals name="days">
<item quantity="one">Ден</item> <item quantity="one">ден</item>
<item quantity="other">Дни</item> <item quantity="other">дни</item>
</plurals> </plurals>
<string name="only_on_wifi_with_charging">Само при Wi-Fi и зареждане</string> <string name="only_on_wifi_with_charging">Само при Wi-Fi и зареждане</string>
<string name="cleanup_title">Интервал за почистване на APK</string> <string name="cleanup_title">Интервал за почистване на APK</string>
<plurals name="hours"> <plurals name="hours">
<item quantity="one">Час</item> <item quantity="one">час</item>
<item quantity="other">Часа</item> <item quantity="other">часа</item>
</plurals> </plurals>
<string name="io_error_DESC">Невъзможност за извършване на определени действия.</string> <string name="io_error_DESC">Невъзможност за извършване на определени действия</string>
<string name="material_you_desc">Използвайте material you цветова тема</string> <string name="material_you_desc">Използване на Material You цветове</string>
<string name="material_you">Material You</string> <string name="material_you">Material You</string>
<string name="auto_update">Автоматично актуализиране на приложения</string> <string name="auto_update">Автоматично актуализиране на приложения</string>
<string name="installing">Инсталиране</string> <string name="installing">Инсталиране</string>
<string name="auto_update_apps">Опитайте се да инсталирате актуализации автоматично</string> <string name="auto_update_apps">Автоматично инсталиране на актуализации</string>
<string name="waiting_to_start_installation">Изчакване за стартиране на инсталацията…</string> <string name="waiting_to_start_installation">Изчакване за стартиране на инсталацията…</string>
<string name="favourites">Любими</string> <string name="favourites">Любими</string>
<string name="enable_repo">Активирайте хранилището</string> <string name="enable_repo">Активиране на хранилище</string>
<string name="force_clean_up">Принудително почистване</string> <string name="force_clean_up">Принудително почистване</string>
<string name="force_clean_up_DESC">Почиства излишните файлове</string> <string name="force_clean_up_DESC">Почиства излишните файлове</string>
<string name="repository_unreachable">Хранилището е недостъпно</string> <string name="repository_unreachable">Хранилището е недостъпно</string>
<string name="socket_error_DESC">Сървърът не успя да предостави нов пакет.</string> <string name="socket_error_DESC">Сървърът не успя да предостави нов пакет</string>
<string name="has_non_free_components">Има несвободни компоненти</string> <string name="has_non_free_components">Има несвободни компоненти</string>
<string name="connection_error_DESC">Неуспешно свързване със сървъра</string> <string name="connection_error_DESC">Неуспешно свързване със сървъра</string>
<string name="home_screen_swiping">Плъзгане на началния екран</string> <string name="home_screen_swiping">Плъзгане на страница</string>
<string name="contains_nsfw">Съдържа неподходящо за работа съдържание</string> <string name="contains_nsfw">Съдържа неподходящо за работа (NSFW) съдържание</string>
<string name="shizuku_not_alive">Shizuku не работи</string> <string name="shizuku_not_alive">Shizuku/Sui не работи</string>
<string name="proxy_port_error_not_int">Прокси портът може да бъде само цяло число</string> <string name="proxy_port_error_not_int">Прокси портът може да бъде само цяло число</string>
<string name="home_screen_swiping_DESC">Позволете на потребителя да плъзга между страниците в началния екран</string> <string name="home_screen_swiping_DESC">Плъзнете наляво или надясно, за да превключите страницата</string>
<string name="repository_not_found">Следното хранилище не бе намерено</string> <string name="repository_not_found">Хранилището не беше намерено</string>
<string name="special_credits">Специални благодарности</string> <string name="special_credits">Специални благодарности</string>
<string name="shizuku_not_installed">Shizuku не е инсталиран</string> <string name="shizuku_not_installed">Shizuku не е инсталиран</string>
<string name="import_export">Внасяне/Изнасяне</string> <string name="import_export">Архивиране и възстановяване</string>
<string name="import_settings_title">Внеси Настройки</string> <string name="import_settings_title">Внасяне на предпочитания и настройки</string>
<string name="import_settings_DESC">Внасяне на настройки и любими от файл</string> <string name="import_settings_DESC">Внасяне на настройки и предпочитания от файл</string>
<string name="export_settings_title">Изнеси Настройки</string> <string name="export_settings_title">Изнасяне на предпочитания и настройки</string>
<string name="export_repos_DESC">Изнеси всички хранилища във файл</string> <string name="export_repos_DESC">Изнасяне на хранилища във файл</string>
<string name="import_repos_title">Внеси хранилища</string> <string name="import_repos_title">Внасяне на хранилища</string>
<string name="export_settings_DESC">Изнасяне на настройки и любими във файл</string> <string name="export_settings_DESC">Изнасяне на настройки и предпочитания във файл</string>
<string name="export_repos_title">Изнеси хранилища</string> <string name="export_repos_title">Изнасяне на хранилища</string>
<string name="cannot_open_link">Линкът не може да се отвори</string> <string name="cannot_open_link">Линкът не може да се отвори</string>
<string name="import_repos_DESC">Внеси всички хранилища от файл</string> <string name="import_repos_DESC">Внасяне на хранилища от файл</string>
<string name="has_tethered_network">Обвързан с определена мрежова услуга</string> <string name="has_tethered_network">Зависи от определена инстанция на мрежова услуга</string>
<string name="require_background_access">Изискване на фонов достъп</string> <string name="require_background_access">Деактивиране на оптимизациите на батерията</string>
<string name="require_background_access_DESC">Необходим е фонов достъп, за да стартирате правилно фоновото синхронизиране</string> <string name="require_background_access_DESC">Деактивирането на оптимизациите на батерията е необходимо, за да може фоновата синхронизация да работи правилно</string>
<string name="installation_failed_DESC">Неуспешно инсталиране на %s</string> <string name="installation_failed_DESC">Неуспешно инсталиране на %s</string>
<string name="uninstalled_application">Деинсталирано</string> <string name="uninstalled_application">Деинсталирано</string>
<string name="uninstalled_application_DESC">%s е било деинсталирано</string> <string name="uninstalled_application_DESC">%s е било деинсталирано</string>
<string name="installation_failed">Неуспешна инсталация</string> <string name="installation_failed">Неуспешна инсталация</string>
<string name="ignore_signature">Пренебрегване на подписа</string> <string name="ignore_signature">Игнориране проверката на подписа</string>
<string name="ignore_signature_summary">*Внимание* Игнорирайте проверката на подписа при инсталиране на APK за LSPosed потребители или напреднали потребители</string> <string name="ignore_signature_summary">Игнориране проверката на подписа при инсталиране на приложение</string>
<string name="insufficient_storage">Недостатъчно място</string> <string name="insufficient_storage">Недостатъчно място</string>
<string name="insufficient_storage_DESC">Няма достатъчно свободно място на устройството за инсталиране на това приложение. Опитайте да освободите малко място</string> <string name="insufficient_storage_DESC">Няма достатъчно свободно място за инсталиране на това приложение</string>
<string name="error_shizuku_not_installed">Shizuku/Sui не е инсталиран</string>
<string name="label_open_video">Видео</string>
<string name="switch_to_default_installer">Превключване към по подразбиране</string>
<string name="open_shizuku">Отворете Shizuku</string>
<string name="error_shizuku_not_running_DESC">Услугата Shizuku/Sui не работи</string>
<string name="error_shizuku_not_granted">Не е предоставено разрешение за Shizuku/Sui</string>
<string name="error_shizuku_not_granted_DESC">Разрешението за Shizuku/Sui не е предоставено</string>
<string name="error_shizuku_not_installed_DESC">Shizuku/Sui не е инсталирана</string>
<string name="error_shizuku_service_unavailable">Shizuku/Sui услугата не работи</string>
<string name="label_unknown_sdk">Неизвестен (%d)</string>
<string name="legacyInstallerComponent">Компонент на наследен инсталатор</string>
<string name="unspecified">Неопределен</string>
<string name="shizuku_legacy_installer">Shizuku/Sui наследен инсталатор</string>
<string name="always_choose">Винаги избирай</string>
<string name="select_installer">Изберете инсталатор</string>
<string name="label_sdk_version">Цели: Android %1$s | Минимум: Android %2$s</string>
</resources> </resources>

View File

@@ -193,4 +193,43 @@
<string name="cannot_open_link">লিংকটি ওপেন করা সম্ভব হয়নি</string> <string name="cannot_open_link">লিংকটি ওপেন করা সম্ভব হয়নি</string>
<string name="import_export">ইম্পোর্ট/এক্সপোর্ট</string> <string name="import_export">ইম্পোর্ট/এক্সপোর্ট</string>
<string name="import_settings_title">সেটিংস ইম্পোর্ট করুন</string> <string name="import_settings_title">সেটিংস ইম্পোর্ট করুন</string>
<string name="uninstalled_application">অপসারিত</string>
<string name="special_credits">বিশেষ কৃতজ্ঞতাস্বীকার</string>
<string name="error_shizuku_not_granted_DESC">শিজুকু সেবার অনুমতি দেওয়া হয়নি। শিজুকু অ্যাপটি দেখো</string>
<string name="ignore_signature_summary">*সতর্কতা* এপিকে ইন্সটলে সময় স্বাক্ষর যাচাইকরণ এড়াও, এলএসপোজড ব্যবহারকারী বা অগ্রগামী ব্যবহারকারীর জন্য</string>
<string name="contains_nsfw">নিষিদ্ধ বিষয়বস্ত বিদ্যমান</string>
<string name="label_open_video">ভিডিও</string>
<string name="export_settings_title">পছন্দসমূহ রপ্তানি করো</string>
<string name="insufficient_storage">অপর্যাপ্ত জায়গা</string>
<string name="shizuku_not_alive">শিজুকু চলছে না</string>
<string name="error_shizuku_not_running_DESC">শিজুকু চলছে না। অনুগ্রহ করে শিজুকু অ্যাপ দেখো</string>
<string name="export_repos_DESC">সব ভাণ্ডার ফাইলে রপ্তানি</string>
<string name="home_screen_swiping_DESC">ব্যবহারকারীকে মূলপাতাসমূহের মধ্যে টান দেওয়া অনুমোদন করো</string>
<string name="connection_error_DESC">সার্ভারে যুক্ত হতে ব্যর্থ</string>
<string name="shizuku_not_installed">শিজুকু ইন্সটল করা নেই</string>
<string name="installation_failed">ইন্সটল ব্যর্থ</string>
<string name="installation_failed_DESC">%s ইন্সটল করতে ব্যর্থ</string>
<string name="ignore_signature">স্বাক্ষর উপেক্ষা করো</string>
<string name="error_shizuku_service_unavailable">শিজুকু চলছে না</string>
<string name="error_shizuku_not_granted">শিজুকু অনুমতি নেই</string>
<string name="error_shizuku_not_installed">শিজুকু ইন্সটল করা হয়নি</string>
<string name="error_shizuku_not_installed_DESC">শিজুকু মনে হচ্ছে না ইন্সটল করা</string>
<string name="import_settings_DESC">পছন্দসমূহ ফাইল থেকে আমদানি করো</string>
<string name="export_settings_DESC">পছন্দসমূহ ফাইলে রপ্তানি করো</string>
<string name="import_repos_title">ভাণ্ডার আমদানি করো</string>
<string name="import_repos_DESC">ফাইল থেকে সব ভাণ্ডার আমদানি করো</string>
<string name="export_repos_title">ভাণ্ডার রপ্তানি</string>
<string name="has_non_free_components">অমুক্ত অংশ আছে</string>
<string name="has_tethered_network">একটি নির্দিষ্ট নেটওয়ার্ক সেবার সাথে আবদ্ধ</string>
<string name="home_screen_swiping">মূলপাতা টান দেওয়া(সোয়াইপিং)</string>
<string name="socket_error_DESC">সার্ভার নতুন প্যাকেট সরবরাহে ব্যর্থ।</string>
<string name="uninstalled_application_DESC">%s অপসারিত হয়েছে</string>
<string name="insufficient_storage_DESC">এই অ্যাপটি ইন্সটল করার জন্য পর্যাপ্ত জায়গা নেই। কিছু জায়গা পরিষ্কারের চেষ্টা করো</string>
<string name="open_shizuku">শিজুকু খুলো</string>
<string name="proxy_port_error_not_int">প্রক্সি পোর্ট শুধু পূর্ণ ধনাত্মক সংখ্যা হতে পারে</string>
<string name="require_background_access">পটভূমিতে চলার অনুমতি প্রয়োজন</string>
<string name="require_background_access_DESC">পটভূমি অনুমোদন প্রয়োজন পটভূমি সিঙ্কের জন্য</string>
<string name="repository_not_found">এই ভাণ্ডারগুলো পাওয়া যায়নি</string>
<string name="switch_to_default_installer">সহজাতে পরিবর্তন করো</string>
<string name="label_unknown_sdk">অজানা (%d)</string>
</resources> </resources>

View File

@@ -37,6 +37,7 @@
<string name="has_security_vulnerabilities">Té vulnerabilitats de seguretat</string> <string name="has_security_vulnerabilities">Té vulnerabilitats de seguretat</string>
<plurals name="hours"> <plurals name="hours">
<item quantity="one">Hora</item> <item quantity="one">Hora</item>
<item quantity="many"></item>
<item quantity="other">Hores</item> <item quantity="other">Hores</item>
</plurals> </plurals>
<string name="http_error_DESC">Resposta de servidor nul.</string> <string name="http_error_DESC">Resposta de servidor nul.</string>
@@ -168,6 +169,7 @@
<string name="never">Mai</string> <string name="never">Mai</string>
<plurals name="new_updates_DESC_FORMAT"> <plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d l\'aplicació té una versió nova.</item> <item quantity="one">%d l\'aplicació té una versió nova.</item>
<item quantity="many"></item>
<item quantity="other">%d aplicacions amb versions noves.</item> <item quantity="other">%d aplicacions amb versions noves.</item>
</plurals> </plurals>
<string name="no_applications_available">Cap aplicació disponible</string> <string name="no_applications_available">Cap aplicació disponible</string>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="action_failed">کردارەکە شکستیهێنا</string>
<string name="amoled">ڕەش</string>
<string name="dark">تاریک</string>
<string name="address">ناونیشان</string>
<string name="always">هەمووکات</string>
</resources>

View File

@@ -1,37 +1,37 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="add_repository">Přidat zdroj</string> <string name="add_repository">Přidat repozitář</string>
<string name="address">Adresa</string> <string name="address">Adresa</string>
<string name="all_applications_up_to_date">Všechny vaše aplikace jsou aktuální</string> <string name="all_applications_up_to_date">Všechny vaše aplikace jsou aktuální</string>
<string name="application_not_found">Nezdařilo se najít tuto aplikaci</string> <string name="application_not_found">Aplikace nebyla nalezena</string>
<string name="available">Procházet</string> <string name="available">Procházet</string>
<string name="bug_tracker">Sledování chyb</string> <string name="bug_tracker">Sledování chyb</string>
<string name="cancel">Zrušit</string> <string name="cancel">Zrušit</string>
<string name="installed">Instalováno</string> <string name="installed">Instalováno</string>
<string name="incompatible_older_DESC">Tato verze je starší než ta instalovaná na vašem zařízení. Nejdříve odinstalujte ji.</string> <string name="incompatible_older_DESC">Tato verze je starší než ta nainstalovaná na vašem zařízení</string>
<string name="incompatible_versions_summary">Zobrait nekompatibilní verze aplikace s vaším zařízením</string> <string name="incompatible_versions_summary">Zobrait verze aplikace nekompatibilní s vaším zařízením</string>
<string name="invalid_signature_error_DESC">Neplatný podpis.</string> <string name="invalid_signature_error_DESC">Neplatný podpis</string>
<string name="link_copied_to_clipboard">Odkaz zkopírován</string> <string name="link_copied_to_clipboard">Odkaz zkopírován</string>
<string name="light">Světlé</string> <string name="light">Světlé</string>
<string name="number_of_applications">Počet aplikací</string> <string name="number_of_applications">Počet aplikací</string>
<string name="processing_FORMAT">Zpracovávání %1$s…</string> <string name="processing_FORMAT">Zpracovávání %1$s…</string>
<string name="parsing_index_error_DESC">Nezdařilo se zpracovat soubor indexu.</string> <string name="parsing_index_error_DESC">Nezdařilo se zpracovat soubor indexu</string>
<string name="password">Heslo</string> <string name="password">Heslo</string>
<string name="password_missing">Chybí heslo</string> <string name="password_missing">Chybí heslo</string>
<string name="project_website">Web projektu</string> <string name="project_website">Webové stránky</string>
<string name="proxy_host">Hostitel proxy</string> <string name="proxy_host">Hostitel proxy</string>
<string name="recently_updated">Nedávno aktualizované</string> <string name="recently_updated">Nedávno aktualizované</string>
<string name="repository">Zdroj</string> <string name="repository">Repozitář</string>
<string name="proxy_port">Port proxy</string> <string name="proxy_port">Port proxy</string>
<string name="proxy_type">Typ proxy</string> <string name="proxy_type">Typ proxy</string>
<string name="repositories">Zdroje</string> <string name="repositories">Zdroje</string>
<string name="anti_features">Anti-funkce</string> <string name="anti_features">Anti-funkce</string>
<string name="already_exists">Již existuje</string> <string name="already_exists">Již existuje</string>
<string name="always">Vždy</string> <string name="always">Vždy</string>
<string name="cant_edit_sync_DESC">Nezdařilo se upravit zdroj protože se právě synchronizuje.</string> <string name="cant_edit_sync_DESC">Repozitář nemůžete upravit, protože se právě synchronizuje</string>
<string name="changes">Změny</string> <string name="changes">Změny</string>
<string name="changelog">Seznam změn</string> <string name="changelog">Seznam změn</string>
<string name="checking_repository">Kontroluji zdroj</string> <string name="checking_repository">Kontroluji repozitář</string>
<string name="confirmation">Potvrzení</string> <string name="confirmation">Potvrzení</string>
<string name="connecting">Spojuji…</string> <string name="connecting">Spojuji…</string>
<string name="contains_non_free_media">Obsahuje ne-svobodná média</string> <string name="contains_non_free_media">Obsahuje ne-svobodná média</string>
@@ -39,40 +39,40 @@
<string name="could_not_sync_FORMAT">Nepodařilo se synchronizovat %s</string> <string name="could_not_sync_FORMAT">Nepodařilo se synchronizovat %s</string>
<string name="could_not_validate_FORMAT">Nepodařilo se ověřit %s</string> <string name="could_not_validate_FORMAT">Nepodařilo se ověřit %s</string>
<string name="dark">Tmavé</string> <string name="dark">Tmavé</string>
<string name="delete">Smazat</string> <string name="delete">Odstranit</string>
<string name="delete_repository_DESC">Smazat zdroj\?</string> <string name="delete_repository_DESC">Odstranit repozitář?</string>
<string name="description">Popis</string> <string name="description">Popis</string>
<string name="details">Detaily</string> <string name="details">Detaily</string>
<string name="donate">Přispět</string> <string name="donate">Přispět</string>
<string name="downloaded_FORMAT">Staženo %s</string> <string name="downloaded_FORMAT">Staženo %s</string>
<string name="downloading">Stahuji</string> <string name="downloading">Stahuji</string>
<string name="downloading_FORMAT">Stahuji %s…</string> <string name="downloading_FORMAT">Stahuji %s…</string>
<string name="edit_repository">Upravit zdroj</string> <string name="edit_repository">Upravit</string>
<string name="file_format_error_DESC">Neplatný formát souboru.</string> <string name="file_format_error_DESC">Neplatný formát souboru</string>
<string name="fingerprint">Otisk prstu</string> <string name="fingerprint">Otisk prstu</string>
<string name="has_advertising">Obsahuje reklamy</string> <string name="has_advertising">Obsahuje reklamy</string>
<string name="has_non_free_dependencies">Obsahuje nesvobodné závislosti</string> <string name="has_non_free_dependencies">Závisí na jiných nesvobodných aplikacích</string>
<string name="has_security_vulnerabilities">Obsahuje bezpečnostní zranitelnosti</string> <string name="has_security_vulnerabilities">Má známé zranitelnosti v zabezpečení</string>
<string name="http_error_DESC">Neplatná odpověď serveru.</string> <string name="http_error_DESC">Neplatná odpověď serveru</string>
<string name="http_proxy">HTTP proxy</string> <string name="http_proxy">HTTP proxy</string>
<string name="ignore_all_updates">Ignorovat všechny nové verze</string> <string name="ignore_all_updates">Ignorovat nové verze</string>
<string name="ignore_this_update">Ignorovat tuto verzi</string> <string name="ignore_this_update">Ignorovat tuto verzi</string>
<string name="incompatible_api_DESC_FORMAT">Váš %1$s (verze API %2$d) není podporován. %3$s</string> <string name="incompatible_api_DESC_FORMAT">Váš %1$s (verze API %2$d) není podporován. %3$s</string>
<string name="incompatible_api_max_DESC_FORMAT">Maximální verze API je %d.</string> <string name="incompatible_api_max_DESC_FORMAT">Maximální verze API je %d</string>
<string name="incompatible_api_min_DESC_FORMAT">Minimální verze API je %d.</string> <string name="incompatible_api_min_DESC_FORMAT">Minimální verze API je %d</string>
<string name="credits">Kredity</string> <string name="credits">Kredity</string>
<string name="incompatible_features_DESC">Chybějící funkce.</string> <string name="incompatible_features_DESC">Chybějící funkce</string>
<string name="incompatible_platforms_DESC_FORMAT">Vaše %1$s platforma není podporována. Podporované platformy: %2$s.</string> <string name="incompatible_platforms_DESC_FORMAT">Vaše architektura %1$s není podporována. Podporované architektury: %2$s</string>
<string name="incompatible_version">Nekompatibilní verze</string> <string name="incompatible_version">Nekompatibilní verze</string>
<string name="incompatible_versions">Nekompatibilní verze</string> <string name="incompatible_versions">Nekompatibilní verze</string>
<string name="incompatible_with_FORMAT">Nekompatibilní s %s</string> <string name="incompatible_with_FORMAT">Nekompatibilní s %s</string>
<string name="install">Instalovat</string> <string name="install">Instalovat</string>
<string name="install_types">Typy Instalace</string> <string name="install_types">Instalace</string>
<string name="integrity_check_error_DESC">Nezdařilo se zkontrolovat integritu.</string> <string name="integrity_check_error_DESC">Nezdařilo se zkontrolovat integritu</string>
<string name="invalid_address">Neplatná adresa</string> <string name="invalid_address">Neplatná adresa</string>
<string name="invalid_fingerprint_format">Neplatný formát otisku prstu</string> <string name="invalid_fingerprint_format">Neplatný formát otisku prstu</string>
<string name="invalid_metadata_error_DESC">Neplatná metadata.</string> <string name="invalid_metadata_error_DESC">Neplatná metadata</string>
<string name="invalid_permissions_error_DESC">Neplatná oprávnění.</string> <string name="invalid_permissions_error_DESC">Neplatná oprávnění</string>
<string name="invalid_username_format">Neplatný formát uživatelského jména</string> <string name="invalid_username_format">Neplatný formát uživatelského jména</string>
<string name="launch">Spustit</string> <string name="launch">Spustit</string>
<string name="license">Licence</string> <string name="license">Licence</string>
@@ -82,33 +82,33 @@
<string name="name">Název</string> <string name="name">Název</string>
<string name="network_error_DESC">Chyba sítě</string> <string name="network_error_DESC">Chyba sítě</string>
<string name="never">Nikdy</string> <string name="never">Nikdy</string>
<string name="new_updates_available">Jsou dostupné nové verze aplikací</string> <string name="new_updates_available">Jsou dostupné nové aktualizace</string>
<plurals name="new_updates_DESC_FORMAT"> <plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d aplikace má novou verzi.</item> <item quantity="one">%d aplikace má dostupné aktualizace</item>
<item quantity="few">%d aplikace mají novou verzi.</item> <item quantity="few">%d aplikace mají dostupné aktualizace</item>
<item quantity="other">%d aplikací má novou verzi.</item> <item quantity="other">%d aplikací má dostupné aktualizace</item>
</plurals> </plurals>
<string name="no_applications_available">Žádné dostupné aplikace</string> <string name="no_applications_available">Žádné dostupné aplikace</string>
<string name="no_applications_installed">Žádné instalované aplikace</string> <string name="no_applications_installed">Žádné nainstalované aplikace</string>
<string name="no_description_available_DESC">Žádný dostupný popis</string> <string name="no_description_available_DESC">Žádný dostupný popis</string>
<string name="no_matching_applications_found">Nepodařilo se najít žádné takové aplikace</string> <string name="no_matching_applications_found">Žádné výsledky</string>
<string name="no_proxy">Žádná proxy</string> <string name="no_proxy">Žádná proxy</string>
<string name="notify_about_updates">Oznámení o aktualizacích</string> <string name="notify_about_updates">Oznámení o aktualizacích</string>
<string name="notify_about_updates_summary">Zobrazit oznámení když jsou dostupné nové verze</string> <string name="notify_about_updates_summary">Zobrazit oznámení, když je dostupná nová verze aplikace</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="only_compatible_with_FORMAT">Kompatibilní pouze s %s</string> <string name="only_compatible_with_FORMAT">Kompatibilní pouze s %s</string>
<string name="only_on_wifi">Pouze na Wi-Fi</string> <string name="only_on_wifi">Pouze na Wi-Fi</string>
<string name="open_DESC_FORMAT">Otevřít %s\?</string> <string name="open_DESC_FORMAT">Otevřít %s\?</string>
<string name="other">Ostatní</string> <string name="other">Ostatní</string>
<string name="incompatible_signature_DESC">Tato verze je podepsána jiným certifikátem než ta instalována na vašem zařízení. Nejdříve odisntalujte ji.</string> <string name="incompatible_signature_DESC">Tato verze je podepsána jiným podpisem než ta nainstalovaná na vašem zařízení</string>
<string name="permissions">Oprávnění</string> <string name="permissions">Oprávnění</string>
<string name="plus_more_FORMAT">+%d více</string> <string name="plus_more_FORMAT">+%d více</string>
<string name="settings">Nastavení</string> <string name="settings">Nastavení</string>
<string name="promotes_non_free_network_services">Propaguje nesvobodné internetové služby</string> <string name="promotes_non_free_network_services">Propaguje nebo zcela závisí na nesvobodné síťové službě</string>
<string name="promotes_non_free_software">Propaguje ne-svobodný software</string> <string name="promotes_non_free_software">Propaguje nesvobodné doplňky</string>
<string name="provided_by_FORMAT">Poskytuje %s</string> <string name="provided_by_FORMAT">Poskytuje %s</string>
<string name="proxy">Proxy</string> <string name="proxy">Proxy</string>
<string name="repository_unsigned_DESC">Nepodepsáno. Nezdařilo se ověřit seznam aplikací. Buďte opatrní při stahování aplikací z nepodepsaných zdrojů.</string> <string name="repository_unsigned_DESC">Nenalezen žádný podpis. Nepodařilo se ověřit seznam aplikací</string>
<string name="requires_FORMAT">Vyžaduje %s</string> <string name="requires_FORMAT">Vyžaduje %s</string>
<string name="save">Uložit</string> <string name="save">Uložit</string>
<string name="saving_details">Ukládám detaily…</string> <string name="saving_details">Ukládám detaily…</string>
@@ -130,27 +130,27 @@
<string name="syncing">Synchronizuji</string> <string name="syncing">Synchronizuji</string>
<string name="syncing_FORMAT">Synchronizuji %s…</string> <string name="syncing_FORMAT">Synchronizuji %s…</string>
<string name="system">Systém</string> <string name="system">Systém</string>
<string name="tap_to_install_DESC">Klikněte pro instalaci.</string> <string name="tap_to_install_DESC">Klikněte pro instalaci</string>
<string name="theme">Téma</string> <string name="theme">Téma</string>
<string name="themes">Témata</string> <string name="themes">Témata</string>
<string name="tracks_or_reports_your_activity">Sleduje nebo hlásí vaší aktivitu</string> <string name="tracks_or_reports_your_activity">Sleduje nebo hlásí vaší aktivitu</string>
<string name="uninstall">Odinstalovat</string> <string name="uninstall">Odinstalovat</string>
<string name="unknown">Neznámé</string> <string name="unknown">Neznámé</string>
<string name="unknown_error_DESC">Neznámá chyba.</string> <string name="unknown_error_DESC">Neznámá chyba</string>
<string name="unknown_FORMAT">Neznámé: %s</string> <string name="unknown_FORMAT">Neznámé: %s</string>
<string name="unstable_updates">Nestabilní aktualizace</string> <string name="unstable_updates">Nestabilní aktualizace</string>
<string name="update">Aktualizovat</string> <string name="update">Aktualizovat</string>
<string name="updates">Aktualizace</string> <string name="updates">Aktualizace</string>
<string name="upstream_source_code_is_not_free">Originální zdrojový kód není svobodný</string> <string name="upstream_source_code_is_not_free">Původní zdrojový kód není svobodný</string>
<string name="username">Uživatelské jméno</string> <string name="username">Uživatelské jméno</string>
<string name="username_missing">Chybí uživatelské jméno</string> <string name="username_missing">Chybí uživatelské jméno</string>
<string name="validation_index_error_DESC">Index nemohl být ověřen.</string> <string name="validation_index_error_DESC">Index nemohl být ověřen</string>
<string name="version">Verze</string> <string name="version">Verze</string>
<string name="version_FORMAT">Verze %s</string> <string name="version_FORMAT">Verze %s</string>
<string name="versions">Verze</string> <string name="versions">Verze</string>
<string name="waiting_to_start_download">Čekám na zahájení stahování…</string> <string name="waiting_to_start_download">Čekám na zahájení stahování…</string>
<string name="whats_new">Co je nového</string> <string name="whats_new">Co je nového</string>
<string name="website">Web</string> <string name="website">Web projektu</string>
<string name="prefs_language_title">Jazyk</string> <string name="prefs_language_title">Jazyk</string>
<string name="prefs_personalization">Personalizace</string> <string name="prefs_personalization">Personalizace</string>
<string name="show_less">Zobrazit méně</string> <string name="show_less">Zobrazit méně</string>
@@ -164,10 +164,10 @@
<string name="compiled_for_debugging">Zkompilováno pro ladění</string> <string name="compiled_for_debugging">Zkompilováno pro ladění</string>
<string name="installer">Instalátor</string> <string name="installer">Instalátor</string>
<string name="legacy_installer">Původní instalátor</string> <string name="legacy_installer">Původní instalátor</string>
<string name="session_installer">Instalátor pomocí relací</string> <string name="session_installer">Relační instalátor</string>
<string name="root_installer">Root instalátor</string> <string name="root_installer">Root instalátor</string>
<string name="shizuku_installer">Instalátor Shizuku</string> <string name="shizuku_installer">Instalátor Shizuku/Sui</string>
<string name="unstable_updates_summary">Navrhnout instalaci nestabilních verzí</string> <string name="unstable_updates_summary">Navrhovat instalaci nestabilních verzí aplikací</string>
<string name="select_mirror">Vybrat mirror</string> <string name="select_mirror">Vybrat mirror</string>
<plurals name="days"> <plurals name="days">
<item quantity="one">den</item> <item quantity="one">den</item>
@@ -180,9 +180,9 @@
<item quantity="other">hodin</item> <item quantity="other">hodin</item>
</plurals> </plurals>
<string name="cleanup_title">Interval čištění APK</string> <string name="cleanup_title">Interval čištění APK</string>
<string name="only_on_wifi_with_charging">Pouze na Wi-Fi a při nabíjení</string> <string name="only_on_wifi_with_charging">Pouze na Wi-Fi při nabíjení</string>
<string name="io_error_DESC">Nepodařilo se vykonat některé akce.</string> <string name="io_error_DESC">Nepodařilo se vykonat některé akce</string>
<string name="material_you_desc">Použít barevný motiv Material You</string> <string name="material_you_desc">Použít barvy Material You</string>
<string name="material_you">Material You</string> <string name="material_you">Material You</string>
<string name="favourites">Oblíbené</string> <string name="favourites">Oblíbené</string>
<string name="force_clean_up_DESC">Vyčistí přebytečné soubory</string> <string name="force_clean_up_DESC">Vyčistí přebytečné soubory</string>
@@ -191,49 +191,54 @@
<string name="enable_repo">Povolit repozitář</string> <string name="enable_repo">Povolit repozitář</string>
<string name="waiting_to_start_installation">Čekání na spuštění instalace…</string> <string name="waiting_to_start_installation">Čekání na spuštění instalace…</string>
<string name="installing">Instalace</string> <string name="installing">Instalace</string>
<string name="auto_update">Automatická aktualizace aplikací</string> <string name="auto_update">Automaticky aktualizovat aplikace</string>
<string name="auto_update_apps">Pokusit se automaticky nainstalovat aktualizace</string> <string name="auto_update_apps">Automaticky instalovat aktualizace</string>
<string name="has_non_free_components">Obsahuje nesvobodné součásti</string> <string name="has_non_free_components">Obsahuje nesvobodné součásti</string>
<string name="socket_error_DESC">Server neodeslal nový paket.</string> <string name="socket_error_DESC">Server neodeslal nový paket</string>
<string name="connection_error_DESC">Nepodařilo se připojit k serveru</string> <string name="connection_error_DESC">Nepodařilo se připojit k serveru</string>
<string name="shizuku_not_alive">Shizuku není spuštěno</string> <string name="shizuku_not_alive">Shizuku/Sui není spuštěno</string>
<string name="shizuku_not_installed">Shizuku není nainstalováno</string> <string name="shizuku_not_installed">Shizuku/Sui není nainstalováno</string>
<string name="contains_nsfw">Obsahuje obsah nevhodný do práce</string> <string name="contains_nsfw">Obsahuje obsah nevhodný do práce (NSFW)</string>
<string name="special_credits">Speciální poděkování</string> <string name="special_credits">Zvláštní poděkování</string>
<string name="home_screen_swiping">Posouvání na domovské stránce</string> <string name="home_screen_swiping">Posouvání stránek</string>
<string name="home_screen_swiping_DESC">Umožnit uživateli posouvat mezi stránkami na domovské stránce</string> <string name="home_screen_swiping_DESC">Posuňte vlevo nebo v pravo pro změnu stránky</string>
<string name="repository_not_found">Následující repozitář nebyl nalezen</string> <string name="repository_not_found">Repozitář nebyl nalezen</string>
<string name="proxy_port_error_not_int">Port proxy smí být pouze celé číslo</string> <string name="proxy_port_error_not_int">Port proxy smí být pouze celé číslo</string>
<string name="import_settings_title">Importovat nastavení</string> <string name="import_settings_title">Importovat oblíbené a nastavení</string>
<string name="import_export">Import/export</string> <string name="import_export">Záloha a obnovení</string>
<string name="import_settings_DESC">Importovat nastavení a oblíbené ze souboru</string> <string name="import_settings_DESC">Importovat nastavení a oblíbené ze souboru</string>
<string name="export_settings_title">Exportovat nastavení</string> <string name="export_settings_title">Exportovat oblíbené a nastavení</string>
<string name="export_repos_DESC">Exportovat všechny repozitáře do souboru</string> <string name="export_repos_DESC">Exportovat repozitáře do souboru</string>
<string name="import_repos_title">Importovat repozitáře</string> <string name="import_repos_title">Importovat repozitáře</string>
<string name="export_settings_DESC">Exportovat nastavení a oblíbené do souboru</string> <string name="export_settings_DESC">Exportovat nastavení a oblíbené do souboru</string>
<string name="export_repos_title">Exportovat repozitáře</string> <string name="export_repos_title">Exportovat repozitáře</string>
<string name="import_repos_DESC">Importovat všechny repozitáře ze souboru</string> <string name="import_repos_DESC">Importovat repozitáře ze souboru</string>
<string name="cannot_open_link">Nelze otevřít odkaz</string> <string name="cannot_open_link">Odkaz se nepodařilo otevřít</string>
<string name="has_tethered_network">Připojeno k určité síťové službě</string> <string name="has_tethered_network">Závisí na určité instanci síťové služby</string>
<string name="require_background_access_DESC">Přístup na pozadí je vyžadován pro správné spuštění synchronizace na pozadí</string> <string name="require_background_access_DESC">Zakázání optimalizací baterie je vyžadováno pro správnou funkčnost synchronizace na pozadí</string>
<string name="require_background_access">Vyžadovat přístup na pozadí</string> <string name="require_background_access">Zakázat optimalizace baterie</string>
<string name="ignore_signature">Ignorovat podpis</string> <string name="ignore_signature">Ignorovat ověření podpisu</string>
<string name="ignore_signature_summary">*Varování* Ignorovat ověřování podpisu při instalaci aplikace, pro uživatele LSPosed nebo pokročilé uživatele</string> <string name="ignore_signature_summary">Ignorovat ověření podpisu při instalaci aplikace</string>
<string name="uninstalled_application_DESC">Aplikace %s byla odinstalována</string> <string name="uninstalled_application_DESC">Aplikace %s byla odinstalována</string>
<string name="installation_failed">Instalace selhala</string> <string name="installation_failed">Instalace selhala</string>
<string name="installation_failed_DESC">Nepodařilo se nainstalovat aplikaci %s</string> <string name="installation_failed_DESC">Nepodařilo se nainstalovat aplikaci %s</string>
<string name="uninstalled_application">Odinstalováno</string> <string name="uninstalled_application">Odinstalováno</string>
<string name="insufficient_storage">Nedostatek místa</string> <string name="insufficient_storage">Nedostatek místa</string>
<string name="insufficient_storage_DESC">Na zařízení není dostatek místa k instalaci této aplikace. Zkuste uvolnit trochu místa</string> <string name="insufficient_storage_DESC">Nemáte dostatek místa k instalaci této aplikace</string>
<string name="error_shizuku_service_unavailable">Shizuku není spuštěno</string> <string name="error_shizuku_service_unavailable">Služba Shizuku/Sui není spuštěna</string>
<string name="error_shizuku_not_granted">Chybějící oprávnění Shizuku</string> <string name="error_shizuku_not_granted">Oprávnění Shizuku/Sui neuděleno</string>
<string name="error_shizuku_not_granted_DESC">Nebylo uděleno oprávnění ke službě Shizuku. Zkontrolujte prosím aplikaci Shizuku</string> <string name="error_shizuku_not_granted_DESC">Nebylo uděleno oprávnění Shizuku/Sui</string>
<string name="error_shizuku_not_installed_DESC">Aplikace Shizuku nejspíše není nainstalována</string> <string name="error_shizuku_not_installed_DESC">Shizuku/Sui není nainstalováno</string>
<string name="switch_to_default_installer">Přepnout na výchozí</string> <string name="switch_to_default_installer">Přepnout na výchozí</string>
<string name="label_unknown_sdk">Neznámé (%d)</string> <string name="label_unknown_sdk">Neznámé (%d)</string>
<string name="error_shizuku_not_running_DESC">Služba Shizuku není spuštěna. Zkontrolujte prosím aplikaci Shizuku</string> <string name="error_shizuku_not_running_DESC">Služba Shizuku/Sui není spuštěna</string>
<string name="error_shizuku_not_installed">Shizuku není nainstalováno</string> <string name="error_shizuku_not_installed">Shizuku/Sui není nainstalováno</string>
<string name="label_open_video">Video</string> <string name="label_open_video">Video</string>
<string name="open_shizuku">Otevřít Shizuku</string> <string name="open_shizuku">Otevřít Shizuku</string>
<string name="label_targets_sdk">Cíle: Android %s</string> <string name="always_choose">Vždy se zeptat</string>
<string name="select_installer">Vyberte instalátor</string>
<string name="shizuku_legacy_installer">Starý instalátor Shizuku/Sui</string>
<string name="legacyInstallerComponent">Komponenta starého instalátoru</string>
<string name="label_sdk_version">Cíl: Android %1$s | Minimum: Android %2$s</string>
<string name="unspecified">Nespecifikováno</string>
</resources> </resources>

View File

@@ -39,10 +39,10 @@
<string name="downloaded_FORMAT">Hentet %s</string> <string name="downloaded_FORMAT">Hentet %s</string>
<string name="downloading">Henter</string> <string name="downloading">Henter</string>
<string name="downloading_FORMAT">Henter %s…</string> <string name="downloading_FORMAT">Henter %s…</string>
<string name="import_export">Import/Eksport</string> <string name="import_export">Import/eksport</string>
<string name="import_settings_title">Importér Indstillinger</string> <string name="import_settings_title">Importér indstillinger</string>
<string name="import_settings_DESC">Importér indstillinger og favoritter fra fil</string> <string name="import_settings_DESC">Importér indstillinger og favoritter fra fil</string>
<string name="export_settings_title">Eksportér Indstillinger</string> <string name="export_settings_title">Eksportér indstillinger</string>
<string name="favourites">Favoritter</string> <string name="favourites">Favoritter</string>
<string name="file_format_error_DESC">Ugyldigt filformat.</string> <string name="file_format_error_DESC">Ugyldigt filformat.</string>
<string name="fingerprint">Fingeraftryk</string> <string name="fingerprint">Fingeraftryk</string>
@@ -60,8 +60,8 @@
<string name="connection_error_DESC">Kunne ikke forbinde til server</string> <string name="connection_error_DESC">Kunne ikke forbinde til server</string>
<string name="ignore_all_updates">Ignorer alle nye versioner</string> <string name="ignore_all_updates">Ignorer alle nye versioner</string>
<string name="incompatible_api_DESC_FORMAT">Din %1$s (API-version %2$d) understøttes ikke. %3$s</string> <string name="incompatible_api_DESC_FORMAT">Din %1$s (API-version %2$d) understøttes ikke. %3$s</string>
<string name="incompatible_api_max_DESC_FORMAT">Maksimal API-version er %d.</string> <string name="incompatible_api_max_DESC_FORMAT">Maks. API-version er %d.</string>
<string name="incompatible_api_min_DESC_FORMAT">Minimum API-version er %d.</string> <string name="incompatible_api_min_DESC_FORMAT">Min. API-version er %d.</string>
<string name="incompatible_features_DESC">Manglende funktioner.</string> <string name="incompatible_features_DESC">Manglende funktioner.</string>
<string name="incompatible_older_DESC">Denne version er ældre end den, der er installeret på din enhed. Afinstaller den først.</string> <string name="incompatible_older_DESC">Denne version er ældre end den, der er installeret på din enhed. Afinstaller den først.</string>
<string name="incompatible_version">Inkompatibel version</string> <string name="incompatible_version">Inkompatibel version</string>
@@ -71,7 +71,7 @@
<string name="install">Installer</string> <string name="install">Installer</string>
<string name="install_types">Installationstyper</string> <string name="install_types">Installationstyper</string>
<string name="installer">Installatør</string> <string name="installer">Installatør</string>
<string name="shizuku_installer">Shizuku Installatør</string> <string name="shizuku_installer">Shizuku-installatør</string>
<string name="shizuku_not_alive">Shizuku kører ikke</string> <string name="shizuku_not_alive">Shizuku kører ikke</string>
<string name="shizuku_not_installed">Shizuku er ikke installeret</string> <string name="shizuku_not_installed">Shizuku er ikke installeret</string>
<string name="installing">Installerer</string> <string name="installing">Installerer</string>
@@ -86,11 +86,11 @@
<string name="light">Lys</string> <string name="light">Lys</string>
<string name="link_copied_to_clipboard">Link kopieret</string> <string name="link_copied_to_clipboard">Link kopieret</string>
<string name="links">Links</string> <string name="links">Links</string>
<string name="home_screen_swiping">Strygning på Startskærm</string> <string name="home_screen_swiping">Strygning på startside</string>
<string name="socket_error_DESC">Server kunne ikke levere ny pakke.</string> <string name="socket_error_DESC">Server kunne ikke levere ny pakke.</string>
<string name="http_proxy">HTTP-proxy</string> <string name="http_proxy">HTTP-proxy</string>
<string name="incompatible_platforms_DESC_FORMAT">Din %1$s platform understøttes ikke. Understøttede platforme: %2$s.</string> <string name="incompatible_platforms_DESC_FORMAT">Din %1$s platform understøttes ikke. Understøttede platforme: %2$s.</string>
<string name="root_installer">Root Installatør</string> <string name="root_installer">Root-installatør</string>
<string name="material_you">Material You</string> <string name="material_you">Material You</string>
<string name="material_you_desc">Brug Material You-farvetema</string> <string name="material_you_desc">Brug Material You-farvetema</string>
<string name="merging_FORMAT">Fletter %s</string> <string name="merging_FORMAT">Fletter %s</string>
@@ -113,10 +113,10 @@
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="only_compatible_with_FORMAT">Kun kompatibel med %s</string> <string name="only_compatible_with_FORMAT">Kun kompatibel med %s</string>
<string name="only_on_wifi">Kun på Wi-Fi</string> <string name="only_on_wifi">Kun på Wi-Fi</string>
<string name="only_on_wifi_with_charging">Kun på Wi-Fi &amp; Opladning</string> <string name="only_on_wifi_with_charging">Kun på Wi-Fi og opladning</string>
<string name="open_DESC_FORMAT">Åbn %s?</string> <string name="open_DESC_FORMAT">Åbn %s?</string>
<string name="other">Andet</string> <string name="other">Andet</string>
<string name="parsing_index_error_DESC">Kunne ikke analysere indeksfilen.</string> <string name="parsing_index_error_DESC">Kunne ikke fortolke indeksfilen.</string>
<string name="password">Adgangskode</string> <string name="password">Adgangskode</string>
<string name="password_missing">Manglende adgangskode</string> <string name="password_missing">Manglende adgangskode</string>
<string name="plus_more_FORMAT">+%d mere</string> <string name="plus_more_FORMAT">+%d mere</string>
@@ -179,7 +179,7 @@
<string name="export_settings_DESC">Eksportér indstillinger og favoritter til fil</string> <string name="export_settings_DESC">Eksportér indstillinger og favoritter til fil</string>
<string name="has_non_free_components">Har ikke-frie komponenter</string> <string name="has_non_free_components">Har ikke-frie komponenter</string>
<string name="has_tethered_network">Bundet til en bestemt netværkstjeneste</string> <string name="has_tethered_network">Bundet til en bestemt netværkstjeneste</string>
<string name="home_screen_swiping_DESC">Tillad at stryge mellem sider på startskærm</string> <string name="home_screen_swiping_DESC">Tillad at stryge mellem sider på startside</string>
<string name="ignore_this_update">Ignorer denne version</string> <string name="ignore_this_update">Ignorer denne version</string>
<string name="incompatible_signature_DESC">Denne version er signeret med et andet certifikat end den, der er installeret på din enhed. Afinstaller den først.</string> <string name="incompatible_signature_DESC">Denne version er signeret med et andet certifikat end den, der er installeret på din enhed. Afinstaller den først.</string>
<string name="installed">Installeret</string> <string name="installed">Installeret</string>
@@ -201,25 +201,39 @@
<string name="compiled_for_debugging">Kompileret til fejlfinding</string> <string name="compiled_for_debugging">Kompileret til fejlfinding</string>
<string name="delete_repository_DESC">Slet repositoriet?</string> <string name="delete_repository_DESC">Slet repositoriet?</string>
<string name="edit_repository">Rediger repository</string> <string name="edit_repository">Rediger repository</string>
<string name="import_repos_title">Importér Repositories</string> <string name="import_repos_title">Importér repositories</string>
<string name="import_repos_DESC">Importér alle repositories fra fil</string> <string name="import_repos_DESC">Importér alle repositories fra fil</string>
<string name="export_repos_title">Eksportér Repositories</string> <string name="export_repos_title">Eksportér repositories</string>
<string name="export_repos_DESC">Eksportér alle repositories til fil</string> <string name="export_repos_DESC">Eksportér alle repositories til fil</string>
<string name="enable_repo">Aktivér repositoriet</string> <string name="enable_repo">Aktivér repositoriet</string>
<string name="credits">Krediteringer</string> <string name="credits">Krediteringer</string>
<string name="update">Opdatering</string> <string name="update">Opdatering</string>
<string name="require_background_access_DESC">Baggrundsadgang er nødvendig for at køre baggrundssynkronisering korrekt</string> <string name="require_background_access_DESC">Baggrundsadgang er nødvendig for at køre baggrundssynkronisering korrekt</string>
<string name="require_background_access">Kræver Baggrundsadgang</string> <string name="require_background_access">Kræver baggrundsadgang</string>
<string name="legacy_installer">Ældre Installatør</string> <string name="legacy_installer">Ældre installatør</string>
<string name="session_installer">Session Installatør</string> <string name="session_installer">Sessionsinstallatør</string>
<string name="special_credits">Særlige Krediteringer</string> <string name="special_credits">Særlige Krediteringer</string>
<string name="contains_nsfw">Indeholder potentielt stødende indhold</string> <string name="contains_nsfw">Indeholder potentielt stødende indhold</string>
<string name="ignore_signature_summary">*Advarsel* Ignorer signaturverifikation ved APK-installation; for LSPosed- eller avancerede brugere</string> <string name="ignore_signature_summary">*Advarsel* Ignorer signaturverifikation ved APK-installation. For LSPosed- eller avancerede brugere</string>
<string name="installation_failed">Installation Mislykkedes</string> <string name="installation_failed">Installation mislykkedes</string>
<string name="installation_failed_DESC">Kunne ikke installere %s</string> <string name="installation_failed_DESC">Kunne ikke installere %s</string>
<string name="uninstalled_application">Afinstalleret</string> <string name="uninstalled_application">Afinstalleret</string>
<string name="uninstalled_application_DESC">%s blev afinstalleret</string> <string name="uninstalled_application_DESC">%s blev afinstalleret</string>
<string name="ignore_signature">Ignorer Signatur</string> <string name="ignore_signature">Ignorer signatur</string>
<string name="insufficient_storage">Utilstrækkelig plads</string> <string name="insufficient_storage">Utilstrækkelig plads</string>
<string name="insufficient_storage_DESC">Enheden har ikke nok ledig plads til at installere denne applikation. Prøv at frigøre noget plads</string> <string name="insufficient_storage_DESC">Enheden har ikke nok ledig plads til at installere denne applikation. Prøv at frigøre noget plads</string>
<string name="error_shizuku_not_running_DESC">Shizuku-tjenesten kører ikke. Tjek venligst i Shizuku-appen</string>
<string name="error_shizuku_not_granted">Shizuku-tilladelse mangler</string>
<string name="error_shizuku_not_granted_DESC">Shizuku-tilladelse er ikke givet. Tjek venligst i Shizuku-appen</string>
<string name="error_shizuku_not_installed">Shizuku ikke installeret</string>
<string name="error_shizuku_not_installed_DESC">Shizuku ser ikke ud til at være installeret</string>
<string name="open_shizuku">Åbn Shizuku</string>
<string name="label_unknown_sdk">Ukendt (%d)</string>
<string name="switch_to_default_installer">Skift til standard</string>
<string name="label_open_video">Video</string>
<string name="error_shizuku_service_unavailable">Shizuku kører ikke</string>
<string name="always_choose">Vælg altid</string>
<string name="select_installer">Vælg installationsprogram</string>
<string name="unspecified">Uspecificeret</string>
<string name="label_sdk_version">Mål: Android %1$s | Minimum: Android %2$s</string>
</resources> </resources>

View File

@@ -1,26 +1,26 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="add_repository">Repository hinzufügen</string> <string name="add_repository">Paketquelle hinzufügen</string>
<string name="all_applications">Alle Anwendungen</string> <string name="all_applications">Alle Apps</string>
<string name="all_applications_up_to_date">All deine Anwendungen sind aktuell</string> <string name="all_applications_up_to_date">Alle Apps sind aktuell</string>
<string name="already_exists">Bereits vorhanden</string> <string name="already_exists">Bereits vorhanden</string>
<string name="always">Immer</string> <string name="always">Immer</string>
<string name="address">Adresse</string> <string name="address">Adresse</string>
<string name="action_failed">Vorgang fehlgeschlagen</string> <string name="action_failed">Vorgang fehlgeschlagen</string>
<string name="amoled">Schwarz</string> <string name="amoled">Schwarz</string>
<string name="application">Anwendung</string> <string name="application">App</string>
<string name="anti_features">Unerwünschte Merkmale</string> <string name="anti_features">Unerwünschte Merkmale</string>
<string name="available">Entdecken</string> <string name="available">Entdecken</string>
<string name="cancel">Abbrechen</string> <string name="cancel">Abbrechen</string>
<string name="application_not_found">Diese Anwendung konnte nicht gefunden werden</string> <string name="application_not_found">App konnte nicht gefunden werden</string>
<string name="bug_tracker">Fehlerverwaltung</string> <string name="bug_tracker">Fehlerverwaltung</string>
<string name="changelog">Änderungsprotokoll</string> <string name="changelog">Änderungsprotokoll</string>
<string name="cant_edit_sync_DESC">Die Paketquelle kann nicht bearbeitet werden, da sie gerade synchronisiert wird.</string> <string name="cant_edit_sync_DESC">Die Paketquelle kann nicht bearbeitet werden, da sie gerade synchronisiert wird.</string>
<string name="checking_repository">Paketquelle wird abgefragt </string> <string name="checking_repository">Paketquelle wird abgefragt </string>
<string name="compiled_for_debugging">Kompiliert für die Fehlersuche</string> <string name="compiled_for_debugging">Kompiliert für die Fehlersuche</string>
<string name="connecting">Verbinde </string> <string name="connecting">Wird verbunden </string>
<string name="confirmation">Bestätigung</string> <string name="confirmation">Bestätigung</string>
<string name="could_not_validate_FORMAT">Konnte %s nicht validieren</string> <string name="could_not_validate_FORMAT">%s konnte nicht überprüft werden</string>
<string name="dark">Dunkel</string> <string name="dark">Dunkel</string>
<string name="contains_non_free_media">Enthält nicht-freie Medien</string> <string name="contains_non_free_media">Enthält nicht-freie Medien</string>
<string name="credits">Mitwirkende</string> <string name="credits">Mitwirkende</string>
@@ -37,31 +37,31 @@
<string name="incompatible_features_DESC">Fehlende Funktionen.</string> <string name="incompatible_features_DESC">Fehlende Funktionen.</string>
<string name="incompatible_platforms_DESC_FORMAT">Deine %1$s-Plattform wird nicht unterstützt. Unterstützte Plattformen: %2$s.</string> <string name="incompatible_platforms_DESC_FORMAT">Deine %1$s-Plattform wird nicht unterstützt. Unterstützte Plattformen: %2$s.</string>
<string name="installed">Installiert</string> <string name="installed">Installiert</string>
<string name="incompatible_versions_summary">Mit dem Gerät inkompatible Anwendungsversionen anzeigen</string> <string name="incompatible_versions_summary">Mit diesem Gerät inkompatible App-Versionen anzeigen</string>
<string name="install_types">Installationstypen</string> <string name="install_types">Installationsarten</string>
<string name="install">Installieren</string> <string name="install">Installieren</string>
<string name="integrity_check_error_DESC">Integrität konnte nicht überprüft werden.</string> <string name="integrity_check_error_DESC">Integrität konnte nicht überprüft werden.</string>
<string name="invalid_metadata_error_DESC">Ungültige Metadaten.</string> <string name="invalid_metadata_error_DESC">Ungültige Metadaten.</string>
<string name="invalid_signature_error_DESC">Ungültige Signatur.</string> <string name="invalid_signature_error_DESC">Ungültige Signatur.</string>
<string name="launch">Öffnen</string> <string name="launch">Öffnen</string>
<string name="light">Hell</string> <string name="light">Hell</string>
<string name="new_updates_available">Neue Anwendungsversionen verfügbar</string> <string name="new_updates_available">Neue Versionen von Apps verfügbar</string>
<string name="never">Nie</string> <string name="never">Nie</string>
<string name="network_error_DESC">Netzwerkfehler</string> <string name="network_error_DESC">Netzwerkfehler</string>
<string name="notify_about_updates">Über neue Versionen benachrichtigen</string> <string name="notify_about_updates">Benachrichtigung über Aktualisierungen</string>
<string name="no_description_available_DESC">Keine Beschreibung vorhanden</string> <string name="no_description_available_DESC">Keine Beschreibung vorhanden</string>
<string name="no_matching_applications_found">Keine derartigen Anwendungen konnten gefunden werden</string> <string name="no_matching_applications_found">Keine derartigen Apps auffindbar</string>
<string name="no_applications_installed">Keine installierten Anwendungen</string> <string name="no_applications_installed">Keine installierten Apps</string>
<string name="only_on_wifi">Nur bei Wi-Fi</string> <string name="only_on_wifi">Nur mit WLAN</string>
<string name="open_DESC_FORMAT">Öffne %s\?</string> <string name="open_DESC_FORMAT">%s öffnen?</string>
<string name="other">Andere</string> <string name="other">Andere</string>
<string name="number_of_applications">Anzahl der Anwendungen</string> <string name="number_of_applications">Anzahl der Apps</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="permissions">Berechtigungen</string> <string name="permissions">Berechtigungen</string>
<string name="plus_more_FORMAT">+%d mehr</string> <string name="plus_more_FORMAT">+%d mehr</string>
<string name="settings">Einstellungen</string> <string name="settings">Einstellungen</string>
<string name="processing_FORMAT">Verarbeitung %1$s …</string> <string name="processing_FORMAT">%1$s wird verarbeitet …</string>
<string name="promotes_non_free_software">Bewirbt unfreie Software</string> <string name="promotes_non_free_software">Bewirbt nicht-freie Software</string>
<string name="proxy">Proxy</string> <string name="proxy">Proxy</string>
<string name="repositories">Paketquellen</string> <string name="repositories">Paketquellen</string>
<string name="requires_FORMAT">Benötigt %s</string> <string name="requires_FORMAT">Benötigt %s</string>
@@ -70,36 +70,36 @@
<string name="skip">Überspringen</string> <string name="skip">Überspringen</string>
<string name="suggested">Empfohlen</string> <string name="suggested">Empfohlen</string>
<string name="syncing">Synchronisierung</string> <string name="syncing">Synchronisierung</string>
<string name="themes">Themen</string> <string name="themes">Designs</string>
<string name="unknown">Unbekannt</string> <string name="unknown">Unbekannt</string>
<string name="uninstall">Deinstallation</string> <string name="uninstall">Deinstallieren</string>
<string name="update">Aktualisierung</string> <string name="update">Aktualisieren</string>
<string name="version">Version</string> <string name="version">Version</string>
<string name="versions">Versionen</string> <string name="versions">Versionen</string>
<string name="whats_new">Was gibt es Neues</string> <string name="whats_new">Neu hinzugefügt</string>
<string name="waiting_to_start_download">Warten auf den Downloadbeginn …</string> <string name="waiting_to_start_download">Warten auf den Start des Downloads …</string>
<string name="validation_index_error_DESC">Der Index konnte nicht validiert werden.</string> <string name="validation_index_error_DESC">Der Index konnte nicht überprüft werden.</string>
<string name="website">Webseite</string> <string name="website">Website</string>
<string name="changes">Änderungen</string> <string name="changes">Änderungen</string>
<string name="author_email">Autor-E-Mail-Adresse</string> <string name="author_email">E-Mail-Adresse</string>
<string name="could_not_download_FORMAT">Konnte %s nicht herunterladen</string> <string name="could_not_download_FORMAT">%s konnte nicht heruntergeladen werden</string>
<string name="author_website">Autor-Webseite</string> <string name="author_website">Website</string>
<string name="delete">Löschen</string> <string name="delete">Löschen</string>
<string name="recently_updated">Zuletzt aktualisiert</string> <string name="recently_updated">Kürzlich aktualisiert</string>
<string name="license">Lizenz</string> <string name="license">Lizenz</string>
<string name="could_not_sync_FORMAT">Konnte %s nicht synchronisieren</string> <string name="could_not_sync_FORMAT">%s konnte nicht synchronisiert werden</string>
<string name="only_compatible_with_FORMAT">Nur kompatibel mit %s</string> <string name="only_compatible_with_FORMAT">Nur kompatibel mit %s</string>
<string name="license_FORMAT">%s-Lizenz</string> <string name="license_FORMAT">%s-Lizenz</string>
<string name="link_copied_to_clipboard">Link kopiert</string> <string name="link_copied_to_clipboard">Link kopiert</string>
<string name="project_website">Projekt-Website</string> <string name="project_website">Website des Projekts</string>
<string name="proxy_type">Proxy Typ</string> <string name="proxy_type">Proxy-Art</string>
<string name="repository">Paketquelle</string> <string name="repository">Paketquelle</string>
<string name="parsing_index_error_DESC">Die Indexdatei konnte nicht geparst werden.</string> <string name="parsing_index_error_DESC">Die Indexdatei konnte nicht geparst werden.</string>
<string name="password">Passwort</string> <string name="password">Passwort</string>
<string name="notify_about_updates_summary">Eine Benachrichtigung anzeigen, wenn neue Versionen verfügbar sind</string> <string name="notify_about_updates_summary">Benachrichtigung anzeigen, wenn neue Versionen verfügbar sind</string>
<string name="provided_by_FORMAT">Bereitgestellt von %s</string> <string name="provided_by_FORMAT">Bereitgestellt von %s</string>
<string name="source_code_no_longer_available">Quellcode nicht mehr verfügbar</string> <string name="source_code_no_longer_available">Quellcode nicht mehr verfügbar</string>
<string name="invalid_username_format">Ungültiges Benutzernamen-Format</string> <string name="invalid_username_format">Ungültiges Format des Benutzernamens</string>
<string name="no_proxy">Kein Proxy</string> <string name="no_proxy">Kein Proxy</string>
<string name="password_missing">Passwort fehlt</string> <string name="password_missing">Passwort fehlt</string>
<string name="source_code">Quellcode</string> <string name="source_code">Quellcode</string>
@@ -109,58 +109,58 @@
<string name="has_non_free_dependencies">Enthält nicht-freie Abhängigkeiten</string> <string name="has_non_free_dependencies">Enthält nicht-freie Abhängigkeiten</string>
<string name="incompatible_version">Inkompatible Version</string> <string name="incompatible_version">Inkompatible Version</string>
<string name="system">System</string> <string name="system">System</string>
<string name="theme">Thema</string> <string name="theme">Design</string>
<string name="ignore_all_updates">Alle neuen Versionen ignorieren</string> <string name="ignore_all_updates">Alle neuen Versionen ignorieren</string>
<string name="ignore_this_update">Diese Version ignorieren</string> <string name="ignore_this_update">Diese Version ignorieren</string>
<string name="size">Größe</string> <string name="size">Größe</string>
<string name="updates">Aktualisierungen</string> <string name="updates">Aktualisierungen</string>
<string name="username">Benutzername</string> <string name="username">Benutzername</string>
<string name="version_FORMAT">Version %s</string> <string name="version_FORMAT">Version %s</string>
<string name="downloading">Herunterladen</string> <string name="downloading">Wird heruntergeladen …</string>
<string name="share">Teilen</string> <string name="share">Teilen</string>
<string name="show_more">Zeige mehr</string> <string name="show_more">Mehr anzeigen</string>
<string name="show_older_versions">Ältere Versionen zeigen</string> <string name="show_older_versions">Ältere Versionen anzeigen</string>
<string name="username_missing">Benutzername fehlt</string> <string name="username_missing">Benutzername fehlt</string>
<string name="edit_repository">Paketquelle bearbeiten</string> <string name="edit_repository">Paketquelle bearbeiten</string>
<string name="file_format_error_DESC">Ungültiges Dateiformat.</string> <string name="file_format_error_DESC">Ungültiges Dateiformat.</string>
<string name="downloading_FORMAT">%s wird heruntergeladen </string> <string name="downloading_FORMAT">%s wird heruntergeladen </string>
<string name="incompatible_with_FORMAT">Inkompatibel mit %s</string> <string name="incompatible_with_FORMAT">Inkompatibel mit %s</string>
<string name="incompatible_versions">Inkompatible Versionen</string> <string name="incompatible_versions">Inkompatible Versionen</string>
<string name="invalid_address">Ungültige Adresse</string> <string name="invalid_address">Ungültige Adresse</string>
<string name="invalid_fingerprint_format">Ungültiges Fingerabdruckformat</string> <string name="invalid_fingerprint_format">Ungültiges Fingerabdruckformat</string>
<string name="invalid_permissions_error_DESC">Ungültige Berechtigungen.</string> <string name="invalid_permissions_error_DESC">Ungültige Berechtigungen.</string>
<string name="promotes_non_free_network_services">Bewirbt unfreie Netzwerkdienste</string> <string name="promotes_non_free_network_services">Bewirbt nicht-freie Netzwerkdienste</string>
<string name="unknown_FORMAT">Unbekannt: %s</string> <string name="unknown_FORMAT">Unbekannt: %s</string>
<string name="unknown_error_DESC">Unbekannter Fehler.</string> <string name="unknown_error_DESC">Unbekannter Fehler.</string>
<string name="syncing_FORMAT">Synchronisierung %s …</string> <string name="syncing_FORMAT">%s wird synchronisiert …</string>
<string name="incompatible_signature_DESC">Diese Version ist mit einem anderen Zertifikat signiert, als die auf Deinem Gerät installierte. Deinstalliere diese zuerst.</string> <string name="incompatible_signature_DESC">Diese Version ist mit einem anderen Zertifikat signiert als die auf deinem Gerät installierte Version. Deinstalliere diese zuerst.</string>
<string name="delete_repository_DESC">Die Paketquelle löschen\?</string> <string name="delete_repository_DESC">Paketquelle löschen?</string>
<string name="incompatible_older_DESC">Diese Version ist älter als die auf deinem Gerät installierte. Deinstalliere diese zuerst.</string> <string name="incompatible_older_DESC">Diese Version ist älter als diejenige, die auf deinem Gerät installiert ist. Deinstalliere diese zuerst.</string>
<string name="incompatible_api_DESC_FORMAT">Deine %1$s (API-Version %2$d) wird nicht unterstützt. %3$s</string> <string name="incompatible_api_DESC_FORMAT">Deine %1$s (API-Version %2$d) wird nicht unterstützt. %3$s</string>
<string name="incompatible_api_min_DESC_FORMAT">Die minimale API-Version ist %d.</string> <string name="incompatible_api_min_DESC_FORMAT">Die minimale API-Version ist %d.</string>
<string name="repository_unsigned_DESC">Nicht signiert. Die Anwendungsliste konnte nicht verifiziert werden. Sei vorsichtig beim Herunterladen von Anwendungen aus nicht signierten Paketquellen.</string> <string name="repository_unsigned_DESC">Nicht signiert. Die App-Liste konnte nicht verifiziert werden. Sei beim Herunterladen von Apps aus nicht signierten Paketquellen vorsichtig.</string>
<string name="unstable_updates_summary">Installation von instabilen Versionen vorschlagen</string> <string name="unstable_updates_summary">Installation von instabilen Versionen vorschlagen</string>
<string name="unstable_updates">Instabile Aktualisierungen</string> <string name="unstable_updates">Instabile Updates</string>
<string name="proxy_host">Proxy Host</string> <string name="proxy_host">Proxy-Host</string>
<string name="tap_to_install_DESC">Tippe um zu installieren.</string> <string name="tap_to_install_DESC">Zum Installieren tippen.</string>
<string name="tracks_or_reports_your_activity">Verfolgt oder erfasst deine Aktivitäten</string> <string name="tracks_or_reports_your_activity">Verfolgt oder versendet deine Aktivitäten</string>
<string name="proxy_port">Proxy Port</string> <string name="proxy_port">Proxy-Port</string>
<string name="search">Suche</string> <string name="search">Suchen</string>
<string name="sorting_order">Sortierreihenfolge</string> <string name="sorting_order">Sortierreihenfolge</string>
<string name="socks_proxy">SOCKS Proxy</string> <string name="socks_proxy">SOCKS-Proxy</string>
<string name="no_applications_available">Keine Anwendungen verfügbar</string> <string name="no_applications_available">Keine Apps verfügbar</string>
<plurals name="new_updates_DESC_FORMAT"> <plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d Anwendung hat eine neue Version.</item> <item quantity="one">%d App hat eine neue Version.</item>
<item quantity="other">%d Anwendungen haben eine neue Version.</item> <item quantity="other">%d Apps haben eine neue Version.</item>
</plurals> </plurals>
<string name="signed_using_unsafe_algorithm">Mit einem unsicheren Algorithmus signiert</string> <string name="signed_using_unsafe_algorithm">Mit einem unsicheren Algorithmus signiert</string>
<string name="select_mirror">Wähle einen Spiegel</string> <string name="select_mirror">Wähle einen Spiegel</string>
<string name="links">Links</string> <string name="links">Links</string>
<string name="merging_FORMAT">Führe %s zusammen</string> <string name="merging_FORMAT">%s wird zusammengeführt </string>
<string name="name">Name</string> <string name="name">Name</string>
<string name="upstream_source_code_is_not_free">Der Upstream-Quellcode ist nicht frei</string> <string name="upstream_source_code_is_not_free">Der Upstream-Quellcode ist nicht frei</string>
<string name="prefs_language_title">Sprache</string> <string name="prefs_language_title">Sprache</string>
<string name="prefs_personalization">Personalisierung</string> <string name="prefs_personalization">Personalisieren</string>
<string name="show_less">Weniger anzeigen</string> <string name="show_less">Weniger anzeigen</string>
<string name="update_all">Alle aktualisieren</string> <string name="update_all">Alle aktualisieren</string>
<plurals name="days"> <plurals name="days">
@@ -171,57 +171,57 @@
<item quantity="one">Stunde</item> <item quantity="one">Stunde</item>
<item quantity="other">Stunden</item> <item quantity="other">Stunden</item>
</plurals> </plurals>
<string name="only_on_wifi_with_charging">Nur während des Ladevorgangs und aktiviertem WLAN</string> <string name="only_on_wifi_with_charging">Nur mit WLAN während des Aufladens</string>
<string name="installer">Installationsmethode</string> <string name="installer">Installation</string>
<string name="cleanup_title">APK-Bereinigungsintervall</string> <string name="cleanup_title">APK-Bereinigungsintervall</string>
<string name="root_installer">Root-Installation</string> <string name="root_installer">Root-Installation</string>
<string name="legacy_installer">Alte Installationsmethode</string> <string name="legacy_installer">Legacy-Installation</string>
<string name="session_installer">Sitzungs-Installation</string> <string name="session_installer">Sitzungsinstallation</string>
<string name="shizuku_installer">Shizuku-Installation</string> <string name="shizuku_installer">Shizuku-Installation</string>
<string name="io_error_DESC">Bestimmte Aktionen können nicht durchgeführt werden.</string> <string name="io_error_DESC">Bestimmte Aktionen können nicht durchgeführt werden.</string>
<string name="favourites">Favoriten</string> <string name="favourites">Favoriten</string>
<string name="material_you">Material You</string> <string name="material_you">Material You</string>
<string name="material_you_desc">Material You-Farbschema verwenden</string> <string name="material_you_desc">Material You-Farbschema verwenden</string>
<string name="repository_unreachable">Repository unerreichbar</string> <string name="repository_unreachable">Paketquelle unerreichbar</string>
<string name="force_clean_up">Aufräumen erzwingen</string> <string name="force_clean_up">Bereinigung erzwingen</string>
<string name="enable_repo">Repository aktivieren</string> <string name="enable_repo">Paketquelle aktivieren</string>
<string name="force_clean_up_DESC">Entfernt doppelte Dateien</string> <string name="force_clean_up_DESC">Entfernt doppelte Dateien</string>
<string name="installing">Installation</string> <string name="installing">Wird installiert </string>
<string name="waiting_to_start_installation">Warten auf den Beginn der Installation </string> <string name="waiting_to_start_installation">Warten auf den Start der Installation </string>
<string name="auto_update">Apps automatisch aktualisieren</string> <string name="auto_update">Apps automatisch aktualisieren</string>
<string name="auto_update_apps">Versuche, Updates automatisch zu installieren</string> <string name="auto_update_apps">Updates möglichst automatisch installieren</string>
<string name="has_non_free_components">Hat nicht-freie Komponenten</string> <string name="has_non_free_components">Enthält nicht-freie Komponenten</string>
<string name="socket_error_DESC">Server konnte kein neues Datenpaket liefern.</string> <string name="socket_error_DESC">Server konnte kein neues Datenpaket liefern.</string>
<string name="shizuku_not_alive">Shizuku läuft nicht</string> <string name="shizuku_not_alive">Shizuku läuft nicht</string>
<string name="contains_nsfw">Enthält für den Arbeitsplatz unangemessene Inhalte</string> <string name="contains_nsfw">Enthält für den Arbeitsplatz unangemessene Inhalte</string>
<string name="connection_error_DESC">Verbindung zum Server nicht möglich</string> <string name="connection_error_DESC">Verbindung zum Server nicht möglich</string>
<string name="shizuku_not_installed">Shizuku ist nicht installiert</string> <string name="shizuku_not_installed">Shizuku ist nicht installiert</string>
<string name="home_screen_swiping">Wischgesten</string> <string name="home_screen_swiping">Wischgesten</string>
<string name="home_screen_swiping_DESC">Dem Benutzer erlauben, auf dem Startbildschirm zwischen den Seiten zu wischen</string> <string name="home_screen_swiping_DESC">Durch Wischen nach links/rechts zwischen Seiten navigieren</string>
<string name="special_credits">Besonderer Dank</string> <string name="special_credits">Besonderer Dank</string>
<string name="proxy_port_error_not_int">Proxy-Port muss eine natürliche Zahl sein</string> <string name="proxy_port_error_not_int">Proxy-Port muss eine natürliche Zahl sein</string>
<string name="repository_not_found">Folgende Repos konnten nicht gefunden werden</string> <string name="repository_not_found">Folgende Paketquelle konnten nicht gefunden werden</string>
<string name="import_settings_title">Einstellungen importieren</string> <string name="import_settings_title">Einstellungen importieren</string>
<string name="import_export">Importieren/Exportieren</string> <string name="import_export">Importieren/Exportieren</string>
<string name="import_settings_DESC">Importiere Einstellung und Favoriten von Datei</string> <string name="import_settings_DESC">Einstellungen und Favoriten aus Datei importieren</string>
<string name="export_settings_title">Einstellungen exportieren</string> <string name="export_settings_title">Einstellungen exportieren</string>
<string name="export_repos_DESC">Alle Repositories in eine Datei exportieren</string> <string name="export_repos_DESC">Paketquellen in eine Datei exportieren</string>
<string name="import_repos_title">Importiere eine Sammlung</string> <string name="import_repos_title">Paketquellen importieren</string>
<string name="export_settings_DESC">Einstellungen und Favoriten in eine Datei exportieren</string> <string name="export_settings_DESC">Einstellungen und Favoriten in eine Datei exportieren</string>
<string name="export_repos_title">Repositories exportieren</string> <string name="export_repos_title">Paketquellen exportieren</string>
<string name="import_repos_DESC">Alle Repositories aus einer Datei importieren</string> <string name="import_repos_DESC">Paketquellen aus einer Datei importieren</string>
<string name="cannot_open_link">Link kann nicht geöffnet werden</string> <string name="cannot_open_link">Link kann nicht geöffnet werden</string>
<string name="has_tethered_network">An einen bestimmten Netzwerkdienst gebunden</string> <string name="has_tethered_network">An einen bestimmten Netzwerkdienst gebunden</string>
<string name="ignore_signature">Signatur ignorieren</string> <string name="ignore_signature">Signatur ignorieren</string>
<string name="ignore_signature_summary">*Achtung* Signaturüberprüfung bei der Installation der APK ignorieren. Für LSPosed-Benutzer oder Experten.</string> <string name="ignore_signature_summary">*Achtung* Signaturüberprüfung bei der Installation der APK ignorieren. Für LSPosed-Benutzer oder Experten</string>
<string name="installation_failed">Installation fehlgeschlagen</string> <string name="installation_failed">Installation fehlgeschlagen</string>
<string name="uninstalled_application_DESC">%s wurde deinstalliert</string> <string name="uninstalled_application_DESC">%s wurde deinstalliert</string>
<string name="installation_failed_DESC">%s konnte nicht installiert werden</string> <string name="installation_failed_DESC">%s konnte nicht installiert werden</string>
<string name="uninstalled_application">Deinstalliert</string> <string name="uninstalled_application">Deinstalliert</string>
<string name="require_background_access">Hintergrundzugriff anfordern</string> <string name="require_background_access">Hintergrundzugriff anfordern</string>
<string name="require_background_access_DESC">Hintergrundzugriff ist erforderlich, um die Hintergrundsynchronisation ordnungsgemäß durchzuführen</string> <string name="require_background_access_DESC">Hintergrundzugriff ist erforderlich, um die Hintergrundsynchronisation ordnungsgemäß durchzuführen</string>
<string name="insufficient_storage">Nicht genug Speicherplatz</string> <string name="insufficient_storage">Nicht genügend Speicherplatz</string>
<string name="insufficient_storage_DESC">Es gibt nicht genug Speicherplatz, um diese Anwendung zu installieren. Versuche etwas Platz zu schafffen.</string> <string name="insufficient_storage_DESC">Nicht genügend Speicherplatz, um diese App zu installieren. Versuche, etwas Platz zu schaffen</string>
<string name="error_shizuku_not_granted">Shizuku-Erlaubnis fehlt</string> <string name="error_shizuku_not_granted">Shizuku-Erlaubnis fehlt</string>
<string name="error_shizuku_not_granted_DESC">Erlaubnis für den Shizuku-Dienst ist nicht gewährt. Bitte in der Shizuku-App prüfen</string> <string name="error_shizuku_not_granted_DESC">Erlaubnis für den Shizuku-Dienst ist nicht gewährt. Bitte in der Shizuku-App prüfen</string>
<string name="error_shizuku_not_installed">Shizuku nicht installiert</string> <string name="error_shizuku_not_installed">Shizuku nicht installiert</string>
@@ -230,4 +230,11 @@
<string name="open_shizuku">Shizuku öffnen</string> <string name="open_shizuku">Shizuku öffnen</string>
<string name="error_shizuku_service_unavailable">Shizuku läuft nicht</string> <string name="error_shizuku_service_unavailable">Shizuku läuft nicht</string>
<string name="error_shizuku_not_running_DESC">Der Shizuku-Dienst läuft nicht. Bitte in der Shizuku-App prüfen</string> <string name="error_shizuku_not_running_DESC">Der Shizuku-Dienst läuft nicht. Bitte in der Shizuku-App prüfen</string>
<string name="label_open_video">Video</string>
<string name="label_unknown_sdk">Unbekannt (%d)</string>
<string name="unspecified">Nicht spezifiziert</string>
<string name="shizuku_legacy_installer">Shizuku Lagacy-Installation</string>
<string name="legacyInstallerComponent">Legacy-Installationskomponente</string>
<string name="always_choose">Immer Wählen</string>
<string name="label_sdk_version">Zielversion: Android %1$s | Minimum: Android %2$s</string>
</resources> </resources>

View File

@@ -220,4 +220,22 @@
<string name="uninstalled_application_DESC">Το %s απεγκαταστάθηκε</string> <string name="uninstalled_application_DESC">Το %s απεγκαταστάθηκε</string>
<string name="ignore_signature">Αγνόησή Υπογραφής</string> <string name="ignore_signature">Αγνόησή Υπογραφής</string>
<string name="ignore_signature_summary">Αγνοήστε την επαλήθευση υπογραφής κατά την εγκατάσταση apk, για χρήστες με LSP ή προχωρημένους χρήστες</string> <string name="ignore_signature_summary">Αγνοήστε την επαλήθευση υπογραφής κατά την εγκατάσταση apk, για χρήστες με LSP ή προχωρημένους χρήστες</string>
<string name="error_shizuku_not_granted_DESC">Η άδεια λειτουργίας της υπηρεσίας Shizuku δεν έχει παραχωρηθεί. Ελέγξτε την εφαρμογή Shizuku</string>
<string name="error_shizuku_not_installed">Το Shizuku δεν έχει εγκατασταθεί</string>
<string name="open_shizuku">Άνοιγμα Shizuku</string>
<string name="label_unknown_sdk">Άγνωστο (%d)</string>
<string name="label_open_video">Βίντεο</string>
<string name="switch_to_default_installer">Μετάβαση στην Προεπιλογή</string>
<string name="insufficient_storage">Ανεπαρκής Χώρος</string>
<string name="error_shizuku_not_installed_DESC">Το Shizuku δεν φαίνεται να είναι εγκατεστημένο</string>
<string name="error_shizuku_service_unavailable">Δεν είναι σε λειτουργία το Shizuku</string>
<string name="error_shizuku_not_running_DESC">Η υπηρεσία Shizuku δεν λειτουργεί. Ελέγξτε την εφαρμογή Shizuku</string>
<string name="error_shizuku_not_granted">Λείπει η άδεια Shizuku</string>
<string name="insufficient_storage_DESC">Δεν υπάρχει αρκετός ελεύθερος χώρος στη συσκευή για την εγκατάσταση αυτής της εφαρμογής. Προσπαθήστε να ελευθερώσετε και να καθαρίσετε λίγο χώρο</string>
<string name="unspecified">Απροσδιόριστο</string>
<string name="legacyInstallerComponent">Εξάρτημα Απαρχαιωμένου Εγκαταστάτη</string>
<string name="shizuku_legacy_installer">Απαρχαιωμένος Εγκαταστάτης Shizuku</string>
<string name="always_choose">Πάντα Επιλογή</string>
<string name="select_installer">Επιλέξτε εγκαταστάτη</string>
<string name="label_sdk_version">Στοχεύει: Android %1$s | Ελάχιστο: Android %2$s</string>
</resources> </resources>

Some files were not shown because too many files have changed in this diff Show More