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}]
indent_size = 4
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999

View File

@@ -61,7 +61,7 @@ jobs:
- name: Extract Version Code
id: extract_version
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 "Version Code: $VERSION_CODE"

View File

@@ -2,17 +2,17 @@
<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 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 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)
[![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)
[![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 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 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)
[![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 align="left">
## Features
* Material & Clean design
* Clean Material 3 design
* Fast repository syncing
* Smooth user experience
* 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%" />
## Building and Installing
## Building and installing
1. **Install Android Studio**:
- Download and install [Android Studio](https://developer.android.com/studio) on your computer
if you haven't already.
2. **Clone the Repository**:
2. **Clone the repository**:
- Open Android Studio and select "Project from Version Control."
- Paste the link to this repository to clone it to your local machine.
@@ -39,15 +39,14 @@
## TODO
- [ ] Add support for `index-v2`
- [ ] Add detekt code-analysis
- [ ] Add GitHub Repo feature
- [ ] Add detekt code analysis
## Contribution
## Contributing
- Pick any issue you would like to resolve
- Fork the project
- Open a Pull Request
- Your PR will undergo review
- Open a pull request
- Your pull request will undergo review
## Translations

View File

@@ -1,10 +1,10 @@
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 {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
@@ -12,7 +12,7 @@ plugins {
}
android {
val latestVersionName = "0.6.5"
val latestVersionName = "0.6.6"
namespace = "com.looker.droidify"
buildToolsVersion = "35.0.0"
compileSdk = 35
@@ -20,39 +20,30 @@ android {
minSdk = 23
targetSdk = 35
applicationId = "com.looker.droidify"
versionCode = 650
versionCode = 660
versionName = latestVersionName
vectorDrawables.useSupportLibrary = false
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "com.looker.droidify.TestRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
compileOptions.isCoreLibraryDesugaringEnabled = true
kotlinOptions.freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-parameters")
androidResources.generateLocaleConfig = true
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs = listOf(
"-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"))
kotlin {
jvmToolchain(17)
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_2_2)
apiVersion.set(KotlinVersion.KOTLIN_2_2)
jvmTarget.set(JvmTarget.JVM_17)
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@@ -64,7 +55,7 @@ android {
resValue("string", "application_name", "Droid-ify")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard.pro"
"proguard.pro",
)
}
create("alpha") {
@@ -73,7 +64,7 @@ android {
resValue("string", "application_name", "Droid-ify Alpha")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard.pro"
"proguard.pro",
)
isDebuggable = true
isMinifyEnabled = true
@@ -82,7 +73,7 @@ android {
buildConfigField(
type = "String",
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 {
coreLibraryDesugaring(libs.desugaring)
@@ -144,19 +123,17 @@ dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.datetime)
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
implementation(libs.coroutines.guava)
implementation(libs.bundles.coroutines)
implementation(libs.libsu.core)
implementation(libs.shizuku.api)
api(libs.shizuku.provider)
implementation(libs.bundles.shizuku)
implementation(libs.jackson.core)
implementation(libs.serialization)
implementation(libs.ktor.core)
implementation(libs.ktor.okhttp)
implementation(libs.bundles.ktor)
implementation(libs.bundles.room)
ksp(libs.room.compiler)
implementation(libs.work.ktx)
@@ -169,13 +146,16 @@ dependencies {
testImplementation(platform(libs.junit.bom))
testImplementation(libs.bundles.test.unit)
testRuntimeOnly(libs.junit.platform)
androidTestImplementation(platform(libs.junit.bom))
androidTestImplementation(libs.hilt.test)
androidTestImplementation(libs.room.test)
androidTestImplementation(libs.bundles.test.android)
kspAndroidTest(libs.hilt.compiler)
// debugImplementation(libs.leakcanary)
}
// 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") {
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.filters.SmallTest
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.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.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.sqrt
import kotlin.system.measureTimeMillis
@RunWith(AndroidJUnit4::class)
@@ -21,7 +27,9 @@ class RepositoryUpdaterTest {
@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().context
context = InstrumentationRegistry.getInstrumentation().targetContext
Database.init(context)
RepositoryUpdater.init(CoroutineScope(Dispatchers.Default), FakeDownloader)
repository = Repository(
id = 15,
address = "https://apt.izzysoft.de/fdroid/repo",
@@ -41,13 +49,14 @@ class RepositoryUpdaterTest {
@Test
fun processFile() {
testRepetition(1) {
val output = benchmark(1) {
val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger")
val jarStream = context.resources.assets.open("index-v1.jar")
jarStream.copyTo(createFile.outputStream())
process(createFile, mergerFile)
}
println(output)
}
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)
@Before
fun before() {
context = InstrumentationRegistry.getInstrumentation().context
context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher)
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.Izzy
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.benchmark
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.common.toV2
import com.looker.droidify.sync.v1.V1Parser
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.IndexV2
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -28,6 +29,8 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class V1SyncableTest {
@@ -42,7 +45,7 @@ class V1SyncableTest {
@Before
fun before() {
context = InstrumentationRegistry.getInstrumentation().context
context = InstrumentationRegistry.getInstrumentation().targetContext
dispatcher = StandardTestDispatcher()
validator = IndexJarValidator(dispatcher)
parser = V1Parser(dispatcher, JsonParser, validator)
@@ -102,9 +105,38 @@ class V1SyncableTest {
testIndexConversion("index-v1.jar", "index-v2-updated.json")
}
// @Test
fun v1tov2FDroidRepo() = runTest(dispatcher) {
testIndexConversion("fdroid-index-v1.jar", "fdroid-index-v2.json")
@Test
fun targetPropertyTest() = runTest(dispatcher) {
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(
@@ -252,6 +284,8 @@ private fun assertVersion(
assertNotNull(foundVersion)
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.src?.name, foundVersion.src?.name)
@@ -261,7 +295,13 @@ private fun assertVersion(
assertEquals(expectedMan.versionCode, foundMan.versionCode)
assertEquals(expectedMan.versionName, foundMan.versionName)
assertEquals(expectedMan.maxSdkVersion, foundMan.maxSdkVersion)
assertNotNull(expectedMan.usesSdk)
assertNotNull(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(
expectedMan.features.sortedBy { it.name },

View File

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

View File

@@ -6,7 +6,7 @@ import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo
val Izzy = Repo(
id = 1L,
id = 1,
enabled = true,
address = "https://apt.izzysoft.de/fdroid/repo",
name = "IzzyOnDroid F-Droid Repo",
@@ -15,6 +15,4 @@ val Izzy = Repo(
authentication = Authentication("", ""),
versionInfo = VersionInfo(0L, null),
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/.*" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />

View File

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

View File

@@ -14,13 +14,6 @@ import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.looker.droidify.utility.common.DeeplinkType
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.deeplinkType
import com.looker.droidify.utility.common.extension.homeAsUp
import com.looker.droidify.utility.common.extension.inputManager
import com.looker.droidify.utility.common.getInstallPackageName
import com.looker.droidify.utility.common.requestNotificationPermission
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.extension.getThemeRes
@@ -34,6 +27,13 @@ import com.looker.droidify.ui.repository.RepositoriesFragment
import com.looker.droidify.ui.repository.RepositoryFragment
import com.looker.droidify.ui.settings.SettingsFragment
import com.looker.droidify.ui.tabsFragment.TabsFragment
import 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.InstallIn
import dagger.hilt.android.AndroidEntryPoint
@@ -64,7 +64,7 @@ class MainActivity : AppCompatActivity() {
@Parcelize
private class FragmentStackItem(
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?,
) : Parcelable
lateinit var cursorOwner: CursorOwner
@@ -87,24 +87,25 @@ class MainActivity : AppCompatActivity() {
}
private fun collectChange() {
val hiltEntryPoint = EntryPointAccessors.fromApplication(
this, CustomUserRepositoryInjector::class.java
)
val hiltEntryPoint =
EntryPointAccessors.fromApplication(this, CustomUserRepositoryInjector::class.java)
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
runBlocking {
val theme = newSettings.first()
setTheme(
resources.configuration.getThemeRes(
theme = theme.first, dynamicTheme = theme.second
)
theme = theme.first,
dynamicTheme = theme.second,
),
)
}
lifecycleScope.launch {
newSettings.drop(1).collect { themeAndDynamic ->
setTheme(
resources.configuration.getThemeRes(
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
)
theme = themeAndDynamic.first,
dynamicTheme = themeAndDynamic.second,
),
)
recreate()
}
@@ -116,9 +117,11 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
val rootView = FrameLayout(this).apply { id = R.id.main_content }
addContentView(
rootView, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
rootView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
requestNotificationPermission(request = notificationPermission::launch)
@@ -188,7 +191,7 @@ class MainActivity : AppCompatActivity() {
if (open != null) {
setCustomAnimations(
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)
@@ -202,8 +205,8 @@ class MainActivity : AppCompatActivity() {
FragmentStackItem(
it::class.java.name,
it.arguments,
supportFragmentManager.saveFragmentInstanceState(it)
)
supportFragmentManager.saveFragmentInstanceState(it),
),
)
}
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.loader.app.LoaderManager
import androidx.loader.content.Loader
import com.looker.droidify.datastore.SettingsRepository
import com.looker.droidify.datastore.model.SortOrder
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> {
sealed class Request {
internal abstract val id: Int
@Inject
lateinit var settingsRepository: SettingsRepository
sealed interface Request {
val id: Int
class Available(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
) : Request() {
override val id: Int
get() = 1
}
override val id: Int = 1,
) : Request
class Installed(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
) : Request() {
override val id: Int
get() = 2
}
override val id: Int = 2,
) : Request
class Updates(
val searchQuery: String,
val section: ProductItem.Section,
val order: SortOrder,
val skipSignatureCheck: Boolean,
) : Request() {
override val id: Int
get() = 3
}
override val id: Int = 3,
) : Request
object Repositories : Request() {
override val id: Int
get() = 4
object Repositories : Request {
override val id = 4
}
}
@@ -56,10 +57,6 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
val cursor: Cursor?,
)
init {
retainInstance = true
}
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
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> {
val request = activeRequests[id]!!.request
return QueryLoader(requireContext()) {
val settings = runBlocking { settingsRepository.getInitial() }
when (request) {
is Request.Available ->
Database.ProductAdapter
@@ -103,6 +101,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section,
order = request.order,
signal = it,
skipSignatureCheck = settings.ignoreSignature,
)
is Request.Installed ->
@@ -114,6 +113,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section,
order = request.order,
signal = it,
skipSignatureCheck = settings.ignoreSignature,
)
is Request.Updates ->
@@ -125,7 +125,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
section = request.section,
order = request.order,
signal = it,
skipSignatureCheck = request.skipSignatureCheck,
skipSignatureCheck = settings.ignoreSignature,
)
is Request.Repositories -> Database.RepositoryAdapter.query(it)

View File

@@ -9,7 +9,8 @@ import android.os.CancellationSignal
import androidx.core.database.sqlite.transaction
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.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.model.InstalledItem
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.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.serialization.product
import com.looker.droidify.utility.serialization.productItem
import com.looker.droidify.utility.serialization.repository
@@ -44,52 +44,15 @@ import kotlin.collections.set
object Database {
fun init(context: Context): Boolean {
val helper = Helper(context)
val helper = DatabaseHelper(context)
db = helper.writableDatabase
if (helper.created) {
for (repository in Repository.defaultRepositories.sortedBy { it.name }) {
RepositoryAdapter.put(repository)
}
}
RepositoryAdapter.removeDuplicates()
return helper.created || helper.updated
}
private lateinit var db: SQLiteDatabase
private 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)",
)
}
}
private object Schema {
object Schema {
object Repository : Table {
const val ROW_ID = "_id"
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 {
data object Repositories : Subject()
data class Repository(val id: Long) : Subject()
@@ -364,7 +207,7 @@ object Database {
}
}
private fun SQLiteDatabase.query(
fun SQLiteDatabase.query(
table: String,
columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null,
@@ -607,6 +450,19 @@ object Database {
.map { getUpdates(skipSignatureCheck) }
.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> {
return db.query(
Schema.Product.name,
@@ -719,7 +575,7 @@ object Database {
when (order) {
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
SortOrder.NAME -> Unit
else -> Unit
}::class
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 com.looker.droidify.datastore.model.AutoSync
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.ProxyType
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.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalTime::class)
class PreferenceSettingsRepository(
private val dataStore: DataStore<Preferences>,
private val exporter: Exporter<Settings>,
@@ -36,7 +39,7 @@ class PreferenceSettingsRepository(
override val data: Flow<Settings> = dataStore.data
.catch { exception ->
if (exception is IOException) {
Log.e("TAG", "Error reading preferences.", exception)
Log.e("PreferencesSettingsRepository", "Error reading preferences.", exception)
} else {
throw exception
}
@@ -85,6 +88,31 @@ class PreferenceSettingsRepository(
override suspend fun setInstallerType(installerType: InstallerType) =
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) =
AUTO_UPDATE.update(allow)
@@ -125,6 +153,18 @@ class PreferenceSettingsRepository(
private fun mapSettings(preferences: Preferences): Settings {
val installerType =
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 incompatibleVersions = preferences[INCOMPATIBLE_VERSIONS] ?: false
@@ -154,6 +194,7 @@ class PreferenceSettingsRepository(
theme = theme,
dynamicTheme = dynamicTheme,
installerType = installerType,
legacyInstallerComponent = legacyInstallerComponent,
autoUpdate = autoUpdate,
autoSync = autoSync,
sortOrder = sortOrder,
@@ -185,6 +226,9 @@ class PreferenceSettingsRepository(
val LAST_CLEAN_UP = longPreferencesKey("key_last_clean_up_time")
val FAVOURITE_APPS = stringSetPreferencesKey("key_favourite_apps")
val HOME_SCREEN_SWIPING = booleanPreferencesKey("key_home_swiping")
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
val THEME = stringPreferencesKey("key_theme")
@@ -200,6 +244,28 @@ class PreferenceSettingsRepository(
set(UNSTABLE_UPDATES, settings.unstableUpdate)
set(THEME, settings.theme.name)
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(AUTO_UPDATE, settings.autoUpdate)
set(AUTO_SYNC, settings.autoSync.name)

View File

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

View File

@@ -3,6 +3,7 @@ package com.looker.droidify.datastore
import android.net.Uri
import com.looker.droidify.datastore.model.AutoSync
import com.looker.droidify.datastore.model.InstallerType
import com.looker.droidify.datastore.model.LegacyInstallerComponent
import com.looker.droidify.datastore.model.ProxyType
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.datastore.model.Theme
@@ -37,6 +38,8 @@ interface SettingsRepository {
suspend fun setInstallerType(installerType: InstallerType)
suspend fun setLegacyInstallerComponent(component: LegacyInstallerComponent?)
suspend fun setAutoUpdate(allow: Boolean)
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.ADDED -> getString(stringRes.whats_new)
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 {
UPDATED,
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 links: Links,
val metadata: Metadata,
val author: Author,
val author: Author?,
val screenshots: Screenshots,
val graphics: Graphics,
val donation: Donation,
@@ -15,34 +15,35 @@ data class App(
)
data class Author(
val id: Long,
val name: String,
val email: String,
val web: String
val id: Int,
val name: String?,
val email: String?,
val phone: String?,
val web: String?,
)
data class Donation(
val regularUrl: String? = null,
val regularUrl: List<String>? = null,
val bitcoinAddress: String? = null,
val flattrId: String? = null,
val liteCoinAddress: String? = null,
val litecoinAddress: String? = null,
val openCollectiveId: String? = null,
val librePayId: String? = null,
val liberapayId: String? = null,
)
data class Graphics(
val featureGraphic: String = "",
val promoGraphic: String = "",
val tvBanner: String = "",
val video: String = ""
val featureGraphic: String? = null,
val promoGraphic: String? = null,
val tvBanner: String? = null,
val video: String? = null,
)
data class Links(
val changelog: String = "",
val issueTracker: String = "",
val sourceCode: String = "",
val translation: String = "",
val webSite: String = ""
val changelog: String? = null,
val issueTracker: String? = null,
val sourceCode: String? = null,
val translation: String? = null,
val webSite: String? = null,
)
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)
.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 {
val bytes = encoded
return if (bytes.size >= 256) {

View File

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

View File

@@ -32,7 +32,7 @@ import kotlinx.coroutines.sync.withLock
class InstallManager(
private val context: Context,
settingsRepository: SettingsRepository
private val settingsRepository: SettingsRepository
) {
private val installItems = Channel<InstallItem>()
@@ -115,7 +115,7 @@ class InstallManager(
private suspend fun setInstaller(installerType: InstallerType) {
lock.withLock {
_installer = when (installerType) {
InstallerType.LEGACY -> LegacyInstaller(context)
InstallerType.LEGACY -> LegacyInstaller(context, settingsRepository)
InstallerType.SESSION -> SessionInstaller(context)
InstallerType.SHIZUKU -> ShizukuInstaller(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.intent
import kotlinx.coroutines.suspendCancellableCoroutine
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuProvider
import rikka.sui.Sui
import kotlin.coroutines.resume
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
@@ -16,42 +18,47 @@ private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
fun launchShizuku(context: Context) {
val activities =
context.packageManager.getLauncherActivities(ShizukuProvider.MANAGER_APPLICATION_ID)
if (activities.isEmpty()) return
val intent = intent(Intent.ACTION_MAIN) {
addCategory(Intent.CATEGORY_LAUNCHER)
setComponent(
ComponentName(
ShizukuProvider.MANAGER_APPLICATION_ID,
activities.first().first
)
activities.first().first,
),
)
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
fun initSui(context: Context) = Sui.init(context.packageName)
fun isSuiAvailable() = Sui.isSui()
fun isShizukuInstalled(context: Context) =
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 {
val listener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
it.resume(grantResult == PackageManager.PERMISSION_GRANTED)
}
}
rikka.shizuku.Shizuku.addRequestPermissionResultListener(listener)
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
Shizuku.addRequestPermissionResultListener(listener)
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
it.invokeOnCancellation {
rikka.shizuku.Shizuku.removeRequestPermissionResultListener(listener)
Shizuku.removeRequestPermissionResultListener(listener)
}
}
fun requestShizuku() {
rikka.shizuku.Shizuku.shouldShowRequestPermissionRationale()
rikka.shizuku.Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
Shizuku.shouldShowRequestPermissionRationale()
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
}
fun isMagiskGranted(): Boolean {

View File

@@ -1,20 +1,29 @@
package com.looker.droidify.installer.installers
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.util.AndroidRuntimeException
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.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.intent
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@Suppress("DEPRECATION")
class LegacyInstaller(private val context: Context) : Installer {
class LegacyInstaller(
private val context: Context,
private val settingsRepository: SettingsRepository
) : Installer {
companion object {
private const val APK_MIME = "application/vnd.android.package-archive"
@@ -22,29 +31,50 @@ class LegacyInstaller(private val context: Context) : Installer {
override suspend fun install(
installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont ->
): InstallState {
val installFlag = if (SdkCheck.isNougat) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0
val fileUri = if (SdkCheck.isNougat) {
Cache.getReleaseUri(
context,
installItem.installFileName
)
Cache.getReleaseUri(context, installItem.installFileName)
} else {
Cache.getReleaseFile(context, installItem.installFileName).toUri()
}
val installIntent = intent(Intent.ACTION_INSTALL_PACKAGE) {
setDataAndType(fileUri, APK_MIME)
flags = installFlag
}
try {
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: AndroidRuntimeException) {
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: Exception) {
cont.resume(InstallState.Failed)
val comp = settingsRepository.get { legacyInstallerComponent }.firstOrNull()
return suspendCancellableCoroutine { cont ->
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(fileUri, APK_MIME)
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 {
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: AndroidRuntimeException) {
installIntent.flags = installFlag or Intent.FLAG_ACTIVITY_NEW_TASK
try {
context.startActivity(installIntent)
cont.resume(InstallState.Installed)
} catch (e: Exception) {
cont.resume(InstallState.Failed)
}
} catch (e: Exception) {
cont.resume(InstallState.Failed)
}
}
}

View File

@@ -1,14 +1,14 @@
package com.looker.droidify.installer.installers.shizuku
import android.content.Context
import com.looker.droidify.utility.common.SdkCheck
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.size
import com.looker.droidify.domain.model.PackageName
import com.looker.droidify.installer.installers.Installer
import com.looker.droidify.installer.installers.uninstallPackage
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import 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 java.io.BufferedReader
import java.io.InputStream
@@ -21,13 +21,14 @@ class ShizukuInstaller(private val context: Context) : Installer {
}
override suspend fun install(
installItem: InstallItem
installItem: InstallItem,
): InstallState = suspendCancellableCoroutine { cont ->
var sessionId: String? = null
val file = Cache.getReleaseFile(context, installItem.installFileName)
val packageName = installItem.packageName.name
try {
val fileSize = file.size ?: run {
val fileSize = file.length()
if (fileSize == 0L) {
cont.cancel()
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
?: run {
cont.cancel()
throw RuntimeException("Failed to create install session")
error("Failed to create install session")
}
if (cont.isCompleted) return@suspendCancellableCoroutine
val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it)
if (writeResult.resultCode != 0) {
cont.cancel()
throw RuntimeException("Failed to write APK to session $sessionId")
error("Failed to write APK to session $sessionId")
}
if (cont.isCompleted) return@suspendCancellableCoroutine
val commitResult = exec("pm install-commit $sessionId")
if (commitResult.resultCode != 0) {
cont.cancel()
throw RuntimeException("Failed to commit install session $sessionId")
error("Failed to commit install session $sessionId")
}
if (cont.isCompleted) return@suspendCancellableCoroutine
cont.resume(InstallState.Installed)
}
} catch (e: Exception) {
} catch (_: Exception) {
if (sessionId != null) exec("pm install-abandon $sessionId")
cont.resume(InstallState.Failed)
}
@@ -71,7 +72,7 @@ class ShizukuInstaller(private val context: Context) : Installer {
override suspend fun uninstall(packageName: PackageName) =
context.uninstallPackage(packageName)
override fun close() {}
override fun close() = Unit
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)
}
private fun defaultRepository(
fun defaultRepository(
address: String,
name: String,
description: String,
@@ -137,9 +137,9 @@ data class Repository(
),
defaultRepository(
address = "https://microg.org/fdroid/repo",
name = "MicroG Project",
description = "The official repository for MicroG." +
" MicroG is a lightweight open-source implementation" +
name = "microG Project",
description = "The official repository for microG." +
" microG is a lightweight open source implementation" +
" of Google Play Services.",
fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165"
),
@@ -171,13 +171,6 @@ data class Repository(
description = "Collabora Office is an office suite based on LibreOffice.",
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(
address = "https://cdn.kde.org/android/fdroid/repo",
name = "KDE Android",
@@ -200,7 +193,7 @@ data class Repository(
address = "https://fdroid.fedilab.app/repo",
name = "Fedilab",
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.",
fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB"
),
@@ -209,14 +202,7 @@ data class Repository(
name = "Kali Nethunter",
description = "Kali Nethunter's official selection of original b" +
"inaries.",
fingerprint = "7E418D34C3AD4F3C37D7E6B0FACE13332364459C862134EB099A3BDA2CCF4494"
),
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"
fingerprint = "FE7A23DFC003A1CF2D2ADD2469B9C0C49B206BA5DC9EDD6563B3B7EB6A8F5FAB"
),
defaultRepository(
address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo",
@@ -257,14 +243,14 @@ data class Repository(
name = "Threema Libre",
description = "The official repository for Threema Libre. R" +
"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"
),
defaultRepository(
address = "https://fdroid.getsession.org/fdroid/repo",
name = "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"
),
defaultRepository(
@@ -413,5 +399,12 @@ data class Repository(
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
}
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.validation.FileValidator
import com.looker.droidify.network.validation.ValidationException
import com.looker.droidify.utility.common.extension.size
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine
@@ -51,12 +50,9 @@ internal class KtorDownloader(
override suspend fun headCall(
url: String,
headers: HeadersBuilder.() -> Unit
headers: HeadersBuilder.() -> Unit,
): NetworkResponse {
val headRequest = createRequest(
url = url,
headers = headers
)
val headRequest = request(url, headers = headers)
return client.head(headRequest).asNetworkResponse()
}
@@ -65,24 +61,26 @@ internal class KtorDownloader(
target: File,
validator: FileValidator?,
headers: HeadersBuilder.() -> Unit,
block: ProgressListener?
block: ProgressListener?,
): NetworkResponse = withContext(dispatcher) {
try {
val request = createRequest(
val fileSize = target.length()
val request = request(
url = url,
headers = {
inRange(target.size)
headers()
},
fileSize = target.size,
block = block
)
fileSize = fileSize,
block = block,
) {
inRange(fileSize)
headers()
}
client.prepareGet(request).execute { response ->
val networkResponse = response.asNetworkResponse()
if (networkResponse !is NetworkResponse.Success) {
return@execute networkResponse
}
response.bodyAsChannel().copyTo(target.outputStream())
target.outputStream().use { output ->
response.bodyAsChannel().copyTo(output)
}
validator?.validate(target)
networkResponse
}
@@ -95,37 +93,34 @@ internal class KtorDownloader(
} catch (e: ValidationException) {
target.delete()
NetworkResponse.Error.Validation(e)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (e is CancellationException) throw e
NetworkResponse.Error.Unknown(e)
}
}
private fun client(
engine: HttpClientEngine = OkHttp.create()
): HttpClient {
return HttpClient(engine) {
userAgentConfig()
timeoutConfig()
}
engine: HttpClientEngine = OkHttp.create(),
) = HttpClient(engine) {
userAgentConfig()
timeoutConfig()
}
private fun createRequest(
private fun request(
url: String,
fileSize: Long = 0L,
block: ProgressListener? = null,
headers: HeadersBuilder.() -> Unit,
fileSize: Long? = null,
block: ProgressListener? = null
) = request {
url(url)
this.headers {
KtorHeadersBuilder(this).headers()
}
onDownload { read, total ->
if (block != null) {
headers { KtorHeadersBuilder(this).headers() }
if (block != null) {
onDownload { read, total ->
block(
DataSize(read + (fileSize ?: 0L)),
DataSize((total ?: 0L) + (fileSize ?: 0L))
DataSize(read + fileSize),
total?.let { DataSize(total + fileSize) },
)
}
}

View File

@@ -16,5 +16,5 @@ interface HeadersBuilder {
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
internal class KtorHeadersBuilder(
private val builder: io.ktor.http.HeadersBuilder
private val builder: io.ktor.http.HeadersBuilder,
) : HeadersBuilder {
override fun String.headsWith(value: Any?) {
@@ -38,8 +38,7 @@ internal class KtorHeadersBuilder(
HttpHeaders.Authorization headsWith base64
}
override fun inRange(start: Number?, end: Number?) {
if (start == null) return
override fun inRange(start: Long, end: Long?) {
val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-"
HttpHeaders.Range headsWith valueString
}

View File

@@ -74,7 +74,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
data object Idle : State("")
data class Connecting(val name: String) : State(name)
data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State(
name
name,
)
data class Error(val name: String) : State(name)
@@ -84,7 +84,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
data class DownloadState(
val currentItem: State = State.Idle,
val queue: List<String> = emptyList()
val queue: List<String> = emptyList(),
) {
infix fun isDownloading(packageName: String): Boolean =
currentItem.packageName == packageName && (
@@ -108,7 +108,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
val release: Release,
val url: String,
val authentication: String,
val isUpdate: Boolean = false
val isUpdate: Boolean = false,
) {
val notificationTag: String
get() = "download-$packageName"
@@ -129,7 +129,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
name: String,
repository: Repository,
release: Release,
isUpdate: Boolean = false
isUpdate: Boolean = false,
) {
val task = Task(
packageName = packageName,
@@ -137,7 +137,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
release = release,
url = release.getDownloadUrl(repository),
authentication = repository.authentication,
isUpdate = isUpdate
isUpdate = isUpdate,
)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
lifecycleScope.launch { publishSuccess(task) }
@@ -147,7 +147,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
cancelCurrentTask(packageName)
notificationManager?.cancel(
task.notificationTag,
Constants.NOTIFICATION_ID_DOWNLOADING
Constants.NOTIFICATION_ID_DOWNLOADING,
)
tasks += task
if (currentTask == null) {
@@ -174,7 +174,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
)
createNotificationChannel(
id = NOTIFICATION_CHANNEL_INSTALL,
name = getString(stringRes.install)
name = getString(stringRes.install),
)
lifecycleScope.launch {
@@ -250,13 +250,13 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
.setOnlyAlertOnce(true)
.setContentIntent(intent)
.errorNotificationContent(task, errorType)
.build()
.build(),
)
}
private fun NotificationCompat.Builder.errorNotificationContent(
task: Task,
errorType: ErrorType
errorType: ErrorType,
): NotificationCompat.Builder {
val title = if (errorType is ErrorType.Validation) {
stringRes.could_not_validate_FORMAT
@@ -325,8 +325,8 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
this,
0,
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 ->
startForeground(
Constants.NOTIFICATION_ID_DOWNLOADING,
notification.build()
notification.build(),
)
} ?: run {
log("Invalid Download State: $state", "DownloadService", Log.ERROR)
@@ -345,7 +345,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
}
private fun NotificationCompat.Builder.downloadingNotificationContent(
state: State
state: State,
): NotificationCompat.Builder? {
return when (state) {
is State.Connecting -> {
@@ -403,19 +403,19 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private fun CoroutineScope.downloadFile(
task: Task,
target: File
target: File,
) = launch {
try {
val releaseValidator = ReleaseFileValidator(
context = this@DownloadService,
packageName = task.packageName,
release = task.release
release = task.release,
)
val response = downloader.downloadToFile(
url = task.url,
target = target,
validator = releaseValidator,
headers = { authentication(task.authentication) }
headers = { if (task.authentication.isNotEmpty()) authentication(task.authentication) },
) { read, total ->
yield()
updateCurrentState(State.Downloading(task.packageName, read, total))
@@ -425,7 +425,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
is NetworkResponse.Success -> {
val releaseFile = Cache.getReleaseFile(
this@DownloadService,
task.release.cacheFileName
task.release.cacheFileName,
)
target.renameTo(releaseFile)
publishSuccess(task)
@@ -438,7 +438,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
is NetworkResponse.Error.IO -> ErrorType.IO
is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout
is NetworkResponse.Error.Validation -> ErrorType.Validation(
response.exception
response.exception,
)
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.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> {
val parser: Parser<T>
suspend fun sync(
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.name
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.FeatureV2
import com.looker.droidify.sync.v2.model.FileV2
@@ -94,7 +95,7 @@ private fun AppV1.toV2(preferredSigner: String?): MetadataV2 = MetadataV2(
added = added ?: 0L,
lastUpdated = lastUpdated ?: 0L,
icon = localized?.localizedIcon(packageName, icon) { it.icon },
name = localized?.localizedString(name) { it.name },
name = localized?.localizedString(name) { it.name } ?: emptyMap(),
description = localized?.localizedString(description) { it.description },
summary = localized?.localizedString(summary) { it.summary },
authorEmail = authorEmail,
@@ -157,7 +158,7 @@ private fun PackageV1.toVersionV2(
packageAntiFeatures: List<String>,
): VersionV2 = VersionV2(
added = added ?: 0L,
file = FileV2(
file = ApkFileV2(
name = "/$apkName",
sha256 = hash,
size = size,

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ data class RepoV2(
val icon: LocalizedIcon? = null,
val name: LocalizedString = emptyMap(),
val description: LocalizedString = emptyMap(),
val antiFeatures: Map<String, AntiFeatureV2> = emptyMap(),
val categories: Map<String, CategoryV2> = emptyMap(),
val antiFeatures: Map<Tag, AntiFeatureV2> = emptyMap(),
val categories: Map<DefaultName, CategoryV2> = emptyMap(),
val mirrors: List<MirrorV2> = emptyList(),
val timestamp: Long,
)
@@ -22,8 +22,8 @@ data class RepoV2Diff(
val icon: LocalizedIcon? = null,
val name: LocalizedString? = null,
val description: LocalizedString? = null,
val antiFeatures: Map<String, AntiFeatureV2?>? = null,
val categories: Map<String, CategoryV2?>? = null,
val antiFeatures: Map<Tag, AntiFeatureV2?>? = null,
val categories: Map<DefaultName, CategoryV2?>? = null,
val mirrors: List<MirrorV2>? = null,
val timestamp: Long,
) {
@@ -69,7 +69,7 @@ data class RepoV2Diff(
data class MirrorV2(
val url: String,
val isPrimary: Boolean? = null,
val location: String? = null
val countryCode: String? = null
)
@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.sizeScaled
import com.looker.droidify.widget.StableRecyclerAdapter
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
@@ -87,10 +86,13 @@ import java.util.Locale
import kotlin.math.PI
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import com.google.android.material.R as MaterialR
import com.looker.droidify.R.drawable as drawableRes
import com.looker.droidify.R.string as stringRes
@OptIn(ExperimentalTime::class)
class AppDetailAdapter(private val callbacks: Callbacks) :
StableRecyclerAdapter<AppDetailAdapter.ViewType, RecyclerView.ViewHolder>() {
@@ -557,7 +559,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
val size = itemView.findViewById<TextView>(R.id.size)!!
val signature = itemView.findViewById<TextView>(R.id.signature)!!
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>
get() = sequenceOf(
@@ -569,7 +571,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
size,
signature,
compatibility,
targetSdk,
sdkVer,
)
}
@@ -1712,15 +1714,22 @@ class AppDetailAdapter(private val callbacks: Callbacks) :
)
}
}
with(holder.targetSdk) {
val sdkVersion = sdkName.getOrDefault(
with(holder.sdkVer) {
val targetSdkVersion = sdkName.getOrDefault(
item.release.targetSdkVersion,
context.getString(
stringRes.label_unknown_sdk,
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
holder.statefulViews.forEach { it.isEnabled = enabled }

View File

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

View File

@@ -4,22 +4,23 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.model.InstallerType
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.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted
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.model.InstallState
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 kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -33,7 +34,7 @@ import javax.inject.Inject
class AppDetailViewModel @Inject constructor(
private val installer: InstallManager,
private val settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle
savedStateHandle: SavedStateHandle,
) : ViewModel() {
val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME])
@@ -52,7 +53,7 @@ class AppDetailViewModel @Inject constructor(
Database.RepositoryAdapter.getAllStream(),
Database.InstalledAdapter.getStream(packageName),
repoAddress,
flow { emit(settingsRepository.getInitial()) }
flow { emit(settingsRepository.getInitial()) },
) { products, repositories, installedItem, suggestedAddress, initialSettings ->
val idAndRepos = repositories.associateBy { it.id }
val filteredProducts = products.filter { product ->
@@ -65,7 +66,7 @@ class AppDetailViewModel @Inject constructor(
isFavourite = packageName in initialSettings.favouriteApps,
allowIncompatibleVersions = initialSettings.incompatibleVersions,
isSelf = packageName == BuildConfig.APPLICATION_ID,
addressIfUnavailable = suggestedAddress
addressIfUnavailable = suggestedAddress,
)
}.asStateFlow(AppDetailUiState())
@@ -74,6 +75,9 @@ class AppDetailViewModel @Inject constructor(
runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU }
if (!isSelected) return null
val isAlive = isShizukuAlive()
val isSuiAvailable = isSuiAvailable()
if (isSuiAvailable) return null
val isGranted = if (isAlive) {
if (isShizukuGranted()) {
true
@@ -144,5 +148,5 @@ data class AppDetailUiState(
val isSelf: Boolean = false,
val isFavourite: 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(
val titleResId: Int,
val sections: Boolean,
val order: Boolean,
val updateAll: Boolean,
) {
AVAILABLE(stringRes.available, true, true, false),
INSTALLED(stringRes.installed, false, true, false),
UPDATES(stringRes.updates, false, false, true)
AVAILABLE(stringRes.available, true, false),
INSTALLED(stringRes.installed, false, false),
UPDATES(stringRes.updates, false, true)
}
constructor(source: Source) : this() {
@@ -134,7 +133,6 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
super.onViewCreated(view, savedInstanceState)
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
updateRequest()
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch {
@@ -143,7 +141,7 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
}
}
launch {
viewModel.sortOrderFlow.collect {
viewModel.state.collect {
updateRequest()
}
}
@@ -185,16 +183,12 @@ class AppListFragment() : Fragment(), CursorOwner.Callback {
}
}
internal fun setSearchQuery(searchQuery: String) {
viewModel.setSearchQuery(searchQuery) {
updateRequest()
}
fun setSearchQuery(searchQuery: String) {
viewModel.setSearchQuery(searchQuery)
}
internal fun setSection(section: ProductItem.Section) {
viewModel.setSection(section) {
updateRequest()
}
fun setSection(section: ProductItem.Section) {
viewModel.setSection(section)
}
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.utility.common.extension.asStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.launch
import javax.inject.Inject
@@ -34,25 +34,37 @@ class AppListViewModel
.get { ignoreSignature }
.asStateFlow(false)
val sortOrderFlow = settingsRepository
private val sortOrderFlow = settingsRepository
.get { sortOrder }
.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
.getAllStream()
.asStateFlow(emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val showUpdateAllButton = skipSignatureStream.flatMapConcat { skip ->
val showUpdateAllButton = skipSignatureStream.flatMapLatest { skip ->
Database.ProductAdapter
.getUpdatesStream(skip)
.map { it.isNotEmpty() }
}.asStateFlow(false)
private val sections = MutableStateFlow<ProductItem.Section>(All)
val searchQuery = MutableStateFlow("")
val syncConnection = Connection(SyncService::class.java)
fun updateAll() {
@@ -79,26 +91,25 @@ class AppListViewModel
searchQuery = searchQuery.value,
section = sections.value,
order = sortOrderFlow.value,
skipSignatureCheck = skipSignatureStream.value,
)
}
}
fun setSection(newSection: ProductItem.Section, perform: () -> Unit) {
fun setSection(newSection: ProductItem.Section) {
viewModelScope.launch {
if (newSection != sections.value) {
sections.emit(newSection)
launch(Dispatchers.Main) { perform() }
}
sections.emit(newSection)
}
}
fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) {
fun setSearchQuery(newSearchQuery: String) {
viewModelScope.launch {
if (newSearchQuery != searchQuery.value) {
searchQuery.emit(newSearchQuery)
launch(Dispatchers.Main) { perform() }
}
searchQuery.emit(newSearchQuery)
}
}
}
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.ScreenFragment
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.getMutatedIcon
import com.looker.droidify.utility.common.nullIfEmpty
@@ -136,7 +137,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Selection.setSelection(
text,
realPosition(outputString, inputStart),
realPosition(outputString, inputEnd)
realPosition(outputString, inputEnd),
)
}
}
@@ -155,7 +156,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Pair(
uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null)
.build().toString(),
fingerprintText
fingerprintText,
)
} catch (e: Exception) {
Pair(null, null)
@@ -171,7 +172,7 @@ class EditRepositoryFragment() : ScreenFragment() {
setEndIconOnClickListener {
SelectMirrorDialog(mirrors).show(
childFragmentManager,
SelectMirrorDialog::class.java.name
SelectMirrorDialog::class.java.name,
)
}
}
@@ -189,7 +190,7 @@ class EditRepositoryFragment() : ScreenFragment() {
if (index >= 0) {
Pair(
it.substring(0, index),
it.substring(index + 1)
it.substring(index + 1),
)
} else {
null
@@ -319,7 +320,7 @@ class EditRepositoryFragment() : ScreenFragment() {
return if (endsWith != null) {
cropped.substring(
0,
cropped.length - endsWith.length - 1
cropped.length - endsWith.length - 1,
)
} else {
cropped
@@ -330,12 +331,12 @@ class EditRepositoryFragment() : ScreenFragment() {
val uri = try {
val uri = URI(address)
if (uri.isAbsolute) uri.normalize() else null
} catch (e: URISyntaxException) {
} catch (_: URISyntaxException) {
return null
}
return try {
uri?.toURL()?.toURI()?.toString()?.removeSuffix("/")
} catch (e: URISyntaxException) {
} catch (_: URISyntaxException) {
null
}
}
@@ -346,7 +347,10 @@ class EditRepositoryFragment() : ScreenFragment() {
private fun onSaveRepositoryClick(check: Boolean) {
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 username = binding.username.text.toString().nullIfEmpty()
val password = binding.password.text.toString().nullIfEmpty()
@@ -354,7 +358,7 @@ class EditRepositoryFragment() : ScreenFragment() {
password?.let { p ->
Base64.encodeToString(
"$u:$p".toByteArray(Charset.defaultCharset()),
Base64.NO_WRAP
Base64.NO_WRAP,
)
}
}?.let { "Basic $it" }.orEmpty()
@@ -364,7 +368,7 @@ class EditRepositoryFragment() : ScreenFragment() {
val resultAddress = try {
checkAddress(address, authentication)
} catch (e: Exception) {
e.printStackTrace()
e.exceptCancellation()
failedAddressCheck()
null
}
@@ -378,7 +382,7 @@ class EditRepositoryFragment() : ScreenFragment() {
onSaveRepositoryProceedInvalidate(
resultAddress,
fingerprint,
authentication
authentication,
)
} else {
invalidateState()
@@ -393,7 +397,7 @@ class EditRepositoryFragment() : ScreenFragment() {
private suspend fun checkAddress(
rawAddress: String,
authentication: String
authentication: String,
): String? = coroutineScope {
checkInProgress = true
invalidateState()
@@ -403,7 +407,7 @@ class EditRepositoryFragment() : ScreenFragment() {
.forEach { address ->
val response = downloader.headCall(
url = "$address/index-v1.jar",
headers = { authentication(authentication) }
headers = { authentication(authentication) },
)
if (response is NetworkResponse.Success) return@coroutineScope address
}
@@ -413,7 +417,7 @@ class EditRepositoryFragment() : ScreenFragment() {
private fun onSaveRepositoryProceedInvalidate(
address: String,
fingerprint: String,
authentication: String
authentication: String,
) {
val binder = syncConnection.binder
if (binder != null) {
@@ -442,7 +446,7 @@ class EditRepositoryFragment() : ScreenFragment() {
Snackbar.make(
requireView(),
R.string.repository_unreachable,
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_SHORT,
).show()
}

View File

@@ -2,6 +2,7 @@ package com.looker.droidify.ui.settings
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
@@ -13,6 +14,7 @@ import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView
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.model.AutoSync
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.Theme
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 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()
@@ -114,7 +117,7 @@ class SettingsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
savedInstanceState: Bundle?,
): View {
_binding = SettingsPageBinding.inflate(inflater, container, false)
binding.nestedScrollView.systemBarsPadding()
@@ -128,42 +131,42 @@ class SettingsFragment : Fragment() {
dynamicTheme.connect(
titleText = getString(R.string.material_you),
contentText = getString(R.string.material_you_desc),
setting = viewModel.getInitialSetting { dynamicTheme }
setting = viewModel.getInitialSetting { dynamicTheme },
)
homeScreenSwiping.connect(
titleText = getString(R.string.home_screen_swiping),
contentText = getString(R.string.home_screen_swiping_DESC),
setting = viewModel.getInitialSetting { homeScreenSwiping }
setting = viewModel.getInitialSetting { homeScreenSwiping },
)
autoUpdate.connect(
titleText = getString(R.string.auto_update),
contentText = getString(R.string.auto_update_apps),
setting = viewModel.getInitialSetting { autoUpdate }
setting = viewModel.getInitialSetting { autoUpdate },
)
notifyUpdates.connect(
titleText = getString(R.string.notify_about_updates),
contentText = getString(R.string.notify_about_updates_summary),
setting = viewModel.getInitialSetting { notifyUpdate }
setting = viewModel.getInitialSetting { notifyUpdate },
)
unstableUpdates.connect(
titleText = getString(R.string.unstable_updates),
contentText = getString(R.string.unstable_updates_summary),
setting = viewModel.getInitialSetting { unstableUpdate }
setting = viewModel.getInitialSetting { unstableUpdate },
)
ignoreSignature.connect(
titleText = getString(R.string.ignore_signature),
contentText = getString(R.string.ignore_signature_summary),
setting = viewModel.getInitialSetting { ignoreSignature }
setting = viewModel.getInitialSetting { ignoreSignature },
)
incompatibleUpdates.connect(
titleText = getString(R.string.incompatible_versions),
contentText = getString(R.string.incompatible_versions_summary),
setting = viewModel.getInitialSetting { incompatibleVersions }
setting = viewModel.getInitialSetting { incompatibleVersions },
)
language.connect(
titleText = getString(R.string.prefs_language_title),
map = { translateLocale(getLocaleOfCode(it)) },
setting = viewModel.getSetting { language }
setting = viewModel.getSetting { language },
) { selectedLocale, valueToString ->
addSingleCorrectDialog(
initialValue = selectedLocale,
@@ -171,13 +174,13 @@ class SettingsFragment : Fragment() {
title = R.string.prefs_language_title,
iconRes = R.drawable.ic_language,
valueToString = valueToString,
onClick = viewModel::setLanguage
onClick = viewModel::setLanguage,
)
}
theme.connect(
titleText = getString(R.string.theme),
setting = viewModel.getSetting { theme },
map = { themeName(it) }
map = { themeName(it) },
) { theme, valueToString ->
addSingleCorrectDialog(
initialValue = theme,
@@ -185,13 +188,13 @@ class SettingsFragment : Fragment() {
title = R.string.themes,
iconRes = R.drawable.ic_themes,
valueToString = valueToString,
onClick = viewModel::setTheme
onClick = viewModel::setTheme,
)
}
cleanUp.connect(
titleText = getString(R.string.cleanup_title),
setting = viewModel.getSetting { cleanUpInterval },
map = { toTime(it) }
map = { toTime(it) },
) { duration, valueToString ->
addSingleCorrectDialog(
initialValue = duration,
@@ -199,13 +202,13 @@ class SettingsFragment : Fragment() {
title = R.string.cleanup_title,
iconRes = R.drawable.ic_time,
valueToString = valueToString,
onClick = viewModel::setCleanUpInterval
onClick = viewModel::setCleanUpInterval,
)
}
autoSync.connect(
titleText = getString(R.string.sync_repositories_automatically),
setting = viewModel.getSetting { autoSync },
map = { autoSyncName(it) }
map = { autoSyncName(it) },
) { autoSync, valueToString ->
addSingleCorrectDialog(
initialValue = autoSync,
@@ -213,13 +216,13 @@ class SettingsFragment : Fragment() {
title = R.string.sync_repositories_automatically,
iconRes = R.drawable.ic_sync_type,
valueToString = valueToString,
onClick = viewModel::setAutoSync
onClick = viewModel::setAutoSync,
)
}
installer.connect(
titleText = getString(R.string.installer),
setting = viewModel.getSetting { installerType },
map = { installerName(it) }
map = { installerName(it) },
) { installerType, valueToString ->
addSingleCorrectDialog(
initialValue = installerType,
@@ -227,13 +230,68 @@ class SettingsFragment : Fragment() {
title = R.string.installer,
iconRes = R.drawable.ic_apk_install,
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(
titleText = getString(R.string.proxy_type),
setting = viewModel.getSetting { proxy.type },
map = { proxyName(it) }
map = { proxyName(it) },
) { proxyType, valueToString ->
addSingleCorrectDialog(
initialValue = proxyType,
@@ -241,29 +299,29 @@ class SettingsFragment : Fragment() {
title = R.string.proxy_type,
iconRes = R.drawable.ic_proxy,
valueToString = valueToString,
onClick = viewModel::setProxyType
onClick = viewModel::setProxyType,
)
}
proxyHost.connect(
titleText = getString(R.string.proxy_host),
setting = viewModel.getSetting { proxy.host },
map = { it }
map = { it },
) { host, _ ->
addEditTextDialog(
initialValue = host,
title = R.string.proxy_host,
onFinish = viewModel::setProxyHost
onFinish = viewModel::setProxyHost,
)
}
proxyPort.connect(
titleText = getString(R.string.proxy_port),
setting = viewModel.getSetting { proxy.port },
map = { it.toString() }
map = { it.toString() },
) { port, _ ->
addEditTextDialog(
initialValue = port.toString(),
title = R.string.proxy_port,
onFinish = viewModel::setProxyPort
onFinish = viewModel::setProxyPort,
)
}
@@ -287,15 +345,15 @@ class SettingsFragment : Fragment() {
allowBackgroundWork.root.setBackgroundColor(
requireContext()
.getColorFromAttr(MaterialR.attr.colorErrorContainer)
.defaultColor
.defaultColor,
)
allowBackgroundWork.title.setTextColor(
requireContext()
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer)
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer),
)
allowBackgroundWork.content.setTextColor(
requireContext()
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer)
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer),
)
creditFoxy.title.text = getString(R.string.special_credits)
creditFoxy.content.text = FOXY_DROID_TITLE
@@ -389,6 +447,9 @@ class SettingsFragment : Fragment() {
proxyHost.root.isVisible = allowProxies
proxyPort.root.isVisible = allowProxies
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(
language.toString(),
true
true,
) != 0
) {
"($country)"
@@ -437,12 +498,12 @@ class SettingsFragment : Fragment() {
localeCode.contains("-r") -> Locale(
localeCode.substring(0, 2),
localeCode.substring(4)
localeCode.substring(4),
)
localeCode.contains("_") -> Locale(
localeCode.substring(0, 2),
localeCode.substring(3)
localeCode.substring(3),
)
localeCode == "system" -> null
@@ -453,7 +514,7 @@ class SettingsFragment : Fragment() {
titleText: String,
setting: Flow<T>,
map: Context.(T) -> String,
dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog
dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog,
) {
title.text = titleText
viewLifecycleOwner.lifecycleScope.launch {
@@ -473,7 +534,7 @@ class SettingsFragment : Fragment() {
private fun SwitchTypeBinding.connect(
titleText: String,
contentText: String,
setting: Flow<Boolean>
setting: Flow<Boolean>,
) {
title.text = titleText
content.text = contentText
@@ -495,13 +556,13 @@ class SettingsFragment : Fragment() {
@StringRes title: Int,
@DrawableRes iconRes: Int,
onClick: (T) -> Unit,
valueToString: Context.(T) -> String
valueToString: Context.(T) -> String,
) = MaterialAlertDialogBuilder(context)
.setTitle(title)
.setIcon(iconRes)
.setSingleChoiceItems(
values.map { context.valueToString(it) }.toTypedArray(),
values.indexOf(initialValue)
values.indexOf(initialValue),
) { dialog, newValue ->
dialog.dismiss()
post {
@@ -514,7 +575,7 @@ class SettingsFragment : Fragment() {
private fun View.addEditTextDialog(
initialValue: String,
@StringRes title: Int,
onFinish: (String) -> Unit
onFinish: (String) -> Unit,
): AlertDialog {
val scroll = NestedScrollView(context)
val customEditText = TextInputEditText(context)
@@ -528,7 +589,7 @@ class SettingsFragment : Fragment() {
scroll.addView(
customEditText,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
ViewGroup.LayoutParams.WRAP_CONTENT,
)
return MaterialAlertDialogBuilder(context)
.setTitle(title)
@@ -540,7 +601,7 @@ class SettingsFragment : Fragment() {
.create()
.apply {
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.ROOT
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.Theme
import com.looker.droidify.installer.installers.initSui
import com.looker.droidify.installer.installers.isMagiskGranted
import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted
@@ -40,7 +42,7 @@ import kotlin.time.Duration
class SettingsViewModel
@Inject constructor(
private val settingsRepository: SettingsRepository,
private val repositoryExporter: RepositoryExporter
private val repositoryExporter: RepositoryExporter,
) : ViewModel() {
private val initialSetting = flow {
@@ -162,7 +164,7 @@ class SettingsViewModel
viewModelScope.launch {
when (installerType) {
SHIZUKU -> {
if (isShizukuInstalled(context)) {
if (isShizukuInstalled(context) || initSui(context)) {
if (!isShizukuAlive()) {
createSnackbar(R.string.shizuku_not_alive)
return@launch
@@ -191,6 +193,12 @@ class SettingsViewModel
}
}
fun setLegacyInstallerComponentComponent(component: LegacyInstallerComponent?) {
viewModelScope.launch {
settingsRepository.setLegacyInstallerComponent(component)
}
}
fun exportSettings(file: Uri) {
viewModelScope.launch {
settingsRepository.export(file)
@@ -227,12 +235,12 @@ class SettingsViewModel
private fun String.toLocale(): Locale = when {
contains("-r") -> Locale(
substring(0, 2),
substring(4)
substring(4),
)
contains("_") -> Locale(
substring(0, 2),
substring(3)
substring(3),
)
else -> Locale(this)

View File

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

View File

@@ -20,7 +20,7 @@ import javax.inject.Inject
@HiltViewModel
class TabsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val savedStateHandle: SavedStateHandle
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val currentSection =
@@ -37,7 +37,7 @@ class TabsViewModel @Inject constructor(
val sections =
combine(
Database.CategoryAdapter.getAllStream(),
Database.RepositoryAdapter.getEnabledStream()
Database.RepositoryAdapter.getEnabledStream(),
) { categories, repos ->
val productCategories = categories
.asSequence()
@@ -60,7 +60,7 @@ class TabsViewModel @Inject constructor(
val backAction = combine(
currentSection,
isSearchActionItemExpanded,
showSections
showSections,
) { currentSection, isSearchActionItemExpanded, showSections ->
when {
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 {
private const val STATE_SECTION = "section"
}

View File

@@ -1,5 +1,18 @@
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> {
return toMutableMap().apply(block)
}

View File

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

View File

@@ -12,6 +12,6 @@ val Number.dpToPx
Resources.getSystem().displayMetrics
)
context(View)
context(view: View)
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" />
<TextView
android:id="@+id/target_sdk"
android:id="@+id/sdk_ver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"

View File

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

View File

@@ -241,8 +241,13 @@
<string name="label_unknown_sdk">غير معروف (%d)</string>
<string name="error_shizuku_not_installed">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="error_shizuku_service_unavailable">Shizuku لا يعمل</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>

View File

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

View File

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

View File

@@ -193,4 +193,43 @@
<string name="cannot_open_link">লিংকটি ওপেন করা সম্ভব হয়নি</string>
<string name="import_export">ইম্পোর্ট/এক্সপোর্ট</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>

View File

@@ -37,6 +37,7 @@
<string name="has_security_vulnerabilities">Té vulnerabilitats de seguretat</string>
<plurals name="hours">
<item quantity="one">Hora</item>
<item quantity="many"></item>
<item quantity="other">Hores</item>
</plurals>
<string name="http_error_DESC">Resposta de servidor nul.</string>
@@ -168,6 +169,7 @@
<string name="never">Mai</string>
<plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d l\'aplicació té una versió nova.</item>
<item quantity="many"></item>
<item quantity="other">%d aplicacions amb versions noves.</item>
</plurals>
<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"?>
<resources>
<string name="add_repository">Přidat zdroj</string>
<string name="add_repository">Přidat repozitář</string>
<string name="address">Adresa</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="bug_tracker">Sledování chyb</string>
<string name="cancel">Zrušit</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_versions_summary">Zobrait nekompatibilní verze aplikace s vaším zařízením</string>
<string name="invalid_signature_error_DESC">Neplatný podpis.</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 verze aplikace nekompatibilní s vaším zařízením</string>
<string name="invalid_signature_error_DESC">Neplatný podpis</string>
<string name="link_copied_to_clipboard">Odkaz zkopírován</string>
<string name="light">Světlé</string>
<string name="number_of_applications">Počet aplikací</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_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="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_type">Typ proxy</string>
<string name="repositories">Zdroje</string>
<string name="anti_features">Anti-funkce</string>
<string name="already_exists">Již existuje</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="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="connecting">Spojuji…</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_validate_FORMAT">Nepodařilo se ověřit %s</string>
<string name="dark">Tmavé</string>
<string name="delete">Smazat</string>
<string name="delete_repository_DESC">Smazat zdroj\?</string>
<string name="delete">Odstranit</string>
<string name="delete_repository_DESC">Odstranit repozitář?</string>
<string name="description">Popis</string>
<string name="details">Detaily</string>
<string name="donate">Přispět</string>
<string name="downloaded_FORMAT">Staženo %s</string>
<string name="downloading">Stahuji</string>
<string name="downloading_FORMAT">Stahuji %s…</string>
<string name="edit_repository">Upravit zdroj</string>
<string name="file_format_error_DESC">Neplatný formát souboru.</string>
<string name="edit_repository">Upravit</string>
<string name="file_format_error_DESC">Neplatný formát souboru</string>
<string name="fingerprint">Otisk prstu</string>
<string name="has_advertising">Obsahuje reklamy</string>
<string name="has_non_free_dependencies">Obsahuje nesvobodné závislosti</string>
<string name="has_security_vulnerabilities">Obsahuje bezpečnostní zranitelnosti</string>
<string name="http_error_DESC">Neplatná odpověď serveru.</string>
<string name="has_non_free_dependencies">Závisí na jiných nesvobodných aplikacích</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_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="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_min_DESC_FORMAT">Minimá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="credits">Kredity</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_features_DESC">Chybějící funkce</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_versions">Nekompatibilní verze</string>
<string name="incompatible_with_FORMAT">Nekompatibilní s %s</string>
<string name="install">Instalovat</string>
<string name="install_types">Typy Instalace</string>
<string name="integrity_check_error_DESC">Nezdařilo se zkontrolovat integritu.</string>
<string name="install_types">Instalace</string>
<string name="integrity_check_error_DESC">Nezdařilo se zkontrolovat integritu</string>
<string name="invalid_address">Neplatná adresa</string>
<string name="invalid_fingerprint_format">Neplatný formát otisku prstu</string>
<string name="invalid_metadata_error_DESC">Neplatná metadata.</string>
<string name="invalid_permissions_error_DESC">Neplatná oprávnění.</string>
<string name="invalid_metadata_error_DESC">Neplatná metadata</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="launch">Spustit</string>
<string name="license">Licence</string>
@@ -82,33 +82,33 @@
<string name="name">Název</string>
<string name="network_error_DESC">Chyba sítě</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">
<item quantity="one">%d aplikace má novou verzi.</item>
<item quantity="few">%d aplikace mají novou verzi.</item>
<item quantity="other">%d aplikací má novou verzi.</item>
<item quantity="one">%d aplikace má dostupné aktualizace</item>
<item quantity="few">%d aplikace mají dostupné aktualizace</item>
<item quantity="other">%d aplikací má dostupné aktualizace</item>
</plurals>
<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_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="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="only_compatible_with_FORMAT">Kompatibilní pouze s %s</string>
<string name="only_on_wifi">Pouze na Wi-Fi</string>
<string name="open_DESC_FORMAT">Otevřít %s\?</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="plus_more_FORMAT">+%d více</string>
<string name="settings">Nastavení</string>
<string name="promotes_non_free_network_services">Propaguje nesvobodné internetové služby</string>
<string name="promotes_non_free_software">Propaguje ne-svobodný software</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 nesvobodné doplňky</string>
<string name="provided_by_FORMAT">Poskytuje %s</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="save">Uložit</string>
<string name="saving_details">Ukládám detaily…</string>
@@ -130,27 +130,27 @@
<string name="syncing">Synchronizuji</string>
<string name="syncing_FORMAT">Synchronizuji %s…</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="themes">Témata</string>
<string name="tracks_or_reports_your_activity">Sleduje nebo hlásí vaší aktivitu</string>
<string name="uninstall">Odinstalovat</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="unstable_updates">Nestabilní aktualizace</string>
<string name="update">Aktualizovat</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_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_FORMAT">Verze %s</string>
<string name="versions">Verze</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="website">Web</string>
<string name="website">Web projektu</string>
<string name="prefs_language_title">Jazyk</string>
<string name="prefs_personalization">Personalizace</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="installer">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="shizuku_installer">Instalátor Shizuku</string>
<string name="unstable_updates_summary">Navrhnout instalaci nestabilních verzí</string>
<string name="shizuku_installer">Instalátor Shizuku/Sui</string>
<string name="unstable_updates_summary">Navrhovat instalaci nestabilních verzí aplikací</string>
<string name="select_mirror">Vybrat mirror</string>
<plurals name="days">
<item quantity="one">den</item>
@@ -180,9 +180,9 @@
<item quantity="other">hodin</item>
</plurals>
<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="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="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="material_you_desc">Použít barvy Material You</string>
<string name="material_you">Material You</string>
<string name="favourites">Oblíbené</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="waiting_to_start_installation">Čekání na spuštění instalace…</string>
<string name="installing">Instalace</string>
<string name="auto_update">Automatická aktualizace aplikací</string>
<string name="auto_update_apps">Pokusit se automaticky nainstalovat aktualizace</string>
<string name="auto_update">Automaticky aktualizovat aplikace</string>
<string name="auto_update_apps">Automaticky instalovat aktualizace</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="shizuku_not_alive">Shizuku není spuštěno</string>
<string name="shizuku_not_installed">Shizuku není nainstalováno</string>
<string name="contains_nsfw">Obsahuje obsah nevhodný do práce</string>
<string name="special_credits">Speciální poděkování</string>
<string name="home_screen_swiping">Posouvání na domovské stránce</string>
<string name="home_screen_swiping_DESC">Umožnit uživateli posouvat mezi stránkami na domovské stránce</string>
<string name="repository_not_found">Následující repozitář nebyl nalezen</string>
<string name="shizuku_not_alive">Shizuku/Sui není spuštěno</string>
<string name="shizuku_not_installed">Shizuku/Sui není nainstalováno</string>
<string name="contains_nsfw">Obsahuje obsah nevhodný do práce (NSFW)</string>
<string name="special_credits">Zvláštní poděkování</string>
<string name="home_screen_swiping">Posouvání stránek</string>
<string name="home_screen_swiping_DESC">Posuňte vlevo nebo v pravo pro změnu stránky</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="import_settings_title">Importovat nastavení</string>
<string name="import_export">Import/export</string>
<string name="import_settings_title">Importovat oblíbené a nastavení</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="export_settings_title">Exportovat nastavení</string>
<string name="export_repos_DESC">Exportovat všechny repozitáře do souboru</string>
<string name="export_settings_title">Exportovat oblíbené a nastavení</string>
<string name="export_repos_DESC">Exportovat repozitáře do souboru</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_repos_title">Exportovat repozitáře</string>
<string name="import_repos_DESC">Importovat všechny repozitáře ze souboru</string>
<string name="cannot_open_link">Nelze otevřít odkaz</string>
<string name="has_tethered_network">Připojeno k určité síťové službě</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">Vyžadovat přístup na pozadí</string>
<string name="ignore_signature">Ignorovat podpis</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="import_repos_DESC">Importovat repozitáře ze souboru</string>
<string name="cannot_open_link">Odkaz se nepodařilo otevřít</string>
<string name="has_tethered_network">Závisí na určité instanci síťové služby</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">Zakázat optimalizace baterie</string>
<string name="ignore_signature">Ignorovat ověření podpisu</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="installation_failed">Instalace selhala</string>
<string name="installation_failed_DESC">Nepodařilo se nainstalovat aplikaci %s</string>
<string name="uninstalled_application">Odinstalováno</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="error_shizuku_service_unavailable">Shizuku není spuštěno</string>
<string name="error_shizuku_not_granted">Chybějící oprávnění Shizuku</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_installed_DESC">Aplikace Shizuku nejspíše není nainstalována</string>
<string name="insufficient_storage_DESC">Nemáte dostatek místa k instalaci této aplikace</string>
<string name="error_shizuku_service_unavailable">Služba Shizuku/Sui není spuštěna</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í Shizuku/Sui</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="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_installed">Shizuku není nainstalováno</string>
<string name="error_shizuku_not_running_DESC">Služba Shizuku/Sui není spuštěna</string>
<string name="error_shizuku_not_installed">Shizuku/Sui není nainstalováno</string>
<string name="label_open_video">Video</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>

View File

@@ -39,10 +39,10 @@
<string name="downloaded_FORMAT">Hentet %s</string>
<string name="downloading">Henter</string>
<string name="downloading_FORMAT">Henter %s…</string>
<string name="import_export">Import/Eksport</string>
<string name="import_settings_title">Importér Indstillinger</string>
<string name="import_export">Import/eksport</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="export_settings_title">Eksportér Indstillinger</string>
<string name="export_settings_title">Eksportér indstillinger</string>
<string name="favourites">Favoritter</string>
<string name="file_format_error_DESC">Ugyldigt filformat.</string>
<string name="fingerprint">Fingeraftryk</string>
@@ -60,8 +60,8 @@
<string name="connection_error_DESC">Kunne ikke forbinde til server</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_max_DESC_FORMAT">Maksimal API-version er %d.</string>
<string name="incompatible_api_min_DESC_FORMAT">Minimum 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">Min. API-version er %d.</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_version">Inkompatibel version</string>
@@ -71,7 +71,7 @@
<string name="install">Installer</string>
<string name="install_types">Installationstyper</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_installed">Shizuku er ikke installeret</string>
<string name="installing">Installerer</string>
@@ -86,11 +86,11 @@
<string name="light">Lys</string>
<string name="link_copied_to_clipboard">Link kopieret</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="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="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_desc">Brug Material You-farvetema</string>
<string name="merging_FORMAT">Fletter %s</string>
@@ -113,10 +113,10 @@
<string name="ok">OK</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_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="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_missing">Manglende adgangskode</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="has_non_free_components">Har ikke-frie komponenter</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="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>
@@ -201,25 +201,39 @@
<string name="compiled_for_debugging">Kompileret til fejlfinding</string>
<string name="delete_repository_DESC">Slet repositoriet?</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="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="enable_repo">Aktivér repositoriet</string>
<string name="credits">Krediteringer</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">Kræver Baggrundsadgang</string>
<string name="legacy_installer">Ældre Installatør</string>
<string name="session_installer">Session Installatør</string>
<string name="require_background_access">Kræver baggrundsadgang</string>
<string name="legacy_installer">Ældre installatør</string>
<string name="session_installer">Sessionsinstallatør</string>
<string name="special_credits">Særlige Krediteringer</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="installation_failed">Installation Mislykkedes</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_DESC">Kunne ikke installere %s</string>
<string name="uninstalled_application">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_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>

View File

@@ -1,26 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="add_repository">Repository hinzufügen</string>
<string name="all_applications">Alle Anwendungen</string>
<string name="all_applications_up_to_date">All deine Anwendungen sind aktuell</string>
<string name="add_repository">Paketquelle hinzufügen</string>
<string name="all_applications">Alle Apps</string>
<string name="all_applications_up_to_date">Alle Apps sind aktuell</string>
<string name="already_exists">Bereits vorhanden</string>
<string name="always">Immer</string>
<string name="address">Adresse</string>
<string name="action_failed">Vorgang fehlgeschlagen</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="available">Entdecken</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="changelog">Änderungsprotokoll</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="connecting">Verbinde </string>
<string name="connecting">Wird verbunden </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="contains_non_free_media">Enthält nicht-freie Medien</string>
<string name="credits">Mitwirkende</string>
@@ -37,31 +37,31 @@
<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="installed">Installiert</string>
<string name="incompatible_versions_summary">Mit dem Gerät inkompatible Anwendungsversionen anzeigen</string>
<string name="install_types">Installationstypen</string>
<string name="incompatible_versions_summary">Mit diesem Gerät inkompatible App-Versionen anzeigen</string>
<string name="install_types">Installationsarten</string>
<string name="install">Installieren</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_signature_error_DESC">Ungültige Signatur.</string>
<string name="launch">Öffnen</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="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_matching_applications_found">Keine derartigen Anwendungen konnten gefunden werden</string>
<string name="no_applications_installed">Keine installierten Anwendungen</string>
<string name="only_on_wifi">Nur bei Wi-Fi</string>
<string name="open_DESC_FORMAT">Öffne %s\?</string>
<string name="no_matching_applications_found">Keine derartigen Apps auffindbar</string>
<string name="no_applications_installed">Keine installierten Apps</string>
<string name="only_on_wifi">Nur mit WLAN</string>
<string name="open_DESC_FORMAT">%s öffnen?</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="permissions">Berechtigungen</string>
<string name="plus_more_FORMAT">+%d mehr</string>
<string name="settings">Einstellungen</string>
<string name="processing_FORMAT">Verarbeitung %1$s …</string>
<string name="promotes_non_free_software">Bewirbt unfreie Software</string>
<string name="processing_FORMAT">%1$s wird verarbeitet …</string>
<string name="promotes_non_free_software">Bewirbt nicht-freie Software</string>
<string name="proxy">Proxy</string>
<string name="repositories">Paketquellen</string>
<string name="requires_FORMAT">Benötigt %s</string>
@@ -70,36 +70,36 @@
<string name="skip">Überspringen</string>
<string name="suggested">Empfohlen</string>
<string name="syncing">Synchronisierung</string>
<string name="themes">Themen</string>
<string name="themes">Designs</string>
<string name="unknown">Unbekannt</string>
<string name="uninstall">Deinstallation</string>
<string name="update">Aktualisierung</string>
<string name="uninstall">Deinstallieren</string>
<string name="update">Aktualisieren</string>
<string name="version">Version</string>
<string name="versions">Versionen</string>
<string name="whats_new">Was gibt es Neues</string>
<string name="waiting_to_start_download">Warten auf den Downloadbeginn …</string>
<string name="validation_index_error_DESC">Der Index konnte nicht validiert werden.</string>
<string name="website">Webseite</string>
<string name="whats_new">Neu hinzugefügt</string>
<string name="waiting_to_start_download">Warten auf den Start des Downloads …</string>
<string name="validation_index_error_DESC">Der Index konnte nicht überprüft werden.</string>
<string name="website">Website</string>
<string name="changes">Änderungen</string>
<string name="author_email">Autor-E-Mail-Adresse</string>
<string name="could_not_download_FORMAT">Konnte %s nicht herunterladen</string>
<string name="author_website">Autor-Webseite</string>
<string name="author_email">E-Mail-Adresse</string>
<string name="could_not_download_FORMAT">%s konnte nicht heruntergeladen werden</string>
<string name="author_website">Website</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="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="license_FORMAT">%s-Lizenz</string>
<string name="link_copied_to_clipboard">Link kopiert</string>
<string name="project_website">Projekt-Website</string>
<string name="proxy_type">Proxy Typ</string>
<string name="project_website">Website des Projekts</string>
<string name="proxy_type">Proxy-Art</string>
<string name="repository">Paketquelle</string>
<string name="parsing_index_error_DESC">Die Indexdatei konnte nicht geparst werden.</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="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="password_missing">Passwort fehlt</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="incompatible_version">Inkompatible Version</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_this_update">Diese Version ignorieren</string>
<string name="size">Größe</string>
<string name="updates">Aktualisierungen</string>
<string name="username">Benutzername</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="show_more">Zeige mehr</string>
<string name="show_older_versions">Ältere Versionen zeigen</string>
<string name="show_more">Mehr anzeigen</string>
<string name="show_older_versions">Ältere Versionen anzeigen</string>
<string name="username_missing">Benutzername fehlt</string>
<string name="edit_repository">Paketquelle bearbeiten</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_versions">Inkompatible Versionen</string>
<string name="invalid_address">Ungültige Adresse</string>
<string name="invalid_fingerprint_format">Ungültiges Fingerabdruckformat</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_error_DESC">Unbekannter Fehler.</string>
<string name="syncing_FORMAT">Synchronisierung %s …</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="delete_repository_DESC">Die 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="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 Version. Deinstalliere diese zuerst.</string>
<string name="delete_repository_DESC">Paketquelle löschen?</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_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">Instabile Aktualisierungen</string>
<string name="proxy_host">Proxy Host</string>
<string name="tap_to_install_DESC">Tippe um zu installieren.</string>
<string name="tracks_or_reports_your_activity">Verfolgt oder erfasst deine Aktivitäten</string>
<string name="proxy_port">Proxy Port</string>
<string name="search">Suche</string>
<string name="unstable_updates">Instabile Updates</string>
<string name="proxy_host">Proxy-Host</string>
<string name="tap_to_install_DESC">Zum Installieren tippen.</string>
<string name="tracks_or_reports_your_activity">Verfolgt oder versendet deine Aktivitäten</string>
<string name="proxy_port">Proxy-Port</string>
<string name="search">Suchen</string>
<string name="sorting_order">Sortierreihenfolge</string>
<string name="socks_proxy">SOCKS Proxy</string>
<string name="no_applications_available">Keine Anwendungen verfügbar</string>
<string name="socks_proxy">SOCKS-Proxy</string>
<string name="no_applications_available">Keine Apps verfügbar</string>
<plurals name="new_updates_DESC_FORMAT">
<item quantity="one">%d Anwendung hat eine neue Version.</item>
<item quantity="other">%d Anwendungen haben eine neue Version.</item>
<item quantity="one">%d App hat eine neue Version.</item>
<item quantity="other">%d Apps haben eine neue Version.</item>
</plurals>
<string name="signed_using_unsafe_algorithm">Mit einem unsicheren Algorithmus signiert</string>
<string name="select_mirror">Wähle einen Spiegel</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="upstream_source_code_is_not_free">Der Upstream-Quellcode ist nicht frei</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="update_all">Alle aktualisieren</string>
<plurals name="days">
@@ -171,57 +171,57 @@
<item quantity="one">Stunde</item>
<item quantity="other">Stunden</item>
</plurals>
<string name="only_on_wifi_with_charging">Nur während des Ladevorgangs und aktiviertem WLAN</string>
<string name="installer">Installationsmethode</string>
<string name="only_on_wifi_with_charging">Nur mit WLAN während des Aufladens</string>
<string name="installer">Installation</string>
<string name="cleanup_title">APK-Bereinigungsintervall</string>
<string name="root_installer">Root-Installation</string>
<string name="legacy_installer">Alte Installationsmethode</string>
<string name="session_installer">Sitzungs-Installation</string>
<string name="legacy_installer">Legacy-Installation</string>
<string name="session_installer">Sitzungsinstallation</string>
<string name="shizuku_installer">Shizuku-Installation</string>
<string name="io_error_DESC">Bestimmte Aktionen können nicht durchgeführt werden.</string>
<string name="favourites">Favoriten</string>
<string name="material_you">Material You</string>
<string name="material_you_desc">Material You-Farbschema verwenden</string>
<string name="repository_unreachable">Repository unerreichbar</string>
<string name="force_clean_up">Aufräumen erzwingen</string>
<string name="enable_repo">Repository aktivieren</string>
<string name="repository_unreachable">Paketquelle unerreichbar</string>
<string name="force_clean_up">Bereinigung erzwingen</string>
<string name="enable_repo">Paketquelle aktivieren</string>
<string name="force_clean_up_DESC">Entfernt doppelte Dateien</string>
<string name="installing">Installation</string>
<string name="waiting_to_start_installation">Warten auf den Beginn der Installation </string>
<string name="installing">Wird installiert </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">Versuche, Updates automatisch zu installieren</string>
<string name="has_non_free_components">Hat nicht-freie Komponenten</string>
<string name="auto_update_apps">Updates möglichst automatisch installieren</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="shizuku_not_alive">Shizuku läuft nicht</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="shizuku_not_installed">Shizuku ist nicht installiert</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="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_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_repos_DESC">Alle Repositories in eine Datei exportieren</string>
<string name="import_repos_title">Importiere eine Sammlung</string>
<string name="export_repos_DESC">Paketquellen in eine Datei exportieren</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_repos_title">Repositories exportieren</string>
<string name="import_repos_DESC">Alle Repositories aus einer Datei importieren</string>
<string name="export_repos_title">Paketquellen exportieren</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="has_tethered_network">An einen bestimmten Netzwerkdienst gebunden</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="uninstalled_application_DESC">%s wurde deinstalliert</string>
<string name="installation_failed_DESC">%s konnte nicht installiert werden</string>
<string name="uninstalled_application">Deinstalliert</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="insufficient_storage">Nicht genug 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">Nicht genügend Speicherplatz</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_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>
@@ -230,4 +230,11 @@
<string name="open_shizuku">Shizuku öffnen</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="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>

View File

@@ -220,4 +220,22 @@
<string name="uninstalled_application_DESC">Το %s απεγκαταστάθηκε</string>
<string name="ignore_signature">Αγνόησή Υπογραφής</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>

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